diff --git a/solutions/platforms-blog-neon/.env.example b/solutions/platforms-blog-neon/.env.example new file mode 100644 index 0000000000..811b01e9cf --- /dev/null +++ b/solutions/platforms-blog-neon/.env.example @@ -0,0 +1,36 @@ +# --- Neon (from the Neon Console for your project) --- + +# Neon Auth service URL. +# Neon Console -> your project -> Auth -> Configuration -> copy the URL. +# Example: https://ep-cool-name-123456.neonauth.c-12.us-east-1.aws.neon.tech/neondb/auth +NEXT_PUBLIC_NEON_AUTH_URL= + +# Neon Data API URL (PostgREST-compatible). Powers the signed-in dashboard. +# Neon Console -> your project -> Postgres database -> Data API -> copy the API URL. +# Example: https://ep-cool-name-123456.apirest.c-12.us-east-1.aws.neon.tech/neondb/rest/v1 +NEXT_PUBLIC_NEON_DATA_API_URL= + +# Direct Postgres connection string. Used on the server to render public tenant +# blogs, and to apply db/schema.sql. Never exposed to the browser. +DATABASE_URL= + +# JWKS endpoint for verifying Neon Auth JWTs on the server (the image upload +# route checks the caller's token against this). +# Usually your auth URL + /.well-known/jwks.json +NEON_JWKS_URL= + +# --- Neon Object Storage (S3-compatible) for cover-image uploads --- +# Neon Console -> your project -> Object Storage. Server-only credentials. +AWS_ENDPOINT_URL_S3= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-2 +AWS_S3_BUCKET= + +# --- App --- + +# The root domain tenants live under. Locally, tenant subdomains resolve +# automatically (e.g. acme.localhost:3000). In production, set this to your +# domain (e.g. yourapp.com) and point a wildcard DNS record (*.yourapp.com) at +# Vercel. +NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000 diff --git a/solutions/platforms-blog-neon/.eslintrc.json b/solutions/platforms-blog-neon/.eslintrc.json new file mode 100644 index 0000000000..a2569c2c7c --- /dev/null +++ b/solutions/platforms-blog-neon/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": "next/core-web-vitals" +} diff --git a/solutions/platforms-blog-neon/.gitignore b/solutions/platforms-blog-neon/.gitignore new file mode 100644 index 0000000000..75728bc85d --- /dev/null +++ b/solutions/platforms-blog-neon/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +/node_modules +/.pnp +.pnp.js + +# Testing +/coverage + +# Next.js +/.next/ +/out/ +next-env.d.ts + +# Production +build +dist + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local ENV files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Vercel +.vercel + +# Turborepo +.turbo + +# typescript +*.tsbuildinfo +.env* +!.env.example diff --git a/solutions/platforms-blog-neon/README.md b/solutions/platforms-blog-neon/README.md new file mode 100644 index 0000000000..0bf56a47f7 --- /dev/null +++ b/solutions/platforms-blog-neon/README.md @@ -0,0 +1,88 @@ +--- +name: Multi-tenant blog platform with Neon +slug: platforms-blog-neon +description: A Platforms-style multi-tenant blog where each tenant gets a subdomain, powered end to end by Neon Postgres, Auth, Data API, and Object Storage. +framework: Next.js +useCase: Starter +css: Tailwind +deployUrl: https://vercel.com/new/clone?repository-url=https://github.com/vercel/examples/tree/main/solutions/platforms-blog-neon&project-name=platforms-blog-neon&repo-name=platforms-blog-neon&env=NEXT_PUBLIC_NEON_AUTH_URL,NEXT_PUBLIC_NEON_DATA_API_URL,DATABASE_URL,NEON_JWKS_URL,NEXT_PUBLIC_ROOT_DOMAIN,AWS_ENDPOINT_URL_S3,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,AWS_S3_BUCKET +demoUrl: https://platforms-blog-neon.vercel.app +relatedTemplates: + - platforms-starter-kit + - domains-api +--- + +# Multi-tenant blog platform with Neon + +A [Platforms](https://vercel.com/docs/platforms)-style, multi-tenant app with [Neon](https://neon.com) powering the entire backend. A user signs up, creates a site, and that site gets its own subdomain (`acme.yourapp.com`) serving a public blog. It runs entirely on Neon and Vercel: + +- **Serverless Postgres** stores the sites and posts. +- **Neon Auth** handles sign in, sign up, and sessions. +- **Data API** is the PostgREST-compatible query layer the dashboard uses. +- **Neon Object Storage** holds post cover images over an S3-compatible API. +- **Vercel** as the deployment and hosting platform. + +## Demo + +[https://platforms-blog-neon.vercel.app/](https://platforms-blog-neon.vercel.app/) + +## Environment variables + +Copy `.env.example` to `.env.local` and set these (all come from the Neon +Console): + +| Variable | Scope | Purpose | +| -------------------------------------------- | ------- | -------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_NEON_AUTH_URL` | browser | Neon Auth service URL | +| `NEXT_PUBLIC_NEON_DATA_API_URL` | browser | Data API (PostgREST) URL for the dashboard | +| `NEXT_PUBLIC_ROOT_DOMAIN` | browser | Domain tenants live under (`localhost:3000` locally, your domain in production) | +| `DATABASE_URL` | server | Direct connection, used to render public blogs and to apply `db/schema.sql` | +| `NEON_JWKS_URL` | server | Verifies the JWT on image uploads (usually the auth URL plus `/.well-known/jwks.json`) | +| `AWS_ENDPOINT_URL_S3` | server | Neon Object Storage endpoint | +| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | server | Object Storage credentials | +| `AWS_REGION` | server | Object Storage region, e.g. `us-east-2` | +| `AWS_S3_BUCKET` | server | Bucket that holds cover images | + +## Deploy your own + +### One-Click Deploy + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/examples/tree/main/solutions/platforms-blog-neon&project-name=platforms-blog-neon&repo-name=platforms-blog-neon&env=NEXT_PUBLIC_NEON_AUTH_URL,NEXT_PUBLIC_NEON_DATA_API_URL,DATABASE_URL,NEON_JWKS_URL,NEXT_PUBLIC_ROOT_DOMAIN,AWS_ENDPOINT_URL_S3,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,AWS_S3_BUCKET) + +Point a wildcard DNS record (`*.yourapp.com`) at Vercel so tenant subdomains +resolve. + +### Clone and run locally + +```bash +pnpm create next-app --example https://github.com/vercel/examples/tree/main/solutions/platforms-blog-neon platforms-blog-neon +``` + +1. In the [Neon Console](https://console.neon.tech), create a project and enable **Neon Auth**, the **Data API**, and **Object Storage** (create a bucket). +2. Copy `.env.example` to `.env.local` and fill in the values (all are shown in the Neon Console). Locally, leave `NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000` so tenant subdomains like `acme.localhost:3000` resolve automatically. +3. Apply the schema once: + + ```bash + psql "$DATABASE_URL" -f db/schema.sql + ``` + + (or paste [`db/schema.sql`](./db/schema.sql) into the Neon SQL Editor). + +4. Start the app and open http://localhost:3000: + + ```bash + pnpm dev + ``` + + Create a site, write a post, publish it, then visit `http://.localhost:3000`. + +## A branch per preview deployment + +Connect your project with the [Neon integration on Vercel](https://neon.com/docs/guides/vercel-overview) and every preview deployment is wired to its own **Neon branch**, a copy-on-write clone of the database, its auth and storage, so each pull request gets an isolated backend to test against. + +## Built on open source + +- [Next.js](https://nextjs.org) (App Router) as the React framework +- [Tailwind](https://tailwindcss.com/) for CSS styling +- [Neon](https://neon.com) for Postgres, Auth, the Data API, and Object Storage +- [Vercel](http://vercel.com/) for deployment diff --git a/solutions/platforms-blog-neon/app/api/file/route.ts b/solutions/platforms-blog-neon/app/api/file/route.ts new file mode 100644 index 0000000000..4ec0172394 --- /dev/null +++ b/solutions/platforms-blog-neon/app/api/file/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { getUserIdFromRequest } from '@/lib/auth-server' +import { isPublishedImageKey } from '@/lib/db' +import { isRemoteUrl, isUserObjectKey } from '@/lib/images' +import { presignGet } from '@/lib/storage' + +// Serves a stored image via a short-lived presigned URL. +// Published covers/author photos: allowed after a DB lookup (owner RLS only +// sees published posts). Drafts: allowed only if the JWT `sub` matches the +// `posts//` prefix written at upload time. Unknown or remote keys 404. +export async function GET(request: Request) { + const url = new URL(request.url) + const key = url.searchParams.get('key') + if (!key || isRemoteUrl(key) || key.includes('..') || key.startsWith('/')) { + return NextResponse.json({ error: 'not found' }, { status: 404 }) + } + const published = await isPublishedImageKey(key) + if (!published) { + const userId = await getUserIdFromRequest(request) + if (!userId || !isUserObjectKey(key, userId)) + return NextResponse.json({ error: 'not found' }, { status: 404 }) + } + const signed = await presignGet(key) + if (url.searchParams.get('format') === 'json') + return NextResponse.json({ url: signed }) + return NextResponse.redirect(signed, { + status: 302, + headers: { 'cache-control': 'private, max-age=1800' }, + }) +} diff --git a/solutions/platforms-blog-neon/app/api/upload/route.ts b/solutions/platforms-blog-neon/app/api/upload/route.ts new file mode 100644 index 0000000000..59cdda9c4c --- /dev/null +++ b/solutions/platforms-blog-neon/app/api/upload/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { getUserIdFromRequest } from '@/lib/auth-server' +import { putObject } from '@/lib/storage' + +const MAX_BYTES = 5 * 1024 * 1024 // 5 MB +const EXT: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', + 'image/gif': 'gif', +} + +export async function POST(request: Request) { + // Only signed-in users may upload. The client sends its Neon Auth JWT, which + // we verify against the project's JWKS. + const userId = await getUserIdFromRequest(request) + if (!userId) + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + const form = await request.formData() + const file = form.get('file') + if (!(file instanceof File)) + return NextResponse.json({ error: 'no file' }, { status: 400 }) + const ext = EXT[file.type] + if (!ext) + return NextResponse.json( + { error: 'unsupported image type' }, + { status: 400 } + ) + if (file.size > MAX_BYTES) + return NextResponse.json({ error: 'file too large' }, { status: 413 }) + // Namespaced by user id so keys never collide across tenants. + const key = `posts/${userId}/${crypto.randomUUID()}.${ext}` + await putObject(key, await file.arrayBuffer(), file.type) + return NextResponse.json({ key }) +} diff --git a/solutions/platforms-blog-neon/app/dashboard/[subdomain]/page.tsx b/solutions/platforms-blog-neon/app/dashboard/[subdomain]/page.tsx new file mode 100644 index 0000000000..3723fa6c28 --- /dev/null +++ b/solutions/platforms-blog-neon/app/dashboard/[subdomain]/page.tsx @@ -0,0 +1,98 @@ +'use client' + +import Link from 'next/link' +import { useParams, useRouter } from 'next/navigation' +import { useCallback, useEffect, useState } from 'react' +import { Button, Page, Text, Link as UILink } from '@vercel/examples-ui' +import { AuthorManager } from '@/components/author-manager' +import { PostManager } from '@/components/post-manager' +import { neon, type Author, type Site } from '@/lib/neon' +import { useSession } from '@/lib/session' + +const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'localhost:3000' + +export default function ManageSitePage() { + const router = useRouter() + const params = useParams<{ subdomain: string }>() + const { user, loading } = useSession() + const [site, setSite] = useState(null) + const [authors, setAuthors] = useState([]) + const [notFound, setNotFound] = useState(false) + + const loadSite = useCallback(async () => { + // RLS only returns the site if the signed-in user owns it. + const { data } = await neon + .from('sites') + .select('*') + .eq('subdomain', params.subdomain) + .limit(1) + const found = (data as Site[])?.[0] ?? null + setSite(found) + setNotFound(!found) + if (!found) return + const { data: authorRows } = await neon + .from('authors') + .select('*') + .eq('site_id', found.id) + .order('created_at', { ascending: true }) + setAuthors((authorRows as Author[]) ?? []) + }, [params.subdomain]) + + useEffect(() => { + if (!loading && !user) router.replace('/login') + }, [loading, user, router]) + + useEffect(() => { + if (user) loadSite() + }, [user, loadSite]) + + if (loading || !user) { + return ( + + Loading… + + ) + } + + if (notFound) { + return ( + + Site not found + You do not have a site with that subdomain. + + + + + ) + } + + if (!site) { + return ( + + Loading… + + ) + } + + return ( + +
+
+ {site.name} + + {site.subdomain}.{ROOT_DOMAIN} ↗ + +
+ + + +
+ + + +
+ ) +} diff --git a/solutions/platforms-blog-neon/app/dashboard/page.tsx b/solutions/platforms-blog-neon/app/dashboard/page.tsx new file mode 100644 index 0000000000..cfa237c9c9 --- /dev/null +++ b/solutions/platforms-blog-neon/app/dashboard/page.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { Button, Page, Text } from '@vercel/examples-ui' +import { SiteList } from '@/components/site-list' +import { useSession } from '@/lib/session' + +export default function DashboardPage() { + const router = useRouter() + const { user, loading, signOut } = useSession() + + useEffect(() => { + if (!loading && !user) router.replace('/login') + }, [loading, user, router]) + + if (loading || !user) { + return ( + + Loading… + + ) + } + + return ( + +
+
+ Your sites + Signed in as {user.email} +
+ +
+ + +
+ ) +} diff --git a/solutions/platforms-blog-neon/app/layout.tsx b/solutions/platforms-blog-neon/app/layout.tsx new file mode 100644 index 0000000000..2102a4ba6e --- /dev/null +++ b/solutions/platforms-blog-neon/app/layout.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' +import { Layout, getMetadata } from '@vercel/examples-ui' +import '@vercel/examples-ui/globals.css' +import { SessionProvider } from '@/lib/session' + +export const metadata = getMetadata({ + title: 'Full-stack app with Neon', + description: + 'Multi-tenant Next.js app backed entirely by Neon: Postgres, Auth, and the Data API.', +}) + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ) +} diff --git a/solutions/platforms-blog-neon/app/login/page.tsx b/solutions/platforms-blog-neon/app/login/page.tsx new file mode 100644 index 0000000000..8d751010b6 --- /dev/null +++ b/solutions/platforms-blog-neon/app/login/page.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { Page, Text } from '@vercel/examples-ui' +import { AuthForm } from '@/components/auth-form' +import { DemoSignIn } from '@/components/demo-sign-in' +import { useSession } from '@/lib/session' + +export default function LoginPage() { + const router = useRouter() + const { user, loading } = useSession() + + useEffect(() => { + if (!loading && user) router.replace('/dashboard') + }, [loading, user, router]) + + return ( + + Welcome + Sign in with Neon Auth to manage your posts. + router.replace('/dashboard')} /> + Or use your own account + router.replace('/dashboard')} /> + + ) +} diff --git a/solutions/platforms-blog-neon/app/page.tsx b/solutions/platforms-blog-neon/app/page.tsx new file mode 100644 index 0000000000..4548b14ae4 --- /dev/null +++ b/solutions/platforms-blog-neon/app/page.tsx @@ -0,0 +1,98 @@ +import { Page, Text, Link as UILink } from '@vercel/examples-ui' +import { DEMO_SITES } from '@/lib/demo' + +const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'localhost:3000' + +export default function Home() { + return ( + +
+ A multi-tenant blog platform, powered by Neon + + Sign up, create a site, and it gets its own subdomain ( + your-site.example.com) serving a public blog. The entire + backend is Neon: serverless + Postgres for data,{' '} + Neon Auth{' '} + for sign-in, the{' '} + + Data API + {' '} + for the dashboard, and{' '} + + Neon Object Storage + {' '} + for cover images. + +
+
+ Public demo blogs + + No login needed. Each site is a tenant on its own subdomain. + + +
+
+ How the pieces fit + + The signed-in dashboard talks to the{' '} + + Data API + {' '} + with a{' '} + Neon Auth{' '} + JWT, and Row-Level Security keeps each user{"'"}s sites and posts + private to them. The server renders public tenant blogs with the{' '} + serverless driver, so every subdomain is fast and + SEO-friendly. Cover images are stored in{' '} + + Neon Object Storage + + . + +
+
+ Preview deployments get their own branch + + With the Neon integration on Vercel, each preview deployment connects + to an isolated Neon branch, a full copy of the database and its auth, + so every pull request gets a real backend to test against. + +
+
+ Try it + + Sign in or{' '} + create an account, add a site, write a + post, and publish it to see it live on the site{"'"}s subdomain. + +
+
+ ) +} diff --git a/solutions/platforms-blog-neon/app/s/[subdomain]/[id]/page.tsx b/solutions/platforms-blog-neon/app/s/[subdomain]/[id]/page.tsx new file mode 100644 index 0000000000..efb06ccbe6 --- /dev/null +++ b/solutions/platforms-blog-neon/app/s/[subdomain]/[id]/page.tsx @@ -0,0 +1,50 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { Page, Text } from '@vercel/examples-ui' +import { AuthorByline } from '@/components/author-byline' +import { Markdown } from '@/components/markdown' +import { getPublishedPost, getSiteBySubdomain } from '@/lib/db' +import { imageSrc } from '@/lib/images' + +export const dynamic = 'force-dynamic' + +export default async function TenantPost({ + params, +}: { + params: Promise<{ subdomain: string; id: string }> +}) { + const { subdomain, id } = await params + const postId = Number(id) + if (!Number.isInteger(postId) || postId <= 0) notFound() + const site = await getSiteBySubdomain(subdomain) + if (!site) notFound() + const post = await getPublishedPost(site.id, postId) + if (!post) notFound() + return ( + + + ← {site.name} + +
+ {post.title} + {post.author && ( + + )} +
+ {post.image_key && ( + // eslint-disable-next-line @next/next/no-img-element + {post.image_alt + )} + {post.content && {post.content}} +
+ ) +} diff --git a/solutions/platforms-blog-neon/app/s/[subdomain]/page.tsx b/solutions/platforms-blog-neon/app/s/[subdomain]/page.tsx new file mode 100644 index 0000000000..33a8a08073 --- /dev/null +++ b/solutions/platforms-blog-neon/app/s/[subdomain]/page.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { Page, Text } from '@vercel/examples-ui' +import { AuthorByline } from '@/components/author-byline' +import { getPublishedPosts, getSiteBySubdomain } from '@/lib/db' +import { imageSrc } from '@/lib/images' +import { excerpt } from '@/lib/markdown' + +export const dynamic = 'force-dynamic' + +export default async function TenantBlog({ + params, +}: { + params: Promise<{ subdomain: string }> +}) { + const { subdomain } = await params + const site = await getSiteBySubdomain(subdomain) + if (!site) notFound() + const posts = await getPublishedPosts(site.id) + return ( + +
+ {site.name} + {site.description && ( + {site.description} + )} +
+
+ {posts.length === 0 && ( + + No posts published yet. + + )} + {posts.map((post) => ( + + {post.image_key && ( + // eslint-disable-next-line @next/next/no-img-element + {post.image_alt + )} +
+ {post.title} + {post.author && ( + + )} + {post.content && ( + + {excerpt(post.content)} + + )} +
+ + ))} +
+
+ ) +} diff --git a/solutions/platforms-blog-neon/components/auth-form.tsx b/solutions/platforms-blog-neon/components/auth-form.tsx new file mode 100644 index 0000000000..a527e30ee9 --- /dev/null +++ b/solutions/platforms-blog-neon/components/auth-form.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useState, type FormEvent } from 'react' +import { Button, Input, Text } from '@vercel/examples-ui' +import { neon } from '@/lib/neon' +import { useSession } from '@/lib/session' + +type Mode = 'sign-in' | 'sign-up' + +export function AuthForm({ onAuthed }: { onAuthed?: () => void }) { + const { refresh } = useSession() + const [mode, setMode] = useState('sign-in') + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + async function onSubmit(e: FormEvent) { + e.preventDefault() + setError(null) + setPending(true) + + const { error } = + mode === 'sign-up' + ? await neon.auth.signUp.email({ email, password, name }) + : await neon.auth.signIn.email({ email, password }) + + setPending(false) + + if (error) { + setError(error.message ?? 'Something went wrong. Please try again.') + return + } + + await refresh() + onAuthed?.() + } + + return ( +
+ {mode === 'sign-up' && ( + setName(e.currentTarget.value)} + required + /> + )} + setEmail(e.currentTarget.value)} + required + /> + setPassword(e.currentTarget.value)} + required + /> + + {error && ( + + {error} + + )} + + + + +
+ ) +} diff --git a/solutions/platforms-blog-neon/components/authed-image.tsx b/solutions/platforms-blog-neon/components/authed-image.tsx new file mode 100644 index 0000000000..5a4d36e940 --- /dev/null +++ b/solutions/platforms-blog-neon/components/authed-image.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useEffect, useState } from 'react' +import { isRemoteUrl } from '@/lib/images' +import { getAccessToken } from '@/lib/neon' + +// Loads a dashboard cover image (a post may be a draft): fetches a presigned URL +// from /api/file with the user's token, since an can't send auth itself. +// Remote URLs (seeded demo photos) are shown as-is. +export function AuthedImage({ + imageKey, + alt = '', + className, +}: { + imageKey: string + alt?: string + className?: string +}) { + const [src, setSrc] = useState( + isRemoteUrl(imageKey) ? imageKey : null + ) + + useEffect(() => { + if (isRemoteUrl(imageKey)) { + setSrc(imageKey) + return + } + let active = true + ;(async () => { + const token = await getAccessToken() + const res = await fetch( + `/api/file?key=${encodeURIComponent(imageKey)}&format=json`, + { headers: token ? { authorization: `Bearer ${token}` } : {} } + ) + if (!res.ok) return + const { url } = await res.json() + if (active) setSrc(url) + })() + return () => { + active = false + } + }, [imageKey]) + + if (!src) return null + // eslint-disable-next-line @next/next/no-img-element + return {alt} +} diff --git a/solutions/platforms-blog-neon/components/author-byline.tsx b/solutions/platforms-blog-neon/components/author-byline.tsx new file mode 100644 index 0000000000..e8ef099a0e --- /dev/null +++ b/solutions/platforms-blog-neon/components/author-byline.tsx @@ -0,0 +1,33 @@ +'use client' + +import { AuthedImage } from '@/components/authed-image' +import { imageSrc } from '@/lib/images' + +export function AuthorByline({ + name, + imageKey, + imageAlt, + authed = false, + className = 'text-sm text-gray-500', +}: { + name: string + imageKey?: string | null + imageAlt?: string | null + authed?: boolean + className?: string +}) { + const photoClass = 'h-8 w-8 rounded-full object-cover shrink-0' + const alt = imageAlt || name + return ( +
+ {imageKey && + (authed ? ( + + ) : ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ))} + By {name} +
+ ) +} diff --git a/solutions/platforms-blog-neon/components/author-manager.tsx b/solutions/platforms-blog-neon/components/author-manager.tsx new file mode 100644 index 0000000000..ef744f7ced --- /dev/null +++ b/solutions/platforms-blog-neon/components/author-manager.tsx @@ -0,0 +1,119 @@ +'use client' + +import { useRef, useState, type FormEvent } from 'react' +import { Button, Input, Text } from '@vercel/examples-ui' +import { AuthedImage } from '@/components/authed-image' +import { neon, type Author, type Site } from '@/lib/neon' +import { uploadImage } from '@/lib/upload' + +export function AuthorManager({ + site, + authors, + onChange, +}: { + site: Site + authors: Author[] + onChange: () => Promise +}) { + const [name, setName] = useState('') + const [imageAlt, setImageAlt] = useState('') + const [file, setFile] = useState(null) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + const fileInput = useRef(null) + + async function createAuthor(e: FormEvent) { + e.preventDefault() + setError(null) + setPending(true) + try { + const image_key = file ? await uploadImage(file) : null + const { error } = await neon.from('authors').insert({ + site_id: site.id, + name, + image_key, + image_alt: image_key ? imageAlt || name : '', + }) + if (error) throw new Error(error.message) + setName('') + setImageAlt('') + setFile(null) + if (fileInput.current) fileInput.current.value = '' + await onChange() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setPending(false) + } + } + + async function remove(author: Author) { + const { error } = await neon.from('authors').delete().eq('id', author.id) + if (error) setError(error.message) + else await onChange() + } + + return ( +
+
+ Authors + + Add the people who write on this site. Each can have a photo, and you + pick one when you publish a post. + + setName(e.currentTarget.value)} + required + /> + + setImageAlt(e.currentTarget.value)} + /> + +
+ + {error && {error}} + + {authors.length === 0 ? ( + No authors yet. Add one to start posting. + ) : ( +
+ {authors.map((author) => ( +
+
+ {author.image_key && ( + + )} + {author.name} +
+ +
+ ))} +
+ )} +
+ ) +} diff --git a/solutions/platforms-blog-neon/components/demo-sign-in.tsx b/solutions/platforms-blog-neon/components/demo-sign-in.tsx new file mode 100644 index 0000000000..1234988770 --- /dev/null +++ b/solutions/platforms-blog-neon/components/demo-sign-in.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { Button, Text } from '@vercel/examples-ui' +import { DEMO_EMAIL, DEMO_PASSWORD } from '@/lib/demo' +import { neon } from '@/lib/neon' +import { useSession } from '@/lib/session' + +export function DemoSignIn({ + onAuthed, + redirectTo, +}: { + onAuthed?: () => void + redirectTo?: string +}) { + const router = useRouter() + const { refresh } = useSession() + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + + async function signInDemo() { + setError(null) + setPending(true) + const { error } = await neon.auth.signIn.email({ + email: DEMO_EMAIL, + password: DEMO_PASSWORD, + }) + setPending(false) + if (error) { + setError(error.message ?? 'Demo sign-in failed') + return + } + await refresh() + onAuthed?.() + if (redirectTo) router.push(redirectTo) + } + + return ( +
+ Demo login + + Use the shared account to browse the sample tenants, authors, and posts. + +
+ Email: {DEMO_EMAIL} + Password: {DEMO_PASSWORD} +
+ {error && {error}} + +
+ ) +} diff --git a/solutions/platforms-blog-neon/components/markdown.tsx b/solutions/platforms-blog-neon/components/markdown.tsx new file mode 100644 index 0000000000..97c1fadf1f --- /dev/null +++ b/solutions/platforms-blog-neon/components/markdown.tsx @@ -0,0 +1,12 @@ +'use client' + +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +export function Markdown({ children }: { children: string }) { + return ( +
+ {children} +
+ ) +} diff --git a/solutions/platforms-blog-neon/components/post-manager.tsx b/solutions/platforms-blog-neon/components/post-manager.tsx new file mode 100644 index 0000000000..004292c02d --- /dev/null +++ b/solutions/platforms-blog-neon/components/post-manager.tsx @@ -0,0 +1,306 @@ +'use client' + +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' +import { Button, Input, Text } from '@vercel/examples-ui' +import { AuthorByline } from '@/components/author-byline' +import { AuthedImage } from '@/components/authed-image' +import { Markdown } from '@/components/markdown' +import { excerpt } from '@/lib/markdown' +import { neon, type Author, type Post, type Site } from '@/lib/neon' +import { uploadImage } from '@/lib/upload' + +export function PostManager({ + site, + authors, +}: { + site: Site + authors: Author[] +}) { + const [posts, setPosts] = useState([]) + const [title, setTitle] = useState('') + const [authorId, setAuthorId] = useState('') + const [content, setContent] = useState('') + const [tab, setTab] = useState<'write' | 'preview'>('write') + const [imageAlt, setImageAlt] = useState('') + const [file, setFile] = useState(null) + const [editing, setEditing] = useState(null) + const [error, setError] = useState(null) + const [pending, setPending] = useState(false) + const fileInput = useRef(null) + const formRef = useRef(null) + + useEffect(() => { + if (!authorId && authors[0]) setAuthorId(String(authors[0].id)) + }, [authorId, authors]) + + const load = useCallback(async () => { + const { data, error } = await neon + .from('posts') + .select('*') + .eq('site_id', site.id) + .order('created_at', { ascending: false }) + if (error) setError(error.message) + else setPosts((data as Post[]) ?? []) + }, [site.id]) + + useEffect(() => { + load() + }, [load]) + + const authorsById = new Map(authors.map((author) => [author.id, author])) + + function resetForm() { + setEditing(null) + setTitle('') + setContent('') + setImageAlt('') + setFile(null) + setTab('write') + if (authors[0]) setAuthorId(String(authors[0].id)) + if (fileInput.current) fileInput.current.value = '' + } + + function startEdit(post: Post) { + setError(null) + setEditing(post) + setTitle(post.title) + setAuthorId(post.author_id ? String(post.author_id) : '') + setContent(post.content) + setImageAlt(post.image_alt || '') + setFile(null) + setTab('write') + if (fileInput.current) fileInput.current.value = '' + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + async function savePost(e: FormEvent) { + e.preventDefault() + setError(null) + setPending(true) + try { + const selected = authors.find((author) => String(author.id) === authorId) + if (!selected) throw new Error('Pick an author') + const image_key = file + ? await uploadImage(file) + : editing?.image_key ?? null + const fields = { + title, + author: selected.name, + author_id: selected.id, + content, + image_key, + image_alt: image_key ? imageAlt : '', + } + const { error } = editing + ? await neon.from('posts').update(fields).eq('id', editing.id) + : await neon.from('posts').insert({ + site_id: site.id, + ...fields, + is_published: false, + }) + if (error) throw new Error(error.message) + resetForm() + await load() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setPending(false) + } + } + + async function togglePublished(post: Post) { + const { error } = await neon + .from('posts') + .update({ is_published: !post.is_published }) + .eq('id', post.id) + if (error) setError(error.message) + else await load() + } + + async function remove(post: Post) { + const { error } = await neon.from('posts').delete().eq('id', post.id) + if (error) setError(error.message) + else await load() + } + + return ( +
+
+ {editing ? 'Edit post' : 'New post'} + setTitle(e.currentTarget.value)} + required + /> + {authors.length === 0 ? ( + + Add an author above before writing a post. + + ) : ( + + )} +
+
+ + +
+ {tab === 'write' ? ( +