Scaffold Next.js + FastAPI + Postgres tasks board (no auth)

This commit is contained in:
Abhimanyu Saharan
2026-02-01 22:25:28 +05:30
commit 8b6e8c8d07
2967 changed files with 621159 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,42 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,141 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

212
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,212 @@
"use client";
import {useEffect, useMemo, useState} from "react";
type TaskStatus = "todo" | "doing" | "done";
type Task = {
id: number;
title: string;
description: string | null;
status: TaskStatus;
assignee: string | null;
created_at: string;
updated_at: string | null;
};
const STATUSES: Array<{key: TaskStatus; label: string}> = [
{key: "todo", label: "To do"},
{key: "doing", label: "Doing"},
{key: "done", label: "Done"},
];
function apiUrl(path: string) {
const base = process.env.NEXT_PUBLIC_API_URL;
if (!base) throw new Error("NEXT_PUBLIC_API_URL is not set");
return `${base}${path}`;
}
export default function Home() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [assignee, setAssignee] = useState("");
const [description, setDescription] = useState("");
const byStatus = useMemo(() => {
const map: Record<TaskStatus, Task[]> = {todo: [], doing: [], done: []};
for (const t of tasks) map[t.status].push(t);
return map;
}, [tasks]);
async function refresh() {
setLoading(true);
setError(null);
try {
const res = await fetch(apiUrl("/tasks"), {cache: "no-store"});
if (!res.ok) throw new Error(`Failed to load tasks (${res.status})`);
setTasks(await res.json());
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
setError(msg);
} finally {
setLoading(false);
}
}
useEffect(() => {
refresh();
}, []);
async function createTask() {
if (!title.trim()) return;
setError(null);
const payload = {
title,
description: description.trim() ? description : null,
assignee: assignee.trim() ? assignee : null,
status: "todo" as const,
};
const res = await fetch(apiUrl("/tasks"), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!res.ok) {
setError(`Failed to create task (${res.status})`);
return;
}
setTitle("");
setAssignee("");
setDescription("");
await refresh();
}
async function move(task: Task, status: TaskStatus) {
const res = await fetch(apiUrl(`/tasks/${task.id}`), {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({status}),
});
if (!res.ok) {
setError(`Failed to update task (${res.status})`);
return;
}
await refresh();
}
async function remove(task: Task) {
const res = await fetch(apiUrl(`/tasks/${task.id}`), {method: "DELETE"});
if (!res.ok) {
setError(`Failed to delete task (${res.status})`);
return;
}
await refresh();
}
return (
<main style={{padding: 24, fontFamily: "ui-sans-serif, system-ui"}}>
<header style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 16}}>
<div>
<h1 style={{fontSize: 28, fontWeight: 700, margin: 0}}>OpenClaw Agency Board</h1>
<p style={{marginTop: 8, color: "#555"}}>
Simple Kanban (no auth). Everyone can see who owns what.
</p>
</div>
<button onClick={refresh} disabled={loading} style={btn()}>Refresh</button>
</header>
<section style={{marginTop: 18, padding: 16, border: "1px solid #eee", borderRadius: 12}}>
<h2 style={{margin: 0, fontSize: 16}}>Create task</h2>
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr", gap: 12, marginTop: 12}}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Task title"
style={input()}
/>
<input
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
placeholder="Assignee (e.g. Head: Design)"
style={input()}
/>
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
style={{...input(), marginTop: 12, minHeight: 80}}
/>
<div style={{display: "flex", gap: 12, marginTop: 12, alignItems: "center"}}>
<button onClick={createTask} style={btn("primary")}>Add</button>
{error ? <span style={{color: "#b00020"}}>{error}</span> : null}
</div>
</section>
<section style={{marginTop: 18}}>
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 14}}>
{STATUSES.map((s) => (
<div key={s.key} style={{border: "1px solid #eee", borderRadius: 12, padding: 12, background: "#fafafa"}}>
<h3 style={{marginTop: 0}}>{s.label} ({byStatus[s.key].length})</h3>
<div style={{display: "flex", flexDirection: "column", gap: 10}}>
{byStatus[s.key].map((t) => (
<div key={t.id} style={{border: "1px solid #e5e5e5", background: "white", borderRadius: 12, padding: 12}}>
<div style={{display: "flex", justifyContent: "space-between", gap: 12}}>
<div>
<div style={{fontWeight: 650}}>{t.title}</div>
<div style={{fontSize: 13, color: "#666", marginTop: 6}}>
{t.assignee ? <>Owner: <strong>{t.assignee}</strong></> : "Unassigned"}
</div>
</div>
<button onClick={() => remove(t)} style={btn("danger")}>Delete</button>
</div>
{t.description ? <p style={{marginTop: 10, color: "#333"}}>{t.description}</p> : null}
<div style={{display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap"}}>
{STATUSES.filter((x) => x.key !== t.status).map((x) => (
<button key={x.key} onClick={() => move(t, x.key)} style={btn()}>
Move {x.label}
</button>
))}
</div>
</div>
))}
{byStatus[s.key].length === 0 ? <div style={{color: "#777", fontSize: 13}}>No tasks</div> : null}
</div>
</div>
))}
</div>
</section>
</main>
);
}
function input(): React.CSSProperties {
return {
width: "100%",
padding: "10px 12px",
borderRadius: 10,
border: "1px solid #ddd",
outline: "none",
};
}
function btn(kind: "primary" | "danger" | "default" = "default"): React.CSSProperties {
const base: React.CSSProperties = {
padding: "9px 12px",
borderRadius: 10,
border: "1px solid #ddd",
background: "white",
cursor: "pointer",
};
if (kind === "primary") return {...base, background: "#111", color: "white", borderColor: "#111"};
if (kind === "danger") return {...base, background: "#fff", borderColor: "#f2b8b5", color: "#b00020"};
return base;
}