v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -1,13 +1,18 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate } from "react-router";
import { Link, useNavigate, useSearchParams } from "react-router";
import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
type UserRegisterState =
type TokenState =
| { status: "checking" }
| { status: "invalid" }
| { status: "valid" };
type FormState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
@@ -15,13 +20,29 @@ type UserRegisterState =
export function UserRegister() {
const navigate = useNavigate();
const { login } = useAuth();
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const [state, setState] = useState<UserRegisterState>({ status: "idle" });
const [tokenState, setTokenState] = useState<TokenState>({
status: "checking",
});
const [formState, setFormState] = useState<FormState>({ status: "idle" });
useEffect(() => {
if (!token) {
setTokenState({ status: "invalid" });
return;
}
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
.then((r) => {
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
})
.catch(() => setTokenState({ status: "invalid" }));
}, [token]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
setFormState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
@@ -31,34 +52,56 @@ export function UserRegister() {
const res = await fetch(`${API_URL}/api/users/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, inviteToken: token }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const apiResponse = await res.json();
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({ status: "error", error: apiResponse.error.message });
setFormState({
status: "error",
error: apiResponse.error?.message ?? "Registration failed.",
});
}
} catch (err) {
setState({
setFormState({
status: "error",
error: err instanceof Error ? err.message : "Registration failed.",
});
}
};
if (tokenState.status === "checking") {
return (
<PageShell centered>
<p className="page-loading">Checking invite</p>
</PageShell>
);
}
if (tokenState.status === "invalid") {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Invalid invite</h1>
<p className="auth-card-footer">
This invite link is missing, expired, or already used.
</p>
</div>
</PageShell>
);
}
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title">Register</h1>
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
{formState.status === "error" && (
<div className="error-banner">{formState.error}</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
@@ -67,7 +110,7 @@ export function UserRegister() {
type="text"
placeholder="Username"
required
disabled={state.status === "submitting"}
disabled={formState.status === "submitting"}
autoFocus
/>
<input
@@ -75,14 +118,14 @@ export function UserRegister() {
type="password"
placeholder="Password"
required
disabled={state.status === "submitting"}
disabled={formState.status === "submitting"}
/>
<button
type="submit"
className="btn-primary"
disabled={state.status === "submitting"}
disabled={formState.status === "submitting"}
>
{state.status === "submitting" ? "Registering…" : "Register"}
{formState.status === "submitting" ? "Registering…" : "Register"}
</button>
</form>