initial commit, boilerplate stuff

This commit is contained in:
khannurien
2026-03-15 17:15:46 +00:00
commit 6207a7549f
52 changed files with 4400 additions and 0 deletions

92
src/pages/Dump.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
type DumpState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dump: Dump };
export function Dump() {
const { selectedDump } = useParams();
const [dumpState, setDumpState] = useState<DumpState>({ status: "loading" });
const { user } = useAuth();
// Fetch dump data
useEffect(() => {
if (!selectedDump) return;
setDumpState({ status: "loading" });
(async () => {
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const apiResponse = await res.json();
setDumpState({ status: "loaded", dump: apiResponse.data });
} catch (err) {
setDumpState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load dump",
});
}
})();
}, [selectedDump]);
if (dumpState.status === "loading") {
return <div className="loading">Loading dump...</div>;
}
if (dumpState.status === "error") {
return (
<div className="error-container">
<h2>Error</h2>
<p>{dumpState.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Retry
</button>
<p>
<Link to="/"> Back to all dumps</Link>
</p>
</div>
);
}
const { dump } = dumpState;
const canEdit = !!user &&
(dump.userId === user.id || user.isAdmin === true);
return (
<div className="dump-container">
<div className="dump-meta">
<h1>{dump.title}</h1>
{dump.description && (
<p className="dump-description">{dump.description}</p>
)}
</div>
<div className="dump-grid">
~
</div>
<div className="dump-actions">
{canEdit && (
<Link to={`/dumps/${dump.id}/edit`}>
Edit dump
</Link>
)}
<Link to="/"> Back to all dumps</Link>
</div>
</div>
);
}

120
src/pages/DumpCreate.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { SubmitEvent, useState } from "react";
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import type { CreateDumpRequest } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
type DumpCreateState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
export function DumpCreate() {
const navigate = useNavigate();
const { authFetch } = useRequiredAuth();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [state, setState] = useState<DumpCreateState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) {
setState({ status: "error", error: "Title is required." });
return;
}
const body: CreateDumpRequest = {
title,
description: description || undefined,
};
setState({ status: "submitting" });
try {
const res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const apiResponse = await res.json();
if (apiResponse.success) {
const createdDump = apiResponse.data;
navigate(`/dumps/${createdDump.id}`);
} else {
setState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Failed to create dump.",
});
}
};
return (
<div className="dump-container">
<div className="dump-meta">
<h1>Create Dump</h1>
</div>
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
)}
<form onSubmit={handleSubmit} className="dump-form">
<div className="form-group">
<label htmlFor="title">
<strong>Title</strong>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={state.status === "submitting"}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">
<strong>Description (optional)</strong>
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={state.status === "submitting"}
rows={3}
/>
</div>
<div className="dump-actions">
<button
type="submit"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Creating..." : "Create dump"}
</button>
<Link to="/">Cancel</Link>
</div>
</form>
</div>
);
}

169
src/pages/DumpEdit.tsx Normal file
View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, UpdateDumpRequest } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
type DumpEditState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dump: Dump };
export function DumpEdit() {
const { selectedDump } = useParams();
const navigate = useNavigate();
const { authFetch } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
if (!selectedDump) return;
setState({ status: "loading" });
(async () => {
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (apiResponse.success) {
const dump: Dump = apiResponse.data;
setTitle(dump.title);
setDescription(dump.description ?? "");
setState({ status: "loaded", dump });
} else {
setState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Load failed",
});
}
})();
}, [selectedDump]);
const handleSave = async () => {
if (state.status !== "loaded") return;
const body: UpdateDumpRequest = {
title,
description: description || undefined,
};
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
});
if (!res.ok) {
setState({
status: "error",
error: `Update failed (${res.status})`,
});
return;
}
navigate(`/dumps/${state.dump.id}`);
};
const handleDelete = async () => {
if (state.status !== "loaded") return;
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
setState({
status: "error",
error: `Delete failed (${res.status})`,
});
return;
}
navigate("/");
};
if (state.status === "loading") {
return <div className="loading">Loading dump...</div>;
}
if (state.status === "error") {
return (
<div className="error-container">
<h2>Error</h2>
<p>{state.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Retry
</button>
<p>
<Link to="/"> Back to all dumps</Link>
</p>
</div>
);
}
return (
<div className="dump-container">
<div className="dump-meta">
<h1>Edit Dump</h1>
</div>
<form
className="dump-form"
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div className="form-group">
<label htmlFor="title">
<strong>Title</strong>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">
<strong>Description (optional)</strong>
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
rows={3}
/>
</div>
<div className="dump-actions">
<button type="submit">Save</button>
<button
type="button"
onClick={handleDelete}
style={{ backgroundColor: "#a02b2b" }}
>
Delete
</button>
<Link to={`/dumps/${state.dump.id}`}>Cancel</Link>
</div>
</form>
</div>
);
}

122
src/pages/Index.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { type Dump } from "../model.ts";
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dumps: Dump[] };
export function Index() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleCreateDump = () => {
navigate("/dumps/new");
};
const handleRegister = () => {
navigate("/register");
};
const handleLogin = () => {
navigate("/login");
};
const handleLogout = () => {
logout();
navigate("/", { replace: true });
};
const [dumpsState, setDumpsState] = useState<DumpsState>({
status: "loading",
});
useEffect(() => {
(async () => {
try {
const response = await fetch(`${API_URL}/api/dumps/`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const apiResponse = await response.json();
setDumpsState({ status: "loaded", dumps: apiResponse.data });
} catch (err) {
setDumpsState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load dumps",
});
}
})();
}, []);
if (dumpsState.status === "loading") {
return (
<main id="content">
<div className="loading">Loading dumps...</div>
</main>
);
}
if (dumpsState.status === "error") {
return (
<main id="content">
<div className="error-container">
<h2>Error</h2>
<p>{dumpsState.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Retry
</button>
</div>
</main>
);
}
const { dumps } = dumpsState;
return (
<main id="content">
<h1>🚚 Dumps</h1>
<p>Welcome, {user?.username ?? "guest"}!</p>
{user &&
<button type="button" onClick={handleCreateDump}>New dump</button>}
<p>Click on a dump below to participate.</p>
{dumps.length === 0
? <p className="empty-state">No dumps available yet.</p>
: (
<ul>
{dumps.map((dump) => (
<li key={dump.id}>
<Link to={`/dumps/${dump.id}`} className="dump">
{dump.title}
</Link>
</li>
))}
</ul>
)}
{user
? (
<form>
<button type="button" onClick={handleLogout}>Logout</button>
</form>
)
: (
<form>
<button type="button" onClick={handleRegister}>Register</button>
<button type="button" onClick={handleLogin}>Log in</button>
</form>
)}
</main>
);
}

View File

@@ -0,0 +1,9 @@
import { Navigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
export function RestrictedGuest({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
return user ? <Navigate to="/" /> : <>{children}</>;
}

View File

@@ -0,0 +1,11 @@
import { Navigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
export function RestrictedLoggedIn(
{ children }: { children: React.ReactNode },
) {
const { user } = useAuth();
return user ? <>{children}</> : <Navigate to="/login" />;
}

88
src/pages/UserLogin.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { SubmitEvent, useState } from "react";
import { useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
type UserLoginState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
export function UserLogin() {
const navigate = useNavigate();
const { login } = useAuth();
const [state, setState] = useState<UserLoginState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
try {
const res = await fetch(`${API_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const apiResponse = await res.json();
if (apiResponse.success) {
login(apiResponse.data);
navigate("/");
} else {
setState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Login failed.",
});
}
};
return (
<div className="auth-container">
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder="Username"
required
disabled={state.status === "submitting"}
/>
<input
name="password"
type="password"
placeholder="Password"
required
disabled={state.status === "submitting"}
/>
<button
type="submit"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Logging in..." : "Login"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { useRequiredAuth } from "../hooks/useAuth.ts";
export function UserProfile() {
const { user } = useRequiredAuth();
return (
`Hello, ${user.username}!`
);
}

View File

@@ -0,0 +1,88 @@
import { SubmitEvent, useState } from "react";
import { useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
type UserRegisterState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
export function UserRegister() {
const navigate = useNavigate();
const { login } = useAuth();
const [state, setState] = useState<UserRegisterState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
try {
const res = await fetch(`${API_URL}/api/users/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const apiResponse = await res.json();
if (apiResponse.success) {
login(apiResponse.data);
navigate("/");
} else {
setState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Registration failed.",
});
}
};
return (
<div className="registration-container">
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
)}
<form onSubmit={handleSubmit} className="registration-form">
<input
name="username"
type="text"
placeholder="Username"
required
disabled={state.status === "submitting"}
/>
<input
name="password"
type="password"
placeholder="Password"
required
disabled={state.status === "submitting"}
/>
<button
type="submit"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Registering..." : "Register"}
</button>
</form>
</div>
);
}