diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18a0ff5 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d325cf2 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..585488b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..2109c80 --- /dev/null +++ b/app/api/health/route.ts @@ -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 } + ); +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..179a0cb --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { AuthForm } from "@/components/AuthForm"; + +export default function LoginPage() { + return ( +
+
+

Sign in

+

// fireside-test-app · auth smoke test

+ + + No account? Sign up → + +
+
+ ); +} diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx new file mode 100644 index 0000000..271698e --- /dev/null +++ b/app/auth/signup/page.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { AuthForm } from "@/components/AuthForm"; + +export default function SignupPage() { + return ( +
+
+

Create account

+

// fireside-test-app · new user registration

+ + + Already have an account? Sign in → + +
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..d5811d3 --- /dev/null +++ b/app/dashboard/page.tsx @@ -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 ( +
+
+
+ FIRESIDE TEST +
+
+ {user.email} + +
+
+ +
+
+ Forgejo + Dokploy + Supabase Auth ✓ + Supabase DB ✓ + Next.js 15 SSR +
+ +

// personal notes — user_id: {user.id.slice(0, 8)}…

+ + {notesError && ( +
DB error: {notesError.message}
+ )} + + +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..32d2931 --- /dev/null +++ b/app/globals.css @@ -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); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..bc6c701 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..cf3f11c --- /dev/null +++ b/app/page.tsx @@ -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"); +} diff --git a/components/AuthForm.tsx b/components/AuthForm.tsx new file mode 100644 index 0000000..ec72703 --- /dev/null +++ b/components/AuthForm.tsx @@ -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 &&
{error}
} + {info &&
{info}
} + +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + autoComplete={mode === "login" ? "current-password" : "new-password"} + onKeyDown={e => e.key === "Enter" && handleSubmit()} + /> +
+ + + + ); +} diff --git a/components/NotesList.tsx b/components/NotesList.tsx new file mode 100644 index 0000000..2dd22c0 --- /dev/null +++ b/components/NotesList.tsx @@ -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(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 &&
{error}
} + +
+