CrossCode journal
+{post.title}
+{post.excerpt}
} +diff --git a/apps/web/.env.example b/apps/web/.env.example index 4f33046..e98edf0 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -12,3 +12,11 @@ SMTP_FROM_NAME=CrossCode BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 + +# Public site URL (used for sitemap.xml and robots.txt) +NEXT_PUBLIC_APP_URL=https://crosscode.site + +# Sanity CMS +NEXT_PUBLIC_SANITY_PROJECT_ID= +NEXT_PUBLIC_SANITY_DATASET=production +SANITY_API_READ_TOKEN= diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index c85fb67..25867b6 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -1,16 +1,4 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import nextTypeScript from "eslint-config-next/typescript"; -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"), -]; - -export default eslintConfig; +export default [...nextCoreWebVitals, ...nextTypeScript]; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 511b4d0..f38939a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -5,6 +5,9 @@ const nextConfig: NextConfig = { transpilePackages: ["@crosscode/shared"], output: "standalone", pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"], + images: { + remotePatterns: [{ protocol: "https", hostname: "cdn.sanity.io" }], + }, }; const withMDX = createMDX({}); diff --git a/apps/web/package.json b/apps/web/package.json index f7b0c93..b72a8f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "clean": "rm -rf .next", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", @@ -17,6 +17,9 @@ "@crosscode/shared": "workspace:^", "@mdx-js/loader": "^3.1.1", "@next/mdx": "^16.2.12", + "@portabletext/react": "^8.0.0", + "@sanity/image-url": "^2.1.1", + "@sanity/vision": "^6.9.2", "@tailwindcss/postcss": "^4.1.11", "@types/mdx": "^2.0.14", "@types/qrcode": "^1.5.6", @@ -26,6 +29,7 @@ "drizzle-orm": "^0.45.2", "lucide-react": "^0.525.0", "next": "^16.2.12", + "next-sanity": "^13.3.3", "next-themes": "^0.4.6", "nodemailer": "^9.0.3", "postgres": "^3.4.9", @@ -34,6 +38,8 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "remark-gfm": "^4.0.1", + "sanity": "^6.9.2", + "styled-components": "^6.5.3", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11" }, diff --git a/apps/web/sanity.cli.ts b/apps/web/sanity.cli.ts new file mode 100644 index 0000000..25b40e1 --- /dev/null +++ b/apps/web/sanity.cli.ts @@ -0,0 +1,8 @@ +import { defineCliConfig } from "sanity/cli"; + +export default defineCliConfig({ + api: { + projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, + dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production", + }, +}); diff --git a/apps/web/sanity.config.ts b/apps/web/sanity.config.ts new file mode 100644 index 0000000..9251e55 --- /dev/null +++ b/apps/web/sanity.config.ts @@ -0,0 +1,31 @@ +"use client"; + +import { defineConfig } from "sanity"; +import { structureTool } from "sanity/structure"; +import { presentationTool } from "sanity/presentation"; +import { visionTool } from "@sanity/vision"; +import { schemaTypes } from "./src/sanity/schemaTypes"; +import { resolve } from "./src/sanity/presentation/resolve"; + +export default defineConfig({ + name: "default", + title: "CrossCode Blog", + basePath: "/studio", + projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || "", + dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production", + plugins: [ + structureTool(), + presentationTool({ + resolve, + previewUrl: { + previewMode: { + enable: "/api/draft-mode/enable", + }, + }, + }), + visionTool(), + ], + schema: { + types: schemaTypes, + }, +}); diff --git a/apps/web/src/app/api/draft-mode/disable/route.ts b/apps/web/src/app/api/draft-mode/disable/route.ts new file mode 100644 index 0000000..3f96f48 --- /dev/null +++ b/apps/web/src/app/api/draft-mode/disable/route.ts @@ -0,0 +1,7 @@ +import { draftMode } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + (await draftMode()).disable(); + return NextResponse.redirect(new URL("/blog", "http://localhost:3000")); +} diff --git a/apps/web/src/app/api/draft-mode/enable/route.ts b/apps/web/src/app/api/draft-mode/enable/route.ts new file mode 100644 index 0000000..1111ded --- /dev/null +++ b/apps/web/src/app/api/draft-mode/enable/route.ts @@ -0,0 +1,6 @@ +import { defineEnableDraftMode } from "next-sanity/draft-mode"; +import { client } from "@/sanity/lib/client"; + +export const { GET } = defineEnableDraftMode({ + client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN || "" }), +}); diff --git a/apps/web/src/app/blog/[id]/page.tsx b/apps/web/src/app/blog/[id]/page.tsx new file mode 100644 index 0000000..8ad391f --- /dev/null +++ b/apps/web/src/app/blog/[id]/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import { notFound } from "next/navigation"; +import { PortableText, type PortableTextComponents } from "@portabletext/react"; +import { sanityFetch } from "@/sanity/lib/live"; +import { POST_QUERY } from "@/sanity/lib/queries"; + +type Props = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +type Post = { + title: string; + slug: string; + excerpt?: string; + publishedAt?: string; + body?: unknown[]; + coverImage?: string; + coverImageAlt?: string; + author?: { name?: string; role?: string }; + tags?: string[]; +}; + +const portableTextComponents: PortableTextComponents = { + block: { + normal: ({ children }) =>
{children}
, + h2: ({ children }) =>{children}, + }, + marks: { + link: ({ children, value }) => ( + + {children} + + ), + }, +}; + +function formatDate(value?: string) { + if (!value) return ""; + return new Intl.DateTimeFormat("en", { dateStyle: "long" }).format(new Date(value)); +} + +export async function generateMetadata({ params }: Props): Promise
CrossCode journal
+{post.excerpt}
} +CrossCode journal
++ Product updates, practical guides, and notes on remote development with AI coding agents. +
+{formatDate(post.publishedAt)}
+{post.excerpt}
} + {post.author?.name &&By {post.author.name}
} +Create and publish a post in the Sanity Studio to see it here.
+ + Open Studio + +