diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ad44e79 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.gitignore +.gitattributes +node_modules +.turbo +.next +.astro +dist +.crush +.agents +*.md +.vscode +.github +.crush +.env +.env.* +.DS_Store +apps/cms/.next +apps/cms/node_modules +apps/web/node_modules +apps/web/.astro +apps/web/dist +packages/*/node_modules diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f70abf..af3b8c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,20 @@ on: jobs: build: runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: sn_cms + POSTGRES_USER: sn_cms + POSTGRES_PASSWORD: sn_cms + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v6 @@ -28,3 +42,7 @@ jobs: - name: Build run: bun run build + env: + DATABASE_URL: postgres://sn_cms:sn_cms@postgres:5432/sn_cms + PAYLOAD_SECRET: test-secret-for-ci + NEXT_PUBLIC_SERVER_URL: http://localhost:3000 diff --git a/.gitignore b/.gitignore index 9ed2429..354ecb6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,19 @@ review-import.json .env.local .env.development .env.test -.env.production \ No newline at end of file +.env.production + +# Payload default media upload directory +public/media/ +public/robots.txt +public/sitemap*.xml + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +# Next.js +next-env.d.ts + diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 43fb300..0000000 --- a/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM oven/bun:latest AS build -WORKDIR /app -COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile -COPY . . -RUN bun run build - -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 diff --git a/README.md b/README.md index 5a889a9..9c3895c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ Start the development server with hot reload: bun run dev ``` +```bash +docker compose build cms && docker compose stop cms && docker compose rm -f cms && docker compose up -d cms +``` + The site will be available at `http://localhost:3000` by default. Changes to content files, components, and styles rebuild automatically. Styles are authored in `src/styles/main.scss` and bundled by Astro. diff --git a/_posts/post template.md b/_posts/post template.md deleted file mode 100644 index 24dd258..0000000 --- a/_posts/post template.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -layout: -date: -author: "" -category: -title: -responseTo: "column/slug" -followUpTo: - - "column/slug" - - "column/slug" ---- diff --git a/apps/cms/.editorconfig b/apps/cms/.editorconfig new file mode 100644 index 0000000..d8e085a --- /dev/null +++ b/apps/cms/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +end_of_line = lf +max_line_length = null diff --git a/apps/cms/.env.example b/apps/cms/.env.example new file mode 100644 index 0000000..9401717 --- /dev/null +++ b/apps/cms/.env.example @@ -0,0 +1,23 @@ +# Database connection string +DATABASE_URL=postgres://sn_cms:sn_cms@postgres:5432/sn_cms + +# Used to encrypt JWT tokens +PAYLOAD_SECRET=YOUR_SECRET_HERE + +# Used to configure CORS, format links and more. No trailing slash +NEXT_PUBLIC_SERVER_URL=http://localhost:3000 + +# Secret used to authenticate cron jobs +CRON_SECRET=YOUR_CRON_SECRET_HERE + +# Used to validate preview requests (must match web app's PREVIEW_SECRET) +PREVIEW_SECRET=YOUR_SECRET_HERE + +# URL to the Astro frontend for preview links +ASTRO_URL=http://web:80 + +# Hack Club OIDC +HACKCLUB_CLIENT_ID=YOUR_CLIENT_ID_HERE +HACKCLUB_CLIENT_SECRET=YOUR_CLIENT_SECRET_HERE +# Optional override; defaults to https://your-site/admin/auth/hackclub/callback +HACKCLUB_REDIRECT_URI=http://localhost:3000/admin/auth/hackclub/callback diff --git a/apps/cms/.gitignore b/apps/cms/.gitignore new file mode 100644 index 0000000..7ad09c3 --- /dev/null +++ b/apps/cms/.gitignore @@ -0,0 +1,22 @@ +build +dist / media +node_modules +.DS_Store +.env +.next +next-env.d.ts +.vercel + +# Payload default media upload directory +public/media/ + +public/robots.txt +public/sitemap*.xml + + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/apps/cms/.npmrc b/apps/cms/.npmrc new file mode 100644 index 0000000..5ff455c --- /dev/null +++ b/apps/cms/.npmrc @@ -0,0 +1,2 @@ +legacy-peer-deps=true +enable-pre-post-scripts=true diff --git a/apps/cms/.prettierignore b/apps/cms/.prettierignore new file mode 100644 index 0000000..996b10e --- /dev/null +++ b/apps/cms/.prettierignore @@ -0,0 +1,14 @@ +**/payload-types.ts +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp +**/docs/** +tsconfig.json + diff --git a/apps/cms/.prettierrc.json b/apps/cms/.prettierrc.json new file mode 100644 index 0000000..cb8ee26 --- /dev/null +++ b/apps/cms/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": false +} diff --git a/apps/cms/.vscode/extensions.json b/apps/cms/.vscode/extensions.json new file mode 100644 index 0000000..1d7ac85 --- /dev/null +++ b/apps/cms/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/apps/cms/.vscode/launch.json b/apps/cms/.vscode/launch.json new file mode 100644 index 0000000..572ee15 --- /dev/null +++ b/apps/cms/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug full stack", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/next/dist/bin/next", + "runtimeArgs": ["--inspect"], + "skipFiles": ["/**"], + "serverReadyAction": { + "action": "debugWithChrome", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + }, + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/apps/cms/.vscode/settings.json b/apps/cms/.vscode/settings.json new file mode 100644 index 0000000..d53193e --- /dev/null +++ b/apps/cms/.vscode/settings.json @@ -0,0 +1,41 @@ +{ + "npm.packageManager": "pnpm", + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "editor.formatOnSaveMode": "file", + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "[javascript][typescript][typescriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + } +} diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile new file mode 100644 index 0000000..f972e2b --- /dev/null +++ b/apps/cms/Dockerfile @@ -0,0 +1,50 @@ +FROM oven/bun:1.3.12-alpine AS base +RUN apk add --no-cache libc6-compat + +FROM base AS deps +WORKDIR /app +COPY package.json bun.lock turbo.json ./ +COPY apps/cms/package.json apps/cms/ +COPY packages/ packages/ +RUN bun install + +FROM base AS dev +WORKDIR /app/apps/cms +COPY --from=deps /app/node_modules /app/node_modules +COPY packages/ /app/packages/ +COPY apps/cms/ . +EXPOSE 3000 +ENV PORT 3000 +CMD ["sh", "-c", "bun run dev"] + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/cms/node_modules ./apps/cms/node_modules +COPY apps/cms/ apps/cms/ +COPY packages/ packages/ +ENV NEXT_TELEMETRY_DISABLED 1 +ENV NODE_OPTIONS="--max-old-space-size=1024 --no-deprecation" +ENV NEXT_PRIVATE_BUILD_WORKER=1 +RUN cd apps/cms && bun run build + +FROM base AS runner +WORKDIR /app/apps/cms +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 +RUN apk add --no-cache nodejs +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +COPY --from=builder --chown=nextjs:nodejs /app/apps/cms/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/apps/cms/.next ./.next +COPY --from=builder --chown=nextjs:nodejs /app/apps/cms/package.json ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/cms/tsconfig.json ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/cms/src ./src +COPY --from=builder --chown=nextjs:nodejs /app/apps/cms/scripts/seed-trigger.mjs ./ +COPY apps/web/src/content/posts /app/mdx-posts +COPY --from=deps --chown=nextjs:nodejs /app/node_modules /app/node_modules +COPY --from=deps --chown=nextjs:nodejs /app/apps/cms/node_modules /app/apps/cms/node_modules +USER nextjs +EXPOSE 3000 +ENV PORT 3000 +CMD ["sh", "-c", "/usr/bin/node /app/apps/cms/node_modules/.bin/payload migrate && bun run start & sleep 8 && /usr/bin/node seed-trigger.mjs && tail -f /dev/null"] diff --git a/apps/cms/components.json b/apps/cms/components.json new file mode 100644 index 0000000..ff4511f --- /dev/null +++ b/apps/cms/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.mjs", + "css": "src/app/(frontend)/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/utilities/ui" + } +} diff --git a/apps/cms/dev-entry.sh b/apps/cms/dev-entry.sh new file mode 100644 index 0000000..41346e6 --- /dev/null +++ b/apps/cms/dev-entry.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Run next dev and pipe "y" to handle the schema push prompt +printf "y\n" | cross-env NODE_OPTIONS=--no-deprecation next dev --webpack diff --git a/apps/cms/eslint.config.mjs b/apps/cms/eslint.config.mjs new file mode 100644 index 0000000..9896220 --- /dev/null +++ b/apps/cms/eslint.config.mjs @@ -0,0 +1,38 @@ +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import { FlatCompat } from '@eslint/eslintrc' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}) + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + rules: { + '@typescript-eslint/ban-ts-comment': 'warn', + '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: false, + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^(_|ignore)', + }, + ], + }, + }, + { + ignores: ['.next/', 'src/payload-types.ts', 'src/payload-generated-schema.ts'], + }, +] + +export default eslintConfig diff --git a/apps/cms/next-sitemap.config.cjs b/apps/cms/next-sitemap.config.cjs new file mode 100644 index 0000000..689f088 --- /dev/null +++ b/apps/cms/next-sitemap.config.cjs @@ -0,0 +1,20 @@ +const SITE_URL = + process.env.NEXT_PUBLIC_SERVER_URL || + process.env.VERCEL_PROJECT_PRODUCTION_URL || + 'https://example.com' + +/** @type {import('next-sitemap').IConfig} */ +module.exports = { + siteUrl: SITE_URL, + generateRobotsTxt: true, + exclude: ['/posts-sitemap.xml', '/pages-sitemap.xml', '/*', '/posts/*'], + robotsTxtOptions: { + policies: [ + { + userAgent: '*', + disallow: '/admin/*', + }, + ], + additionalSitemaps: [`${SITE_URL}/pages-sitemap.xml`, `${SITE_URL}/posts-sitemap.xml`], + }, +} diff --git a/apps/cms/next.config.ts b/apps/cms/next.config.ts new file mode 100644 index 0000000..c85970c --- /dev/null +++ b/apps/cms/next.config.ts @@ -0,0 +1,58 @@ +import { withPayload } from '@payloadcms/next/withPayload' +import type { NextConfig } from 'next' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(__filename) +const monorepoRoot = path.resolve(dirname, '../..') +import { redirects } from './redirects' + +const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : process.env.__NEXT_PRIVATE_ORIGIN || 'http://localhost:3000' + +const nextConfig: NextConfig = { + typescript: { + ignoreBuildErrors: false, + }, + // Temporarily required on Windows until Next.js fixes Turbopack Sass resolution. + // See: https://github.com/vercel/next.js/issues/86431 + sassOptions: { + loadPaths: ['./node_modules/@payloadcms/ui/dist/scss/'], + }, + images: { + localPatterns: [ + { + pathname: '/api/media/file/**', + }, + ], + qualities: [100], + remotePatterns: [ + ...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => { + const url = new URL(item) + + return { + hostname: url.hostname, + protocol: url.protocol.replace(':', '') as 'http' | 'https', + } + }), + ], + }, + webpack: (webpackConfig) => { + webpackConfig.resolve.extensionAlias = { + '.cjs': ['.cts', '.cjs'], + '.js': ['.ts', '.tsx', '.js', '.jsx'], + '.mjs': ['.mts', '.mjs'], + } + + return webpackConfig + }, + reactStrictMode: true, + redirects, + turbopack: { + root: monorepoRoot, + }, +} + +export default withPayload(nextConfig, { devBundleServerPackages: false }) diff --git a/apps/cms/package.json b/apps/cms/package.json new file mode 100644 index 0000000..3a3caa1 --- /dev/null +++ b/apps/cms/package.json @@ -0,0 +1,93 @@ +{ + "name": "@slacker-news/cms", + "version": "1.0.0", + "description": "Website template for Payload", + "license": "MIT", + "type": "module", + "scripts": { + "build": "next build", + "postbuild": "next-sitemap --config next-sitemap.config.cjs", + "dev": "next dev --webpack", + "dev:prod": "rm -rf .next && bun run build && bun run start", + "generate:importmap": "payload generate:importmap", + "generate:types": "payload generate:types", + "ii": "bun install", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "payload": "payload", + "reinstall": "rm -rf node_modules bun.lock && bun install", + "start": "next start", + "test": "bun run test:int && bun run test:e2e", + "test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts", + "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts" + }, + "dependencies": { + "@payloadcms/admin-bar": "3.84.1", + "@payloadcms/db-postgres": "3.84.1", + "@payloadcms/live-preview-react": "3.84.1", + "@payloadcms/next": "3.84.1", + "@payloadcms/plugin-form-builder": "3.84.1", + "@payloadcms/plugin-nested-docs": "3.84.1", + "@payloadcms/plugin-redirects": "3.84.1", + "@payloadcms/plugin-search": "3.84.1", + "@payloadcms/plugin-seo": "3.84.1", + "@payloadcms/richtext-lexical": "3.84.1", + "@payloadcms/ui": "3.84.1", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slot": "^1.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cross-env": "^7.0.3", + "dotenv": "16.4.7", + "geist": "^1.3.0", + "graphql": "^16.8.2", + "jose": "^6.2.3", + "lucide-react": "0.563.0", + "next": "16.2.6", + "next-sitemap": "^4.2.3", + "payload": "3.84.1", + "prism-react-renderer": "^2.3.1", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-hook-form": "7.71.1", + "sharp": "0.34.2", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@playwright/test": "1.58.2", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/typography": "^0.5.19", + "@testing-library/react": "16.3.0", + "@types/escape-html": "^1.0.2", + "@types/node": "22.19.9", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "4.5.2", + "autoprefixer": "^10.4.19", + "eslint": "^9.16.0", + "eslint-config-next": "16.2.6", + "jsdom": "28.0.0", + "postcss": "^8.4.38", + "prettier": "^3.4.2", + "tailwindcss": "^4.1.18", + "tsx": "4.21.0", + "tw-animate-css": "^1.4.0", + "typescript": "5.7.3", + "vite-tsconfig-paths": "6.0.5", + "vitest": "4.0.18" + }, + "engines": { + "node": "^18.20.2 || >=20.9.0", + "pnpm": "^9 || ^10" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "sharp", + "esbuild", + "unrs-resolver" + ] + } +} diff --git a/apps/cms/playwright.config.ts b/apps/cms/playwright.config.ts new file mode 100644 index 0000000..c500edb --- /dev/null +++ b/apps/cms/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +import 'dotenv/config' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], channel: 'chromium' }, + }, + ], + webServer: { + command: 'bun dev', + reuseExistingServer: true, + url: 'http://localhost:3000', + }, +}) diff --git a/apps/cms/postcss.config.js b/apps/cms/postcss.config.js new file mode 100644 index 0000000..ae85b2f --- /dev/null +++ b/apps/cms/postcss.config.js @@ -0,0 +1,7 @@ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +} + +export default config diff --git a/apps/cms/public/Core SN logo paths.svg b/apps/cms/public/Core SN logo paths.svg new file mode 100644 index 0000000..e22fc60 --- /dev/null +++ b/apps/cms/public/Core SN logo paths.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/cms/public/SN H Logo.svg b/apps/cms/public/SN H Logo.svg new file mode 100644 index 0000000..a8fbe67 --- /dev/null +++ b/apps/cms/public/SN H Logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/cms/public/favicon.ico b/apps/cms/public/favicon.ico new file mode 100644 index 0000000..1ec2c19 Binary files /dev/null and b/apps/cms/public/favicon.ico differ diff --git a/apps/cms/public/favicon.svg b/apps/cms/public/favicon.svg new file mode 100644 index 0000000..d7ccc5a --- /dev/null +++ b/apps/cms/public/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + \ No newline at end of file diff --git a/apps/cms/public/website-template-OG.webp b/apps/cms/public/website-template-OG.webp new file mode 100644 index 0000000..ceb0efc Binary files /dev/null and b/apps/cms/public/website-template-OG.webp differ diff --git a/apps/cms/redirects.ts b/apps/cms/redirects.ts new file mode 100644 index 0000000..64ea7cd --- /dev/null +++ b/apps/cms/redirects.ts @@ -0,0 +1,18 @@ +import type { NextConfig } from 'next' + +export const redirects: NextConfig['redirects'] = async () => { + const internetExplorerRedirect = { + destination: '/ie-incompatible.html', + has: [ + { + type: 'header' as const, + key: 'user-agent', + value: '(.*Trident.*)', // all ie browsers + }, + ], + permanent: false, + source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page + } + + return [internetExplorerRedirect] +} diff --git a/apps/cms/scripts/migrate-mdx.mjs b/apps/cms/scripts/migrate-mdx.mjs new file mode 100644 index 0000000..1e44fd0 --- /dev/null +++ b/apps/cms/scripts/migrate-mdx.mjs @@ -0,0 +1,357 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const CMS_URL = process.env.CMS_URL || 'http://localhost:3000'; +const MDX_DIR = path.resolve(__dirname, '../../../apps/web/src/content/posts'); + +console.log('CMS_URL:', CMS_URL); + +// Simple frontmatter parser (YAML-like) +function parseFrontmatter(text) { + const match = text.match(/^---\s*\n([\s\S]*?)\n(?:---|\.\.\.)\s*\n([\s\S]*)$/); + if (!match) return { data: {}, content: text }; + const frontmatter = {}; + let key = null; + for (const line of match[1].split('\n')) { + const kvMatch = line.match(/^(\w+):\s*(.*)/); + if (kvMatch) { + key = kvMatch[1]; + let value = kvMatch[2].replace(/^['"]|['"]$/g, '').trim(); + if (value === 'true') value = true; + else if (value === 'false') value = false; + frontmatter[key] = value; + } else if (key && line.startsWith(' ')) { + frontmatter[key] += '\n' + line.trim(); + } + } + return { data: frontmatter, content: match[2] }; +} + +function astroComponentToText(text) { + return text + .replace(//g, '#$1') + .replace(//g, '@$1') + .replace(/<[^>]+>/g, '') + .replace(/^\s*import\s+.*/gm, ''); +} + +function escapeLexicalText(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function parseInlineFormatting(text) { + const children = []; + const parts = []; + let remaining = text; + + const inlineRe = /(\*\*\*([^*]+)\*\*\*|\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|!\[([^\]]*)\]\(([^)]+)\)|\[([^\]]*)\]\(([^)]+)\))/; + + while (remaining.length > 0) { + const match = remaining.match(inlineRe); + if (!match) { + if (remaining.trim()) { + children.push({ type: 'text', text: escapeLexicalText(remaining) }); + } + break; + } + + if (match.index > 0) { + const before = remaining.slice(0, match.index); + if (before.trim()) { + children.push({ type: 'text', text: escapeLexicalText(before) }); + } + } + + if (match[1].startsWith('***')) { + children.push({ type: 'text', text: escapeLexicalText(match[2]), format: 3 }); + } else if (match[1].startsWith('**')) { + children.push({ type: 'text', text: escapeLexicalText(match[3]), format: 1 }); + } else if (match[1].startsWith('*') && !match[1].startsWith('**')) { + children.push({ type: 'text', text: escapeLexicalText(match[4]), format: 2 }); + } else if (match[1].startsWith('`')) { + children.push({ type: 'text', text: escapeLexicalText(match[5]), format: 0, code: true }); + } else if (match[1].startsWith('![')) { + children.push({ type: 'text', text: `[Image: ${match[6]}](${match[7]})` }); + } else if (match[1].startsWith('[')) { + children.push({ type: 'text', text: escapeLexicalText(match[8]), format: 0, link: match[9] }); + } + + remaining = remaining.slice(match.index + match[0].length); + } + + return children; +} + +function markdownToLexical(md) { + const rootChildren = []; + const lines = md.split('\n'); + + let currentQuote = null; + let inList = null; + let inOrderedList = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (line.trim() === '---') continue; + + if (line.trim() === '') { + if (currentQuote) { currentQuote = null; } + inList = null; + inOrderedList = null; + continue; + } + + const headingMatch = line.match(/^(#{1,6})\s+(.+)/); + if (headingMatch) { + const tag = `h${headingMatch[1].length}`; + rootChildren.push({ + type: 'heading', + tag, + children: parseInlineFormatting(headingMatch[2]), + format: '', + indent: 0, + version: 1, + }); + continue; + } + + const quoteMatch = line.match(/^>\s?(.*)/); + if (quoteMatch) { + const text = quoteMatch[1]; + if (!currentQuote) { + currentQuote = { + type: 'quote', + children: [], + format: '', + indent: 0, + version: 1, + }; + rootChildren.push(currentQuote); + } + if (text.trim()) { + currentQuote.children.push({ + type: 'paragraph', + children: parseInlineFormatting(text), + format: '', + indent: 0, + version: 1, + }); + } + continue; + } + + const ulMatch = line.match(/^[-*]\s+(.+)/); + if (ulMatch) { + inOrderedList = null; + if (!inList) { + inList = { + type: 'list', + listType: 'bullet', + children: [], + format: '', + indent: 0, + version: 1, + }; + rootChildren.push(inList); + } + inList.children.push({ + type: 'listitem', + children: parseInlineFormatting(ulMatch[1]), + value: 1, + format: '', + indent: 0, + version: 1, + }); + continue; + } + + const olMatch = line.match(/^\d+\.\s+(.+)/); + if (olMatch) { + inList = null; + if (!inOrderedList) { + inOrderedList = { + type: 'list', + listType: 'number', + children: [], + format: '', + indent: 0, + version: 1, + }; + rootChildren.push(inOrderedList); + } + inOrderedList.children.push({ + type: 'listitem', + children: parseInlineFormatting(olMatch[1]), + value: 1, + format: '', + indent: 0, + version: 1, + }); + continue; + } + + // Image on its own line + const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/); + if (imgMatch) { + rootChildren.push({ + type: 'paragraph', + children: [{ type: 'text', text: `[Image: ${imgMatch[1]}](${imgMatch[2]})` }], + format: '', + indent: 0, + version: 1, + }); + continue; + } + + // Horizontal rule + if (line.match(/^---\s*$/)) { + rootChildren.push({ + type: 'horizontalrule', + format: '', + indent: 0, + version: 1, + }); + continue; + } + + // Regular paragraph + const textChildren = parseInlineFormatting(line); + if (textChildren.length > 0) { + inList = null; + inOrderedList = null; + rootChildren.push({ + type: 'paragraph', + children: textChildren, + format: '', + indent: 0, + version: 1, + }); + } + } + + return { + root: { + type: 'root', + format: '', + indent: 0, + version: 1, + children: rootChildren, + direction: null, + }, + }; +} + +async function createPost(postData) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + + try { + const { _status, ...createData } = postData; + const createResponse = await fetch(`${CMS_URL}/api/posts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(createData), + signal: controller.signal, + }); + + if (!createResponse.ok) { + const errorText = await createResponse.text(); + console.error(`Create post failed (${createResponse.status}): ${errorText}`); + return null; + } + + const result = await createResponse.json(); + const postId = result.doc.id; + + if (_status === 'published') { + const publishResponse = await fetch(`${CMS_URL}/api/posts/${postId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ _status: 'published' }), + signal: controller.signal, + }); + + if (!publishResponse.ok) { + console.error(`Publish failed for ${postData.slug}: ${await publishResponse.text()}`); + } else { + console.log(` Published: id=${postId}`); + } + } + + return result; + } catch (err) { + console.error(`Fetch error: ${err.message}`); + return null; + } finally { + clearTimeout(timeout); + } +} + +async function main() { + const categories = ['news', 'opinion', 'essays', 'changelogs']; + const categoryIdMap = {}; + + // Get category IDs + try { + const catResponse = await fetch(`${CMS_URL}/api/categories`, { signal: AbortSignal.timeout(10000) }); + if (catResponse.ok) { + const catData = await catResponse.json(); + for (const cat of catData.docs) { + categoryIdMap[cat.title.toLowerCase()] = cat.id; + } + } + } catch (err) { + console.error('Failed to fetch categories:', err.message); + } + console.log('Category map:', categoryIdMap); + + for (const category of categories) { + const dir = path.join(MDX_DIR, category === 'changelogs' ? 'changelogs' : category); + if (!fs.existsSync(dir)) continue; + + const files = fs.readdirSync(dir).filter(f => f.endsWith('.mdx')); + for (const file of files) { + const filePath = path.join(dir, file); + const raw = fs.readFileSync(filePath, 'utf-8'); + const { data, content: rawContent } = parseFrontmatter(raw); + const cleanedContent = astroComponentToText(rawContent); + const lexical = markdownToLexical(cleanedContent); + + const slug = file.replace(/\.mdx$/, ''); + const postTitle = data.title || slug; + const authorName = data.author || ''; + + const postData = { + title: postTitle, + slug, + content: lexical, + authors: authorName ? [{ name: authorName }] : [], + publishedAt: data.date ? new Date(data.date).toISOString() : null, + _status: 'published', + loginRequired: false, + responseTo: data.responseTo || null, + followUpTo: data.followUpTo || null, + }; + + if (categoryIdMap[category]) { + postData.categories = [categoryIdMap[category]]; + } + + console.log(`Creating post: "${postTitle}" (${slug})`); + const result = await createPost(postData); + if (result) { + console.log(` Created: id=${result.doc.id}`); + } + } + } +} + +main().catch(console.error); diff --git a/apps/cms/scripts/seed-categories.mjs b/apps/cms/scripts/seed-categories.mjs new file mode 100644 index 0000000..e24fc11 --- /dev/null +++ b/apps/cms/scripts/seed-categories.mjs @@ -0,0 +1,33 @@ +import { getPayload } from 'payload' +import config from '@payload-config' + +async function main() { + const payload = await getPayload({ config }) + + const categories = ['News', 'Opinion', 'Essays'] + + for (const title of categories) { + const existing = await payload.find({ + collection: 'categories', + where: { title: { equals: title } }, + }) + + if (existing.docs.length === 0) { + await payload.create({ + collection: 'categories', + data: { title, slug: title.toLowerCase() }, + }) + console.log(`Created category: ${title}`) + } else { + console.log(`Category already exists: ${title}`) + } + } + + console.log('Done') + process.exit(0) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/cms/scripts/seed-trigger.mjs b/apps/cms/scripts/seed-trigger.mjs new file mode 100644 index 0000000..e15fc9c --- /dev/null +++ b/apps/cms/scripts/seed-trigger.mjs @@ -0,0 +1,49 @@ +const CMS_URL = process.env.CMS_URL || 'http://localhost:3000' +const SEED_SECRET = process.env.SEED_SECRET || '' +const MAX_RETRIES = 60 +const RETRY_DELAY = 2000 + +async function waitForServer() { + for (let i = 0; i < MAX_RETRIES; i++) { + try { + const res = await fetch(`${CMS_URL}/api/categories`, { signal: AbortSignal.timeout(5000) }) + if (res.ok) { + console.log('CMS server is ready') + return + } + console.log(`Server responded with status ${res.status}, retrying... (${i + 1}/${MAX_RETRIES})`) + const body = await res.text().catch(() => '') + if (body) console.log(` Response: ${body.slice(0, 200)}`) + } catch (err) { + console.log(`Server not ready yet (${err?.cause?.code || err?.message || err}), retrying... (${i + 1}/${MAX_RETRIES})`) + } + await new Promise(r => setTimeout(r, RETRY_DELAY)) + } + throw new Error('CMS server did not become ready') +} + +async function triggerSeed() { + try { + const headers = { 'Content-Type': 'application/json' } + if (SEED_SECRET) headers['Authorization'] = `Bearer ${SEED_SECRET}` + + console.log('Triggering seed...') + const res = await fetch(`${CMS_URL}/api/seed`, { + method: 'POST', + headers, + signal: AbortSignal.timeout(120000), + }) + + const data = await res.json() + if (res.ok) { + console.log('Seed completed:', data.results?.join(', ') || 'ok') + } else { + console.error('Seed failed:', data.error || data) + } + } catch (err) { + console.error('Seed trigger error:', err.message) + } +} + +await waitForServer() +await triggerSeed() diff --git a/apps/cms/src/Footer/Component.tsx b/apps/cms/src/Footer/Component.tsx new file mode 100644 index 0000000..737a295 --- /dev/null +++ b/apps/cms/src/Footer/Component.tsx @@ -0,0 +1,32 @@ +import { getCachedGlobal } from '@/utilities/getGlobals' +import Link from 'next/link' +import React from 'react' + +import { ThemeSelector } from '@/providers/Theme/ThemeSelector' +import { CMSLink } from '@/components/Link' +import { Logo } from '@/components/Logo/Logo' + +export async function Footer() { + const footerData = await getCachedGlobal('footer', 1)() + + const navItems = footerData?.navItems || [] + + return ( +
+
+ + + + +
+ + +
+
+
+ ) +} diff --git a/apps/cms/src/Footer/RowLabel.tsx b/apps/cms/src/Footer/RowLabel.tsx new file mode 100644 index 0000000..a6f9494 --- /dev/null +++ b/apps/cms/src/Footer/RowLabel.tsx @@ -0,0 +1,13 @@ +'use client' +import { Header } from '@/payload-types' +import { RowLabelProps, useRowLabel } from '@payloadcms/ui' + +export const RowLabel: React.FC = () => { + const data = useRowLabel[number]>() + + const label = data?.data?.link?.label + ? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}` + : 'Row' + + return
{label}
+} diff --git a/apps/cms/src/Footer/config.ts b/apps/cms/src/Footer/config.ts new file mode 100644 index 0000000..61eb24c --- /dev/null +++ b/apps/cms/src/Footer/config.ts @@ -0,0 +1,35 @@ +import type { GlobalConfig } from 'payload' + +import { link } from '@/fields/link' +import { revalidateFooter } from './hooks/revalidateFooter' + +export const Footer: GlobalConfig = { + slug: 'footer', + admin: { + hidden: true, + }, + access: { + read: () => true, + }, + fields: [ + { + name: 'navItems', + type: 'array', + fields: [ + link({ + appearances: false, + }), + ], + maxRows: 6, + admin: { + initCollapsed: true, + components: { + RowLabel: '@/Footer/RowLabel#RowLabel', + }, + }, + }, + ], + hooks: { + afterChange: [revalidateFooter], + }, +} diff --git a/apps/cms/src/Footer/hooks/revalidateFooter.ts b/apps/cms/src/Footer/hooks/revalidateFooter.ts new file mode 100644 index 0000000..1484bf5 --- /dev/null +++ b/apps/cms/src/Footer/hooks/revalidateFooter.ts @@ -0,0 +1,13 @@ +import type { GlobalAfterChangeHook } from 'payload' + +import { revalidateTag } from 'next/cache' + +export const revalidateFooter: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => { + if (!context.disableRevalidate) { + payload.logger.info(`Revalidating footer`) + + revalidateTag('global_footer', 'max') + } + + return doc +} diff --git a/apps/cms/src/Header/Component.client.tsx b/apps/cms/src/Header/Component.client.tsx new file mode 100644 index 0000000..a4bd658 --- /dev/null +++ b/apps/cms/src/Header/Component.client.tsx @@ -0,0 +1,42 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import React, { useEffect, useState } from 'react' + +import type { Header } from '@/payload-types' + +import { Logo } from '@/components/Logo/Logo' +import { HeaderNav } from './Nav' + +interface HeaderClientProps { + data: Header +} + +export const HeaderClient: React.FC = ({ data }) => { + /* Storing the value in a useState to avoid hydration errors */ + const [theme, setTheme] = useState(null) + const { headerTheme, setHeaderTheme } = useHeaderTheme() + const pathname = usePathname() + + useEffect(() => { + setHeaderTheme(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]) + + useEffect(() => { + if (headerTheme && headerTheme !== theme) setTheme(headerTheme) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [headerTheme]) + + return ( +
+
+ + + + +
+
+ ) +} diff --git a/apps/cms/src/Header/Component.tsx b/apps/cms/src/Header/Component.tsx new file mode 100644 index 0000000..e7beeed --- /dev/null +++ b/apps/cms/src/Header/Component.tsx @@ -0,0 +1,9 @@ +import { HeaderClient } from './Component.client' +import { getCachedGlobal } from '@/utilities/getGlobals' +import React from 'react' + +export async function Header() { + const headerData = await getCachedGlobal('header', 1)() + + return +} diff --git a/apps/cms/src/Header/Nav/index.tsx b/apps/cms/src/Header/Nav/index.tsx new file mode 100644 index 0000000..070fcca --- /dev/null +++ b/apps/cms/src/Header/Nav/index.tsx @@ -0,0 +1,25 @@ +'use client' + +import React from 'react' + +import type { Header as HeaderType } from '@/payload-types' + +import { CMSLink } from '@/components/Link' +import Link from 'next/link' +import { SearchIcon } from 'lucide-react' + +export const HeaderNav: React.FC<{ data: HeaderType }> = ({ data }) => { + const navItems = data?.navItems || [] + + return ( + + ) +} diff --git a/apps/cms/src/Header/RowLabel.tsx b/apps/cms/src/Header/RowLabel.tsx new file mode 100644 index 0000000..a6f9494 --- /dev/null +++ b/apps/cms/src/Header/RowLabel.tsx @@ -0,0 +1,13 @@ +'use client' +import { Header } from '@/payload-types' +import { RowLabelProps, useRowLabel } from '@payloadcms/ui' + +export const RowLabel: React.FC = () => { + const data = useRowLabel[number]>() + + const label = data?.data?.link?.label + ? `Nav item ${data.rowNumber !== undefined ? data.rowNumber + 1 : ''}: ${data?.data?.link?.label}` + : 'Row' + + return
{label}
+} diff --git a/apps/cms/src/Header/config.ts b/apps/cms/src/Header/config.ts new file mode 100644 index 0000000..c081ca7 --- /dev/null +++ b/apps/cms/src/Header/config.ts @@ -0,0 +1,35 @@ +import type { GlobalConfig } from 'payload' + +import { link } from '@/fields/link' +import { revalidateHeader } from './hooks/revalidateHeader' + +export const Header: GlobalConfig = { + slug: 'header', + admin: { + hidden: true, + }, + access: { + read: () => true, + }, + fields: [ + { + name: 'navItems', + type: 'array', + fields: [ + link({ + appearances: false, + }), + ], + maxRows: 6, + admin: { + initCollapsed: true, + components: { + RowLabel: '@/Header/RowLabel#RowLabel', + }, + }, + }, + ], + hooks: { + afterChange: [revalidateHeader], + }, +} diff --git a/apps/cms/src/Header/hooks/revalidateHeader.ts b/apps/cms/src/Header/hooks/revalidateHeader.ts new file mode 100644 index 0000000..c6745d3 --- /dev/null +++ b/apps/cms/src/Header/hooks/revalidateHeader.ts @@ -0,0 +1,13 @@ +import type { GlobalAfterChangeHook } from 'payload' + +import { revalidateTag } from 'next/cache' + +export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => { + if (!context.disableRevalidate) { + payload.logger.info(`Revalidating header`) + + revalidateTag('global_header', 'max') + } + + return doc +} diff --git a/apps/cms/src/access/anyone.ts b/apps/cms/src/access/anyone.ts new file mode 100644 index 0000000..bf37c3a --- /dev/null +++ b/apps/cms/src/access/anyone.ts @@ -0,0 +1,3 @@ +import type { Access } from 'payload' + +export const anyone: Access = () => true diff --git a/apps/cms/src/access/authenticated.ts b/apps/cms/src/access/authenticated.ts new file mode 100644 index 0000000..e2dc34d --- /dev/null +++ b/apps/cms/src/access/authenticated.ts @@ -0,0 +1,9 @@ +import type { AccessArgs } from 'payload' + +import type { User } from '@/payload-types' + +type isAuthenticated = (args: AccessArgs) => boolean + +export const authenticated: isAuthenticated = ({ req: { user } }) => { + return Boolean(user) +} diff --git a/apps/cms/src/access/authenticatedOrPublished.ts b/apps/cms/src/access/authenticatedOrPublished.ts new file mode 100644 index 0000000..e49198f --- /dev/null +++ b/apps/cms/src/access/authenticatedOrPublished.ts @@ -0,0 +1,13 @@ +import type { Access } from 'payload' + +export const authenticatedOrPublished: Access = ({ req: { user } }) => { + if (user) { + return true + } + + return { + _status: { + equals: 'published', + }, + } +} diff --git a/apps/cms/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts b/apps/cms/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts new file mode 100644 index 0000000..c73e1ea --- /dev/null +++ b/apps/cms/src/app/(frontend)/(sitemaps)/pages-sitemap.xml/route.ts @@ -0,0 +1,68 @@ +import { getServerSideSitemap } from 'next-sitemap' +import { getPayload } from 'payload' +import config from '@payload-config' +import { unstable_cache } from 'next/cache' + +const getPagesSitemap = unstable_cache( + async () => { + const payload = await getPayload({ config }) + const SITE_URL = + process.env.NEXT_PUBLIC_SERVER_URL || + process.env.VERCEL_PROJECT_PRODUCTION_URL || + 'https://example.com' + + const results = await payload.find({ + collection: 'pages', + overrideAccess: false, + draft: false, + depth: 0, + limit: 1000, + pagination: false, + where: { + _status: { + equals: 'published', + }, + }, + select: { + slug: true, + updatedAt: true, + }, + }) + + const dateFallback = new Date().toISOString() + + const defaultSitemap = [ + { + loc: `${SITE_URL}/search`, + lastmod: dateFallback, + }, + { + loc: `${SITE_URL}/posts`, + lastmod: dateFallback, + }, + ] + + const sitemap = results.docs + ? results.docs + .filter((page) => Boolean(page?.slug)) + .map((page) => { + return { + loc: page?.slug === 'home' ? `${SITE_URL}/` : `${SITE_URL}/${page?.slug}`, + lastmod: page.updatedAt || dateFallback, + } + }) + : [] + + return [...defaultSitemap, ...sitemap] + }, + ['pages-sitemap'], + { + tags: ['pages-sitemap'], + }, +) + +export async function GET() { + const sitemap = await getPagesSitemap() + + return getServerSideSitemap(sitemap) +} diff --git a/apps/cms/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts b/apps/cms/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts new file mode 100644 index 0000000..0716abb --- /dev/null +++ b/apps/cms/src/app/(frontend)/(sitemaps)/posts-sitemap.xml/route.ts @@ -0,0 +1,55 @@ +import { getServerSideSitemap } from 'next-sitemap' +import { getPayload } from 'payload' +import config from '@payload-config' +import { unstable_cache } from 'next/cache' + +const getPostsSitemap = unstable_cache( + async () => { + const payload = await getPayload({ config }) + const SITE_URL = + process.env.NEXT_PUBLIC_SERVER_URL || + process.env.VERCEL_PROJECT_PRODUCTION_URL || + 'https://example.com' + + const results = await payload.find({ + collection: 'posts', + overrideAccess: false, + draft: false, + depth: 0, + limit: 1000, + pagination: false, + where: { + _status: { + equals: 'published', + }, + }, + select: { + slug: true, + updatedAt: true, + }, + }) + + const dateFallback = new Date().toISOString() + + const sitemap = results.docs + ? results.docs + .filter((post) => Boolean(post?.slug)) + .map((post) => ({ + loc: `${SITE_URL}/posts/${post?.slug}`, + lastmod: post.updatedAt || dateFallback, + })) + : [] + + return sitemap + }, + ['posts-sitemap'], + { + tags: ['posts-sitemap'], + }, +) + +export async function GET() { + const sitemap = await getPostsSitemap() + + return getServerSideSitemap(sitemap) +} diff --git a/apps/cms/src/app/(frontend)/[slug]/page.client.tsx b/apps/cms/src/app/(frontend)/[slug]/page.client.tsx new file mode 100644 index 0000000..2d52669 --- /dev/null +++ b/apps/cms/src/app/(frontend)/[slug]/page.client.tsx @@ -0,0 +1,15 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import React, { useEffect } from 'react' + +const PageClient: React.FC = () => { + /* Force the header to be dark mode while we have an image behind it */ + const { setHeaderTheme } = useHeaderTheme() + + useEffect(() => { + setHeaderTheme('light') + }, [setHeaderTheme]) + return +} + +export default PageClient diff --git a/apps/cms/src/app/(frontend)/[slug]/page.tsx b/apps/cms/src/app/(frontend)/[slug]/page.tsx new file mode 100644 index 0000000..d42fa4b --- /dev/null +++ b/apps/cms/src/app/(frontend)/[slug]/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from 'next' + +import { PayloadRedirects } from '@/components/PayloadRedirects' +import configPromise from '@payload-config' +import { getPayload, type RequiredDataFromCollectionSlug } from 'payload' +import { draftMode } from 'next/headers' +import React, { cache } from 'react' +import { RenderBlocks } from '@/blocks/RenderBlocks' +import { RenderHero } from '@/heros/RenderHero' +import { generateMeta } from '@/utilities/generateMeta' +import PageClient from './page.client' +import { LivePreviewListener } from '@/components/LivePreviewListener' + +export const dynamic = 'force-dynamic' + +export async function generateStaticParams() { + return [] +} + +type Args = { + params: Promise<{ + slug?: string + }> +} + +export default async function Page({ params: paramsPromise }: Args) { + const { isEnabled: draft } = await draftMode() + const { slug = 'home' } = await paramsPromise + // Decode to support slugs with special characters + const decodedSlug = decodeURIComponent(slug) + const url = '/' + decodedSlug + let page: RequiredDataFromCollectionSlug<'pages'> | null + + page = await queryPageBySlug({ + slug: decodedSlug, + }) + + if (!page) { + return + } + + const { hero, layout } = page + + return ( +
+ + {/* Allows redirects for valid pages too */} + + + {draft && } + + + +
+ ) +} + +export async function generateMetadata({ params: paramsPromise }: Args): Promise { + const { slug = 'home' } = await paramsPromise + // Decode to support slugs with special characters + const decodedSlug = decodeURIComponent(slug) + const page = await queryPageBySlug({ + slug: decodedSlug, + }) + + return generateMeta({ doc: page }) +} + +const queryPageBySlug = cache(async ({ slug }: { slug: string }) => { + const { isEnabled: draft } = await draftMode() + + const payload = await getPayload({ config: configPromise }) + + const result = await payload.find({ + collection: 'pages', + draft, + limit: 1, + pagination: false, + overrideAccess: draft, + where: { + slug: { + equals: slug, + }, + }, + }) + + return result.docs?.[0] || null +}) diff --git a/apps/cms/src/app/(frontend)/globals.css b/apps/cms/src/app/(frontend)/globals.css new file mode 100644 index 0000000..eca271c --- /dev/null +++ b/apps/cms/src/app/(frontend)/globals.css @@ -0,0 +1,224 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@config '../../../tailwind.config.mjs'; + +@custom-variant dark (&:is([data-theme='dark'] *)); +@custom-variant sm (@media (width >= theme(--breakpoint-sm))); +@custom-variant md (@media (width >= theme(--breakpoint-md))); +@custom-variant lg (@media (width >= theme(--breakpoint-lg))); +@custom-variant xl (@media (width >= theme(--breakpoint-xl))); +@custom-variant 2xl (@media (width >= theme(--breakpoint-2xl))); + +@layer base { + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: unset; + font-size: unset; + } +} + +@plugin "@tailwindcss/typography"; + +@source inline("lg:col-span-4"); +@source inline("lg:col-span-6"); +@source inline("lg:col-span-8"); +@source inline("lg:col-span-12"); +@source inline("border-border"); +@source inline("bg-card"); +@source inline("border-error"); +@source inline("bg-error/30"); +@source inline("border-success"); +@source inline("bg-success/30"); +@source inline("border-warning"); +@source inline("bg-warning/30"); + +@theme { + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 86rem; + --font-mono: var(--font-geist-mono); + --font-sans: var(--font-geist-sans); +} + +@layer utilities { + .container { + width: 100%; + margin-inline: auto; + padding-inline: 1rem; + } + + @variant sm { + .container { + max-width: var(--breakpoint-sm); + } + } + + @variant md { + .container { + max-width: var(--breakpoint-md); + padding-inline: 2rem; + } + } + + @variant lg { + .container { + max-width: var(--breakpoint-lg); + } + } + + @variant xl { + .container { + max-width: var(--breakpoint-xl); + } + } + + @variant 2xl { + .container { + max-width: var(--breakpoint-2xl); + } + } +} + +:root { + --background: oklch(100% 0 0deg); + --foreground: oklch(14.5% 0 0deg); + --card: oklch(96.5% 0.005 265deg); + --card-foreground: oklch(14.5% 0 0deg); + --popover: oklch(100% 0 0deg); + --popover-foreground: oklch(14.5% 0 0deg); + --primary: oklch(20.5% 0 0deg); + --primary-foreground: oklch(98.5% 0 0deg); + --secondary: oklch(97% 0 0deg); + --secondary-foreground: oklch(20.5% 0 0deg); + --muted: oklch(97% 0 0deg); + --muted-foreground: oklch(55.6% 0 0deg); + --accent: oklch(97% 0 0deg); + --accent-foreground: oklch(20.5% 0 0deg); + --destructive: oklch(57.7% 0.245 27.325deg); + --destructive-foreground: oklch(57.7% 0.245 27.325deg); + --border: oklch(92.2% 0 0deg); + --input: oklch(92.2% 0 0deg); + --ring: oklch(70.8% 0 0deg); + --chart-1: oklch(64.6% 0.222 41.116deg); + --chart-2: oklch(60% 0.118 184.704deg); + --chart-3: oklch(39.8% 0.07 227.392deg); + --chart-4: oklch(82.8% 0.189 84.429deg); + --chart-5: oklch(76.9% 0.188 70.08deg); + --radius: 0.625rem; + --sidebar: oklch(98.5% 0 0deg); + --sidebar-foreground: oklch(14.5% 0 0deg); + --sidebar-primary: oklch(20.5% 0 0deg); + --sidebar-primary-foreground: oklch(98.5% 0 0deg); + --sidebar-accent: oklch(97% 0 0deg); + --sidebar-accent-foreground: oklch(20.5% 0 0deg); + --sidebar-border: oklch(92.2% 0 0deg); + --sidebar-ring: oklch(70.8% 0 0deg); + --success: oklch(78% 0.08 200deg); + --warning: oklch(89% 0.1 75deg); + --error: oklch(75% 0.15 25deg); +} + +[data-theme='dark'] { + --background: oklch(14.5% 0 0deg); + --foreground: oklch(98.5% 0 0deg); + --card: oklch(17% 0 0deg); + --card-foreground: oklch(98.5% 0 0deg); + --popover: oklch(14.5% 0 0deg); + --popover-foreground: oklch(98.5% 0 0deg); + --primary: oklch(98.5% 0 0deg); + --primary-foreground: oklch(20.5% 0 0deg); + --secondary: oklch(26.9% 0 0deg); + --secondary-foreground: oklch(98.5% 0 0deg); + --muted: oklch(26.9% 0 0deg); + --muted-foreground: oklch(70.8% 0 0deg); + --accent: oklch(26.9% 0 0deg); + --accent-foreground: oklch(98.5% 0 0deg); + --destructive: oklch(39.6% 0.141 25.723deg); + --destructive-foreground: oklch(63.7% 0.237 25.331deg); + --border: oklch(26.9% 0 0deg); + --input: oklch(26.9% 0 0deg); + --ring: oklch(43.9% 0 0deg); + --chart-1: oklch(48.8% 0.243 264.376deg); + --chart-2: oklch(69.6% 0.17 162.48deg); + --chart-3: oklch(76.9% 0.188 70.08deg); + --chart-4: oklch(62.7% 0.265 303.9deg); + --chart-5: oklch(64.5% 0.246 16.439deg); + --sidebar: oklch(20.5% 0 0deg); + --sidebar-foreground: oklch(98.5% 0 0deg); + --sidebar-primary: oklch(48.8% 0.243 264.376deg); + --sidebar-primary-foreground: oklch(98.5% 0 0deg); + --sidebar-accent: oklch(26.9% 0 0deg); + --sidebar-accent-foreground: oklch(98.5% 0 0deg); + --sidebar-border: oklch(26.9% 0 0deg); + --sidebar-ring: oklch(43.9% 0 0deg); + --success: oklch(28% 0.1 200deg); + --warning: oklch(35% 0.08 70deg); + --error: oklch(45% 0.1 25deg); +} + +@theme inline { + --color-background: var(--background); + --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-destructive-foreground: var(--destructive-foreground); + --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); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --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); + --color-success: var(--success); + --color-warning: var(--warning); + --color-error: var(--error); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground min-h-[100vh] flex flex-col; + } +} + +html { + opacity: 0; +} + +html[data-theme='dark'], +html[data-theme='light'] { + opacity: initial; +} diff --git a/apps/cms/src/app/(frontend)/layout.tsx b/apps/cms/src/app/(frontend)/layout.tsx new file mode 100644 index 0000000..2597b49 --- /dev/null +++ b/apps/cms/src/app/(frontend)/layout.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next' + +import { cn } from '@/utilities/ui' +import { GeistMono } from 'geist/font/mono' +import { GeistSans } from 'geist/font/sans' +import React from 'react' + +import { AdminBar } from '@/components/AdminBar' +import { Footer } from '@/Footer/Component' +import { Header } from '@/Header/Component' +import { Providers } from '@/providers' +import { mergeOpenGraph } from '@/utilities/mergeOpenGraph' +import { draftMode } from 'next/headers' + +import './globals.css' +import { getServerSideURL } from '@/utilities/getURL' + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const { isEnabled } = await draftMode() + + return ( + + + + + + + + + +
+ {children} +