Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1",
"motion": "^12.38.0",
"motion": "^12.42.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.80.0",
Expand Down
75 changes: 75 additions & 0 deletions client/src/components/admin/ImageUploadField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState } from "react";
import { useImageUpload } from "../../hooks/useImageUpload";

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];

type ImageValue = { url: string; publicId: string };

type Props = {
value?: ImageValue;
onChange: (value: ImageValue) => void;
folder: string;
};

export function ImageUploadField({ value, onChange, folder }: Props) {
const [previewUrl, setPreviewUrl] = useState<string | undefined>(value?.url);
const [validationError, setValidationError] = useState<string | undefined>();
const upload = useImageUpload(folder);

function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;

if (file.size > MAX_BYTES) {
setValidationError("File is too large (max 5 MB)");
return;
}
if (!ALLOWED_TYPES.includes(file.type)) {
setValidationError("Only JPEG, PNG or WebP images are allowed");
return;
}
setValidationError(undefined);

const localPreview = URL.createObjectURL(file);
setPreviewUrl(localPreview);

upload.mutate(file, {
onSuccess: (result) => {
URL.revokeObjectURL(localPreview);
setPreviewUrl(result.url);
onChange(result);
},
});
}

return (
<div className="border-2 border-solid border-white bg-transparent flex flex-col items-center gap-3 p-4 rounded-2xl max-w-[600px] mx-auto">

<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileChange}
disabled={upload.isPending}
className="bg-blue-300 max-w-full"
/>

{previewUrl && (
<img src={previewUrl} alt="Preview" className="w-full max-w-[600px] h-auto rounded" />
)}

{upload.isPending && <p>Uploading…</p>}
{validationError && <p className="text-red-600 text-sm text-center">{validationError}</p>}
{upload.isError && (
<p className="text-red-600 text-sm text-center">Upload failed. Please try again.</p>
)}

{value && (
<div className="w-full text-xs text-white break-all text-center">
<p className="w-full min-w-0 truncate">{value.url}</p>
<p className="w-full min-w-0 truncate">{value.publicId}</p>
</div>
)}
</div>
);
}
36 changes: 36 additions & 0 deletions client/src/hooks/useImageUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../lib/api";
import axios from "axios";
import { isAxiosError } from "axios";
import { ME_KEY } from "./useAuth";

export const useImageUpload = (folder: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const { data: sig } = await api.post("/admin/uploads/signature", { folder });
const form = new FormData();
form.append("file", file);
form.append("api_key", sig.apiKey);
form.append("timestamp", sig.timestamp);
form.append("signature", sig.signature);
form.append("folder", sig.folder);
// ^send a request with post method + /admin/uploads/signature path
// include the folder from when useImageUpload was called with folder in param
// wait for express to respond with apiKey, timestamp, signature and folder
// new formData object and we append file, apiKey, timestamp, signature and folder

const res = await axios.post(`https://api.cloudinary.com/v1_1/${sig.cloudName}/image/upload`, form);
return { url: res.data.secure_url, publicId: res.data.public_id };
// ^send request with post method to cloudinary w/ our cloudname we got from backend + all the formdata
// wait for cloudinary to send back response
// return res.data.secure_url and res.data.public_id as url and publicId
},
onError: (err) => {
if (isAxiosError(err) && err.response?.status === 401) {
// session died mid-edit -> nulls auth cache and AdminLayout redirects
queryClient.setQueryData(ME_KEY, null);
}
},
});
};
15 changes: 15 additions & 0 deletions client/src/layouts/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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" }];
const ImageUpload = [{ to: "/admin/image-upload", label: "Image Upload(temp)" }];//remove later

export default function AdminLayout() {
const { data: me, isPending } = useMe();
Expand Down Expand Up @@ -41,6 +42,20 @@ export default function AdminLayout() {
{item.label}
</NavLink>
))}
{ImageUpload.map((item) => (//remve later from here
<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>//to the line below
))}
</nav>
<button
onClick={() => logout.mutate()}
Expand Down
2 changes: 2 additions & 0 deletions client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import Alex from "./pages/project-team/Alex";
import AdminLayout from "./layouts/AdminLayout";
import AdminLogin from "./pages/admin/Login";
import HomeContentEditor from "./pages/admin/HomeContentEditor";
import TempImageUploadPage from "./pages/admin/tempImageUploadPage";//remove later

{
/* here's where we set up all our routing */
Expand Down Expand Up @@ -71,6 +72,7 @@ const router = createBrowserRouter([
children: [
{ index: true, element: <Navigate to="/admin/home-content" replace /> },
{ path: "home-content", element: <HomeContentEditor /> },
{ path: "image-upload", element: <TempImageUploadPage /> },//remove later
],
},
]);
Expand Down
13 changes: 13 additions & 0 deletions client/src/pages/admin/tempImageUploadPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useState } from "react";
import { ImageUploadField } from "../../components/admin/ImageUploadField";

