Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ RUN npm install -g pnpm

WORKDIR /app

# Production behavior: enables the Secure cookie flag and the JWT_SECRET boot check
ENV NODE_ENV=production

# Copy workspace files
COPY package.json pnpm-workspace.yaml ./
COPY server/package.json ./server/
Expand Down
9 changes: 7 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@
"prepare": "husky"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.80.0",
"react-icons": "^5.6.0",
"react-router-hash-link": "^2.4.3"
"react-router-dom": "^7.13.2",
"react-router-hash-link": "^2.4.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
Expand All @@ -34,7 +40,6 @@
"husky": "^9.1.7",
"only-allow": "^1.2.2",
"postcss": "^8.5.8",
"react-router-dom": "^7.13.2",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
Expand Down
18 changes: 14 additions & 4 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { useHomeContent } from "./hooks/useHomeContent";
import type { HomeContentValues } from "./schemas/content";

// exemplar CMS default data -> shown when data does not exist / API is down
const DEFAULTS: HomeContentValues = {
heading: "Welcome to Project UMSA!",
subtitle: "to get started, go to the project team section and have a look :)",
};

function App() {
const { data } = useHomeContent();
const content = data ?? DEFAULTS;

return (
<div className="flex flex-col min-h-200 justify-center items-center">
<h1 className="text-4xl mt-7 font-bold text-white">
Welcome to Project UMSA!
</h1>
<h1 className="text-4xl mt-7 font-bold text-white">{content.heading}</h1>
<div className="text-sm mt-3 text-gray-400">
<p>to get started, go to the project team section and have a look :)</p>
<p>{content.subtitle}</p>
</div>
</div>
);
Expand Down
50 changes: 50 additions & 0 deletions client/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { api } from "../lib/api";
import type { LoginValues } from "../schemas/auth";

// The ["auth", "me"] cache entry IS our auth state — every component that calls
// useMe() reads the same entry, and the login/logout mutations write to it.
// No AuthContext needed: react-query is already an app-wide store.
export const ME_KEY = ["auth", "me"] as const;

export interface Me {
email: string;
}

export function useMe() {
return useQuery({
queryKey: ME_KEY,
queryFn: async (): Promise<Me | null> => {
try {
const { data } = await api.get<Me>("/admin/auth/me");
return data;
} catch (err) {
if (isAxiosError(err) && err.response?.status === 401) {
return null; // logged-out is a normal STATE, not an error
}
throw err;
}
},
retry: false,
staleTime: 5 * 60 * 1000,
});
}

export function useLogin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: LoginValues) =>
api.post<Me>("/admin/auth/login", values).then((res) => res.data),
onSuccess: (me) => queryClient.setQueryData(ME_KEY, me),
});
}

export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.post("/admin/auth/logout"),
// Nulling the cache entry makes AdminLayout redirect declaratively
onSuccess: () => queryClient.setQueryData(ME_KEY, null),
});
}
30 changes: 30 additions & 0 deletions client/src/hooks/useHomeContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { api } from "../lib/api";
import { ME_KEY } from "./useAuth";
import type { HomeContentValues } from "../schemas/content";

export const HOME_CONTENT_KEY = ["content", "home"] as const;

export function useHomeContent() {
return useQuery({
queryKey: HOME_CONTENT_KEY,
queryFn: () => api.get<HomeContentValues>("/content/home").then((res) => res.data),
});
}

export function useSaveHomeContent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: HomeContentValues) =>
api.put<HomeContentValues>("/admin/content/home", values).then((res) => res.data),
// forces refetch so every render sees the new values
onSuccess: () => queryClient.invalidateQueries({ queryKey: HOME_CONTENT_KEY }),
onError: (err) => {
if (isAxiosError(err) && err.response?.status === 401) {
// session died mid-edit -> nulls auth cache and AdminLayout redirects
queryClient.setQueryData(ME_KEY, null);
}
},
});
}
58 changes: 58 additions & 0 deletions client/src/layouts/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Navigate, NavLink, Outlet } from "react-router-dom";
import { useLogout, useMe } from "../hooks/useAuth";

// Adding an admin page = one entry here + one route in main.tsx.
const MENU = [{ to: "/admin/home-content", label: "Homepage text" }];

export default function AdminLayout() {
const { data: me, isPending } = useMe();
const logout = useLogout();

if (isPending) {
return (
<div className="min-h-screen bg-gray-950 grid place-items-center text-gray-400">
Checking login…
</div>
);
}

// if not logged in -> redirect
if (!me) {
return <Navigate to="/admin/login" replace />;
}

return (
<div className="min-h-screen bg-gray-950 text-white flex">
<aside className="w-64 flex flex-col border-r border-gray-800 p-4">
<h2 className="text-lg font-bold mb-1">UMSA Admin</h2>
<p className="text-xs text-gray-400 mb-6">{me.email}</p>
<nav className="flex flex-col gap-1 flex-1">
{MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`rounded-lg px-3 py-2 text-sm transition ${isActive
? "bg-blue-500 text-white"
: "text-gray-400 hover:bg-gray-800 hover:text-white"
}`
}
>
{item.label}
</NavLink>
))}
</nav>
<button
onClick={() => logout.mutate()}
disabled={logout.isPending}
className="mt-4 rounded-lg px-3 py-2 text-sm text-left text-gray-400 hover:bg-gray-800 hover:text-white disabled:opacity-50"
>
{logout.isPending ? "Logging out…" : "Log out"}
</button>
</aside>
<main className="flex-1 p-8">
<Outlet />
</main>
</div>
);
}
15 changes: 15 additions & 0 deletions client/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import axios, { isAxiosError } from "axios";

// one shared client for public AND admin requests
export const api = axios.create({
baseURL: "/api",
withCredentials: true, // send/receive the umsa_admin session cookie
});

export function apiErrorMessage(err: unknown): string {
if (isAxiosError(err)) {
// our server wraps errors as { error: { code, message } }
return err.response?.data?.error?.message ?? err.message;
}
return "Something went wrong";
}
31 changes: 29 additions & 2 deletions client/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import RootLayout from "./layouts/RootLayout";
import App from "./App";
import "./index.css";
Expand All @@ -27,6 +28,13 @@ import Tadiwa from "./pages/project-team/Tadiwa";
import Terrence from "./pages/project-team/Terrence";
import Alex from "./pages/project-team/Alex";

{
/* admin (CMS) imports */
}
import AdminLayout from "./layouts/AdminLayout";
import AdminLogin from "./pages/admin/Login";
import HomeContentEditor from "./pages/admin/HomeContentEditor";

{
/* here's where we set up all our routing */
}
Expand All @@ -52,10 +60,29 @@ const router = createBrowserRouter([
{ path: "about", element: <About /> },
],
},
/* admin routes live OUTSIDE RootLayout so they don't get the public navbar/footer */
{
path: "/admin/login",
element: <AdminLogin />,
},
{
path: "/admin",
element: <AdminLayout />,
children: [
{ index: true, element: <Navigate to="/admin/home-content" replace /> },
{ path: "home-content", element: <HomeContentEditor /> },
],
},
]);

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1 } },
});

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RouterProvider router={router} />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
);
73 changes: 73 additions & 0 deletions client/src/pages/admin/HomeContentEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { homeContentSchema, type HomeContentValues } from "../../schemas/content";
import { useHomeContent, useSaveHomeContent } from "../../hooks/useHomeContent";
import { apiErrorMessage } from "../../lib/api";

// template for future CMS pages:
// -> read with a query, prefill the form using react-hook-form, write with mutation
// can copy format for events / gallery / team editors
export default function HomeContentEditor() {
const { data, isPending, isError } = useHomeContent();
const save = useSaveHomeContent();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<HomeContentValues>({
resolver: zodResolver(homeContentSchema),
values: data, // syncs form whenever data changes
resetOptions: { keepDirtyValues: true },
});

if (isPending) {
return <p className="text-gray-400">Loading current content…</p>;
}
if (isError) {
return <p className="text-red-400">Could not load content — is the server running?</p>;
}

const onSubmit = handleSubmit((values) => save.mutate(values));

return (
<div className="max-w-2xl">
<h1 className="text-2xl font-bold mb-2">Homepage text</h1>
<p className="text-sm text-gray-400 mb-6">
This is the welcome message on the public homepage. Changes go live as soon as
you save.
</p>

<form onSubmit={onSubmit} className="flex flex-col gap-4">
<label className="flex flex-col gap-2 text-sm text-gray-300">
Heading
<input
{...register("heading")}
className="bg-transparent border-2 rounded-2xl px-6 py-4 text-white placeholder-gray-400 outline-none focus:border-blue-300 transition"
/>
</label>
{errors.heading && <p className="text-red-400 text-sm">{errors.heading.message}</p>}

<label className="flex flex-col gap-2 text-sm text-gray-300">
Subtitle
<textarea
{...register("subtitle")}
rows={3}
className="bg-transparent border-2 rounded-2xl px-6 py-4 text-white placeholder-gray-400 outline-none focus:border-blue-300 transition"
/>
</label>
{errors.subtitle && <p className="text-red-400 text-sm">{errors.subtitle.message}</p>}

<button
type="submit"
disabled={save.isPending}
className="bg-blue-300 text-black font-bold py-3 px-6 rounded-full hover:bg-blue-400 transition disabled:opacity-50 self-start"
>
{save.isPending ? "Saving…" : "Save"}
</button>

{save.isSuccess && <p className="text-green-400">Saved — check the homepage!</p>}
{save.isError && <p className="text-red-400">{apiErrorMessage(save.error)}</p>}
</form>
</div>
);
}
Loading
Loading