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