export default function TempImageUploadPage() {
const [value, setValue] = useState<{ url: string; publicId: string } | undefined>();

return (
<div className="p-10">
<label className="flex flex-col gap-2 text-sm text-gray-300">Image Upload</label>
<ImageUploadField value={value} onChange={setValue} folder="umsa/gallery" />
</div>
);
}
6 changes: 3 additions & 3 deletions docs/01-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ All of these live in `server/.env`, which is gitignored.
|---|---|---|---|
| `ATLAS_URI` | **yes** | none — connect fails and the process exits 1 | MongoDB Atlas connection string. |
| `JWT_SECRET` | prod only | `"dev-only-secret-change-me"` | Signs session tokens. The server **refuses to boot** in production without it (`server/server.ts`), so a forgotten secret fails loudly instead of silently using the public default. |
| `CLOUDINARY_CLOUD_NAME` | not yet used | none | Cloudinary account identifier — reserved for an upcoming image upload feature, not yet wired into `src/`. |
| `CLOUDINARY_API_KEY` | not yet used | none | Cloudinary API key — same upcoming feature. |
| `CLOUDINARY_API_SECRET` | not yet used | none | Cloudinary API secret — same upcoming feature. |
| `CLOUDINARY_CLOUD_NAME` | for uploads | none | Cloudinary account identifier. All three `CLOUDINARY_*` vars are read by `POST /api/admin/uploads/signature`; without them that endpoint returns 500. |
| `CLOUDINARY_API_KEY` | for uploads | none | Cloudinary API key. Returned to the browser with the upload signature — it is a public value. |
| `CLOUDINARY_API_SECRET` | for uploads | none | Cloudinary API secret. Used server-side to sign uploads; never sent to the browser. |


## Troubleshooting
Expand Down
23 changes: 22 additions & 1 deletion docs/05-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ trimmed, unknown keys stripped.
`200` with the saved values. Uses `findOneAndUpdate({}, …, { upsert: true })`, so it creates the
singleton on first save.

## Admin — uploads

### `POST /api/admin/uploads/signature`
Body validated by `uploadSignatureSchema` — `folder` must be exactly one of `"umsa/gallery"`,
`"umsa/team"`, `"umsa/events"`, `"umsa/branding"`.

```json
{ "folder": "umsa/gallery" }
```

`200` with a short-lived Cloudinary upload signature:

```json
{ "signature": "…", "timestamp": 1730000000, "folder": "umsa/gallery", "cloudName": "…", "apiKey": "…" }
```

The browser uses this to upload the file **directly to Cloudinary** — the API never sees the
bytes. Only the public API key and the derived signature come back; `CLOUDINARY_API_SECRET`
never leaves the server. Returns `500 INTERNAL_ERROR` if the `CLOUDINARY_*` env vars are
missing, and `401` when logged out like every other `/api/admin/*` route.

## Behaviours that surprise people

- **`/api/admin/*` returns 401, not 404, for routes that don't exist** when you're logged out.
Expand All @@ -95,6 +116,6 @@ singleton on first save.

## Endpoints that do not exist yet

Anything for events, gallery, team members, theme, logos or uploads. Each is designed in
Anything for events, gallery, team members, theme or logos. Each is designed in
[blueprints/](blueprints/) — including its exact URL — so that when you build one, the URL is
already agreed and the frontend can be written against it in parallel.
2 changes: 1 addition & 1 deletion docs/08-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

- **`server/dist/` is stale build output. Never read it, never import from it.** It's gitignored
and rebuilt by `pnpm build`. It matters because it contains a *much larger* feature set —
Cloudinary uploads, developer CRUD, admin settings, password reset tokens — that has **no
developer CRUD, admin settings, password reset tokens — that has **no
TypeScript source in this branch**. Someone browsing `dist/` will confidently document features
that do not exist. The blueprints point at it deliberately in a couple of places as a *reference
for how it was done before*; that's the only legitimate use.
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ feature you were given, because it's the piece everything visual depends on.
| Admin login, session, logout | Done. One admin account, created by a seed script. |
| Homepage heading + subtitle | Done, end to end. This is the reference implementation. |
| Events, gallery, team, sponsors, FAQ, about copy | Hardcoded in `client/src/pages/*.tsx`. Not in the database. |
| Image uploads | Does not exist. No upload endpoint, no storage. See the blueprint. |
| Image uploads | Signing endpoint done (`POST /api/admin/uploads/signature`). No feature stores an image yet. See the blueprint. |
| Theme colours, fonts, logo | Static CSS in `client/src/tokens.css` and imported assets. Not editable. |
| Tests | No automated suite — small team, small app, not the point of this project. Run the app and click through the change before merging to `main`, since `main` deploys straight to Fly. |

Expand Down
34 changes: 25 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"type": "module",
"dependencies": {
"bcryptjs": "^3.0.3",
"cloudinary": "^2.10.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"express": "^5.2.1",
Expand Down
2 changes: 2 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import healthRoutes from "./routes/health.js";
import contentRoutes from "./routes/content.js";
import adminAuthRoutes from "./routes/admin/auth.js";
import adminContentRoutes from "./routes/admin/content.js";
import adminUploadRoutes from "./routes/admin/uploads.js";
import { apiRateLimit } from "./middleware/rateLimit.js";
import { requireAdmin } from "./middleware/requireAdmin.js";
import { notFound } from "./middleware/notFound.js";
Expand Down Expand Up @@ -37,6 +38,7 @@ app.use("/api/admin/auth", adminAuthRoutes);
// every /api/admin/* route below requires a logged-in admin account
app.use("/api/admin", requireAdmin);
app.use("/api/admin/content", adminContentRoutes);
app.use("/api/admin/uploads", adminUploadRoutes);

// fallbacks
app.use(notFound);
Expand Down
Loading
Loading