ui/ health checklist

This commit is contained in:
Leon van Zyl
2025-08-13 12:13:16 +02:00
parent 863906de86
commit 9dcb5aa9e3
9 changed files with 1013 additions and 401 deletions

View File

@@ -0,0 +1,123 @@
# react-markdown
React component to render markdown.
## Contents
- [Install](#install)
- [Use](#use)
- [API](#api)
- [Examples](#examples)
- [Plugins](#plugins)
## What is this?
This package is a React component that can be given a string of markdown that it'll safely render to React elements. You can pass plugins to change how markdown is transformed and pass components that will be used instead of normal HTML elements.
## Install
```sh
npm install react-markdown
```
## Use
Basic usage:
```js
import Markdown from "react-markdown";
const markdown = "# Hi, *Pluto*!";
<Markdown>{markdown}</Markdown>
```
With plugins:
```js
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
const markdown = `Just a link: www.nasa.gov.`;
<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
```
## API
Key props:
- `children` — markdown string to render
- `remarkPlugins` — array of remark plugins
- `rehypePlugins` — array of rehype plugins
- `components` — object mapping HTML tags to React components
- `allowedElements` — array of allowed HTML tags
- `disallowedElements` — array of disallowed HTML tags
## Examples
### Using GitHub Flavored Markdown
```js
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
const markdown = `
* [x] todo
* [ ] done
| Column 1 | Column 2 |
|----------|----------|
| Cell 1 | Cell 2 |
`;
<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>
```
### Custom Components (Syntax Highlighting)
```js
import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism";
const markdown = `
\`\`\`js
console.log('Hello, world!');
\`\`\`
`;
<Markdown
components={{
code(props) {
const { children, className, ...rest } = props;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={dark}
/>
) : (
<code {...rest} className={className}>
{children}
</code>
);
},
}}
>
{markdown}
</Markdown>
```
## Plugins
Common plugins:
- `remark-gfm` — GitHub Flavored Markdown (tables, task lists, strikethrough)
- `remark-math` — Math notation support
- `rehype-katex` — Render math with KaTeX
- `rehype-highlight` — Syntax highlighting
- `rehype-raw` — Allow raw HTML (use carefully for security)

947
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build": "pnpm run db:migrate && next build",
"start": "next start",
"lint": "next lint",
"db:generate": "drizzle-kit generate",
@@ -28,7 +28,7 @@
"postgres": "^3.4.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-markdown": "^8.0.6",
"react-markdown": "^10.1.0",
"tailwind-merge": "^3.3.1",
"zod": "^4.0.17"
},

View File

@@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
type StatusLevel = "ok" | "warn" | "error";
interface DiagnosticsResponse {
timestamp: string;
env: {
DATABASE_URL: boolean;
BETTER_AUTH_SECRET: boolean;
GOOGLE_CLIENT_ID: boolean;
GOOGLE_CLIENT_SECRET: boolean;
OPENAI_API_KEY: boolean;
NEXT_PUBLIC_APP_URL: boolean;
};
database: {
connected: boolean;
schemaApplied: boolean;
error?: string;
};
auth: {
configured: boolean;
routeResponding: boolean | null;
};
ai: {
configured: boolean;
};
overallStatus: StatusLevel;
}
export async function GET(req: Request) {
const env = {
DATABASE_URL: Boolean(process.env.DATABASE_URL),
BETTER_AUTH_SECRET: Boolean(process.env.BETTER_AUTH_SECRET),
GOOGLE_CLIENT_ID: Boolean(process.env.GOOGLE_CLIENT_ID),
GOOGLE_CLIENT_SECRET: Boolean(process.env.GOOGLE_CLIENT_SECRET),
OPENAI_API_KEY: Boolean(process.env.OPENAI_API_KEY),
NEXT_PUBLIC_APP_URL: Boolean(process.env.NEXT_PUBLIC_APP_URL),
} as const;
// Database checks
let dbConnected = false;
let schemaApplied = false;
let dbError: string | undefined;
if (env.DATABASE_URL) {
try {
const [{ db }, { sql }, schema] = await Promise.all([
import("@/lib/db"),
import("drizzle-orm"),
import("@/lib/schema"),
]);
// Ping DB
await db.execute(sql`select 1`);
dbConnected = true;
try {
// Touch a known table to verify migrations
await db.select().from(schema.user).limit(1);
schemaApplied = true;
} catch {
schemaApplied = false;
}
} catch (err) {
dbConnected = false;
dbError = err instanceof Error ? err.message : "Unknown database error";
}
} else {
dbConnected = false;
schemaApplied = false;
dbError = "DATABASE_URL is not set";
}
// Auth route check: we consider the route responding if it returns any HTTP response
// for /api/auth/session (status codes in the 2xx-4xx range are acceptable for readiness)
const origin = (() => {
try {
return new URL(req.url).origin;
} catch {
return process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
}
})();
let authRouteResponding: boolean | null = null;
try {
const res = await fetch(`${origin}/api/auth/session`, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
authRouteResponding = res.status >= 200 && res.status < 500;
} catch {
authRouteResponding = false;
}
const authConfigured =
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
const aiConfigured = env.OPENAI_API_KEY; // We avoid live-calling the AI provider here
const overallStatus: StatusLevel = (() => {
if (!env.DATABASE_URL || !dbConnected || !schemaApplied) return "error";
if (!authConfigured) return "error";
// AI is optional; warn if not configured
if (!aiConfigured) return "warn";
return "ok";
})();
const body: DiagnosticsResponse = {
timestamp: new Date().toISOString(),
env,
database: {
connected: dbConnected,
schemaApplied,
error: dbError,
},
auth: {
configured: authConfigured,
routeResponding: authRouteResponding,
},
ai: {
configured: aiConfigured,
},
overallStatus,
};
return NextResponse.json(body, {
status: 200,
});
}

View File

@@ -7,7 +7,6 @@ import { useSession } from "@/lib/auth-client";
import { useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import type { CodeComponent } from "react-markdown/lib/ast-to-react";
const H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h1 className="mt-2 mb-3 text-2xl font-bold" {...props} />
@@ -35,6 +34,7 @@ const Anchor: React.FC<React.AnchorHTMLAttributes<HTMLAnchorElement>> = (
) => (
<a
className="underline underline-offset-2 text-primary hover:opacity-90"
target="_blank"
rel="noreferrer noopener"
{...props}
/>
@@ -47,8 +47,11 @@ const Blockquote: React.FC<React.BlockquoteHTMLAttributes<HTMLElement>> = (
{...props}
/>
);
const Code: CodeComponent = ({ inline, children, ...props }) => {
if (inline) {
const Code: Components["code"] = ({ children, className, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code className="rounded bg-muted px-1 py-0.5 text-xs" {...props}>
{children}
@@ -116,11 +119,7 @@ function renderMessageContent(message: MaybePartsMessage): ReactNode {
: [];
return parts.map((p, idx) =>
p?.type === "text" && p.text ? (
<ReactMarkdown
key={idx}
linkTarget="_blank"
components={markdownComponents}
>
<ReactMarkdown key={idx} components={markdownComponents}>
{p.text}
</ReactMarkdown>
) : null

View File

@@ -1,5 +1,6 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { SetupChecklist } from "@/components/setup-checklist";
export default function Home() {
return (
@@ -44,6 +45,8 @@ export default function Home() {
</div>
<div className="space-y-6 mt-12">
<SetupChecklist />
<h3 className="text-2xl font-semibold">Next Steps</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left">
<div className="p-4 border rounded-lg">

View File

@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
type DiagnosticsResponse = {
timestamp: string;
env: {
DATABASE_URL: boolean;
BETTER_AUTH_SECRET: boolean;
GOOGLE_CLIENT_ID: boolean;
GOOGLE_CLIENT_SECRET: boolean;
OPENAI_API_KEY: boolean;
NEXT_PUBLIC_APP_URL: boolean;
};
database: {
connected: boolean;
schemaApplied: boolean;
error?: string;
};
auth: {
configured: boolean;
routeResponding: boolean | null;
};
ai: {
configured: boolean;
};
overallStatus: "ok" | "warn" | "error";
};
function StatusIcon({ ok }: { ok: boolean }) {
return ok ? (
<span aria-label="ok" title="ok">
</span>
) : (
<span aria-label="not-ok" title="not ok">
</span>
);
}
export function SetupChecklist() {
const [data, setData] = useState<DiagnosticsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/diagnostics", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = (await res.json()) as DiagnosticsResponse;
setData(json);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load diagnostics");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
const steps = [
{
key: "env",
label: "Environment variables",
ok:
!!data?.env.DATABASE_URL &&
!!data?.env.BETTER_AUTH_SECRET &&
!!data?.env.GOOGLE_CLIENT_ID &&
!!data?.env.GOOGLE_CLIENT_SECRET,
detail:
"Requires DATABASE_URL, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET",
},
{
key: "db",
label: "Database connected & schema",
ok: !!data?.database.connected && !!data?.database.schemaApplied,
detail: data?.database.error
? `Error: ${data.database.error}`
: undefined,
},
{
key: "auth",
label: "Auth configured",
ok: !!data?.auth.configured,
detail:
data?.auth.routeResponding === false
? "Auth route not responding"
: undefined,
},
{
key: "ai",
label: "AI integration (optional)",
ok: !!data?.ai.configured,
detail: !data?.ai.configured
? "Set OPENAI_API_KEY for AI chat"
: undefined,
},
] as const;
const completed = steps.filter((s) => s.ok).length;
return (
<div className="p-6 border rounded-lg text-left">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold">Setup checklist</h3>
<p className="text-sm text-muted-foreground">
{completed}/{steps.length} completed
</p>
</div>
<Button size="sm" onClick={load} disabled={loading} className="glow">
{loading ? "Checking..." : "Re-check"}
</Button>
</div>
{error ? <div className="text-sm text-red-500">{error}</div> : null}
<ul className="space-y-2">
{steps.map((s) => (
<li key={s.key} className="flex items-start gap-2">
<div className="mt-0.5">
<StatusIcon ok={Boolean(s.ok)} />
</div>
<div>
<div className="font-medium">{s.label}</div>
{s.detail ? (
<div className="text-sm text-muted-foreground">{s.detail}</div>
) : null}
</div>
</li>
))}
</ul>
{data ? (
<div className="mt-4 text-xs text-muted-foreground">
Last checked: {new Date(data.timestamp).toLocaleString()}
</div>
) : null}
</div>
);
}

View File

@@ -1,31 +1,35 @@
import { pgTable, text, timestamp, boolean, primaryKey } from "drizzle-orm/pg-core"
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").unique(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("emailVerified"),
image: text("image"),
createdAt: timestamp("createdAt").defaultNow(),
updatedAt: timestamp("updatedAt").defaultNow(),
})
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt"),
token: text("token").unique(),
createdAt: timestamp("createdAt").defaultNow(),
updatedAt: timestamp("updatedAt").defaultNow(),
expiresAt: timestamp("expiresAt").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
})
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("accountId"),
providerId: text("providerId"),
userId: text("userId").references(() => user.id, { onDelete: "cascade" }),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
@@ -33,15 +37,15 @@ export const account = pgTable("account", {
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt").defaultNow(),
updatedAt: timestamp("updatedAt").defaultNow(),
})
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier"),
value: text("value"),
expiresAt: timestamp("expiresAt"),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt").defaultNow(),
updatedAt: timestamp("updatedAt").defaultNow(),
})
});

View File

@@ -18,6 +18,7 @@
"name": "next"
}
],
"types": ["react", "react-dom", "node"],
"paths": {
"@/*": ["./src/*"]
}