Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
18 changes: 3 additions & 15 deletions apps/web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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];
3 changes: 3 additions & 0 deletions apps/web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
8 changes: 7 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"
},
Expand Down
8 changes: 8 additions & 0 deletions apps/web/sanity.cli.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
31 changes: 31 additions & 0 deletions apps/web/sanity.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
7 changes: 7 additions & 0 deletions apps/web/src/app/api/draft-mode/disable/route.ts
Original file line number Diff line number Diff line change
@@ -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"));
}
6 changes: 6 additions & 0 deletions apps/web/src/app/api/draft-mode/enable/route.ts
Original file line number Diff line number Diff line change
@@ -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 || "" }),
});
86 changes: 86 additions & 0 deletions apps/web/src/app/blog/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <p className="mb-6 leading-8 text-muted-foreground">{children}</p>,
h2: ({ children }) => <h2 className="mb-4 mt-12 text-3xl font-semibold tracking-tight text-foreground">{children}</h2>,
h3: ({ children }) => <h3 className="mb-3 mt-10 text-2xl font-semibold text-foreground">{children}</h3>,
blockquote: ({ children }) => <blockquote className="my-8 border-l-4 border-primary pl-6 italic text-muted-foreground">{children}</blockquote>,
},
marks: {
link: ({ children, value }) => (
<a href={value?.href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-4">
{children}
</a>
),
},
};

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<Metadata> {
const { id } = await params;
const { data } = await sanityFetch({ query: POST_QUERY, params: { slug: id }, stega: false });
const post = data as Post | null;

return {
title: post ? `${post.title} | CrossCode` : "Blog | CrossCode",
description: post?.excerpt || "CrossCode blog.",
};
}

export default async function BlogPostPage({ params }: Props) {
const { id } = await params;
const { data } = await sanityFetch({ query: POST_QUERY, params: { slug: id } });
const post = data as Post | null;

if (!post) notFound();

return (
<article className="container py-16 md:py-24">
<div className="mx-auto max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary">CrossCode journal</p>
<h1 className="mt-5 text-4xl font-bold tracking-tight text-foreground md:text-6xl">{post.title}</h1>
<div className="mt-6 flex flex-wrap gap-x-4 gap-y-2 text-sm text-muted-foreground">
{post.publishedAt && <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>}
{post.author?.name && <span>By {post.author.name}</span>}
</div>
{post.excerpt && <p className="mt-8 text-xl leading-9 text-muted-foreground">{post.excerpt}</p>}
</div>

{post.coverImage && (
<div className="relative mx-auto mt-12 aspect-[2/1] max-w-5xl overflow-hidden rounded-2xl bg-muted">
<Image src={post.coverImage} alt={post.coverImageAlt || post.title} fill priority className="object-cover" />
</div>
)}

<div className="prose prose-neutral dark:prose-invert mx-auto mt-14 max-w-3xl">
<PortableText value={post.body || []} components={portableTextComponents} />
</div>
</article>
);
}
12 changes: 12 additions & 0 deletions apps/web/src/app/blog/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Footer } from "@/components/landing/footer";
import { Navbar } from "@/components/landing/navbar";

export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<Navbar />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
82 changes: 82 additions & 0 deletions apps/web/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { sanityFetch } from "@/sanity/lib/live";
import { POSTS_QUERY } from "@/sanity/lib/queries";

export const metadata: Metadata = {
title: "Blog | CrossCode",
description: "Updates, guides, and engineering notes from CrossCode.",
};

export const dynamic = "force-dynamic";

type Post = {
_id: string;
title: string;
slug: string;
excerpt?: string;
publishedAt?: string;
coverImage?: string;
coverImageAlt?: string;
author?: { name?: string };
};

function formatDate(value?: string) {
if (!value) return "";
return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(new Date(value));
}

export default async function BlogPage() {
const { data } = await sanityFetch({ query: POSTS_QUERY });
const posts = (data || []) as Post[];

return (
<section className="container py-16 md:py-24">
<div className="mx-auto max-w-3xl">
<p className="mb-4 text-sm font-semibold uppercase tracking-[0.2em] text-primary">CrossCode journal</p>
<h1 className="text-4xl font-bold tracking-tight text-foreground md:text-6xl">Ideas for building from anywhere.</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
Product updates, practical guides, and notes on remote development with AI coding agents.
</p>
</div>

{posts.length > 0 ? (
<div className="mx-auto mt-16 grid max-w-6xl gap-8 md:grid-cols-2">
{posts.map((post) => (
<Link
key={post._id}
href={`/blog/${post.slug}`}
className="group overflow-hidden rounded-2xl border bg-card transition-colors hover:border-primary/50"
>
{post.coverImage && (
<div className="relative aspect-[16/9] overflow-hidden bg-muted">
<Image
src={post.coverImage}
alt={post.coverImageAlt || post.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
)}
<div className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{formatDate(post.publishedAt)}</p>
<h2 className="text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary">{post.title}</h2>
{post.excerpt && <p className="leading-7 text-muted-foreground">{post.excerpt}</p>}
{post.author?.name && <p className="text-sm text-muted-foreground">By {post.author.name}</p>}
</div>
</Link>
))}
</div>
) : (
<div className="mx-auto mt-16 max-w-2xl rounded-2xl border border-dashed p-12 text-center">
<h2 className="text-xl font-semibold text-foreground">The first post is on its way.</h2>
<p className="mt-2 text-muted-foreground">Create and publish a post in the Sanity Studio to see it here.</p>
<Link href="/studio" className="mt-6 inline-flex text-sm font-semibold text-primary hover:underline">
Open Studio
</Link>
</div>
)}
</section>
);
}
16 changes: 16 additions & 0 deletions apps/web/src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};

export default function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
}
15 changes: 14 additions & 1 deletion apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Metadata } from "next";
import { Manrope, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { DisableDraftMode } from "@/components/disable-draft-mode";
import { SanityLive } from "@/sanity/lib/live";
import { VisualEditing } from "next-sanity/visual-editing";
import { draftMode } from "next/headers";
import "./globals.css";

const manrope = Manrope({
Expand All @@ -24,16 +28,25 @@ export const metadata: Metadata = {
},
};

export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const isDraftMode = (await draftMode()).isEnabled;

return (
<html lang="en" suppressHydrationWarning>
<body className={`${manrope.variable} ${jetbrainsMono.variable} antialiased`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
<SanityLive />
{isDraftMode && (
<>
<VisualEditing />
<DisableDraftMode />
</>
)}
</ThemeProvider>
</body>
</html>
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/app/login/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Metadata } from "next";

export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};

export default function LoginLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
}
Loading
Loading