initial commit, boilerplate stuff
This commit is contained in:
92
src/pages/Dump.tsx
Normal file
92
src/pages/Dump.tsx
Normal 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
120
src/pages/DumpCreate.tsx
Normal 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
169
src/pages/DumpEdit.tsx
Normal 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
122
src/pages/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/pages/RestrictedGuest.tsx
Normal file
9
src/pages/RestrictedGuest.tsx
Normal 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}</>;
|
||||
}
|
||||
11
src/pages/RestrictedLoggedIn.tsx
Normal file
11
src/pages/RestrictedLoggedIn.tsx
Normal 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
88
src/pages/UserLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/pages/UserProfile.tsx
Normal file
9
src/pages/UserProfile.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
export function UserProfile() {
|
||||
const { user } = useRequiredAuth();
|
||||
|
||||
return (
|
||||
`Hello, ${user.username}!`
|
||||
);
|
||||
}
|
||||
88
src/pages/UserRegister.tsx
Normal file
88
src/pages/UserRegister.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user