Add Next.js app with Supabase auth, notes feature, and Docker setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
57dab06b8e
commit
fafef34304
21 changed files with 885 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copy this to .env.local for local dev
|
||||
# In Dokploy, set these as environment variables directly
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-supabase.yourdomain.com
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# deps
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# build
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# ── Stage 1: deps ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# ── Stage 2: build ─────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build args become env vars at build time for the Next.js public bundle
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: runner ────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
14
app/api/health/route.ts
Normal file
14
app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "ok",
|
||||
app: "fireside-test-app",
|
||||
ts: new Date().toISOString(),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
17
app/auth/login/page.tsx
Normal file
17
app/auth/login/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Link from "next/link";
|
||||
import { AuthForm } from "@/components/AuthForm";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card">
|
||||
<h1 className="card-title">Sign in</h1>
|
||||
<p className="card-sub">// fireside-test-app · auth smoke test</p>
|
||||
<AuthForm mode="login" />
|
||||
<Link href="/auth/signup" className="link">
|
||||
No account? Sign up →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
app/auth/signup/page.tsx
Normal file
17
app/auth/signup/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Link from "next/link";
|
||||
import { AuthForm } from "@/components/AuthForm";
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="card">
|
||||
<h1 className="card-title">Create account</h1>
|
||||
<p className="card-sub">// fireside-test-app · new user registration</p>
|
||||
<AuthForm mode="signup" />
|
||||
<Link href="/auth/login" className="link">
|
||||
Already have an account? Sign in →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
app/dashboard/page.tsx
Normal file
49
app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { NotesList } from "@/components/NotesList";
|
||||
import { SignOutButton } from "@/components/SignOutButton";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) redirect("/auth/login");
|
||||
|
||||
const { data: notes, error: notesError } = await supabase
|
||||
.from("notes")
|
||||
.select("id, body, created_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="topbar">
|
||||
<div className="topbar-brand">
|
||||
FIRESIDE <span>TEST</span>
|
||||
</div>
|
||||
<div className="topbar-user">
|
||||
<span>{user.email}</span>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main-content">
|
||||
<div className="stack-badge">
|
||||
<span className="badge active">Forgejo</span>
|
||||
<span className="badge active">Dokploy</span>
|
||||
<span className="badge active">Supabase Auth ✓</span>
|
||||
<span className="badge active">Supabase DB ✓</span>
|
||||
<span className="badge">Next.js 15 SSR</span>
|
||||
</div>
|
||||
|
||||
<p className="section-label">// personal notes — user_id: {user.id.slice(0, 8)}…</p>
|
||||
|
||||
{notesError && (
|
||||
<div className="error-msg">DB error: {notesError.message}</div>
|
||||
)}
|
||||
|
||||
<NotesList initialNotes={notes ?? []} userId={user.id} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
app/globals.css
Normal file
306
app/globals.css
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Syne:wght@400;600;700;800&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0d0d0d;
|
||||
--surface: #161616;
|
||||
--border: #2a2a2a;
|
||||
--amber: #f59e0b;
|
||||
--amber-dim: #92400e;
|
||||
--text: #e8e8e8;
|
||||
--muted: #666;
|
||||
--danger: #ef4444;
|
||||
--success: #22c55e;
|
||||
--radius: 4px;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
--sans: 'Syne', sans-serif;
|
||||
}
|
||||
|
||||
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--sans); }
|
||||
|
||||
/* ── Utilities ────────────────────────────────── */
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||
|
||||
/* ── Page shell ───────────────────────────────── */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 40% at 50% 0%, #1c1200 0%, transparent 70%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* ── Card ─────────────────────────────────────── */
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* ── Form ─────────────────────────────────────── */
|
||||
.field { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1.25rem; }
|
||||
|
||||
label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="text"],
|
||||
textarea {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
transition: border-color 0.15s;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.7rem 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.btn:active { transform: scale(0.98); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--amber);
|
||||
color: #000;
|
||||
width: 100%;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text); border-color: var(--text); }
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid #3a1a1a;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
.btn-danger:hover { background: #1a0000; border-color: var(--danger); }
|
||||
|
||||
/* ── Feedback ─────────────────────────────────── */
|
||||
.error-msg {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--danger);
|
||||
background: #1a0000;
|
||||
border: 1px solid #3a0000;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--success);
|
||||
background: #001a00;
|
||||
border: 1px solid #003a00;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Link ─────────────────────────────────────── */
|
||||
.link {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Dashboard layout ─────────────────────────── */
|
||||
.dashboard {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.topbar-brand span {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.topbar-user {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
/* ── Notes ────────────────────────────────────── */
|
||||
.section-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--amber-dim);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.note-form {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.note-form textarea {
|
||||
flex: 1;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.note-form .btn-primary {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.notes-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.note-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.note-body {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.note-time {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ── Stack badge ──────────────────────────────── */
|
||||
.stack-badge {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 2px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--amber);
|
||||
}
|
||||
19
app/layout.tsx
Normal file
19
app/layout.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Fireside Test App",
|
||||
description: "End-to-end deployment test — Forgejo · Dokploy · Supabase",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
10
app/page.tsx
Normal file
10
app/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (user) redirect("/dashboard");
|
||||
else redirect("/auth/login");
|
||||
}
|
||||
75
components/AuthForm.tsx
Normal file
75
components/AuthForm.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [info, setInfo] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
setError("");
|
||||
setInfo("");
|
||||
setLoading(true);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
if (mode === "signup") {
|
||||
const { error } = await supabase.auth.signUp({ email, password });
|
||||
if (error) { setError(error.message); setLoading(false); return; }
|
||||
setInfo("Account created — check your email to confirm, then sign in.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) { setError(error.message); setLoading(false); return; }
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
{info && <div className="success-msg">{info}</div>}
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
onKeyDown={e => e.key === "Enter" && handleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !email || password.length < 6}
|
||||
>
|
||||
{loading ? "Working…" : mode === "login" ? "Sign in" : "Create account"}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
components/NotesList.tsx
Normal file
96
components/NotesList.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
type Note = { id: string; body: string; created_at: string };
|
||||
|
||||
export function NotesList({
|
||||
initialNotes,
|
||||
userId,
|
||||
}: {
|
||||
initialNotes: Note[];
|
||||
userId: string;
|
||||
}) {
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes);
|
||||
const [body, setBody] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
async function addNote() {
|
||||
if (!body.trim()) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("notes")
|
||||
.insert({ body: body.trim(), user_id: userId })
|
||||
.select("id, body, created_at")
|
||||
.single();
|
||||
|
||||
if (error) { setError(error.message); setLoading(false); return; }
|
||||
setNotes([data, ...notes]);
|
||||
setBody("");
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function deleteNote(id: string) {
|
||||
const { error } = await supabase.from("notes").delete().eq("id", id);
|
||||
if (error) { setError(error.message); return; }
|
||||
setNotes(notes.filter(n => n.id !== id));
|
||||
}
|
||||
|
||||
function fmt(ts: string) {
|
||||
return new Date(ts).toLocaleString("en-KE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
|
||||
<div className="note-form">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="Write a note…"
|
||||
onKeyDown={e => e.key === "Enter" && e.metaKey && addNote()}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={addNote}
|
||||
disabled={loading || !body.trim()}
|
||||
>
|
||||
{loading ? "Saving…" : "Add note"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="notes-list">
|
||||
{notes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
No notes yet. Add one above to verify the DB write path.
|
||||
</div>
|
||||
) : (
|
||||
notes.map(note => (
|
||||
<div key={note.id} className="note-item">
|
||||
<div>
|
||||
<p className="note-body">{note.body}</p>
|
||||
<p className="note-time">{fmt(note.created_at)}</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteNote(note.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
components/SignOutButton.tsx
Normal file
21
components/SignOutButton.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export function SignOutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function signOut() {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.signOut();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="btn btn-ghost" style={{ padding: "0.35rem 0.8rem", fontSize: "0.72rem" }} onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
image: fireside-test-app:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
10
lib/supabase/client.ts
Normal file
10
lib/supabase/client.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
27
lib/supabase/server.ts
Normal file
27
lib/supabase/server.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// Server Component — middleware handles session refresh
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
55
middleware.ts
Normal file
55
middleware.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request });
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value)
|
||||
);
|
||||
supabaseResponse = NextResponse.next({ request });
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Protect /dashboard routes
|
||||
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/auth/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// Redirect logged-in users away from auth pages
|
||||
if (
|
||||
user &&
|
||||
(request.nextUrl.pathname === "/auth/login" ||
|
||||
request.nextUrl.pathname === "/auth/signup")
|
||||
) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/dashboard";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return supabaseResponse;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/dashboard/:path*", "/auth/:path*"],
|
||||
};
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "fireside-test-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.5.2",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"next": "15.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
23
supabase/migrations/001_notes.sql
Normal file
23
supabase/migrations/001_notes.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- Run this in your Supabase SQL editor or via psql
|
||||
|
||||
create table if not exists public.notes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
body text not null check (char_length(body) > 0),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Row Level Security: users can only touch their own notes
|
||||
alter table public.notes enable row level security;
|
||||
|
||||
create policy "Users can select own notes"
|
||||
on public.notes for select
|
||||
using (auth.uid() = user_id);
|
||||
|
||||
create policy "Users can insert own notes"
|
||||
on public.notes for insert
|
||||
with check (auth.uid() = user_id);
|
||||
|
||||
create policy "Users can delete own notes"
|
||||
on public.notes for delete
|
||||
using (auth.uid() = user_id);
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue