silly features in place
This commit is contained in:
parent
c5d3c7f4c9
commit
322bdd3e6e
5 changed files with 997 additions and 6 deletions
174
backend/main.py
174
backend/main.py
|
|
@ -1,11 +1,12 @@
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Response, status
|
from fastapi import FastAPI, Depends, HTTPException, Response, status, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func, desc
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
from database import engine, get_db, Base
|
from database import engine, get_db, Base
|
||||||
from models import Counter, User
|
from models import Counter, User, SumRecord, CounterRecord
|
||||||
from auth import (
|
from auth import (
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
COOKIE_NAME,
|
COOKIE_NAME,
|
||||||
|
|
@ -129,10 +130,175 @@ async def get_counter(
|
||||||
@app.post("/api/counter/increment")
|
@app.post("/api/counter/increment")
|
||||||
async def increment_counter(
|
async def increment_counter(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
counter = await get_or_create_counter(db)
|
counter = await get_or_create_counter(db)
|
||||||
|
value_before = counter.value
|
||||||
counter.value += 1
|
counter.value += 1
|
||||||
|
|
||||||
|
record = CounterRecord(
|
||||||
|
user_id=current_user.id,
|
||||||
|
value_before=value_before,
|
||||||
|
value_after=counter.value,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"value": counter.value}
|
return {"value": counter.value}
|
||||||
|
|
||||||
|
|
||||||
|
# Sum endpoints
|
||||||
|
class SumRequest(BaseModel):
|
||||||
|
a: float
|
||||||
|
b: float
|
||||||
|
|
||||||
|
|
||||||
|
class SumResponse(BaseModel):
|
||||||
|
a: float
|
||||||
|
b: float
|
||||||
|
result: float
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/sum", response_model=SumResponse)
|
||||||
|
async def calculate_sum(
|
||||||
|
data: SumRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = data.a + data.b
|
||||||
|
record = SumRecord(
|
||||||
|
user_id=current_user.id,
|
||||||
|
a=data.a,
|
||||||
|
b=data.b,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
return SumResponse(a=data.a, b=data.b, result=result)
|
||||||
|
|
||||||
|
|
||||||
|
# Audit endpoints
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class CounterRecordResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_email: str
|
||||||
|
value_before: int
|
||||||
|
value_after: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SumRecordResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_email: str
|
||||||
|
a: float
|
||||||
|
b: float
|
||||||
|
result: float
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedCounterRecords(BaseModel):
|
||||||
|
records: List[CounterRecordResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedSumRecords(BaseModel):
|
||||||
|
records: List[SumRecordResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/audit/counter", response_model=PaginatedCounterRecords)
|
||||||
|
async def get_counter_records(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(10, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Get total count
|
||||||
|
count_result = await db.execute(select(func.count(CounterRecord.id)))
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||||
|
|
||||||
|
# Get paginated records with user email
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
query = (
|
||||||
|
select(CounterRecord, User.email)
|
||||||
|
.join(User, CounterRecord.user_id == User.id)
|
||||||
|
.order_by(desc(CounterRecord.created_at))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
records = [
|
||||||
|
CounterRecordResponse(
|
||||||
|
id=record.id,
|
||||||
|
user_email=email,
|
||||||
|
value_before=record.value_before,
|
||||||
|
value_after=record.value_after,
|
||||||
|
created_at=record.created_at,
|
||||||
|
)
|
||||||
|
for record, email in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return PaginatedCounterRecords(
|
||||||
|
records=records,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/audit/sum", response_model=PaginatedSumRecords)
|
||||||
|
async def get_sum_records(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(10, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Get total count
|
||||||
|
count_result = await db.execute(select(func.count(SumRecord.id)))
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||||
|
|
||||||
|
# Get paginated records with user email
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
query = (
|
||||||
|
select(SumRecord, User.email)
|
||||||
|
.join(User, SumRecord.user_id == User.id)
|
||||||
|
.order_by(desc(SumRecord.created_at))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
records = [
|
||||||
|
SumRecordResponse(
|
||||||
|
id=record.id,
|
||||||
|
user_email=email,
|
||||||
|
a=record.a,
|
||||||
|
b=record.b,
|
||||||
|
result=record.result,
|
||||||
|
created_at=record.created_at,
|
||||||
|
)
|
||||||
|
for record, email in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return PaginatedSumRecords(
|
||||||
|
records=records,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from sqlalchemy import Integer, String
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Integer, String, Float, DateTime, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from database import Base
|
from database import Base
|
||||||
|
|
||||||
|
|
@ -17,3 +18,24 @@ class User(Base):
|
||||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SumRecord(Base):
|
||||||
|
__tablename__ = "sum_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
a: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
b: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
result: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CounterRecord(Base):
|
||||||
|
__tablename__ = "counter_records"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
value_before: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
value_after: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
|
||||||
428
frontend/app/audit/page.tsx
Normal file
428
frontend/app/audit/page.tsx
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "../auth-context";
|
||||||
|
import { API_URL } from "../config";
|
||||||
|
|
||||||
|
interface CounterRecord {
|
||||||
|
id: number;
|
||||||
|
user_email: string;
|
||||||
|
value_before: number;
|
||||||
|
value_after: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SumRecord {
|
||||||
|
id: number;
|
||||||
|
user_email: string;
|
||||||
|
a: number;
|
||||||
|
b: number;
|
||||||
|
result: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
records: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditPage() {
|
||||||
|
const [counterData, setCounterData] = useState<PaginatedResponse<CounterRecord> | null>(null);
|
||||||
|
const [sumData, setSumData] = useState<PaginatedResponse<SumRecord> | null>(null);
|
||||||
|
const [counterPage, setCounterPage] = useState(1);
|
||||||
|
const [sumPage, setSumPage] = useState(1);
|
||||||
|
const { user, isLoading, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [isLoading, user, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchCounterRecords(counterPage);
|
||||||
|
}
|
||||||
|
}, [user, counterPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
fetchSumRecords(sumPage);
|
||||||
|
}
|
||||||
|
}, [user, sumPage]);
|
||||||
|
|
||||||
|
const fetchCounterRecords = async (page: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/audit/counter?page=${page}&per_page=10`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setCounterData(data);
|
||||||
|
} catch {
|
||||||
|
setCounterData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSumRecords = async (page: number) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/audit/sum?page=${page}&per_page=10`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setSumData(data);
|
||||||
|
} catch {
|
||||||
|
setSumData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={styles.main}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<div style={styles.nav}>
|
||||||
|
<a href="/" style={styles.navLink}>Counter</a>
|
||||||
|
<span style={styles.navDivider}>•</span>
|
||||||
|
<a href="/sum" style={styles.navLink}>Sum</a>
|
||||||
|
<span style={styles.navDivider}>•</span>
|
||||||
|
<span style={styles.navCurrent}>Audit</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.userInfo}>
|
||||||
|
<span style={styles.userEmail}>{user.email}</span>
|
||||||
|
<button onClick={handleLogout} style={styles.logoutBtn}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.content}>
|
||||||
|
<div style={styles.tablesContainer}>
|
||||||
|
{/* Counter Records Table */}
|
||||||
|
<div style={styles.tableCard}>
|
||||||
|
<div style={styles.tableHeader}>
|
||||||
|
<h2 style={styles.tableTitle}>Counter Activity</h2>
|
||||||
|
<span style={styles.totalCount}>
|
||||||
|
{counterData?.total ?? 0} records
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.tableWrapper}>
|
||||||
|
<table style={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={styles.th}>User</th>
|
||||||
|
<th style={styles.th}>Before</th>
|
||||||
|
<th style={styles.th}>After</th>
|
||||||
|
<th style={styles.th}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{counterData?.records.map((record) => (
|
||||||
|
<tr key={record.id} style={styles.tr}>
|
||||||
|
<td style={styles.td}>{record.user_email}</td>
|
||||||
|
<td style={styles.tdNum}>{record.value_before}</td>
|
||||||
|
<td style={styles.tdNum}>{record.value_after}</td>
|
||||||
|
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(!counterData || counterData.records.length === 0) && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} style={styles.emptyRow}>No records yet</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{counterData && counterData.total_pages > 1 && (
|
||||||
|
<div style={styles.pagination}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCounterPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={counterPage === 1}
|
||||||
|
style={styles.pageBtn}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span style={styles.pageInfo}>
|
||||||
|
{counterPage} / {counterData.total_pages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCounterPage((p) => Math.min(counterData.total_pages, p + 1))}
|
||||||
|
disabled={counterPage === counterData.total_pages}
|
||||||
|
style={styles.pageBtn}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sum Records Table */}
|
||||||
|
<div style={styles.tableCard}>
|
||||||
|
<div style={styles.tableHeader}>
|
||||||
|
<h2 style={styles.tableTitle}>Sum Activity</h2>
|
||||||
|
<span style={styles.totalCount}>
|
||||||
|
{sumData?.total ?? 0} records
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.tableWrapper}>
|
||||||
|
<table style={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={styles.th}>User</th>
|
||||||
|
<th style={styles.th}>A</th>
|
||||||
|
<th style={styles.th}>B</th>
|
||||||
|
<th style={styles.th}>Result</th>
|
||||||
|
<th style={styles.th}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sumData?.records.map((record) => (
|
||||||
|
<tr key={record.id} style={styles.tr}>
|
||||||
|
<td style={styles.td}>{record.user_email}</td>
|
||||||
|
<td style={styles.tdNum}>{record.a}</td>
|
||||||
|
<td style={styles.tdNum}>{record.b}</td>
|
||||||
|
<td style={styles.tdResult}>{record.result}</td>
|
||||||
|
<td style={styles.tdDate}>{formatDate(record.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(!sumData || sumData.records.length === 0) && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} style={styles.emptyRow}>No records yet</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{sumData && sumData.total_pages > 1 && (
|
||||||
|
<div style={styles.pagination}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSumPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={sumPage === 1}
|
||||||
|
style={styles.pageBtn}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span style={styles.pageInfo}>
|
||||||
|
{sumPage} / {sumData.total_pages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSumPage((p) => Math.min(sumData.total_pages, p + 1))}
|
||||||
|
disabled={sumPage === sumData.total_pages}
|
||||||
|
style={styles.pageBtn}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
main: {
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
borderBottom: "1px solid rgba(255, 255, 255, 0.06)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
navLink: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "color 0.2s",
|
||||||
|
},
|
||||||
|
navDivider: {
|
||||||
|
color: "rgba(255, 255, 255, 0.2)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
},
|
||||||
|
navCurrent: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "#a78bfa",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
},
|
||||||
|
logoutBtn: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
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: "8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: "2rem",
|
||||||
|
overflowY: "auto",
|
||||||
|
},
|
||||||
|
tablesContainer: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "2rem",
|
||||||
|
maxWidth: "1200px",
|
||||||
|
margin: "0 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)",
|
||||||
|
},
|
||||||
|
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 Sans', monospace",
|
||||||
|
},
|
||||||
|
tdResult: {
|
||||||
|
padding: "0.875rem 1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#a78bfa",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "'DM Sans', monospace",
|
||||||
|
},
|
||||||
|
tdDate: {
|
||||||
|
padding: "0.875rem 1rem",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "rgba(255, 255, 255, 0.4)",
|
||||||
|
},
|
||||||
|
emptyRow: {
|
||||||
|
padding: "2rem 1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "rgba(255, 255, 255, 0.3)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
paddingTop: "1rem",
|
||||||
|
borderTop: "1px solid rgba(255, 255, 255, 0.06)",
|
||||||
|
},
|
||||||
|
pageBtn: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
pageInfo: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -56,6 +56,13 @@ export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main style={styles.main}>
|
<main style={styles.main}>
|
||||||
<div style={styles.header}>
|
<div style={styles.header}>
|
||||||
|
<div style={styles.nav}>
|
||||||
|
<span style={styles.navCurrent}>Counter</span>
|
||||||
|
<span style={styles.navDivider}>•</span>
|
||||||
|
<a href="/sum" style={styles.navLink}>Sum</a>
|
||||||
|
<span style={styles.navDivider}>•</span>
|
||||||
|
<a href="/audit" style={styles.navLink}>Audit</a>
|
||||||
|
</div>
|
||||||
<div style={styles.userInfo}>
|
<div style={styles.userInfo}>
|
||||||
<span style={styles.userEmail}>{user.email}</span>
|
<span style={styles.userEmail}>{user.email}</span>
|
||||||
<button onClick={handleLogout} style={styles.logoutBtn}>
|
<button onClick={handleLogout} style={styles.logoutBtn}>
|
||||||
|
|
@ -97,11 +104,35 @@ const styles: Record<string, React.CSSProperties> = {
|
||||||
header: {
|
header: {
|
||||||
padding: "1.5rem 2rem",
|
padding: "1.5rem 2rem",
|
||||||
borderBottom: "1px solid rgba(255, 255, 255, 0.06)",
|
borderBottom: "1px solid rgba(255, 255, 255, 0.06)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
navLink: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "color 0.2s",
|
||||||
|
},
|
||||||
|
navDivider: {
|
||||||
|
color: "rgba(255, 255, 255, 0.2)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
},
|
||||||
|
navCurrent: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "#a78bfa",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 600,
|
||||||
},
|
},
|
||||||
userInfo: {
|
userInfo: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: "1rem",
|
gap: "1rem",
|
||||||
},
|
},
|
||||||
userEmail: {
|
userEmail: {
|
||||||
|
|
|
||||||
344
frontend/app/sum/page.tsx
Normal file
344
frontend/app/sum/page.tsx
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "../auth-context";
|
||||||
|
import { API_URL } from "../config";
|
||||||
|
|
||||||
|
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 { user, isLoading, logout } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [isLoading, user, router]);
|
||||||
|
|
||||||
|
const handleSum = async () => {
|
||||||
|
const numA = parseFloat(a) || 0;
|
||||||
|
const numB = parseFloat(b) || 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/sum`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ a: numA, b: numB }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setResult(data.result);
|
||||||
|
setShowResult(true);
|
||||||
|
} catch {
|
||||||
|
// Fallback to local calculation if API fails
|
||||||
|
setResult(numA + numB);
|
||||||
|
setShowResult(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setA("");
|
||||||
|
setB("");
|
||||||
|
setResult(null);
|
||||||
|
setShowResult(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<main style={styles.main}>
|
||||||
|
<div style={styles.loader}>Loading...</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={styles.main}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<div style={styles.nav}>
|
||||||
|
<a href="/" style={styles.navLink}>Counter</a>
|
||||||
|
<span style={styles.navDivider}>•</span>
|
||||||
|
<span style={styles.navCurrent}>Sum</span>
|
||||||
|
<span style={styles.navDivider}>•</span>
|
||||||
|
<a href="/audit" style={styles.navLink}>Audit</a>
|
||||||
|
</div>
|
||||||
|
<div style={styles.userInfo}>
|
||||||
|
<span style={styles.userEmail}>{user.email}</span>
|
||||||
|
<button onClick={handleLogout} style={styles.logoutBtn}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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 styles: Record<string, React.CSSProperties> = {
|
||||||
|
main: {
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
borderBottom: "1px solid rgba(255, 255, 255, 0.06)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
navLink: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "color 0.2s",
|
||||||
|
},
|
||||||
|
navDivider: {
|
||||||
|
color: "rgba(255, 255, 255, 0.2)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
},
|
||||||
|
navCurrent: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "#a78bfa",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
},
|
||||||
|
logoutBtn: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
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: "8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue