Skip to content
Open
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 backend/apps/authentications/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,4 @@ def validate(self, attrs):
raise serializers.ValidationError(
{"confirm_password": "Passwords do not match."}
)
return attrs
return attrs
2 changes: 1 addition & 1 deletion backend/apps/authentications/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,4 @@ def post(self, request):
return Response(
APIResponse.get_response(message="User created successfully.", data=data),
status=status.HTTP_201_CREATED,
)
)
1 change: 1 addition & 0 deletions backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
class HealthCheckAPI(APIView):
permission_classes = (AllowAny,)


def get(self, request):
status = {"status": "OK"}
return Response(status)
1 change: 1 addition & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8000
40 changes: 40 additions & 0 deletions frontend/app/components/HealthCheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* HealthCheck — presentational component that displays backend health status.
*
* Receives data as props from the route's clientLoader.
* No useEffect, no useState, no fetch — just renders what it's given.
*/

import { CenteredPageLayout } from "~/components/layout/CenteredPageLayout";

const statusStyles: Record<string, string> = {
ok: "text-green-600 dark:text-green-400",
error: "text-red-600 dark:text-red-400",
};

interface HealthCheckProps {
ok: boolean;
detail: string;
}

export function HealthCheck({ ok, detail }: HealthCheckProps) {
const status = ok ? "ok" : "error";

return (
<CenteredPageLayout maxWidth="max-w-md">
<h1 className="mb-4 text-center text-lg font-semibold text-gray-900 dark:text-gray-100">
Backend health check
</h1>

<p className={`mb-4 text-center text-sm font-medium ${statusStyles[status]}`}>
{ok ? "✅ Backend is healthy" : "❌ Backend unreachable"}
</p>

{detail && (
<pre className="overflow-x-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-left text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-400">
{detail}
</pre>
)}
</CenteredPageLayout>
);
}
64 changes: 64 additions & 0 deletions frontend/app/components/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* LoginForm — presentational component for the login page.
*
* All mutation logic lives in the route's clientAction.
* This component just renders the form UI and displays errors.
*/

import { Form, Link } from "react-router";
import { CenteredPageLayout } from "~/components/layout/CenteredPageLayout";
import { TextField } from "~/components/ui/TextField";

interface LoginFormProps {
error?: string;
isSubmitting: boolean;
}

export function LoginForm({ error, isSubmitting }: LoginFormProps) {
return (
<CenteredPageLayout>
<h1 className="mb-6 text-xl font-semibold text-gray-900 dark:text-gray-100">
Log in
</h1>

<Form method="post" className="space-y-4">
<TextField
label="Email"
name="email"
type="email"
required
autoComplete="email"
/>
<TextField
label="Password"
name="password"
type="password"
required
autoComplete="current-password"
/>

{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}

<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300"
>
{isSubmitting ? "Logging in…" : "Log in"}
</button>
</Form>

<p className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{" "}
<Link
to="/signup"
className="font-semibold text-gray-900 hover:underline dark:text-gray-100"
>
Sign up
</Link>
</p>
</CenteredPageLayout>
);
}
71 changes: 71 additions & 0 deletions frontend/app/components/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* SignupForm — presentational component for the signup page.
*
* All mutation logic lives in the route's clientAction.
* This component just renders the form UI and displays errors.
*/

import { Form, Link } from "react-router";
import { CenteredPageLayout } from "~/components/layout/CenteredPageLayout";
import { TextField } from "~/components/ui/TextField";

interface SignupFormProps {
error?: string;
isSubmitting: boolean;
}

export function SignupForm({ error, isSubmitting }: SignupFormProps) {
return (
<CenteredPageLayout>
<h1 className="mb-6 text-xl font-semibold text-gray-900 dark:text-gray-100">
Sign up
</h1>

<Form method="post" className="space-y-4">
<TextField
label="Email"
name="email"
type="email"
required
autoComplete="email"
/>
<TextField
label="Password"
name="password"
type="password"
required
autoComplete="new-password"
/>
<TextField
label="Confirm password"
name="confirm_password"
type="password"
required
autoComplete="new-password"
/>

{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}

<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300"
>
{isSubmitting ? "Signing up…" : "Sign up"}
</button>
</Form>

<p className="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-gray-900 hover:underline dark:text-gray-100"
>
Log in
</Link>
</p>
</CenteredPageLayout>
);
}
26 changes: 26 additions & 0 deletions frontend/app/components/layout/CenteredPageLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Shared page layout — centered card on a full-height background.
*
* Used by login, signup, health, and any future standalone pages.
*/

interface CenteredPageLayoutProps {
children: React.ReactNode;
/** Tailwind max-width class, defaults to "max-w-sm" */
maxWidth?: string;
}

export function CenteredPageLayout({
children,
maxWidth = "max-w-sm",
}: CenteredPageLayoutProps) {
return (
<div className="flex min-h-screen w-full items-center justify-center bg-white px-4 dark:bg-gray-950">
<div
className={`w-full ${maxWidth} rounded-xl border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900`}
>
{children}
</div>
</div>
);
}
54 changes: 54 additions & 0 deletions frontend/app/components/ui/TextField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Reusable text input with proper accessibility.
*
* - Renders <label htmlFor> + <input id name> for a11y
* - Uses name + defaultValue (uncontrolled) so React Router <Form> can read via formData
* - Supports an optional error message
*/

interface TextFieldProps {
label: string;
name: string;
id?: string;
type?: React.HTMLInputTypeAttribute;
required?: boolean;
defaultValue?: string;
error?: string;
autoComplete?: string;
}

export function TextField({
label,
name,
id,
type = "text",
required = false,
defaultValue,
error,
autoComplete,
}: TextFieldProps) {
const fieldId = id ?? name;

return (
<div>
<label
htmlFor={fieldId}
className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{label}
</label>
<input
id={fieldId}
name={name}
type={type}
required={required}
defaultValue={defaultValue}
autoComplete={autoComplete}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100 dark:focus:border-gray-100 dark:focus:ring-gray-100"
/>
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
}
68 changes: 68 additions & 0 deletions frontend/app/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Shared HTTP client — the only module that calls fetch().
*
* Every backend response follows the envelope:
* { message, code, data, error }
*
* apiFetch() unwraps that envelope:
* - On success → returns envelope.data (typed as T)
* - On failure → throws an Error with the backend's message or first error string
*/

import { API_URL } from "./config";
import { getAccessToken, refreshAccessToken } from "./auth";
import type { ApiEnvelope } from "./types";

export interface ApiFetchOptions {
method?: string;
body?: unknown;
/** Attach Authorization: Bearer header when true */
auth?: boolean;
}

export class ApiError extends Error {
status: number;
errors: Record<string, unknown>;

constructor(message: string, status: number, errors: Record<string, unknown> = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.errors = errors;
}
}

export async function apiFetch<T = Record<string, unknown>>(
path: string,
options: ApiFetchOptions = {}
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};

if (options.auth) {
let token = getAccessToken();
if (!token){
token = await refreshAccessToken();
}
if (token) headers["Authorization"] = `Bearer ${token}`;
}

const res = await fetch(`${API_URL}${path}`, {
method: options.method ?? "GET",
headers,
credentials: "include",
body: options.body ? JSON.stringify(options.body) : undefined,
});

const json: ApiEnvelope<T> | null = await res.json().catch(() => null);

if (!res.ok) {
const message =
json?.message || `Request failed (${res.status})`;
throw new ApiError(message, res.status, json?.error ?? {});
}

// Unwrap the envelope — callers get data directly
return json!.data;
}
Loading