Phase 0.2: Remove frontend deprecated code
- Delete pages: sum, audit, admin/random-jobs - Delete old homepage (counter) and create redirect page - Update Header.tsx: remove Counter, Sum, Audit, Random Jobs nav items - Update auth-context.tsx: remove VIEW_COUNTER, INCREMENT_COUNTER, USE_SUM permissions - Update profile/page.test.tsx: fix nav link assertions
This commit is contained in:
parent
5bad1e7e17
commit
a5c1eccb4b
9 changed files with 27 additions and 1260 deletions
|
|
@ -1,229 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Permission } from "../../auth-context";
|
||||
import { api } from "../../api";
|
||||
import { sharedStyles } from "../../styles/shared";
|
||||
import { Header } from "../../components/Header";
|
||||
import { useRequireAuth } from "../../hooks/useRequireAuth";
|
||||
import { components } from "../../generated/api";
|
||||
|
||||
type RandomNumberOutcome = components["schemas"]["RandomNumberOutcomeResponse"];
|
||||
|
||||
export default function AdminRandomJobsPage() {
|
||||
const [outcomes, setOutcomes] = useState<RandomNumberOutcome[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_AUDIT,
|
||||
fallbackRedirect: "/",
|
||||
});
|
||||
|
||||
const fetchOutcomes = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<RandomNumberOutcome[]>("/api/audit/random-jobs");
|
||||
setOutcomes(data);
|
||||
} catch (err) {
|
||||
setOutcomes([]);
|
||||
setError(err instanceof Error ? err.message : "Failed to load outcomes");
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && isAuthorized) {
|
||||
fetchOutcomes();
|
||||
}
|
||||
}, [user, isAuthorized, fetchOutcomes]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<Header currentPage="admin-random-jobs" />
|
||||
|
||||
<div style={styles.content}>
|
||||
<div style={styles.tableCard}>
|
||||
<div style={styles.tableHeader}>
|
||||
<h2 style={styles.tableTitle}>Random Number Job Outcomes</h2>
|
||||
<span style={styles.totalCount}>{outcomes.length} outcomes</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.tableWrapper}>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>ID</th>
|
||||
<th style={styles.th}>Job ID</th>
|
||||
<th style={styles.th}>Triggered By</th>
|
||||
<th style={styles.th}>Value</th>
|
||||
<th style={styles.th}>Duration</th>
|
||||
<th style={styles.th}>Status</th>
|
||||
<th style={styles.th}>Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={7} style={styles.errorRow}>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!error && isLoadingData && (
|
||||
<tr>
|
||||
<td colSpan={7} style={styles.emptyRow}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!error && !isLoadingData && outcomes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={styles.emptyRow}>
|
||||
No job outcomes yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!error &&
|
||||
!isLoadingData &&
|
||||
outcomes.map((outcome) => (
|
||||
<tr key={outcome.id} style={styles.tr}>
|
||||
<td style={styles.tdNum}>{outcome.id}</td>
|
||||
<td style={styles.tdNum}>{outcome.job_id}</td>
|
||||
<td style={styles.td}>{outcome.triggered_by_email}</td>
|
||||
<td style={styles.tdValue}>{outcome.value}</td>
|
||||
<td style={styles.tdNum}>{outcome.duration_ms}ms</td>
|
||||
<td style={styles.td}>
|
||||
<span style={styles.statusBadge}>{outcome.status}</span>
|
||||
</td>
|
||||
<td style={styles.tdDate}>{formatDate(outcome.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyles: Record<string, React.CSSProperties> = {
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: "2rem",
|
||||
overflowY: "auto",
|
||||
},
|
||||
tableCard: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "20px",
|
||||
padding: "1.5rem",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
|
||||
maxWidth: "1200px",
|
||||
margin: "0 auto",
|
||||
},
|
||||
tableHeader: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
tableTitle: {
|
||||
fontFamily: "'Instrument Serif', Georgia, serif",
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 400,
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
},
|
||||
totalCount: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
},
|
||||
tableWrapper: {
|
||||
overflowX: "auto",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
},
|
||||
th: {
|
||||
textAlign: "left",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
},
|
||||
tr: {
|
||||
borderBottom: "1px solid rgba(255, 255, 255, 0.04)",
|
||||
},
|
||||
td: {
|
||||
padding: "0.875rem 1rem",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
},
|
||||
tdNum: {
|
||||
padding: "0.875rem 1rem",
|
||||
fontSize: "0.875rem",
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
},
|
||||
tdValue: {
|
||||
padding: "0.875rem 1rem",
|
||||
fontSize: "1rem",
|
||||
color: "#a78bfa",
|
||||
fontWeight: 600,
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
},
|
||||
tdDate: {
|
||||
padding: "0.875rem 1rem",
|
||||
fontSize: "0.75rem",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
},
|
||||
statusBadge: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.25rem 0.5rem",
|
||||
borderRadius: "4px",
|
||||
textTransform: "uppercase",
|
||||
background: "rgba(34, 197, 94, 0.2)",
|
||||
color: "rgba(34, 197, 94, 0.9)",
|
||||
},
|
||||
emptyRow: {
|
||||
padding: "2rem 1rem",
|
||||
textAlign: "center",
|
||||
color: "rgba(255, 255, 255, 0.3)",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
errorRow: {
|
||||
padding: "2rem 1rem",
|
||||
textAlign: "center",
|
||||
color: "#f87171",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = { ...sharedStyles, ...pageStyles };
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import AuditPage from "./page";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
// Default mock values for admin user
|
||||
let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
roles: ["admin"],
|
||||
permissions: ["view_audit"],
|
||||
};
|
||||
let mockIsLoading = false;
|
||||
const mockLogout = vi.fn();
|
||||
const mockHasPermission = vi.fn(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
|
||||
vi.mock("../auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockUser,
|
||||
isLoading: mockIsLoading,
|
||||
logout: mockLogout,
|
||||
hasPermission: mockHasPermission,
|
||||
hasRole: mockHasRole,
|
||||
}),
|
||||
Permission: {
|
||||
VIEW_COUNTER: "view_counter",
|
||||
INCREMENT_COUNTER: "increment_counter",
|
||||
USE_SUM: "use_sum",
|
||||
VIEW_AUDIT: "view_audit",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
roles: ["admin"],
|
||||
permissions: ["view_audit"],
|
||||
};
|
||||
mockIsLoading = false;
|
||||
mockHasPermission.mockImplementation(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
// Default: successful empty response
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ records: [], total: 0, page: 1, per_page: 10, total_pages: 1 }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("AuditPage", () => {
|
||||
it("shows loading state", () => {
|
||||
mockIsLoading = true;
|
||||
render(<AuditPage />);
|
||||
expect(screen.getByText("Loading...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("redirects to login when not authenticated", async () => {
|
||||
mockUser = null;
|
||||
render(<AuditPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to home when user lacks audit permission", async () => {
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "user@example.com",
|
||||
roles: ["regular"],
|
||||
permissions: ["view_counter"],
|
||||
};
|
||||
mockHasPermission.mockReturnValue(false);
|
||||
render(<AuditPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays error message when API fetch fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||
render(<AuditPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Both tables should show errors since both calls fail
|
||||
const errors = screen.getAllByText("Network error");
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays error when API returns non-ok response", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ detail: "Internal server error" }),
|
||||
});
|
||||
|
||||
render(<AuditPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
const errors = screen.getAllByText("Request failed: 500");
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays records when fetch succeeds", async () => {
|
||||
const counterResponse = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
user_email: "recorduser@example.com",
|
||||
value_before: 0,
|
||||
value_after: 1,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total_pages: 1,
|
||||
};
|
||||
|
||||
const sumResponse = {
|
||||
records: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total_pages: 1,
|
||||
};
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(counterResponse),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(sumResponse),
|
||||
});
|
||||
|
||||
render(<AuditPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("recorduser@example.com")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows table headers", async () => {
|
||||
render(<AuditPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for counter table headers
|
||||
expect(screen.getByText("Counter Activity")).toBeTruthy();
|
||||
expect(screen.getByText("Sum Activity")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Permission } from "../auth-context";
|
||||
import { api } from "../api";
|
||||
import { layoutStyles, cardStyles, tableStyles, paginationStyles } from "../styles/shared";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
|
||||
// Use generated types from OpenAPI schema
|
||||
type _CounterRecord = components["schemas"]["CounterRecordResponse"];
|
||||
type _SumRecord = components["schemas"]["SumRecordResponse"];
|
||||
type PaginatedCounterRecords = components["schemas"]["PaginatedResponse_CounterRecordResponse_"];
|
||||
type PaginatedSumRecords = components["schemas"]["PaginatedResponse_SumRecordResponse_"];
|
||||
|
||||
export default function AuditPage() {
|
||||
const [counterData, setCounterData] = useState<PaginatedCounterRecords | null>(null);
|
||||
const [sumData, setSumData] = useState<PaginatedSumRecords | null>(null);
|
||||
const [counterError, setCounterError] = useState<string | null>(null);
|
||||
const [sumError, setSumError] = useState<string | null>(null);
|
||||
const [counterPage, setCounterPage] = useState(1);
|
||||
const [sumPage, setSumPage] = useState(1);
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_AUDIT,
|
||||
fallbackRedirect: "/",
|
||||
});
|
||||
|
||||
const fetchCounterRecords = useCallback(async (page: number) => {
|
||||
setCounterError(null);
|
||||
try {
|
||||
const data = await api.get<PaginatedCounterRecords>(
|
||||
`/api/audit/counter?page=${page}&per_page=10`
|
||||
);
|
||||
setCounterData(data);
|
||||
} catch (err) {
|
||||
setCounterData(null);
|
||||
setCounterError(err instanceof Error ? err.message : "Failed to load counter records");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSumRecords = useCallback(async (page: number) => {
|
||||
setSumError(null);
|
||||
try {
|
||||
const data = await api.get<PaginatedSumRecords>(`/api/audit/sum?page=${page}&per_page=10`);
|
||||
setSumData(data);
|
||||
} catch (err) {
|
||||
setSumData(null);
|
||||
setSumError(err instanceof Error ? err.message : "Failed to load sum records");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && isAuthorized) {
|
||||
fetchCounterRecords(counterPage);
|
||||
}
|
||||
}, [user, counterPage, isAuthorized, fetchCounterRecords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && isAuthorized) {
|
||||
fetchSumRecords(sumPage);
|
||||
}
|
||||
}, [user, sumPage, isAuthorized, fetchSumRecords]);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<div style={layoutStyles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<Header currentPage="audit" />
|
||||
|
||||
<div style={layoutStyles.contentScrollable}>
|
||||
<div style={styles.tablesContainer}>
|
||||
{/* Counter Records Table */}
|
||||
<div style={cardStyles.tableCard}>
|
||||
<div style={tableStyles.tableHeader}>
|
||||
<h2 style={tableStyles.tableTitle}>Counter Activity</h2>
|
||||
<span style={tableStyles.totalCount}>{counterData?.total ?? 0} records</span>
|
||||
</div>
|
||||
<div style={tableStyles.tableWrapper}>
|
||||
<table style={tableStyles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={tableStyles.th}>User</th>
|
||||
<th style={tableStyles.th}>Before</th>
|
||||
<th style={tableStyles.th}>After</th>
|
||||
<th style={tableStyles.th}>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{counterError && (
|
||||
<tr>
|
||||
<td colSpan={4} style={tableStyles.errorRow}>
|
||||
{counterError}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!counterError &&
|
||||
counterData?.records.map((record) => (
|
||||
<tr key={record.id} style={tableStyles.tr}>
|
||||
<td style={tableStyles.td}>{record.user_email}</td>
|
||||
<td style={tableStyles.tdNum}>{record.value_before}</td>
|
||||
<td style={tableStyles.tdNum}>{record.value_after}</td>
|
||||
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!counterError && (!counterData || counterData.records.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={4} style={tableStyles.emptyRow}>
|
||||
No records yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{counterData && counterData.total_pages > 1 && (
|
||||
<div style={paginationStyles.pagination}>
|
||||
<button
|
||||
onClick={() => setCounterPage((p) => Math.max(1, p - 1))}
|
||||
disabled={counterPage === 1}
|
||||
style={paginationStyles.pageBtn}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span style={paginationStyles.pageInfo}>
|
||||
{counterPage} / {counterData.total_pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCounterPage((p) => Math.min(counterData.total_pages, p + 1))}
|
||||
disabled={counterPage === counterData.total_pages}
|
||||
style={paginationStyles.pageBtn}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sum Records Table */}
|
||||
<div style={cardStyles.tableCard}>
|
||||
<div style={tableStyles.tableHeader}>
|
||||
<h2 style={tableStyles.tableTitle}>Sum Activity</h2>
|
||||
<span style={tableStyles.totalCount}>{sumData?.total ?? 0} records</span>
|
||||
</div>
|
||||
<div style={tableStyles.tableWrapper}>
|
||||
<table style={tableStyles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={tableStyles.th}>User</th>
|
||||
<th style={tableStyles.th}>A</th>
|
||||
<th style={tableStyles.th}>B</th>
|
||||
<th style={tableStyles.th}>Result</th>
|
||||
<th style={tableStyles.th}>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sumError && (
|
||||
<tr>
|
||||
<td colSpan={5} style={tableStyles.errorRow}>
|
||||
{sumError}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!sumError &&
|
||||
sumData?.records.map((record) => (
|
||||
<tr key={record.id} style={tableStyles.tr}>
|
||||
<td style={tableStyles.td}>{record.user_email}</td>
|
||||
<td style={tableStyles.tdNum}>{record.a}</td>
|
||||
<td style={tableStyles.tdNum}>{record.b}</td>
|
||||
<td style={styles.tdResult}>{record.result}</td>
|
||||
<td style={tableStyles.tdDate}>{formatDate(record.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!sumError && (!sumData || sumData.records.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={5} style={tableStyles.emptyRow}>
|
||||
No records yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{sumData && sumData.total_pages > 1 && (
|
||||
<div style={paginationStyles.pagination}>
|
||||
<button
|
||||
onClick={() => setSumPage((p) => Math.max(1, p - 1))}
|
||||
disabled={sumPage === 1}
|
||||
style={paginationStyles.pageBtn}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span style={paginationStyles.pageInfo}>
|
||||
{sumPage} / {sumData.total_pages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSumPage((p) => Math.min(sumData.total_pages, p + 1))}
|
||||
disabled={sumPage === sumData.total_pages}
|
||||
style={paginationStyles.pageBtn}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Page-specific styles only
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
tablesContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "2rem",
|
||||
maxWidth: "1200px",
|
||||
margin: "0 auto",
|
||||
},
|
||||
tdResult: {
|
||||
padding: "0.875rem 1rem",
|
||||
fontSize: "0.875rem",
|
||||
color: "#a78bfa",
|
||||
fontWeight: 600,
|
||||
fontFamily: "'DM Sans', monospace",
|
||||
},
|
||||
};
|
||||
|
|
@ -12,9 +12,6 @@ export type PermissionType = components["schemas"]["Permission"];
|
|||
// The type annotation ensures compile-time validation against the generated schema.
|
||||
// Adding a new permission in the backend will cause a type error here until updated.
|
||||
export const Permission: Record<string, PermissionType> = {
|
||||
VIEW_COUNTER: "view_counter",
|
||||
INCREMENT_COUNTER: "increment_counter",
|
||||
USE_SUM: "use_sum",
|
||||
VIEW_AUDIT: "view_audit",
|
||||
FETCH_PRICE: "fetch_price",
|
||||
MANAGE_OWN_PROFILE: "manage_own_profile",
|
||||
|
|
|
|||
|
|
@ -8,17 +8,13 @@ import constants from "../../../shared/constants.json";
|
|||
const { ADMIN, REGULAR } = constants.roles;
|
||||
|
||||
type PageId =
|
||||
| "counter"
|
||||
| "sum"
|
||||
| "profile"
|
||||
| "invites"
|
||||
| "booking"
|
||||
| "appointments"
|
||||
| "audit"
|
||||
| "admin-invites"
|
||||
| "admin-availability"
|
||||
| "admin-appointments"
|
||||
| "admin-random-jobs"
|
||||
| "admin-price-history";
|
||||
|
||||
interface HeaderProps {
|
||||
|
|
@ -34,8 +30,6 @@ interface NavItem {
|
|||
}
|
||||
|
||||
const REGULAR_NAV_ITEMS: NavItem[] = [
|
||||
{ id: "counter", label: "Counter", href: "/" },
|
||||
{ id: "sum", label: "Sum", href: "/sum" },
|
||||
{ id: "booking", label: "Book", href: "/booking", regularOnly: true },
|
||||
{ id: "appointments", label: "Appointments", href: "/appointments", regularOnly: true },
|
||||
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
|
||||
|
|
@ -43,11 +37,9 @@ const REGULAR_NAV_ITEMS: NavItem[] = [
|
|||
];
|
||||
|
||||
const ADMIN_NAV_ITEMS: NavItem[] = [
|
||||
{ id: "audit", label: "Audit", href: "/audit", adminOnly: true },
|
||||
{ id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true },
|
||||
{ id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true },
|
||||
{ id: "admin-appointments", label: "Appointments", href: "/admin/appointments", adminOnly: true },
|
||||
{ id: "admin-random-jobs", label: "Random Jobs", href: "/admin/random-jobs", adminOnly: true },
|
||||
{ id: "admin-price-history", label: "Prices", href: "/admin/price-history", adminOnly: true },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,263 +0,0 @@
|
|||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { expect, test, vi, beforeEach, afterEach, describe } from "vitest";
|
||||
import Home from "./page";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Default mock values
|
||||
let mockUser: { id: number; email: string; roles: string[]; permissions: string[] } | null = {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
roles: ["regular"],
|
||||
permissions: ["view_counter", "increment_counter", "use_sum"],
|
||||
};
|
||||
let mockIsLoading = false;
|
||||
const mockLogout = vi.fn();
|
||||
const mockHasPermission = vi.fn(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
const mockHasRole = vi.fn((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
|
||||
vi.mock("./auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: mockUser,
|
||||
isLoading: mockIsLoading,
|
||||
logout: mockLogout,
|
||||
hasPermission: mockHasPermission,
|
||||
hasRole: mockHasRole,
|
||||
}),
|
||||
Permission: {
|
||||
VIEW_COUNTER: "view_counter",
|
||||
INCREMENT_COUNTER: "increment_counter",
|
||||
USE_SUM: "use_sum",
|
||||
VIEW_AUDIT: "view_audit",
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset to authenticated state
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
roles: ["regular"],
|
||||
permissions: ["view_counter", "increment_counter", "use_sum"],
|
||||
};
|
||||
mockIsLoading = false;
|
||||
mockHasPermission.mockImplementation(
|
||||
(permission: string) => mockUser?.permissions.includes(permission) ?? false
|
||||
);
|
||||
mockHasRole.mockImplementation((role: string) => mockUser?.roles.includes(role) ?? false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Home - Authenticated", () => {
|
||||
test("renders loading state when isLoading is true", () => {
|
||||
mockIsLoading = true;
|
||||
vi.spyOn(global, "fetch").mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<Home />);
|
||||
expect(screen.getByText("Loading...")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders user email in header", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ value: 42 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
expect(screen.getByText("test@example.com")).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders sign out button", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ value: 42 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
expect(screen.getByText("Sign out")).toBeDefined();
|
||||
});
|
||||
|
||||
test("clicking sign out calls logout and redirects", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ value: 42 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
fireEvent.click(screen.getByText("Sign out"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
test("renders counter value after fetch", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ value: 42 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("42")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("fetches counter with credentials", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ value: 0 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:8000/api/counter",
|
||||
expect.objectContaining({
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders increment button", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ value: 0 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
expect(screen.getByText("Increment")).toBeDefined();
|
||||
});
|
||||
|
||||
test("clicking increment button calls API with credentials", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response);
|
||||
|
||||
render(<Home />);
|
||||
await waitFor(() => expect(screen.getByText("0")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByText("Increment"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"http://localhost:8000/api/counter/increment",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking increment updates displayed count", async () => {
|
||||
vi.spyOn(global, "fetch")
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 0 }) } as Response)
|
||||
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 1 }) } as Response);
|
||||
|
||||
render(<Home />);
|
||||
await waitFor(() => expect(screen.getByText("0")).toBeDefined());
|
||||
|
||||
fireEvent.click(screen.getByText("Increment"));
|
||||
await waitFor(() => expect(screen.getByText("1")).toBeDefined());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Home - Unauthenticated", () => {
|
||||
test("redirects to login when not authenticated", async () => {
|
||||
mockUser = null;
|
||||
|
||||
render(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when not authenticated", () => {
|
||||
mockUser = null;
|
||||
|
||||
const { container } = render(<Home />);
|
||||
// Should render nothing (just redirects)
|
||||
expect(container.querySelector("main")).toBeNull();
|
||||
});
|
||||
|
||||
test("does not fetch counter when no user", () => {
|
||||
mockUser = null;
|
||||
const fetchSpy = vi.spyOn(global, "fetch");
|
||||
|
||||
render(<Home />);
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Home - Loading State", () => {
|
||||
test("does not redirect while loading", () => {
|
||||
mockIsLoading = true;
|
||||
mockUser = null;
|
||||
|
||||
render(<Home />);
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows loading indicator while loading", () => {
|
||||
mockIsLoading = true;
|
||||
|
||||
render(<Home />);
|
||||
|
||||
expect(screen.getByText("Loading...")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Home - Navigation", () => {
|
||||
test("shows My Profile link for regular user", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
json: () => Promise.resolve({ value: 0 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("My Profile")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not show My Profile link for admin user", async () => {
|
||||
mockUser = {
|
||||
id: 1,
|
||||
email: "admin@example.com",
|
||||
roles: ["admin"],
|
||||
permissions: ["view_counter", "view_audit"],
|
||||
};
|
||||
|
||||
vi.spyOn(global, "fetch").mockResolvedValue({
|
||||
json: () => Promise.resolve({ value: 0 }),
|
||||
} as Response);
|
||||
|
||||
render(<Home />);
|
||||
|
||||
// Wait for render - admin sees admin nav (Audit, Invites) not regular nav
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Audit")).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByText("My Profile")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,103 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Permission } from "./auth-context";
|
||||
import { api } from "./api";
|
||||
import { layoutStyles, cardStyles, buttonStyles } from "./styles/shared";
|
||||
import { Header } from "./components/Header";
|
||||
import { useRequireAuth } from "./hooks/useRequireAuth";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "./auth-context";
|
||||
import { layoutStyles } from "./styles/shared";
|
||||
import constants from "../../shared/constants.json";
|
||||
|
||||
const { ADMIN, REGULAR } = constants.roles;
|
||||
|
||||
export default function Home() {
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.VIEW_COUNTER,
|
||||
});
|
||||
const { user, isLoading, hasRole } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user && isAuthorized) {
|
||||
api
|
||||
.get<{ value: number }>("/api/counter")
|
||||
.then((data) => setCount(data.value))
|
||||
.catch(() => setCount(null));
|
||||
if (isLoading) return;
|
||||
|
||||
if (!user) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
}, [user, isAuthorized]);
|
||||
|
||||
const increment = async () => {
|
||||
const data = await api.post<{ value: number }>("/api/counter/increment");
|
||||
setCount(data.value);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<div style={layoutStyles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
// Redirect based on role
|
||||
if (hasRole(ADMIN)) {
|
||||
router.replace("/admin/appointments");
|
||||
} else if (hasRole(REGULAR)) {
|
||||
router.replace("/booking");
|
||||
} else {
|
||||
// User with no roles - redirect to login
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [user, isLoading, hasRole, router]);
|
||||
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<Header currentPage="counter" />
|
||||
|
||||
<div style={layoutStyles.contentCentered}>
|
||||
<div style={styles.counterCard}>
|
||||
<span style={styles.counterLabel}>Current Count</span>
|
||||
<h1 style={styles.counter}>{count === null ? "..." : count}</h1>
|
||||
<button onClick={increment} style={styles.incrementBtn}>
|
||||
<span style={styles.plusIcon}>+</span>
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={layoutStyles.loader}>Redirecting...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Page-specific styles only - truly unique to this page
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
counterCard: {
|
||||
...cardStyles.card,
|
||||
borderRadius: "32px",
|
||||
padding: "4rem 5rem",
|
||||
textAlign: "center",
|
||||
},
|
||||
counterLabel: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
display: "block",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
fontSize: "0.875rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.1em",
|
||||
marginBottom: "1rem",
|
||||
},
|
||||
counter: {
|
||||
fontFamily: "'Instrument Serif', Georgia, serif",
|
||||
fontSize: "8rem",
|
||||
fontWeight: 400,
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
lineHeight: 1,
|
||||
background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
},
|
||||
incrementBtn: {
|
||||
...buttonStyles.primaryButton,
|
||||
marginTop: "2.5rem",
|
||||
padding: "1rem 2.5rem",
|
||||
fontSize: "1.125rem",
|
||||
borderRadius: "16px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
plusIcon: {
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 400,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -211,8 +211,8 @@ describe("ProfilePage - Navigation", () => {
|
|||
|
||||
render(<ProfilePage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Counter")).toBeDefined();
|
||||
expect(screen.getByText("Sum")).toBeDefined();
|
||||
expect(screen.getByText("Book")).toBeDefined();
|
||||
expect(screen.getByText("Appointments")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,248 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Permission } from "../auth-context";
|
||||
import { api } from "../api";
|
||||
import { sharedStyles } from "../styles/shared";
|
||||
import { Header } from "../components/Header";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
|
||||
export default function SumPage() {
|
||||
const [a, setA] = useState("");
|
||||
const [b, setB] = useState("");
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||
requiredPermission: Permission.USE_SUM,
|
||||
});
|
||||
|
||||
const handleSum = async () => {
|
||||
const numA = parseFloat(a) || 0;
|
||||
const numB = parseFloat(b) || 0;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await api.post<{ result: number }>("/api/sum", { a: numA, b: numB });
|
||||
setResult(data.result);
|
||||
setShowResult(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Calculation failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setA("");
|
||||
setB("");
|
||||
setResult(null);
|
||||
setShowResult(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<div style={styles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAuthorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={styles.main}>
|
||||
<Header currentPage="sum" />
|
||||
|
||||
<div style={styles.content}>
|
||||
<div style={styles.card}>
|
||||
<span style={styles.label}>Sum Calculator</span>
|
||||
|
||||
{!showResult ? (
|
||||
<div style={styles.inputSection}>
|
||||
<div style={styles.inputRow}>
|
||||
<div style={styles.inputWrapper}>
|
||||
<input
|
||||
type="number"
|
||||
value={a}
|
||||
onChange={(e) => setA(e.target.value)}
|
||||
placeholder="0"
|
||||
style={styles.input}
|
||||
aria-label="First number"
|
||||
/>
|
||||
</div>
|
||||
<span style={styles.operator}>+</span>
|
||||
<div style={styles.inputWrapper}>
|
||||
<input
|
||||
type="number"
|
||||
value={b}
|
||||
onChange={(e) => setB(e.target.value)}
|
||||
placeholder="0"
|
||||
style={styles.input}
|
||||
aria-label="Second number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleSum} style={styles.sumBtn} disabled={a === "" && b === ""}>
|
||||
<span style={styles.equalsIcon}>=</span>
|
||||
Calculate
|
||||
</button>
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.resultSection}>
|
||||
<div style={styles.equation}>
|
||||
<span style={styles.equationNum}>{parseFloat(a) || 0}</span>
|
||||
<span style={styles.equationOp}>+</span>
|
||||
<span style={styles.equationNum}>{parseFloat(b) || 0}</span>
|
||||
<span style={styles.equationOp}>=</span>
|
||||
</div>
|
||||
<h1 style={styles.result}>{result}</h1>
|
||||
<button onClick={handleReset} style={styles.resetBtn}>
|
||||
<span style={styles.resetIcon}>↺</span>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyles: Record<string, React.CSSProperties> = {
|
||||
card: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "32px",
|
||||
padding: "3rem 4rem",
|
||||
textAlign: "center",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
|
||||
minWidth: "400px",
|
||||
},
|
||||
label: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
display: "block",
|
||||
color: "rgba(255, 255, 255, 0.4)",
|
||||
fontSize: "0.875rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.1em",
|
||||
marginBottom: "2rem",
|
||||
},
|
||||
inputSection: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "2rem",
|
||||
},
|
||||
inputRow: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
},
|
||||
inputWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
input: {
|
||||
fontFamily: "'Instrument Serif', Georgia, serif",
|
||||
fontSize: "3rem",
|
||||
fontWeight: 400,
|
||||
width: "120px",
|
||||
padding: "0.75rem 1rem",
|
||||
textAlign: "center",
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
border: "2px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "16px",
|
||||
color: "#fff",
|
||||
outline: "none",
|
||||
transition: "border-color 0.2s, box-shadow 0.2s",
|
||||
},
|
||||
operator: {
|
||||
fontFamily: "'Instrument Serif', Georgia, serif",
|
||||
fontSize: "3rem",
|
||||
color: "#a78bfa",
|
||||
fontWeight: 400,
|
||||
},
|
||||
sumBtn: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
padding: "1rem 2.5rem",
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: 600,
|
||||
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: "16px",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
transition: "transform 0.2s, box-shadow 0.2s",
|
||||
boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)",
|
||||
},
|
||||
equalsIcon: {
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 400,
|
||||
},
|
||||
resultSection: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
},
|
||||
equation: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
},
|
||||
equationNum: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.25rem",
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
},
|
||||
equationOp: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
fontSize: "1.25rem",
|
||||
color: "rgba(139, 92, 246, 0.8)",
|
||||
},
|
||||
result: {
|
||||
fontFamily: "'Instrument Serif', Georgia, serif",
|
||||
fontSize: "7rem",
|
||||
fontWeight: 400,
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
lineHeight: 1,
|
||||
background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
},
|
||||
resetBtn: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
marginTop: "2rem",
|
||||
padding: "0.875rem 2rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 500,
|
||||
background: "rgba(255, 255, 255, 0.05)",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: "12px",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
transition: "all 0.2s",
|
||||
},
|
||||
resetIcon: {
|
||||
fontSize: "1.25rem",
|
||||
},
|
||||
error: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "#f87171",
|
||||
fontSize: "0.875rem",
|
||||
marginTop: "0.5rem",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = { ...sharedStyles, ...pageStyles };
|
||||
Loading…
Add table
Add a link
Reference in a new issue