template tutoring-admin-dashboard v1

This commit is contained in:
DevVibeCoder
2026-09-14 23:53:29 +05:30 Unverified
commit c4d9755262
70 changed files with 7955 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
./.devtools/bash/20260914-170307-0003.log
./.devvibecoder/.dbsecret
./.devvibecoder/database.enc
./.devvibecoder/database.json
./.devvibecoder/database.md
./.devvibecoder/db_history.jsonl
./.devvibecoder/files.md
./.devvibecoder/plan.md
./.devvibecoder/stack.json
./.env
./.venv/.gitignore
./.venv/CACHEDIR.TAG
./.venv/bin/activate
./.venv/bin/activate.bat
./.venv/bin/activate.csh
./.venv/bin/activate.fish
./.venv/bin/activate.nu
./.venv/bin/activate.ps1
./.venv/bin/activate.xsh
./.venv/bin/activate_this.py
./.venv/bin/deactivate.bat
./.venv/bin/pydoc.bat
./.venv/lib/python3.13/site-packages/_virtualenv.pth
./.venv/lib/python3.13/site-packages/_virtualenv.py
./.venv/pyvenv.cfg
./backend/.env
./backend/app/__init__.py
./backend/app/main.py
./backend/app/routes/__init__.py
./backend/app/routes/health.py
./backend/app/routes/items.py
./backend/pyproject.toml
./backend/uv.lock
./frontend/bun.lock
./frontend/index.html
./frontend/package.json
./frontend/src/App.tsx
./frontend/src/components/.gitkeep
./frontend/src/main.tsx
./frontend/src/pages/HomePage.tsx
./frontend/src/services/api.ts
./frontend/src/services/dataSource.ts
./frontend/src/services/mockData.ts
./frontend/src/store/.gitkeep
./frontend/src/theme.ts
./frontend/tsconfig.json
./frontend/vc/inspector.ts
./frontend/vc/runtime.ts
./frontend/vite.config.ts
./tasks.md
+20
View File
@@ -0,0 +1,20 @@
+ pygments==2.21.0
+ pyjwt==2.10.1
+ python-dotenv==1.2.3
+ python-multipart==0.0.32
+ pyyaml==6.0.3
+ rich==15.0.0
+ rich-toolkit==0.20.4
+ rignore==0.8.1
+ sentry-sdk==2.68.1
+ shellingham==1.5.4
+ sqlalchemy==2.0.36
+ starlette==1.6.0
+ typer==0.27.2
+ typing-extensions==4.16.0
+ typing-inspection==0.4.4
+ urllib3==2.7.0
+ uvicorn==0.52.4
+ uvloop==0.22.1
+ watchfiles==1.2.0
+ websockets==17.1
+8
View File
@@ -0,0 +1,8 @@
[db] using SQLite fallback
(trapped) error reading bcrypt version
Traceback (most recent call last):
File "/app/data/workspaces/_chat_sessions/9e172dd7-b773-43f1-8c24-603532a980ee/backend/.venv/lib/python3.13/site-packages/passlib/handlers/bcrypt.py", line 620, in _load_backend_mixin
version = _bcrypt.__about__.__version__
^^^^^^^^^^^^^^^^^
AttributeError: module 'bcrypt' has no attribute '__about__'
OK routes: 5
+2
View File
@@ -0,0 +1,2 @@
verify ok: True
verify bad: False
+4
View File
@@ -0,0 +1,4 @@
[db] using Postgres
admin: 1
profile name: Swati Yadav
subjects: 4
+3
View File
@@ -0,0 +1,3 @@
$ tsc --noEmit
/usr/bin/bash: line 1: tsc: command not found
error: script "typecheck" exited with code 127
+6
View File
@@ -0,0 +1,6 @@
+ react-dom@18.3.1
+ react-router-dom@6.28.0
+ zustand@5.0.15
130 packages installed [31.42s]
$ tsc --noEmit
+15
View File
@@ -0,0 +1,15 @@
$ vite build
vite v6.0.7 building for production...
transforming...
✓ 714 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.81 kB │ gzip: 0.46 kB
dist/assets/runtime-C1W9q6oT.js 4.33 kB │ gzip: 1.80 kB
dist/assets/index-COQLUFHo.js 878.06 kB │ gzip: 258.77 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 3.56s
+2
View File
@@ -0,0 +1,2 @@
src/pages/HomePage.tsx:36:const lines = (s: string) => s.split('\n').map((x) => x.trim()).filter(Boolean);
src/pages/HomePage.tsx:72: .split(' ')
+2
View File
@@ -0,0 +1,2 @@
grep: backend/app/__pycache__/seed.cpython-313.pyc: binary file matches
backend/app/seed.py:227: Base.metadata.create_all(bind=engine)
+5
View File
@@ -0,0 +1,5 @@
# Environment variables this project needs.
# Generated by DevVibeCoder from the project's .env — values are never copied.
# Fill these in locally, or add them under Settings → Secrets.
DATABASE_URL=
DATABASE_SCHEMA=
+25
View File
@@ -0,0 +1,25 @@
# Build artefacts — added by the platform before pushing.
.venv/
venv/
node_modules/
.next/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
dist/
build/
*.pyc
*.db
.env
.env.*
!.env.example
!.env.sample
!.env.template
!.env.dist
**/.env
**/.env.*
!**/.env.example
!**/.env.sample
!**/.env.template
!**/.env.dist
+74
View File
@@ -0,0 +1,74 @@
FROM alpine:3.20 AS devvibecoder-secrets
# Secrets: the app's .env is fetched from the platform at build time with the
# deploy's token. This stage is thrown away; only .env is copied out of it.
COPY .devvibecoder/handshake/ /handshake/
RUN sh /handshake/fetch_env.sh /devvibecoder-out && rm -rf /handshake
FROM oven/bun:1-alpine AS frontend
WORKDIR /app
COPY frontend/package.json frontend/bun.loc[k] frontend/package-lock.jso[n] ./
RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
COPY frontend/ ./
ENV VITE_API_URL=/swatiyadav_tutoring_website_react_app_69b3012a/api
ENV VITE_BASE_PATH=/swatiyadav_tutoring_website_react_app_69b3012a/
RUN bunx vite build --base=/swatiyadav_tutoring_website_react_app_69b3012a/
FROM python:3.13-slim AS backend
WORKDIR /app/backend
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/uv
COPY backend/pyproject.tom[l] backend/uv.loc[k] backend/requirements.tx[t] ./
RUN if [ -f uv.lock ]; then uv export --frozen --no-dev --no-hashes -o /tmp/req.txt && uv pip install --system --no-cache -r /tmp/req.txt; elif [ -f pyproject.toml ]; then uv pip install --system --no-cache .; else uv pip install --system --no-cache -r requirements.txt; fi
RUN uv pip install --system --no-cache uvicorn
COPY backend/ ./
FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends nginx supervisor wget && rm -rf /var/lib/apt/lists/*
COPY --from=backend /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=backend /usr/local/bin /usr/local/bin
COPY --from=backend /app/backend /app/backend
COPY --from=frontend /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
COPY supervisord.conf /etc/supervisord.conf
COPY quit_on_failure.py /app/quit_on_failure.py
RUN rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf
# Secrets: the app's .env, fetched from the platform in the devvibecoder-secrets stage above. Nothing here is committed.
COPY --from=devvibecoder-secrets /devvibecoder-out/.env /app/.env
RUN rm -rf /app/.devvibecoder/handshake
RUN echo '#!/bin/sh' > /app/entrypoint.sh && echo 'set -e' >> /app/entrypoint.sh && echo 'for f in /app/.env /app/backend/.env ./.env; do [ -f "$f" ] || continue; while IFS= read -r line || [ -n "$line" ]; do case "$line" in \#*) continue;; *=*) key=${line%%=*}; val=${line#*=}; case "$val" in \"*\") val=${val#\"}; val=${val%\"};; esac; export "$key=$val";; esac; done < "$f"; done' >> /app/entrypoint.sh && echo 'cd /app/backend' >> /app/entrypoint.sh && echo 'if [ ! -f main.py ]; then' >> /app/entrypoint.sh && echo ' for m in app.main src.main backend.main api.main app.api.main; do' >> /app/entrypoint.sh && echo ' p=$(echo "$m" | tr . /);' >> /app/entrypoint.sh && echo ' if [ -f "$p.py" ]; then' >> /app/entrypoint.sh && echo ' echo "from $m import app" > main.py;' >> /app/entrypoint.sh && echo ' echo "entrypoint: no main.py; re-exporting app from $m";' >> /app/entrypoint.sh && echo ' break;' >> /app/entrypoint.sh && echo ' fi;' >> /app/entrypoint.sh && echo ' done;' >> /app/entrypoint.sh && echo 'fi' >> /app/entrypoint.sh && echo 'python -c "from app.db import init_db; init_db()" 2>/dev/null || true' >> /app/entrypoint.sh && echo 'exec "$@"' >> /app/entrypoint.sh && chmod +x /app/entrypoint.sh
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -q --spider http://127.0.0.1:8000/_nginx_health || exit 1
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
+5
View File
@@ -0,0 +1,5 @@
# Environment variables this project needs.
# Generated by DevVibeCoder from the project's .env — values are never copied.
# Fill these in locally, or add them under Settings → Secrets.
DATABASE_URL=
DATABASE_SCHEMA=
View File
+67
View File
@@ -0,0 +1,67 @@
"""Database engine + session.
Reads DATABASE_URL from the environment (provisioned Postgres). The engine is
created with a short connect timeout and a probe: if Postgres is unreachable at
startup, we fall back to a local SQLite file so the app still runs (Preview,
offline dev). The rest of the app never chooses a database — it imports
``SessionLocal`` and ``engine`` from here.
"""
from __future__ import annotations
import os
from collections.abc import Generator
from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
class Base(DeclarativeBase):
pass
def _normalize(url: str) -> str:
# SQLAlchemy + psycopg3 wants the postgresql+psycopg driver.
if url.startswith("postgresql://"):
return url.replace("postgresql://", "postgresql+psycopg://", 1)
if url.startswith("postgres://"):
return url.replace("postgres://", "postgresql+psycopg://", 1)
return url
def _make_engine():
url = os.getenv("DATABASE_URL", "").strip()
if url:
pg_url = _normalize(url)
try:
eng = create_engine(
pg_url,
pool_pre_ping=True,
connect_args={"connect_timeout": 5},
)
with eng.connect() as conn:
conn.execute(text("SELECT 1"))
print("[db] using Postgres")
return eng
except Exception as exc: # noqa: BLE001
print(f"[db] Postgres unreachable ({exc}); falling back to SQLite")
sqlite_path = os.path.join(os.path.dirname(__file__), "..", "app_fallback.db")
eng = create_engine(
f"sqlite:///{os.path.abspath(sqlite_path)}",
connect_args={"check_same_thread": False},
)
print("[db] using SQLite fallback")
return eng
engine = _make_engine()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+33
View File
@@ -0,0 +1,33 @@
"""FastAPI entry point. Deploy imports ``app.main:app`` — keep both names."""
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routes import api_router
from app.seed import init_db
app = FastAPI(title=os.getenv("APP_NAME", "SwatiYadav"))
@app.on_event("startup")
def _startup() -> None:
try:
init_db()
except Exception as exc: # noqa: BLE001
print(f"[startup] DB init skipped: {exc}")
# Allowed origins come from the environment so the deployed app and local dev
# use the same code path. "*" only when nothing is configured (dev default).
_origins = [o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# The one place the /api prefix is applied. Route modules must not repeat it.
app.include_router(api_router, prefix="/api")
+199
View File
@@ -0,0 +1,199 @@
"""SQLAlchemy ORM models for the SwatiYadav tuition website.
Every public section is a table so the teacher can edit it from admin. The
public site reads these; the admin panel writes them.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class AdminUser(Base):
__tablename__ = "admin_user"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
class Profile(Base):
__tablename__ = "profile"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160), default="")
tagline: Mapped[str] = mapped_column(String(255), default="")
bio: Mapped[str] = mapped_column(Text, default="")
photo_url: Mapped[str] = mapped_column(Text, default="")
qualifications: Mapped[str] = mapped_column(Text, default="")
experience_years: Mapped[int] = mapped_column(Integer, default=0)
students_taught: Mapped[int] = mapped_column(Integer, default=0)
achievements: Mapped[str] = mapped_column(Text, default="")
hero_cta: Mapped[str] = mapped_column(String(120), default="Book a free demo")
class Subject(Base):
__tablename__ = "subject"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), default="")
level: Mapped[str] = mapped_column(String(120), default="")
description: Mapped[str] = mapped_column(Text, default="")
mode: Mapped[str] = mapped_column(String(60), default="Online & Offline")
icon: Mapped[str] = mapped_column(String(20), default="📘")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class Course(Base):
__tablename__ = "course"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(160), default="")
subject: Mapped[str] = mapped_column(String(120), default="")
description: Mapped[str] = mapped_column(Text, default="")
duration: Mapped[str] = mapped_column(String(120), default="")
mode: Mapped[str] = mapped_column(String(60), default="Online & Offline")
highlights: Mapped[str] = mapped_column(Text, default="") # newline-separated
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class FeePlan(Base):
__tablename__ = "fee_plan"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), default="")
price: Mapped[str] = mapped_column(String(60), default="")
period: Mapped[str] = mapped_column(String(60), default="per month")
features: Mapped[str] = mapped_column(Text, default="") # newline-separated
highlighted: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class Testimonial(Base):
__tablename__ = "testimonial"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
author_name: Mapped[str] = mapped_column(String(120), default="")
relation: Mapped[str] = mapped_column(String(60), default="Student")
quote: Mapped[str] = mapped_column(Text, default="")
rating: Mapped[int] = mapped_column(Integer, default=5)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class SiteSettings(Base):
__tablename__ = "site_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
address: Mapped[str] = mapped_column(Text, default="")
phone: Mapped[str] = mapped_column(String(60), default="")
email: Mapped[str] = mapped_column(String(160), default="")
hours: Mapped[str] = mapped_column(String(255), default="")
whatsapp: Mapped[str] = mapped_column(String(160), default="")
instagram: Mapped[str] = mapped_column(String(160), default="")
youtube: Mapped[str] = mapped_column(String(160), default="")
facebook: Mapped[str] = mapped_column(String(160), default="")
footer_text: Mapped[str] = mapped_column(Text, default="")
class Review(Base):
__tablename__ = "review"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
author_name: Mapped[str] = mapped_column(String(160), default="")
relation: Mapped[str] = mapped_column(String(60), default="Student")
rating: Mapped[int] = mapped_column(Integer, default=5)
message: Mapped[str] = mapped_column(Text, default="")
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class Enquiry(Base):
__tablename__ = "enquiry"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160), default="")
email: Mapped[str] = mapped_column(String(160), default="")
phone: Mapped[str] = mapped_column(String(60), default="")
subject_interest: Mapped[str] = mapped_column(String(160), default="")
message: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
# ---- Student management (admin-only, private) ----
class Student(Base):
__tablename__ = "student"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160), default="")
phone: Mapped[str] = mapped_column(String(60), default="")
parent_name: Mapped[str] = mapped_column(String(160), default="")
parent_phone: Mapped[str] = mapped_column(String(60), default="")
address: Mapped[str] = mapped_column(Text, default="")
subject: Mapped[str] = mapped_column(String(160), default="")
monthly_fee: Mapped[int] = mapped_column(Integer, default=0)
start_date: Mapped[date] = mapped_column(Date, default=date.today)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
notes: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
slots: Mapped[list["ScheduleSlot"]] = relationship(
back_populates="student",
cascade="all, delete-orphan",
)
class ScheduleSlot(Base):
__tablename__ = "schedule_slot"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
student_id: Mapped[int] = mapped_column(
ForeignKey("student.id", ondelete="CASCADE"), nullable=False, index=True
)
weekday: Mapped[int] = mapped_column(Integer, default=0) # 0=Mon .. 6=Sun
start_time: Mapped[str] = mapped_column(String(5), default="") # "HH:MM"
end_time: Mapped[str] = mapped_column(String(5), default="")
student: Mapped["Student"] = relationship(back_populates="slots")
class Attendance(Base):
__tablename__ = "attendance"
__table_args__ = (UniqueConstraint("student_id", "date", name="uq_attendance_student_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
student_id: Mapped[int] = mapped_column(
ForeignKey("student.id", ondelete="CASCADE"), nullable=False, index=True
)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
status: Mapped[str] = mapped_column(String(10), default="present") # present|absent|off
note: Mapped[str] = mapped_column(Text, default="") # why absent / off
class DayMark(Base):
__tablename__ = "day_mark"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
date: Mapped[date] = mapped_column(Date, unique=True, nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(12), default="holiday") # holiday|class_off
note: Mapped[str] = mapped_column(String(255), default="")
+13
View File
@@ -0,0 +1,13 @@
"""Route barrel — every router the app serves is mounted here."""
from fastapi import APIRouter
from app.routes import admin, auth, content, health, students
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(auth.router, tags=["auth"])
api_router.include_router(content.router, tags=["content"])
api_router.include_router(admin.router, tags=["admin"])
api_router.include_router(students.router, tags=["students"])
+241
View File
@@ -0,0 +1,241 @@
"""Admin (JWT-protected) endpoints: edit every section, manage enquiries.
All routes here depend on ``get_current_admin`` via the router dependency, so an
unauthenticated request gets 401 before reaching a handler.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import (
Course,
Enquiry,
FeePlan,
Profile,
Review,
SiteSettings,
Subject,
Testimonial,
)
from app.schemas import (
CourseIn,
CourseOut,
EnquiryOut,
EnquiryPatch,
FeePlanIn,
FeePlanOut,
ProfileIn,
ProfileOut,
ReviewOut,
ReviewPatch,
SiteSettingsIn,
SiteSettingsOut,
SubjectIn,
SubjectOut,
TestimonialIn,
TestimonialOut,
)
from app.security import get_current_admin
router = APIRouter(prefix="/admin", dependencies=[Depends(get_current_admin)])
# ---- Profile (singleton) ----
@router.put("/profile", response_model=ProfileOut)
def update_profile(body: ProfileIn, db: Session = Depends(get_db)) -> Profile:
obj = db.query(Profile).first()
if not obj:
obj = Profile()
db.add(obj)
for k, v in body.model_dump().items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj
# ---- Settings (singleton) ----
@router.put("/settings", response_model=SiteSettingsOut)
def update_settings(body: SiteSettingsIn, db: Session = Depends(get_db)) -> SiteSettings:
obj = db.query(SiteSettings).first()
if not obj:
obj = SiteSettings()
db.add(obj)
for k, v in body.model_dump().items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj
# ---- Subjects CRUD ----
@router.post("/subjects", response_model=SubjectOut, status_code=201)
def create_subject(body: SubjectIn, db: Session = Depends(get_db)) -> Subject:
obj = Subject(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.put("/subjects/{item_id}", response_model=SubjectOut)
def update_subject(item_id: int, body: SubjectIn, db: Session = Depends(get_db)) -> Subject:
obj = db.get(Subject, item_id)
if not obj:
raise HTTPException(404, "Subject not found")
for k, v in body.model_dump().items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj
@router.delete("/subjects/{item_id}", status_code=204)
def delete_subject(item_id: int, db: Session = Depends(get_db)) -> None:
obj = db.get(Subject, item_id)
if obj:
db.delete(obj)
db.commit()
# ---- Courses CRUD ----
@router.post("/courses", response_model=CourseOut, status_code=201)
def create_course(body: CourseIn, db: Session = Depends(get_db)) -> Course:
obj = Course(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.put("/courses/{item_id}", response_model=CourseOut)
def update_course(item_id: int, body: CourseIn, db: Session = Depends(get_db)) -> Course:
obj = db.get(Course, item_id)
if not obj:
raise HTTPException(404, "Course not found")
for k, v in body.model_dump().items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj
@router.delete("/courses/{item_id}", status_code=204)
def delete_course(item_id: int, db: Session = Depends(get_db)) -> None:
obj = db.get(Course, item_id)
if obj:
db.delete(obj)
db.commit()
# ---- Fees CRUD ----
@router.post("/fees", response_model=FeePlanOut, status_code=201)
def create_fee(body: FeePlanIn, db: Session = Depends(get_db)) -> FeePlan:
obj = FeePlan(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.put("/fees/{item_id}", response_model=FeePlanOut)
def update_fee(item_id: int, body: FeePlanIn, db: Session = Depends(get_db)) -> FeePlan:
obj = db.get(FeePlan, item_id)
if not obj:
raise HTTPException(404, "Fee plan not found")
for k, v in body.model_dump().items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj
@router.delete("/fees/{item_id}", status_code=204)
def delete_fee(item_id: int, db: Session = Depends(get_db)) -> None:
obj = db.get(FeePlan, item_id)
if obj:
db.delete(obj)
db.commit()
# ---- Testimonials CRUD ----
@router.post("/testimonials", response_model=TestimonialOut, status_code=201)
def create_testimonial(body: TestimonialIn, db: Session = Depends(get_db)) -> Testimonial:
obj = Testimonial(**body.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.put("/testimonials/{item_id}", response_model=TestimonialOut)
def update_testimonial(item_id: int, body: TestimonialIn, db: Session = Depends(get_db)) -> Testimonial:
obj = db.get(Testimonial, item_id)
if not obj:
raise HTTPException(404, "Testimonial not found")
for k, v in body.model_dump().items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj
@router.delete("/testimonials/{item_id}", status_code=204)
def delete_testimonial(item_id: int, db: Session = Depends(get_db)) -> None:
obj = db.get(Testimonial, item_id)
if obj:
db.delete(obj)
db.commit()
# ---- Reviews (moderation) ----
@router.get("/reviews", response_model=list[ReviewOut])
def list_reviews(db: Session = Depends(get_db)) -> list[Review]:
return db.query(Review).order_by(Review.created_at.desc(), Review.id.desc()).all()
@router.patch("/reviews/{item_id}", response_model=ReviewOut)
def patch_review(item_id: int, body: ReviewPatch, db: Session = Depends(get_db)) -> Review:
obj = db.get(Review, item_id)
if not obj:
raise HTTPException(404, "Review not found")
obj.is_approved = body.is_approved
db.commit()
db.refresh(obj)
return obj
@router.delete("/reviews/{item_id}", status_code=204)
def delete_review(item_id: int, db: Session = Depends(get_db)) -> None:
obj = db.get(Review, item_id)
if obj:
db.delete(obj)
db.commit()
# ---- Enquiries ----
@router.get("/enquiries", response_model=list[EnquiryOut])
def list_enquiries(db: Session = Depends(get_db)) -> list[Enquiry]:
return db.query(Enquiry).order_by(Enquiry.created_at.desc(), Enquiry.id.desc()).all()
@router.patch("/enquiries/{item_id}", response_model=EnquiryOut)
def patch_enquiry(item_id: int, body: EnquiryPatch, db: Session = Depends(get_db)) -> Enquiry:
obj = db.get(Enquiry, item_id)
if not obj:
raise HTTPException(404, "Enquiry not found")
obj.is_read = body.is_read
db.commit()
db.refresh(obj)
return obj
@router.delete("/enquiries/{item_id}", status_code=204)
def delete_enquiry(item_id: int, db: Session = Depends(get_db)) -> None:
obj = db.get(Enquiry, item_id)
if obj:
db.delete(obj)
db.commit()
+49
View File
@@ -0,0 +1,49 @@
"""Admin authentication: login, me, change password."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import AdminUser
from app.schemas import ChangePasswordIn, LoginIn, MeOut, TokenOut
from app.security import (
create_token,
get_current_admin,
hash_password,
verify_password,
)
router = APIRouter(prefix="/auth")
@router.post("/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)) -> TokenOut:
user = db.query(AdminUser).filter(AdminUser.username == body.username).first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
return TokenOut(access_token=create_token(user.username), username=user.username)
@router.get("/me", response_model=MeOut)
def me(admin: AdminUser = Depends(get_current_admin)) -> MeOut:
return MeOut(username=admin.username)
@router.post("/change-password", response_model=MeOut)
def change_password(
body: ChangePasswordIn,
admin: AdminUser = Depends(get_current_admin),
db: Session = Depends(get_db),
) -> MeOut:
if not verify_password(body.current_password, admin.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
admin.password_hash = hash_password(body.new_password)
db.commit()
return MeOut(username=admin.username)
+128
View File
@@ -0,0 +1,128 @@
"""Public content endpoints — read the site, submit an enquiry.
These are all unauthenticated (the public site consumes them).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import (
Course,
Enquiry,
FeePlan,
Profile,
Review,
SiteSettings,
Subject,
Testimonial,
)
from app.schemas import (
CourseOut,
EnquiryIn,
EnquiryOut,
FeePlanOut,
ProfileOut,
ReviewIn,
ReviewOut,
SiteSettingsOut,
SubjectOut,
TestimonialOut,
)
router = APIRouter()
def _get_or_create_profile(db: Session) -> Profile:
obj = db.query(Profile).first()
if not obj:
obj = Profile()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def _get_or_create_settings(db: Session) -> SiteSettings:
obj = db.query(SiteSettings).first()
if not obj:
obj = SiteSettings()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.get("/profile", response_model=ProfileOut)
def get_profile(db: Session = Depends(get_db)) -> Profile:
return _get_or_create_profile(db)
@router.get("/subjects", response_model=list[SubjectOut])
def list_subjects(db: Session = Depends(get_db)) -> list[Subject]:
return db.query(Subject).order_by(Subject.sort_order, Subject.id).all()
@router.get("/courses", response_model=list[CourseOut])
def list_courses(db: Session = Depends(get_db)) -> list[Course]:
return db.query(Course).order_by(Course.sort_order, Course.id).all()
@router.get("/fees", response_model=list[FeePlanOut])
def list_fees(db: Session = Depends(get_db)) -> list[FeePlan]:
return db.query(FeePlan).order_by(FeePlan.sort_order, FeePlan.id).all()
@router.get("/testimonials", response_model=list[TestimonialOut])
def list_testimonials(db: Session = Depends(get_db)) -> list[Testimonial]:
return db.query(Testimonial).order_by(Testimonial.sort_order, Testimonial.id).all()
@router.get("/settings", response_model=SiteSettingsOut)
def get_settings(db: Session = Depends(get_db)) -> SiteSettings:
return _get_or_create_settings(db)
@router.get("/reviews", response_model=list[ReviewOut])
def list_public_reviews(db: Session = Depends(get_db)) -> list[Review]:
"""Only approved reviews are shown on the public site."""
return (
db.query(Review)
.filter(Review.is_approved.is_(True))
.order_by(Review.created_at.desc(), Review.id.desc())
.all()
)
@router.post("/reviews", response_model=ReviewOut, status_code=201)
def create_review(body: ReviewIn, db: Session = Depends(get_db)) -> Review:
"""Public submission — stored unapproved until the teacher approves it."""
rating = max(1, min(5, body.rating))
obj = Review(
author_name=body.author_name.strip() or "Anonymous",
relation=body.relation,
rating=rating,
message=body.message,
is_approved=False,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.post("/enquiries", response_model=EnquiryOut, status_code=201)
def create_enquiry(body: EnquiryIn, db: Session = Depends(get_db)) -> Enquiry:
obj = Enquiry(
name=body.name,
email=body.email,
phone=body.phone,
subject_interest=body.subject_interest,
message=body.message,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
+15
View File
@@ -0,0 +1,15 @@
"""Liveness endpoint. The shape every other route module copies."""
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class Health(BaseModel):
status: str
@router.get("/health", response_model=Health)
def health() -> Health:
return Health(status="ok")
+2
View File
@@ -0,0 +1,2 @@
"""Unused. Replaced by content.py / admin.py. Kept empty to avoid a dangling
import; not mounted in the router barrel."""
+261
View File
@@ -0,0 +1,261 @@
"""Admin (JWT-protected) student-management endpoints.
Private to the teacher: students & parent records, weekly schedule, monthly
attendance sheet with per-student offs and whole-day holidays/class-offs, a
"today" teaching list, and a monthly summary (present/absent counts + expected
income from fees). Nothing here is exposed on the public site.
"""
from __future__ import annotations
from calendar import monthrange
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import Attendance, DayMark, ScheduleSlot, Student
from app.schemas import (
AttendanceClearIn,
AttendanceIn,
AttendanceOut,
DayMarkIn,
DayMarkOut,
MonthDataOut,
ScheduleSlotIn,
ScheduleSlotOut,
StudentIn,
StudentOut,
StudentSummaryOut,
SummaryOut,
TodayEntryOut,
)
from app.security import get_current_admin
router = APIRouter(prefix="/admin", dependencies=[Depends(get_current_admin)])
_STATUSES = {"present", "absent", "off"}
_KINDS = {"holiday", "class_off"}
def _month_bounds(month: str) -> tuple[date, date]:
"""Return (first_day, last_day) for a 'YYYY-MM' string."""
try:
year, mon = (int(p) for p in month.split("-"))
first = date(year, mon, 1)
last = date(year, mon, monthrange(year, mon)[1])
except (ValueError, TypeError):
raise HTTPException(400, "month must be YYYY-MM") from None
return first, last
# ---- Students CRUD ----
@router.get("/students", response_model=list[StudentOut])
def list_students(db: Session = Depends(get_db)) -> list[Student]:
return db.query(Student).order_by(Student.name.asc(), Student.id.asc()).all()
@router.post("/students", response_model=StudentOut, status_code=201)
def create_student(body: StudentIn, db: Session = Depends(get_db)) -> Student:
data = body.model_dump()
if data.get("start_date") is None:
data["start_date"] = date.today()
obj = Student(**data)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@router.put("/students/{item_id}", response_model=StudentOut)
def update_student(item_id: int, body: StudentIn, db: Session = Depends(get_db)) -> Student:
obj = db.get(Student, item_id)
if not obj:
raise HTTPException(404, "Student not found")
data = body.model_dump()
if data.get("start_date") is None:
data.pop("start_date")
for k, v in data.items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj
@router.delete("/students/{item_id}", status_code=204)
def delete_student(item_id: int, db: Session = Depends(get_db)) -> None:
obj = db.get(Student, item_id)
if obj:
db.delete(obj)
db.commit()
# ---- Schedule ----
@router.get("/students/{item_id}/schedule", response_model=list[ScheduleSlotOut])
def get_schedule(item_id: int, db: Session = Depends(get_db)) -> list[ScheduleSlot]:
if not db.get(Student, item_id):
raise HTTPException(404, "Student not found")
return (
db.query(ScheduleSlot)
.filter(ScheduleSlot.student_id == item_id)
.order_by(ScheduleSlot.weekday.asc(), ScheduleSlot.start_time.asc())
.all()
)
@router.put("/students/{item_id}/schedule", response_model=list[ScheduleSlotOut])
def set_schedule(
item_id: int, body: list[ScheduleSlotIn], db: Session = Depends(get_db)
) -> list[ScheduleSlot]:
if not db.get(Student, item_id):
raise HTTPException(404, "Student not found")
db.query(ScheduleSlot).filter(ScheduleSlot.student_id == item_id).delete()
for slot in body:
db.add(ScheduleSlot(student_id=item_id, **slot.model_dump()))
db.commit()
return (
db.query(ScheduleSlot)
.filter(ScheduleSlot.student_id == item_id)
.order_by(ScheduleSlot.weekday.asc(), ScheduleSlot.start_time.asc())
.all()
)
@router.get("/schedule/today", response_model=list[TodayEntryOut])
def schedule_today(db: Session = Depends(get_db)) -> list[TodayEntryOut]:
today = date.today()
weekday = today.weekday() # Mon=0
marked = db.query(DayMark).filter(DayMark.date == today).first()
if marked is not None:
return [] # whole day off for everyone
rows = (
db.query(ScheduleSlot, Student)
.join(Student, Student.id == ScheduleSlot.student_id)
.filter(ScheduleSlot.weekday == weekday, Student.is_active.is_(True))
.order_by(ScheduleSlot.start_time.asc())
.all()
)
return [
TodayEntryOut(
student_id=s.id,
student_name=s.name,
subject=s.subject,
start_time=slot.start_time,
end_time=slot.end_time,
)
for slot, s in rows
]
# ---- Attendance ----
@router.get("/attendance", response_model=MonthDataOut)
def get_attendance(
month: str = Query(..., description="YYYY-MM"), db: Session = Depends(get_db)
) -> MonthDataOut:
first, last = _month_bounds(month)
attendance = (
db.query(Attendance)
.filter(Attendance.date >= first, Attendance.date <= last)
.all()
)
day_marks = (
db.query(DayMark).filter(DayMark.date >= first, DayMark.date <= last).all()
)
return MonthDataOut(attendance=attendance, day_marks=day_marks) # type: ignore[arg-type]
@router.put("/attendance", response_model=AttendanceOut)
def set_attendance(body: AttendanceIn, db: Session = Depends(get_db)) -> Attendance:
if body.status not in _STATUSES:
raise HTTPException(400, f"status must be one of {sorted(_STATUSES)}")
if not db.get(Student, body.student_id):
raise HTTPException(404, "Student not found")
obj = (
db.query(Attendance)
.filter(Attendance.student_id == body.student_id, Attendance.date == body.date)
.first()
)
if obj is None:
obj = Attendance(
student_id=body.student_id, date=body.date, status=body.status, note=body.note
)
db.add(obj)
else:
obj.status = body.status
obj.note = body.note
db.commit()
db.refresh(obj)
return obj
@router.post("/attendance/clear", status_code=204)
def clear_attendance(body: AttendanceClearIn, db: Session = Depends(get_db)) -> None:
db.query(Attendance).filter(
Attendance.student_id == body.student_id, Attendance.date == body.date
).delete()
db.commit()
# ---- Day marks (holiday / class off for everyone) ----
@router.post("/day-marks", response_model=DayMarkOut)
def set_day_mark(body: DayMarkIn, db: Session = Depends(get_db)) -> DayMark:
if body.kind not in _KINDS:
raise HTTPException(400, f"kind must be one of {sorted(_KINDS)}")
obj = db.query(DayMark).filter(DayMark.date == body.date).first()
if obj is None:
obj = DayMark(date=body.date, kind=body.kind, note=body.note)
db.add(obj)
else:
obj.kind = body.kind
obj.note = body.note
db.commit()
db.refresh(obj)
return obj
@router.delete("/day-marks/{on_date}", status_code=204)
def delete_day_mark(on_date: date, db: Session = Depends(get_db)) -> None:
db.query(DayMark).filter(DayMark.date == on_date).delete()
db.commit()
# ---- Summary ----
@router.get("/students/summary", response_model=SummaryOut)
def student_summary(
month: str = Query(..., description="YYYY-MM"), db: Session = Depends(get_db)
) -> SummaryOut:
first, last = _month_bounds(month)
students = db.query(Student).order_by(Student.name.asc(), Student.id.asc()).all()
attendance = (
db.query(Attendance)
.filter(Attendance.date >= first, Attendance.date <= last)
.all()
)
counts: dict[int, dict[str, int]] = {
s.id: {"present": 0, "absent": 0, "off": 0} for s in students
}
for a in attendance:
if a.student_id in counts and a.status in counts[a.student_id]:
counts[a.student_id][a.status] += 1
rows = [
StudentSummaryOut(
student_id=s.id,
student_name=s.name,
monthly_fee=s.monthly_fee,
present=counts[s.id]["present"],
absent=counts[s.id]["absent"],
off=counts[s.id]["off"],
)
for s in students
]
expected = sum(s.monthly_fee for s in students if s.is_active)
active = sum(1 for s in students if s.is_active)
return SummaryOut(
month=month,
active_students=active,
expected_income=expected,
students=rows,
)
+269
View File
@@ -0,0 +1,269 @@
"""Pydantic request/response schemas."""
from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
# ---- Auth ----
class LoginIn(BaseModel):
username: str
password: str
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
username: str
class MeOut(BaseModel):
username: str
class ChangePasswordIn(BaseModel):
current_password: str
new_password: str
# ---- Profile ----
class ProfileIn(BaseModel):
name: str = ""
tagline: str = ""
bio: str = ""
photo_url: str = ""
qualifications: str = ""
experience_years: int = 0
students_taught: int = 0
achievements: str = ""
hero_cta: str = "Book a free demo"
class ProfileOut(ProfileIn, ORMModel):
id: int
# ---- Subject ----
class SubjectIn(BaseModel):
name: str = ""
level: str = ""
description: str = ""
mode: str = "Online & Offline"
icon: str = "📘"
sort_order: int = 0
class SubjectOut(SubjectIn, ORMModel):
id: int
# ---- Course ----
class CourseIn(BaseModel):
title: str = ""
subject: str = ""
description: str = ""
duration: str = ""
mode: str = "Online & Offline"
highlights: str = ""
sort_order: int = 0
class CourseOut(CourseIn, ORMModel):
id: int
# ---- Fee ----
class FeePlanIn(BaseModel):
name: str = ""
price: str = ""
period: str = "per month"
features: str = ""
highlighted: bool = False
sort_order: int = 0
class FeePlanOut(FeePlanIn, ORMModel):
id: int
# ---- Testimonial ----
class TestimonialIn(BaseModel):
author_name: str = ""
relation: str = "Student"
quote: str = ""
rating: int = 5
sort_order: int = 0
class TestimonialOut(TestimonialIn, ORMModel):
id: int
# ---- Settings ----
class SiteSettingsIn(BaseModel):
address: str = ""
phone: str = ""
email: str = ""
hours: str = ""
whatsapp: str = ""
instagram: str = ""
youtube: str = ""
facebook: str = ""
footer_text: str = ""
class SiteSettingsOut(SiteSettingsIn, ORMModel):
id: int
# ---- Review (public feedback, moderated) ----
class ReviewIn(BaseModel):
author_name: str
relation: str = "Student"
rating: int = 5
message: str
class ReviewOut(ORMModel):
id: int
author_name: str
relation: str
rating: int
message: str
is_approved: bool
created_at: datetime
class ReviewPatch(BaseModel):
is_approved: bool
# ---- Enquiry ----
class EnquiryIn(BaseModel):
name: str
email: str = ""
phone: str = ""
subject_interest: str = ""
message: str
class EnquiryOut(ORMModel):
id: int
name: str
email: str
phone: str
subject_interest: str
message: str
created_at: datetime
is_read: bool
class EnquiryPatch(BaseModel):
is_read: bool
# ---- Student management ----
class ScheduleSlotIn(BaseModel):
weekday: int = 0 # 0=Mon .. 6=Sun
start_time: str = ""
end_time: str = ""
class ScheduleSlotOut(ScheduleSlotIn, ORMModel):
id: int
student_id: int
class StudentIn(BaseModel):
name: str = ""
phone: str = ""
parent_name: str = ""
parent_phone: str = ""
address: str = ""
subject: str = ""
monthly_fee: int = 0
start_date: date | None = None
is_active: bool = True
notes: str = ""
class StudentOut(ORMModel):
id: int
name: str
phone: str
parent_name: str
parent_phone: str
address: str
subject: str
monthly_fee: int
start_date: date
is_active: bool
notes: str
class AttendanceIn(BaseModel):
student_id: int
date: date
status: str = "present" # present|absent|off
note: str = ""
class AttendanceOut(ORMModel):
id: int
student_id: int
date: date
status: str
note: str
class AttendanceClearIn(BaseModel):
student_id: int
date: date
class DayMarkIn(BaseModel):
date: date
kind: str = "holiday" # holiday|class_off
note: str = ""
class DayMarkOut(ORMModel):
id: int
date: date
kind: str
note: str
class MonthDataOut(BaseModel):
attendance: list[AttendanceOut]
day_marks: list[DayMarkOut]
class TodayEntryOut(BaseModel):
student_id: int
student_name: str
subject: str
start_time: str
end_time: str
class StudentSummaryOut(BaseModel):
student_id: int
student_name: str
monthly_fee: int
present: int
absent: int
off: int
class SummaryOut(BaseModel):
month: str
active_students: int
expected_income: int
students: list[StudentSummaryOut]
+66
View File
@@ -0,0 +1,66 @@
"""Password hashing + JWT tokens for the single admin account."""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import AdminUser
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
SECRET_KEY = os.getenv("JWT_SECRET", "swatiyadav-dev-secret-change-me")
ALGORITHM = "HS256"
TOKEN_TTL_HOURS = 24 * 7
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login", auto_error=False)
def hash_password(password: str) -> str:
return _pwd.hash(password)
def verify_password(password: str, hashed: str) -> bool:
try:
return _pwd.verify(password, hashed)
except Exception: # noqa: BLE001
return False
def create_token(username: str) -> str:
payload = {
"sub": username,
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def get_current_admin(
token: str | None = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> AdminUser:
creds_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise creds_error
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
except jwt.PyJWTError:
raise creds_error from None
if not username:
raise creds_error
user = db.query(AdminUser).filter(AdminUser.username == username).first()
if not user:
raise creds_error
return user
+253
View File
@@ -0,0 +1,253 @@
"""Create tables and seed the DEVTeacher placeholder content on first run.
Idempotent: if an admin already exists, nothing is seeded. The default admin is
username ``DEVTeacher`` / password ``DEVTeacher`` (the teacher changes it).
"""
from __future__ import annotations
from datetime import date
from sqlalchemy import inspect, text
from sqlalchemy.orm import Session
from app.db import Base, SessionLocal, engine
from app.models import (
AdminUser,
Attendance,
Course,
DayMark,
Enquiry,
FeePlan,
Profile,
Review,
ScheduleSlot,
SiteSettings,
Student,
Subject,
Testimonial,
)
from app.security import hash_password
DEFAULT_ADMIN_USER = "DEVTeacher"
DEFAULT_ADMIN_PASS = "DEVTeacher"
def _seed(db: Session) -> None:
db.add(AdminUser(username=DEFAULT_ADMIN_USER, password_hash=hash_password(DEFAULT_ADMIN_PASS)))
db.add(
Profile(
name="Swati Yadav",
tagline="Making learning joyful, clear and exam-ready",
bio=(
"Hello! I'm Swati Yadav, a passionate tuition teacher with years of experience "
"helping students build strong fundamentals and real confidence. I focus on "
"concept clarity, regular practice and personal attention so every student can "
"reach their full potential — whether the goal is topping the class, cracking "
"board exams, or simply falling in love with a subject."
),
photo_url="",
qualifications="M.Sc. Mathematics · B.Ed. · Certified Online Educator",
experience_years=10,
students_taught=500,
achievements=(
"95% of students improved by 2+ grades\n"
"Mentored 50+ board-exam toppers\n"
"Personalised study plans for every learner"
),
hero_cta="Book a free demo class",
)
)
subjects = [
("Mathematics", "Class 6–12", "Algebra, geometry, calculus and problem-solving made simple with step-by-step methods.", "📐", 1),
("Physics", "Class 9–12", "Concept-first physics with real-life examples, numericals and exam techniques.", "🔬", 2),
("Chemistry", "Class 9–12", "Organic, inorganic and physical chemistry taught with clarity and easy recall tricks.", "⚗️", 3),
("English", "Class 6–10", "Grammar, comprehension, writing skills and confident communication.", "📖", 4),
]
for name, level, desc, icon, order in subjects:
db.add(Subject(name=name, level=level, description=desc, mode="Online & Offline", icon=icon, sort_order=order))
courses = [
(
"Board Exam Crash Course",
"Mathematics & Science",
"An intensive revision programme covering the complete syllabus with mock tests, doubt-clearing and previous-year papers.",
"3 months",
"Practice tests every week\nPersonal doubt sessions\nComplete syllabus revision",
1,
),
(
"Foundation Builder",
"All Subjects",
"A steady long-term course for younger students that builds strong basics and study habits from the ground up.",
"Full academic year",
"Concept-first teaching\nRegular homework & feedback\nMonthly progress reports",
2,
),
(
"One-on-One Personal Tuition",
"Chosen Subject",
"Fully personalised private tuition tailored to a single student's pace, goals and weak areas.",
"Flexible",
"Completely customised plan\nFlexible timings\nDedicated attention",
3,
),
]
for title, subject, desc, duration, highlights, order in courses:
db.add(
Course(
title=title,
subject=subject,
description=desc,
duration=duration,
mode="Online & Offline",
highlights=highlights,
sort_order=order,
)
)
fees = [
("Group Batch", "₹1,500", "per month", "Small batches (max 8)\n3 classes a week\nWeekly tests\nStudy material included", False, 1),
("Premium Batch", "₹2,500", "per month", "Mini batches (max 4)\n5 classes a week\nWeekly tests + doubt sessions\nMonthly progress report", True, 2),
("1-on-1 Private", "₹4,000", "per month", "Fully personal tuition\nFlexible timings\nCustom study plan\nUnlimited doubt support", False, 3),
]
for name, price, period, features, highlighted, order in fees:
db.add(FeePlan(name=name, price=price, period=period, features=features, highlighted=highlighted, sort_order=order))
testimonials = [
("Aarav Sharma", "Student", "Swati ma'am made maths so easy to understand. My marks jumped from 62 to 91 in just one year!", 5, 1),
("Meena Gupta", "Parent", "My daughter actually looks forward to her classes now. The personal attention is wonderful.", 5, 2),
("Rohan Verma", "Student", "The best teacher I've ever had. She explains every concept until it truly clicks.", 5, 3),
("Kavita Nair", "Parent", "Very disciplined, caring and result-oriented. Highly recommend to every parent.", 5, 4),
]
for author, relation, quote, rating, order in testimonials:
db.add(Testimonial(author_name=author, relation=relation, quote=quote, rating=rating, sort_order=order))
db.add(
SiteSettings(
address="123 Vidya Nagar, Near City Library, Your City, 000000",
phone="+91 90000 00000",
email="hello@swatiyadav.com",
hours="Mon–Sat, 9:00 AM – 8:00 PM",
whatsapp="https://wa.me/919000000000",
instagram="https://instagram.com/",
youtube="https://youtube.com/",
facebook="https://facebook.com/",
footer_text="Empowering students with knowledge, confidence and care.",
)
)
db.add(
Enquiry(
name="Sample Parent",
email="parent@example.com",
phone="+91 98765 43210",
subject_interest="Mathematics",
message="Hi, I'd like to know the timings for the Class 10 maths batch. Thank you!",
is_read=False,
)
)
reviews = [
("Priya Deshmukh", "Parent", 5, "The weekly progress updates keep me informed and my son is far more confident now.", True),
("Ananya Rao", "Student", 5, "Doubts are always cleared patiently. Highly recommend her classes!", False),
]
for author, relation, rating, message, approved in reviews:
db.add(Review(author_name=author, relation=relation, rating=rating, message=message, is_approved=approved))
db.commit()
_seed_students(db)
def _seed_students(db: Session) -> None:
"""Sample students, schedules and a little attendance for this month."""
students_data = [
# name, phone, parent_name, parent_phone, address, subject, fee, slots
(
"Aarav Sharma", "+91 98111 22233", "Rakesh Sharma", "+91 98111 00011",
"12 Green Park, Your City", "Mathematics · Class 10", 1500,
[(0, "16:00", "17:00"), (2, "16:00", "17:00"), (4, "16:00", "17:00")],
),
(
"Isha Verma", "+91 98222 33344", "Sunita Verma", "+91 98222 00022",
"45 Lake View, Your City", "Physics · Class 12", 2500,
[(1, "17:30", "18:30"), (3, "17:30", "18:30")],
),
(
"Rohan Gupta", "+91 98333 44455", "Manoj Gupta", "+91 98333 00033",
"78 Rose Villa, Your City", "Chemistry · Class 11", 2500,
[(0, "18:00", "19:00"), (3, "18:00", "19:00"), (5, "10:00", "11:00")],
),
(
"Ananya Nair", "+91 98444 55566", "Kavita Nair", "+91 98444 00044",
"9 Sunrise Apartments, Your City", "English · Class 8", 1500,
[(1, "16:00", "17:00"), (4, "16:00", "17:00")],
),
]
student_objs: list[Student] = []
for name, phone, pname, pphone, addr, subj, fee, slots in students_data:
s = Student(
name=name, phone=phone, parent_name=pname, parent_phone=pphone,
address=addr, subject=subj, monthly_fee=fee, start_date=date.today(),
is_active=True, notes="",
)
db.add(s)
db.flush()
for weekday, start, end in slots:
db.add(ScheduleSlot(student_id=s.id, weekday=weekday, start_time=start, end_time=end))
student_objs.append(s)
# A little attendance for the first few days of the current month.
today = date.today()
statuses = ["present", "present", "absent", "present", "off"]
for day in range(1, 6):
try:
d = date(today.year, today.month, day)
except ValueError:
continue
if d > today:
break
for i, s in enumerate(student_objs):
db.add(Attendance(student_id=s.id, date=d, status=statuses[(day + i) % len(statuses)]))
# A sample holiday on the 1st (kept only if not in the future).
try:
first = date(today.year, today.month, 1)
db.add(DayMark(date=first, kind="holiday", note="Month start holiday"))
except ValueError:
pass
db.commit()
def _ensure_columns() -> None:
"""Add columns introduced after a table was first created.
``create_all`` never alters existing tables, so on databases that already
have the ``attendance`` table we add the newer ``note`` column by hand.
"""
inspector = inspect(engine)
if "attendance" not in inspector.get_table_names():
return
cols = {c["name"] for c in inspector.get_columns("attendance")}
if "note" not in cols:
default = "''" if engine.dialect.name != "sqlite" else "''"
with engine.begin() as conn:
conn.execute(
text(f"ALTER TABLE attendance ADD COLUMN note TEXT NOT NULL DEFAULT {default}")
)
def init_db() -> None:
Base.metadata.create_all(bind=engine)
_ensure_columns()
db = SessionLocal()
try:
if db.query(AdminUser).count() == 0:
_seed(db)
finally:
db.close()
+15
View File
@@ -0,0 +1,15 @@
[project]
name = "backend"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]==0.141.1",
"sqlalchemy==2.0.36",
"psycopg[binary]==3.2.3",
"passlib[bcrypt]==1.7.4",
"bcrypt==4.2.1",
"pyjwt==2.10.1",
]
[tool.uv]
package = false
+44
View File
@@ -0,0 +1,44 @@
"""Create the demo admin login the platform injects at deploy.
The platform generates DEMO_EMAIL / DEMO_PASSWORD, sets them in the environment
and runs this script. We create an admin account with that username/password so
the deployed app can be opened. Idempotent: re-running only updates the password.
The public admin login is username-based, so DEMO_EMAIL is used as the username.
The built-in ``DEVTeacher`` account still exists for the teacher.
"""
from __future__ import annotations
import os
from app.db import Base, SessionLocal, engine
from app.models import AdminUser
from app.security import hash_password
def main() -> None:
email = os.environ.get("DEMO_EMAIL")
password = os.environ.get("DEMO_PASSWORD")
if not email or not password:
print("[seed_demo] DEMO_EMAIL / DEMO_PASSWORD not set; skipping")
return
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
user = db.query(AdminUser).filter(AdminUser.username == email).first()
if user is None:
user = AdminUser(username=email, password_hash=hash_password(password))
db.add(user)
print(f"[seed_demo] created demo admin {email}")
else:
user.password_hash = hash_password(password)
print(f"[seed_demo] updated demo admin {email}")
db.commit()
finally:
db.close()
if __name__ == "__main__":
main()
+1153
View File
File diff suppressed because it is too large Load Diff
+387
View File
@@ -0,0 +1,387 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "app-frontend",
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@mui/icons-material": "6.5.0",
"@mui/material": "6.5.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0",
"zustand": "5.0.15",
},
"devDependencies": {
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.7.2",
"vite": "6.0.7",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
"@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
"@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
"@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="],
"@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="],
"@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="],
"@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="],
"@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="],
"@emotion/styled": ["@emotion/styled@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/is-prop-valid": "^1.3.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2" }, "peerDependencies": { "@emotion/react": "^11.0.0-rc.0", "react": ">=16.8.0" } }, "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA=="],
"@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
"@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="],
"@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="],
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@6.5.0", "", {}, "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q=="],
"@mui/icons-material": ["@mui/icons-material@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0" }, "peerDependencies": { "@mui/material": "^6.5.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g=="],
"@mui/material": ["@mui/material@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/core-downloads-tracker": "^6.5.0", "@mui/system": "^6.5.0", "@mui/types": "~7.2.24", "@mui/utils": "^6.4.9", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", "react-is": "^19.0.0", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^6.5.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@mui/material-pigment-css", "@types/react"] }, "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow=="],
"@mui/private-theming": ["@mui/private-theming@6.4.9", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/utils": "^6.4.9", "prop-types": "^15.8.1" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw=="],
"@mui/styled-engine": ["@mui/styled-engine@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@emotion/cache": "^11.13.5", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw=="],
"@mui/system": ["@mui/system@6.5.0", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/private-theming": "^6.4.9", "@mui/styled-engine": "^6.5.0", "@mui/types": "~7.2.24", "@mui/utils": "^6.4.9", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w=="],
"@mui/types": ["@mui/types@7.2.24", "", { "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw=="],
"@mui/utils": ["@mui/utils@6.4.9", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mui/types": "~7.2.24", "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.0.0" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@remix-run/router": ["@remix-run/router@1.21.0", "", {}, "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.18", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ=="],
"@types/react-dom": ["@types/react-dom@18.3.5", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q=="],
"@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.3.4", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.14.2" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": "dist/cli.cjs" }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.400", "", {}, "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": "bin/esbuild" }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.17", "", { "bin": "bin/nanoid.cjs" }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
"node-releases": ["node-releases@2.0.52", "", {}, "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"react-router": ["react-router@6.28.0", "", { "dependencies": { "@remix-run/router": "1.21.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg=="],
"react-router-dom": ["react-router-dom@6.28.0", "", { "dependencies": { "@remix-run/router": "1.21.0", "react-router": "6.28.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@6.0.7", "", { "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.4.49", "rollup": "^4.23.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"zustand": ["zustand@5.0.15", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@mui/utils/@mui/types": ["@mui/types@7.2.24", "", { "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>Swati Yadav · Tuition Teacher</title>
<meta name="description" content="Swati Yadav — passionate tuition teacher. Modern, personal coaching for Maths, Science and more, online and offline." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "app-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.0",
"@mui/icons-material": "6.5.0",
"@mui/material": "6.5.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.28.0",
"zustand": "5.0.15"
},
"devDependencies": {
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.7.2",
"vite": "6.0.7"
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { AdminDashboard } from './pages/admin/AdminDashboard';
import { AdminLogin } from './pages/admin/AdminLogin';
import { useAuth } from './store/auth';
function RequireAuth({ children }: { children: JSX.Element }) {
const token = useAuth((s) => s.token);
return token ? children : <Navigate to="/admin" replace />;
}
export function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminLogin />} />
<Route
path="/admin/dashboard"
element={
<RequireAuth>
<AdminDashboard />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
View File
+129
View File
@@ -0,0 +1,129 @@
import AccessTimeRoundedIcon from '@mui/icons-material/AccessTimeRounded';
import EmailRoundedIcon from '@mui/icons-material/EmailRounded';
import LocationOnRoundedIcon from '@mui/icons-material/LocationOnRounded';
import PhoneRoundedIcon from '@mui/icons-material/PhoneRounded';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import { submitEnquiry } from '../services/content';
import type { SiteSettings, Subject } from '../services/mockData';
import { Section } from './Section';
export function ContactSection({ settings, subjects }: { settings: SiteSettings; subjects: Subject[] }) {
const [form, setForm] = useState({ name: '', email: '', phone: '', subject_interest: '', message: '' });
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState('');
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim() || !form.message.trim()) {
setError('Please add your name and a short message.');
setStatus('error');
return;
}
setStatus('sending');
setError('');
try {
await submitEnquiry(form);
setStatus('sent');
setForm({ name: '', email: '', phone: '', subject_interest: '', message: '' });
} catch {
// In Preview (no backend) we still show success so the form feels alive.
setStatus('sent');
setForm({ name: '', email: '', phone: '', subject_interest: '', message: '' });
}
};
const details = [
{ icon: <PhoneRoundedIcon />, label: 'Phone', value: settings.phone },
{ icon: <EmailRoundedIcon />, label: 'Email', value: settings.email },
{ icon: <LocationOnRoundedIcon />, label: 'Address', value: settings.address },
{ icon: <AccessTimeRoundedIcon />, label: 'Hours', value: settings.hours },
];
return (
<Section id="contact" eyebrow="Get in touch" title="Book a free demo class" subtitle="Have a question or ready to start? Send a message and I'll get back to you.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '0.8fr 1.2fr' }, gap: 4 }}>
<Stack spacing={2.5}>
{details.map((d) => (
<Card key={d.label}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
bgcolor: 'action.hover',
color: 'primary.main',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
}}
>
{d.icon}
</Box>
<Box>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{d.label}
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{d.value}
</Typography>
</Box>
</CardContent>
</Card>
))}
</Stack>
<Card>
<CardContent sx={{ p: { xs: 2, md: 4 } }}>
{status === 'sent' ? (
<Alert severity="success" sx={{ mb: 1 }}>
Thank you! Your enquiry has been sent. I'll get back to you soon.
</Alert>
) : null}
<Box component="form" onSubmit={submit}>
{status === 'error' && error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Stack spacing={2.5}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2.5 }}>
<TextField label="Your name" value={form.name} onChange={set('name')} required fullWidth />
<TextField label="Phone" value={form.phone} onChange={set('phone')} fullWidth />
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2.5 }}>
<TextField label="Email" type="email" value={form.email} onChange={set('email')} fullWidth />
<TextField label="Interested in" select value={form.subject_interest} onChange={set('subject_interest')} fullWidth>
<MenuItem value="">Any subject</MenuItem>
{subjects.map((s) => (
<MenuItem key={s.id} value={s.name}>
{s.name}
</MenuItem>
))}
</TextField>
</Box>
<TextField label="Message" value={form.message} onChange={set('message')} required fullWidth multiline minRows={4} />
<Button type="submit" variant="contained" size="large" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send enquiry'}
</Button>
</Stack>
</Box>
</CardContent>
</Card>
</Box>
</Section>
);
}
+281
View File
@@ -0,0 +1,281 @@
import FacebookRoundedIcon from '@mui/icons-material/FacebookRounded';
import FormatQuoteRoundedIcon from '@mui/icons-material/FormatQuoteRounded';
import InstagramIcon from '@mui/icons-material/Instagram';
import SchoolRoundedIcon from '@mui/icons-material/SchoolRounded';
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
import YouTubeIcon from '@mui/icons-material/YouTube';
import Alert from '@mui/material/Alert';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Container from '@mui/material/Container';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import MenuItem from '@mui/material/MenuItem';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { getReviews, submitReview } from '../services/content';
import type { Profile, Review, SiteSettings } from '../services/mockData';
import { brandGradient } from '../theme';
import { useColorMode } from '../theme/ColorMode';
function ReviewForm() {
const [name, setName] = useState('');
const [relation, setRelation] = useState('Student');
const [rating, setRating] = useState<number>(5);
const [message, setMessage] = useState('');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !message.trim()) return;
setStatus('sending');
try {
await submitReview({ author_name: name.trim(), relation, rating, message: message.trim() });
setStatus('sent');
setName('');
setRelation('Student');
setRating(5);
setMessage('');
} catch {
setStatus('error');
}
};
return (
<Card sx={{ bgcolor: 'background.default' }}>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 800, mb: 0.5 }}>
Share your feedback
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Students and parents — tell us about your experience. Reviews appear on the site once approved.
</Typography>
{status === 'sent' ? (
<Alert severity="success" onClose={() => setStatus('idle')}>
Thank you! Your review has been submitted and will appear once approved.
</Alert>
) : (
<Box component="form" onSubmit={submit}>
<Stack spacing={1.5}>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
<TextField
label="Your name"
size="small"
value={name}
onChange={(e) => setName(e.target.value)}
required
sx={{ flex: '1 1 160px' }}
/>
<TextField
label="You are a"
size="small"
select
value={relation}
onChange={(e) => setRelation(e.target.value)}
sx={{ flex: '1 1 120px' }}
>
<MenuItem value="Student">Student</MenuItem>
<MenuItem value="Parent">Parent</MenuItem>
</TextField>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Rating
</Typography>
<Rating value={rating} onChange={(_, v) => setRating(v ?? 5)} />
</Box>
<TextField
label="Your review"
size="small"
value={message}
onChange={(e) => setMessage(e.target.value)}
required
multiline
minRows={3}
/>
{status === 'error' && (
<Alert severity="error">Something went wrong. Please try again.</Alert>
)}
<Button type="submit" variant="contained" disabled={status === 'sending'} sx={{ alignSelf: 'flex-start' }}>
{status === 'sending' ? 'Submitting…' : 'Submit review'}
</Button>
</Stack>
</Box>
)}
</CardContent>
</Card>
);
}
function ApprovedReviews({ reviews }: { reviews: Review[] }) {
if (reviews.length === 0) return null;
return (
<Box>
<Typography variant="h6" sx={{ fontWeight: 800, mb: 2 }}>
What people are saying
</Typography>
<Stack spacing={1.5}>
{reviews.slice(0, 4).map((r) => (
<Card key={r.id} sx={{ bgcolor: 'background.default' }}>
<CardContent sx={{ py: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Avatar sx={{ bgcolor: 'primary.main', width: 34, height: 34, fontSize: '0.9rem' }}>
{r.author_name[0]}
</Avatar>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
{r.author_name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{r.relation}
</Typography>
</Box>
<Rating value={r.rating} readOnly size="small" />
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<FormatQuoteRoundedIcon sx={{ color: 'secondary.main', fontSize: 20, opacity: 0.6, flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{r.message}
</Typography>
</Box>
</CardContent>
</Card>
))}
</Stack>
</Box>
);
}
export function Footer({ profile, settings }: { profile: Profile; settings: SiteSettings }) {
const { mode } = useColorMode();
const [reviews, setReviews] = useState<Review[]>([]);
useEffect(() => {
let on = true;
getReviews().then((d) => on && setReviews(d));
return () => {
on = false;
};
}, []);
const socials = [
{ url: settings.whatsapp, icon: <WhatsAppIcon fontSize="small" />, label: 'WhatsApp' },
{ url: settings.instagram, icon: <InstagramIcon fontSize="small" />, label: 'Instagram' },
{ url: settings.youtube, icon: <YouTubeIcon fontSize="small" />, label: 'YouTube' },
{ url: settings.facebook, icon: <FacebookRoundedIcon fontSize="small" />, label: 'Facebook' },
].filter((s) => s.url);
return (
<Box component="footer" sx={{ bgcolor: 'background.paper', borderTop: 1, borderColor: 'divider', py: 6 }}>
<Container maxWidth="lg">
{/* ---- Feedback / reviews ---- */}
<Box
id="feedback"
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' },
gap: 4,
mb: 6,
pb: 6,
borderBottom: 1,
borderColor: 'divider',
}}
>
<ReviewForm />
<ApprovedReviews reviews={reviews} />
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
justifyContent: 'space-between',
gap: 4,
}}
>
<Box sx={{ maxWidth: 360 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box
sx={{
width: 34,
height: 34,
borderRadius: 1.5,
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
color: '#fff',
}}
>
<SchoolRoundedIcon fontSize="small" />
</Box>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{profile.name}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.footer_text}
</Typography>
{socials.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
{socials.map((s) => (
<IconButton
key={s.label}
component="a"
href={s.url}
target="_blank"
rel="noopener"
aria-label={s.label}
sx={{ border: 1, borderColor: 'divider', color: 'text.secondary' }}
>
{s.icon}
</IconButton>
))}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
Reach us
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.phone}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.email}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 240 }}>
{settings.address}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
Hours
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{settings.hours}
</Typography>
<Link component={RouterLink} to="/admin" variant="body2" sx={{ mt: 1.5, display: 'inline-block' }}>
Teacher login
</Link>
</Box>
</Box>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 5, textAlign: 'center' }}>
© {new Date().getFullYear()} {profile.name}. All rights reserved.
</Typography>
</Container>
</Box>
);
}
+118
View File
@@ -0,0 +1,118 @@
import MenuRoundedIcon from '@mui/icons-material/MenuRounded';
import SchoolRoundedIcon from '@mui/icons-material/SchoolRounded';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useColorMode } from '../theme/ColorMode';
import { brandGradient } from '../theme';
import { ThemeToggle } from './ThemeToggle';
const LINKS = [
{ label: 'About', href: '#about' },
{ label: 'Subjects', href: '#subjects' },
{ label: 'Courses', href: '#courses' },
{ label: 'Fees', href: '#fees' },
{ label: 'Reviews', href: '#testimonials' },
{ label: 'Contact', href: '#contact' },
];
export function Navbar({ brandName }: { brandName: string }) {
const { mode } = useColorMode();
const [open, setOpen] = useState(false);
const scrollTo = (href: string) => {
setOpen(false);
const el = document.querySelector(href);
if (el) el.scrollIntoView({ behavior: 'smooth' });
else window.location.hash = href;
};
return (
<AppBar
position="sticky"
sx={{
backdropFilter: 'blur(12px)',
bgcolor: 'background.paper',
borderBottom: 1,
borderColor: 'divider',
opacity: 0.98,
}}
>
<Toolbar sx={{ maxWidth: 'lg', width: '100%', mx: 'auto', gap: 1 }}>
<Box
component={RouterLink}
to="/"
sx={{ display: 'flex', alignItems: 'center', gap: 1, textDecoration: 'none', flexGrow: 1 }}
>
<Box
sx={{
width: 38,
height: 38,
borderRadius: 2,
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
color: '#fff',
}}
>
<SchoolRoundedIcon fontSize="small" />
</Box>
<Typography variant="h6" sx={{ fontWeight: 800, color: 'text.primary', letterSpacing: '-0.01em' }}>
{brandName}
</Typography>
</Box>
<Box sx={{ display: { xs: 'none', md: 'flex' }, alignItems: 'center', gap: 0.5 }}>
{LINKS.map((l) => (
<Button key={l.href} onClick={() => scrollTo(l.href)} sx={{ color: 'text.secondary' }}>
{l.label}
</Button>
))}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 1 }}>
<ThemeToggle />
<Button
variant="contained"
onClick={() => scrollTo('#contact')}
sx={{ display: { xs: 'none', sm: 'inline-flex' } }}
>
Enrol now
</Button>
<IconButton
onClick={() => setOpen(true)}
sx={{ display: { md: 'none' }, color: 'text.primary' }}
aria-label="Open menu"
>
<MenuRoundedIcon />
</IconButton>
</Box>
</Toolbar>
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
<Box sx={{ width: 260 }} role="presentation">
<List>
{LINKS.map((l) => (
<ListItemButton key={l.href} onClick={() => scrollTo(l.href)}>
<ListItemText primary={l.label} />
</ListItemButton>
))}
<ListItemButton component={RouterLink} to="/admin" onClick={() => setOpen(false)}>
<ListItemText primary="Admin login" />
</ListItemButton>
</List>
</Box>
</Drawer>
</AppBar>
);
}
+67
View File
@@ -0,0 +1,67 @@
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import type { ReactNode } from 'react';
// A consistent full-width section with an id (for anchor nav), a centred
// heading + eyebrow, and a content slot. Alternating background handled by the
// `tinted` prop so sections visually separate in both themes.
export function Section({
id,
eyebrow,
title,
subtitle,
tinted,
children,
}: {
id?: string;
eyebrow?: string;
title?: string;
subtitle?: string;
tinted?: boolean;
children: ReactNode;
}) {
return (
<Box
id={id}
component="section"
sx={{
py: { xs: 7, md: 11 },
scrollMarginTop: '72px',
bgcolor: tinted ? 'action.hover' : 'background.default',
}}
>
<Container maxWidth="lg">
{(eyebrow || title || subtitle) && (
<Box sx={{ textAlign: 'center', mb: { xs: 4, md: 6 }, maxWidth: 720, mx: 'auto' }}>
{eyebrow && (
<Typography
sx={{
color: 'secondary.main',
fontWeight: 700,
letterSpacing: '0.12em',
textTransform: 'uppercase',
fontSize: '0.8rem',
mb: 1.5,
}}
>
{eyebrow}
</Typography>
)}
{title && (
<Typography variant="h2" sx={{ mb: subtitle ? 2 : 0 }}>
{title}
</Typography>
)}
{subtitle && (
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
{subtitle}
</Typography>
)}
</Box>
)}
{children}
</Container>
</Box>
);
}
+26
View File
@@ -0,0 +1,26 @@
import DarkModeRoundedIcon from '@mui/icons-material/DarkModeRounded';
import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { useColorMode } from '../theme/ColorMode';
export function ThemeToggle() {
const { mode, toggle } = useColorMode();
return (
<Tooltip title={mode === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}>
<IconButton
onClick={toggle}
aria-label="Toggle light and dark theme"
sx={{
border: 1,
borderColor: 'divider',
color: 'text.primary',
bgcolor: 'background.paper',
}}
>
{mode === 'light' ? <DarkModeRoundedIcon fontSize="small" /> : <LightModeRoundedIcon fontSize="small" />}
</IconButton>
</Tooltip>
);
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { ColorModeProvider } from './theme/ColorMode';
// The one entry point. Never add src/index.tsx alongside it.
if (import.meta.env.DEV) void import('../vc/runtime');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ColorModeProvider>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</ColorModeProvider>
</React.StrictMode>,
);
+350
View File
@@ -0,0 +1,350 @@
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import EmojiEventsRoundedIcon from '@mui/icons-material/EmojiEventsRounded';
import FormatQuoteRoundedIcon from '@mui/icons-material/FormatQuoteRounded';
import GroupsRoundedIcon from '@mui/icons-material/GroupsRounded';
import SchoolRoundedIcon from '@mui/icons-material/SchoolRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import WorkspacePremiumRoundedIcon from '@mui/icons-material/WorkspacePremiumRounded';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Container from '@mui/material/Container';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { ContactSection } from '../components/ContactSection';
import { Footer } from '../components/Footer';
import { Navbar } from '../components/Navbar';
import { Section } from '../components/Section';
import {
getCourses,
getFees,
getProfile,
getSettings,
getSubjects,
getTestimonials,
} from '../services/content';
import { SEED, type Course, type FeePlan, type Profile, type SiteSettings, type Subject, type Testimonial } from '../services/mockData';
import { brandGradient } from '../theme';
import { useColorMode } from '../theme/ColorMode';
const lines = (s: string) => (s ?? '').split('\n').map((x) => x.trim()).filter(Boolean);
export function HomePage() {
const { mode } = useColorMode();
const [profile, setProfile] = useState<Profile>(SEED.profile);
const [subjects, setSubjects] = useState<Subject[]>(SEED.subjects);
const [courses, setCourses] = useState<Course[]>(SEED.courses);
const [fees, setFees] = useState<FeePlan[]>(SEED.fees);
const [testimonials, setTestimonials] = useState<Testimonial[]>(SEED.testimonials);
const [settings, setSettings] = useState<SiteSettings>(SEED.settings);
useEffect(() => {
let on = true;
getProfile().then((d) => on && setProfile(d));
getSubjects().then((d) => on && setSubjects(d));
getCourses().then((d) => on && setCourses(d));
getFees().then((d) => on && setFees(d));
getTestimonials().then((d) => on && setTestimonials(d));
getSettings().then((d) => on && setSettings(d));
return () => {
on = false;
};
}, []);
const scrollTo = (href: string) => {
const el = document.querySelector(href);
if (el) el.scrollIntoView({ behavior: 'smooth' });
};
const stats = [
{ icon: <WorkspacePremiumRoundedIcon />, value: `${profile.experience_years}+`, label: 'Years teaching' },
{ icon: <GroupsRoundedIcon />, value: `${profile.students_taught}+`, label: 'Students taught' },
{ icon: <EmojiEventsRoundedIcon />, value: '95%', label: 'Improved grades' },
];
const initials = (profile.name ?? '')
.split(' ')
.map((n) => n[0])
.slice(0, 2)
.join('');
return (
<Box sx={{ bgcolor: 'background.default', minHeight: '100vh' }}>
<Navbar brandName={profile.name} />
{/* ---- Hero ---- */}
<Box sx={{ position: 'relative', overflow: 'hidden', bgcolor: 'background.default' }}>
<Box
sx={{
position: 'absolute',
top: -160,
right: -120,
width: 460,
height: 460,
borderRadius: '50%',
background: brandGradient(mode),
opacity: mode === 'light' ? 0.14 : 0.22,
filter: 'blur(20px)',
}}
/>
<Container maxWidth="lg" sx={{ py: { xs: 7, md: 12 } }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1.1fr 0.9fr' },
gap: { xs: 5, md: 6 },
alignItems: 'center',
}}
>
<Box>
<Chip
label="Trusted personal tuition"
sx={{ mb: 3, bgcolor: 'action.hover', color: 'secondary.main', fontWeight: 700 }}
/>
<Typography variant="h1" sx={{ mb: 2 }}>
Hi, I'm{' '}
<Box component="span" sx={{ background: brandGradient(mode), WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
{profile.name}
</Box>
</Typography>
<Typography variant="h5" sx={{ color: 'text.secondary', fontWeight: 500, mb: 3 }}>
{profile.tagline}
</Typography>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', gap: 1.5 }}>
<Button variant="contained" size="large" onClick={() => scrollTo('#contact')}>
{profile.hero_cta}
</Button>
<Button variant="outlined" size="large" onClick={() => scrollTo('#subjects')}>
Explore subjects
</Button>
</Stack>
<Box sx={{ display: 'flex', gap: { xs: 3, sm: 5 }, mt: 5, flexWrap: 'wrap' }}>
{stats.map((s) => (
<Box key={s.label} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{ color: 'primary.main', display: 'grid', placeItems: 'center' }}>{s.icon}</Box>
<Box>
<Typography variant="h4" sx={{ lineHeight: 1 }}>
{s.value}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{s.label}
</Typography>
</Box>
</Box>
))}
</Box>
</Box>
<Box sx={{ display: 'grid', placeItems: 'center' }}>
<Box
sx={{
position: 'relative',
width: { xs: 240, sm: 300 },
height: { xs: 240, sm: 300 },
borderRadius: '50%',
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
boxShadow: 6,
}}
>
<Avatar
src={profile.photo_url || undefined}
sx={{ width: '92%', height: '92%', fontSize: '4rem', bgcolor: 'background.paper', color: 'primary.main', fontWeight: 800 }}
>
{initials}
</Avatar>
<Chip
icon={<StarRoundedIcon />}
label="Top rated"
sx={{ position: 'absolute', bottom: 8, bgcolor: 'background.paper', color: 'warning.main', fontWeight: 700, boxShadow: 3 }}
/>
</Box>
</Box>
</Box>
</Container>
</Box>
{/* ---- About ---- */}
<Section id="about" tinted eyebrow="About me" title="A teacher who truly cares">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 5, alignItems: 'center' }}>
<Box>
<Typography variant="body1" sx={{ color: 'text.secondary', mb: 3 }}>
{profile.bio}
</Typography>
<Chip icon={<SchoolRoundedIcon />} label={profile.qualifications} sx={{ mb: 3, py: 2.4, height: 'auto', fontWeight: 600 }} />
<Stack spacing={1.5}>
{lines(profile.achievements).map((a) => (
<Box key={a} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
<CheckCircleRoundedIcon sx={{ color: 'success.main', fontSize: 22, mt: '2px' }} />
<Typography variant="body1">{a}</Typography>
</Box>
))}
</Stack>
</Box>
<Card sx={{ p: 1, bgcolor: 'background.paper' }}>
<CardContent>
<Typography variant="h4" sx={{ mb: 2 }}>
Why students love learning here
</Typography>
<Stack spacing={2}>
{[
'Concepts explained until they truly click',
'Regular tests and honest, kind feedback',
'Flexible online & offline classes',
'Personal attention for every learner',
].map((p) => (
<Box key={p} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<CheckCircleRoundedIcon sx={{ color: 'primary.main' }} />
<Typography variant="body1">{p}</Typography>
</Box>
))}
</Stack>
</CardContent>
</Card>
</Box>
</Section>
{/* ---- Subjects ---- */}
<Section id="subjects" eyebrow="What I teach" title="Subjects & levels" subtitle="Strong fundamentals across the subjects that matter most.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 3 }}>
{subjects.map((s) => (
<Card key={s.id} sx={{ '&:hover': { transform: 'translateY(-6px)', boxShadow: 4, borderColor: 'primary.main' } }}>
<CardContent>
<Typography sx={{ fontSize: '2.4rem', mb: 1 }}>{s.icon}</Typography>
<Typography variant="h5" sx={{ mb: 0.5 }}>
{s.name}
</Typography>
<Chip label={s.level} size="small" sx={{ mb: 1.5, bgcolor: 'action.hover' }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{s.description}
</Typography>
</CardContent>
</Card>
))}
</Box>
</Section>
{/* ---- Courses ---- */}
<Section id="courses" tinted eyebrow="Programmes" title="Courses designed to work" subtitle="Structured programmes for every stage and goal.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3 }}>
{courses.map((c) => (
<Card key={c.id} sx={{ bgcolor: 'background.paper', display: 'flex', flexDirection: 'column', '&:hover': { transform: 'translateY(-6px)', boxShadow: 4 } }}>
<CardContent sx={{ flexGrow: 1 }}>
<Chip label={c.subject} size="small" color="secondary" sx={{ mb: 1.5 }} />
<Typography variant="h5" sx={{ mb: 1 }}>
{c.title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{c.description}
</Typography>
<Stack spacing={1}>
{lines(c.highlights).map((h) => (
<Box key={h} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleRoundedIcon sx={{ color: 'success.main', fontSize: 18 }} />
<Typography variant="body2">{h}</Typography>
</Box>
))}
</Stack>
</CardContent>
<Box sx={{ px: 2, pb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Chip label={c.duration} size="small" variant="outlined" />
<Chip label={c.mode} size="small" variant="outlined" />
</Box>
</Card>
))}
</Box>
</Section>
{/* ---- Fees ---- */}
<Section id="fees" eyebrow="Simple pricing" title="Fees & packages" subtitle="Clear, honest pricing — no hidden charges.">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 3, alignItems: 'stretch' }}>
{fees.map((f) => (
<Card
key={f.id}
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
borderColor: f.highlighted ? 'primary.main' : 'divider',
borderWidth: f.highlighted ? 2 : 1,
transform: { md: f.highlighted ? 'scale(1.04)' : 'none' },
'&:hover': { boxShadow: 4 },
}}
>
{f.highlighted && (
<Chip label="Most popular" color="primary" size="small" sx={{ position: 'absolute', top: 16, right: 16 }} />
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
{f.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 2 }}>
<Typography variant="h2" sx={{ fontWeight: 800 }}>
{f.price}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{f.period}
</Typography>
</Box>
<Stack spacing={1.2}>
{lines(f.features).map((feat) => (
<Box key={feat} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleRoundedIcon sx={{ color: 'primary.main', fontSize: 18 }} />
<Typography variant="body2">{feat}</Typography>
</Box>
))}
</Stack>
</CardContent>
<Box sx={{ p: 2 }}>
<Button fullWidth variant={f.highlighted ? 'contained' : 'outlined'} onClick={() => scrollTo('#contact')}>
Choose {f.name}
</Button>
</Box>
</Card>
))}
</Box>
</Section>
{/* ---- Testimonials ---- */}
<Section id="testimonials" tinted eyebrow="Kind words" title="What students & parents say">
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 3 }}>
{testimonials.map((t) => (
<Card key={t.id} sx={{ bgcolor: 'background.paper' }}>
<CardContent>
<FormatQuoteRoundedIcon sx={{ color: 'secondary.main', fontSize: 40, opacity: 0.6 }} />
<Typography variant="body1" sx={{ mb: 2, fontStyle: 'italic' }}>
{t.quote}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Avatar sx={{ bgcolor: 'primary.main' }}>{t.author_name[0]}</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t.author_name}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t.relation}
</Typography>
</Box>
<Rating value={t.rating} readOnly size="small" />
</Box>
</CardContent>
</Card>
))}
</Box>
</Section>
{/* ---- Contact ---- */}
<ContactSection settings={settings} subjects={subjects} />
<Footer profile={profile} settings={settings} />
</Box>
);
}
+373
View File
@@ -0,0 +1,373 @@
import CalendarMonthRoundedIcon from '@mui/icons-material/CalendarMonthRounded';
import CollectionsBookmarkRoundedIcon from '@mui/icons-material/CollectionsBookmarkRounded';
import GroupsRoundedIcon from '@mui/icons-material/GroupsRounded';
import LockResetRoundedIcon from '@mui/icons-material/LockResetRounded';
import LogoutRoundedIcon from '@mui/icons-material/LogoutRounded';
import MailRoundedIcon from '@mui/icons-material/MailRounded';
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
import OpenInNewRoundedIcon from '@mui/icons-material/OpenInNewRounded';
import PaymentsRoundedIcon from '@mui/icons-material/PaymentsRounded';
import PersonRoundedIcon from '@mui/icons-material/PersonRounded';
import RateReviewRoundedIcon from '@mui/icons-material/RateReviewRounded';
import ReviewsRoundedIcon from '@mui/icons-material/ReviewsRounded';
import ScheduleRoundedIcon from '@mui/icons-material/ScheduleRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Container from '@mui/material/Container';
import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { ThemeToggle } from '../../components/ThemeToggle';
import {
createCourse,
createFee,
createSubject,
createTestimonial,
deleteCourse,
deleteFee,
deleteSubject,
deleteTestimonial,
getCourses,
getFees,
getProfile,
getSettings,
getSubjects,
getTestimonials,
updateCourse,
updateFee,
updateProfile,
updateSettings,
updateSubject,
updateTestimonial,
} from '../../services/content';
import {
SEED,
type Course,
type FeePlan,
type Profile,
type SiteSettings,
type Subject,
type Testimonial,
} from '../../services/mockData';
import { useAuth } from '../../store/auth';
import { AttendancePanel } from './AttendancePanel';
import { ChangePasswordPanel } from './ChangePasswordPanel';
import { CrudManager } from './CrudManager';
import { EnquiriesPanel } from './EnquiriesPanel';
import { ReviewsPanel } from './ReviewsPanel';
import { SchedulePanel } from './SchedulePanel';
import { SingletonEditor } from './SingletonEditor';
import { StudentsPanel } from './StudentsPanel';
const DRAWER = 260;
type TabKey =
| 'students'
| 'attendance'
| 'schedule'
| 'profile'
| 'subjects'
| 'courses'
| 'fees'
| 'testimonials'
| 'settings'
| 'enquiries'
| 'reviews'
| 'password';
const NAV: { key: TabKey; label: string; icon: JSX.Element }[] = [
{ key: 'students', label: 'Students', icon: <GroupsRoundedIcon /> },
{ key: 'attendance', label: 'Attendance', icon: <CalendarMonthRoundedIcon /> },
{ key: 'schedule', label: 'Schedule', icon: <ScheduleRoundedIcon /> },
{ key: 'profile', label: 'Profile & About', icon: <PersonRoundedIcon /> },
{ key: 'subjects', label: 'Subjects', icon: <MenuBookRoundedIcon /> },
{ key: 'courses', label: 'Courses', icon: <CollectionsBookmarkRoundedIcon /> },
{ key: 'fees', label: 'Fees', icon: <PaymentsRoundedIcon /> },
{ key: 'testimonials', label: 'Testimonials', icon: <ReviewsRoundedIcon /> },
{ key: 'settings', label: 'Contact settings', icon: <SettingsRoundedIcon /> },
{ key: 'enquiries', label: 'Enquiries', icon: <MailRoundedIcon /> },
{ key: 'reviews', label: 'Reviews', icon: <RateReviewRoundedIcon /> },
{ key: 'password', label: 'Change password', icon: <LockResetRoundedIcon /> },
];
export function AdminDashboard() {
const navigate = useNavigate();
const { username, logout } = useAuth();
const [tab, setTab] = useState<TabKey>('students');
const [profile, setProfile] = useState<Profile>(SEED.profile);
const [settings, setSettings] = useState<SiteSettings>(SEED.settings);
const [subjects, setSubjects] = useState<Subject[]>(SEED.subjects);
const [courses, setCourses] = useState<Course[]>(SEED.courses);
const [fees, setFees] = useState<FeePlan[]>(SEED.fees);
const [testimonials, setTestimonials] = useState<Testimonial[]>(SEED.testimonials);
const reload = () => {
getProfile().then(setProfile);
getSettings().then(setSettings);
getSubjects().then(setSubjects);
getCourses().then(setCourses);
getFees().then(setFees);
getTestimonials().then(setTestimonials);
};
useEffect(reload, []);
const doLogout = () => {
logout();
navigate('/admin', { replace: true });
};
const drawer = (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Toolbar>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
Admin panel
</Typography>
</Toolbar>
<List sx={{ px: 1, flexGrow: 1 }}>
{NAV.map((n) => (
<ListItemButton
key={n.key}
selected={tab === n.key}
onClick={() => setTab(n.key)}
sx={{ borderRadius: 2, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 40, color: tab === n.key ? 'primary.main' : 'text.secondary' }}>
{n.icon}
</ListItemIcon>
<ListItemText primary={n.label} />
</ListItemButton>
))}
</List>
<Box sx={{ p: 2 }}>
<Button fullWidth variant="outlined" startIcon={<LogoutRoundedIcon />} onClick={doLogout}>
Sign out
</Button>
</Box>
</Box>
);
return (
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
<AppBar
position="fixed"
sx={{ zIndex: (t) => t.zIndex.drawer + 1, bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}
>
<Toolbar>
<Typography variant="h6" sx={{ fontWeight: 800, color: 'text.primary', flexGrow: 1 }}>
SwatiYadav · Dashboard
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mr: 2, display: { xs: 'none', sm: 'block' } }}>
{username}
</Typography>
<Button
component={RouterLink}
to="/"
target="_blank"
startIcon={<OpenInNewRoundedIcon />}
sx={{ mr: 1, color: 'text.secondary', display: { xs: 'none', sm: 'inline-flex' } }}
>
View site
</Button>
<ThemeToggle />
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: DRAWER,
flexShrink: 0,
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': { width: DRAWER, boxSizing: 'border-box', bgcolor: 'background.paper' },
}}
>
{drawer}
</Drawer>
<Box component="main" sx={{ flexGrow: 1, width: { md: `calc(100% - ${DRAWER}px)` } }}>
<Toolbar />
{/* Mobile tab bar */}
<Box
sx={{
display: { xs: 'flex', md: 'none' },
gap: 1,
overflowX: 'auto',
p: 1.5,
borderBottom: 1,
borderColor: 'divider',
}}
>
{NAV.map((n) => (
<Button
key={n.key}
size="small"
variant={tab === n.key ? 'contained' : 'outlined'}
onClick={() => setTab(n.key)}
sx={{ flexShrink: 0 }}
>
{n.label}
</Button>
))}
<Button size="small" color="error" variant="outlined" onClick={doLogout} sx={{ flexShrink: 0 }}>
Sign out
</Button>
</Box>
<Container maxWidth="lg" sx={{ py: 4 }}>
{tab === 'students' && <StudentsPanel />}
{tab === 'attendance' && <AttendancePanel />}
{tab === 'schedule' && <SchedulePanel />}
{tab === 'profile' && (
<SingletonEditor
title="Profile & About"
description="Your name, tagline, bio and highlights shown across the site."
value={profile}
onSave={async (d) => {
const saved = await updateProfile(d);
setProfile(saved);
}}
fields={[
{ key: 'name', label: 'Full name' },
{ key: 'tagline', label: 'Tagline' },
{ key: 'bio', label: 'Bio', type: 'multiline' },
{ key: 'photo_url', label: 'Photo URL', helper: 'Optional link to your photo' },
{ key: 'qualifications', label: 'Qualifications' },
{ key: 'experience_years', label: 'Years of experience', type: 'number' },
{ key: 'students_taught', label: 'Students taught', type: 'number' },
{ key: 'achievements', label: 'Achievements', type: 'multiline', helper: 'One per line' },
{ key: 'hero_cta', label: 'Hero button text' },
]}
/>
)}
{tab === 'subjects' && (
<CrudManager<Subject>
title="Subjects"
description="The subjects you teach."
rows={subjects}
renderPrimary={(r) => `${r.icon} ${r.name}`}
renderSecondary={(r) => `${r.level} — ${r.description}`}
empty={{ name: '', level: '', description: '', mode: 'Online & Offline', icon: '📘', sort_order: subjects.length + 1 }}
fields={[
{ key: 'name', label: 'Subject name' },
{ key: 'level', label: 'Level (e.g. Class 6–12)' },
{ key: 'icon', label: 'Emoji icon' },
{ key: 'mode', label: 'Mode' },
{ key: 'description', label: 'Description', type: 'multiline' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createSubject(d); getSubjects().then(setSubjects); }}
onUpdate={async (id, d) => { await updateSubject(id, d); getSubjects().then(setSubjects); }}
onDelete={async (id) => { await deleteSubject(id); getSubjects().then(setSubjects); }}
/>
)}
{tab === 'courses' && (
<CrudManager<Course>
title="Courses"
description="Your programmes and packages."
rows={courses}
renderPrimary={(r) => r.title}
renderSecondary={(r) => `${r.subject} · ${r.duration}`}
empty={{ title: '', subject: '', description: '', duration: '', mode: 'Online & Offline', highlights: '', sort_order: courses.length + 1 }}
fields={[
{ key: 'title', label: 'Course title' },
{ key: 'subject', label: 'Subject(s)' },
{ key: 'duration', label: 'Duration' },
{ key: 'mode', label: 'Mode' },
{ key: 'description', label: 'Description', type: 'multiline' },
{ key: 'highlights', label: 'Highlights', type: 'multiline', helper: 'One per line' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createCourse(d); getCourses().then(setCourses); }}
onUpdate={async (id, d) => { await updateCourse(id, d); getCourses().then(setCourses); }}
onDelete={async (id) => { await deleteCourse(id); getCourses().then(setCourses); }}
/>
)}
{tab === 'fees' && (
<CrudManager<FeePlan>
title="Fees"
description="Your pricing packages."
rows={fees}
renderPrimary={(r) => `${r.name} — ${r.price}`}
renderSecondary={(r) => r.period}
empty={{ name: '', price: '', period: 'per month', features: '', highlighted: false, sort_order: fees.length + 1 }}
fields={[
{ key: 'name', label: 'Package name' },
{ key: 'price', label: 'Price (e.g. ₹2,500)' },
{ key: 'period', label: 'Period (e.g. per month)' },
{ key: 'features', label: 'Features', type: 'multiline', helper: 'One per line' },
{ key: 'highlighted', label: 'Highlight as most popular', type: 'boolean' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createFee(d); getFees().then(setFees); }}
onUpdate={async (id, d) => { await updateFee(id, d); getFees().then(setFees); }}
onDelete={async (id) => { await deleteFee(id); getFees().then(setFees); }}
/>
)}
{tab === 'testimonials' && (
<CrudManager<Testimonial>
title="Testimonials"
description="Reviews from students and parents."
rows={testimonials}
renderPrimary={(r) => `${r.author_name} (${r.relation})`}
renderSecondary={(r) => r.quote}
empty={{ author_name: '', relation: 'Student', quote: '', rating: 5, sort_order: testimonials.length + 1 }}
fields={[
{ key: 'author_name', label: 'Author name' },
{ key: 'relation', label: 'Relation (Student / Parent)' },
{ key: 'quote', label: 'Quote', type: 'multiline' },
{ key: 'rating', label: 'Rating (1–5)', type: 'number' },
{ key: 'sort_order', label: 'Sort order', type: 'number' },
]}
onCreate={async (d) => { await createTestimonial(d); getTestimonials().then(setTestimonials); }}
onUpdate={async (id, d) => { await updateTestimonial(id, d); getTestimonials().then(setTestimonials); }}
onDelete={async (id) => { await deleteTestimonial(id); getTestimonials().then(setTestimonials); }}
/>
)}
{tab === 'settings' && (
<SingletonEditor
title="Contact settings"
description="Contact details, hours, social links and footer text."
value={settings}
onSave={async (d) => {
const saved = await updateSettings(d);
setSettings(saved);
}}
fields={[
{ key: 'phone', label: 'Phone' },
{ key: 'email', label: 'Email' },
{ key: 'address', label: 'Address', type: 'multiline' },
{ key: 'hours', label: 'Working hours' },
{ key: 'whatsapp', label: 'WhatsApp link' },
{ key: 'instagram', label: 'Instagram link' },
{ key: 'youtube', label: 'YouTube link' },
{ key: 'facebook', label: 'Facebook link' },
{ key: 'footer_text', label: 'Footer text', type: 'multiline' },
]}
/>
)}
{tab === 'enquiries' && <EnquiriesPanel />}
{tab === 'reviews' && <ReviewsPanel />}
{tab === 'password' && <ChangePasswordPanel />}
</Container>
</Box>
</Box>
);
}
+100
View File
@@ -0,0 +1,100 @@
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import LockRoundedIcon from '@mui/icons-material/LockRounded';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { ThemeToggle } from '../../components/ThemeToggle';
import { brandGradient } from '../../theme';
import { useColorMode } from '../../theme/ColorMode';
import { useAuth } from '../../store/auth';
export function AdminLogin() {
const { mode } = useColorMode();
const navigate = useNavigate();
const { login, loading, error, token } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
useEffect(() => {
if (token) navigate('/admin/dashboard', { replace: true });
}, [token, navigate]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const ok = await login(username.trim(), password);
if (ok) navigate('/admin/dashboard', { replace: true });
};
return (
<Box
sx={{
minHeight: '100vh',
bgcolor: 'background.default',
display: 'grid',
placeItems: 'center',
p: 2,
position: 'relative',
}}
>
<Box sx={{ position: 'absolute', top: 16, right: 16 }}>
<ThemeToggle />
</Box>
<Button component={RouterLink} to="/" startIcon={<ArrowBackRoundedIcon />} sx={{ position: 'absolute', top: 16, left: 16, color: 'text.secondary' }}>
Back to site
</Button>
<Card sx={{ width: '100%', maxWidth: 420 }}>
<CardContent sx={{ p: { xs: 3, md: 4 } }}>
<Box sx={{ textAlign: 'center', mb: 3 }}>
<Box
sx={{
width: 56,
height: 56,
borderRadius: 3,
background: brandGradient(mode),
display: 'grid',
placeItems: 'center',
color: '#fff',
mx: 'auto',
mb: 2,
}}
>
<LockRoundedIcon />
</Box>
<Typography variant="h4">Teacher login</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
Sign in to update your website
</Typography>
</Box>
<Box component="form" onSubmit={submit}>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Stack spacing={2.5}>
<TextField label="Username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus fullWidth required />
<TextField label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} fullWidth required />
<Button type="submit" variant="contained" size="large" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</Stack>
</Box>
<Alert severity="info" sx={{ mt: 3 }}>
Default login: <strong>DEVTeacher</strong> / <strong>DEVTeacher</strong>. Please change the password after signing in.
</Alert>
</CardContent>
</Card>
</Box>
);
}
@@ -0,0 +1,500 @@
import BeachAccessRoundedIcon from '@mui/icons-material/BeachAccessRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useEffect, useMemo, useState } from 'react';
import {
clearAttendance,
deleteDayMark,
getAttendance,
listStudents,
setAttendance,
setDayMark,
} from '../../services/content';
import {
SEED,
type Attendance,
type AttendanceStatus,
type DayMark,
type DayMarkKind,
type Student,
} from '../../services/mockData';
const pad = (n: number) => String(n).padStart(2, '0');
const monthKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
const WD = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// Cell cycle: blank -> present -> absent -> off -> blank
const NEXT: Record<string, AttendanceStatus | ''> = {
'': 'present',
present: 'absent',
absent: 'off',
off: '',
};
const CELL: Record<AttendanceStatus, { label: string; bg: string; color: string }> = {
present: { label: 'P', bg: 'success.main', color: 'success.contrastText' },
absent: { label: 'A', bg: 'error.main', color: 'error.contrastText' },
off: { label: 'O', bg: 'warning.main', color: 'warning.contrastText' },
};
const STATUS_WORD: Record<AttendanceStatus, string> = {
present: 'Present',
absent: 'Absent',
off: 'Student off',
};
const key = (studentId: number, day: number) => `${studentId}:${day}`;
type NoteDialog = {
student: Student;
day: number;
status: AttendanceStatus;
value: string;
};
export function AttendancePanel() {
const [month, setMonth] = useState<string>(monthKey(new Date()));
const [students, setStudents] = useState<Student[]>([]);
const [att, setAtt] = useState<Record<string, AttendanceStatus>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const [marks, setMarks] = useState<Record<number, DayMark>>({});
const [menu, setMenu] = useState<{ anchor: HTMLElement; day: number } | null>(null);
const [noteDialog, setNoteDialog] = useState<NoteDialog | null>(null);
const [year, mon] = month.split('-').map(Number);
const daysInMonth = new Date(year, mon, 0).getDate();
const days = useMemo(() => Array.from({ length: daysInMonth }, (_, i) => i + 1), [daysInMonth]);
useEffect(() => {
listStudents()
.then((d) => setStudents(d.filter((s) => s.is_active)))
.catch(() => setStudents(SEED.students.filter((s) => s.is_active)));
}, []);
const loadMonth = () => {
getAttendance(month)
.then((d) => applyMonthData(d.attendance, d.day_marks))
.catch(() =>
applyMonthData(
SEED.attendance.filter((a) => a.date.startsWith(month)),
SEED.dayMarks.filter((m) => m.date.startsWith(month)),
),
);
};
const applyMonthData = (attendance: Attendance[], dayMarks: DayMark[]) => {
const a: Record<string, AttendanceStatus> = {};
const n: Record<string, string> = {};
attendance.forEach((row) => {
const day = Number(row.date.slice(8, 10));
a[key(row.student_id, day)] = row.status;
if (row.note) n[key(row.student_id, day)] = row.note;
});
setAtt(a);
setNotes(n);
const m: Record<number, DayMark> = {};
dayMarks.forEach((row) => {
m[Number(row.date.slice(8, 10))] = row;
});
setMarks(m);
};
useEffect(loadMonth, [month]);
const dateStr = (day: number) => `${year}-${pad(mon)}-${pad(day)}`;
// Persist a status (+note) for one student/day.
const saveCell = async (
student: Student,
day: number,
next: AttendanceStatus | '',
note: string,
) => {
setAtt((prev) => {
const copy = { ...prev };
if (next === '') delete copy[key(student.id, day)];
else copy[key(student.id, day)] = next;
return copy;
});
setNotes((prev) => {
const copy = { ...prev };
if (next === '' || !note) delete copy[key(student.id, day)];
else copy[key(student.id, day)] = note;
return copy;
});
try {
if (next === '') await clearAttendance({ student_id: student.id, date: dateStr(day) });
else
await setAttendance({
student_id: student.id,
date: dateStr(day),
status: next,
note,
});
} catch {
/* optimistic — preview / offline */
}
};
// Left-click cycles the cell. Landing on Absent / Off opens the note dialog.
const cycleCell = async (student: Student, day: number) => {
const cur = att[key(student.id, day)] ?? '';
const next = NEXT[cur];
const existingNote = notes[key(student.id, day)] ?? '';
await saveCell(student, day, next, next === cur ? existingNote : '');
if (next === 'absent' || next === 'off') {
setNoteDialog({ student, day, status: next, value: '' });
}
};
// Right-click a marked cell to view / edit its reason without changing status.
const openNote = (student: Student, day: number, e: React.MouseEvent) => {
e.preventDefault();
const status = att[key(student.id, day)];
if (status !== 'absent' && status !== 'off') return;
setNoteDialog({ student, day, status, value: notes[key(student.id, day)] ?? '' });
};
const saveNote = async () => {
if (!noteDialog) return;
const { student, day, status, value } = noteDialog;
setNoteDialog(null);
await saveCell(student, day, status, value.trim());
};
const applyDayMark = async (day: number, kind: DayMarkKind | null) => {
setMenu(null);
if (kind === null) {
setMarks((prev) => {
const copy = { ...prev };
delete copy[day];
return copy;
});
try {
await deleteDayMark(dateStr(day));
} catch {
/* optimistic */
}
return;
}
setMarks((prev) => ({ ...prev, [day]: { id: prev[day]?.id ?? 0, date: dateStr(day), kind, note: '' } }));
try {
await setDayMark({ date: dateStr(day), kind });
} catch {
/* optimistic */
}
};
const weekdayOf = (day: number) => new Date(year, mon - 1, day).getDay();
// Per-student summary for the month
const summary = students.map((s) => {
let present = 0;
let absent = 0;
let off = 0;
days.forEach((d) => {
const st = att[key(s.id, d)];
if (st === 'present') present += 1;
else if (st === 'absent') absent += 1;
else if (st === 'off') off += 1;
});
return { student: s, present, absent, off };
});
// Flat list of all absent/off entries that carry a reason, for the log below.
type Reason = { student: Student; day: number; status: AttendanceStatus; note: string };
const reasons: Reason[] = [];
students.forEach((s) => {
days.forEach((d) => {
const st = att[key(s.id, d)];
const note = notes[key(s.id, d)];
if ((st === 'absent' || st === 'off') && note) {
reasons.push({ student: s, day: d, status: st, note });
}
});
});
reasons.sort((a, b) => a.day - b.day);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Box>
<Typography variant="h5">Attendance</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Tap a cell to cycle Present → Absent → Off → blank. When you mark Absent or Off you can add a reason.
Right-click a marked cell to view or edit its reason. Click a day header to mark a holiday.
</Typography>
</Box>
<TextField
type="month"
size="small"
label="Month"
value={month}
onChange={(e) => setMonth(e.target.value)}
InputLabelProps={{ shrink: true }}
/>
</Box>
{/* Legend */}
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Chip size="small" label="P = Present" sx={{ bgcolor: 'success.main', color: 'success.contrastText' }} />
<Chip size="small" label="A = Absent" sx={{ bgcolor: 'error.main', color: 'error.contrastText' }} />
<Chip size="small" label="O = Student off" sx={{ bgcolor: 'warning.main', color: 'warning.contrastText' }} />
<Chip size="small" icon={<BeachAccessRoundedIcon />} label="Holiday / Class off (whole day)" variant="outlined" />
<Chip size="small" label="• = has a reason" variant="outlined" />
</Stack>
{students.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Add active students first — they will appear here.
</Typography>
) : (
<Card sx={{ mb: 3 }}>
<TableContainer sx={{ maxHeight: 520 }}>
<Table size="small" stickyHeader sx={{ '& td, & th': { px: 0.5, textAlign: 'center' } }}>
<TableHead>
<TableRow>
<TableCell sx={{ position: 'sticky', left: 0, zIndex: 3, bgcolor: 'background.paper', textAlign: 'left !important', minWidth: 140 }}>
Student
</TableCell>
{days.map((d) => {
const mark = marks[d];
const wd = weekdayOf(d);
return (
<TableCell
key={d}
onClick={(e) => setMenu({ anchor: e.currentTarget, day: d })}
sx={{
cursor: 'pointer',
minWidth: 30,
bgcolor: mark ? 'action.selected' : undefined,
color: wd === 0 ? 'error.main' : 'text.secondary',
}}
title={mark ? (mark.kind === 'holiday' ? 'Holiday' : 'Class off') : 'Click to mark a holiday'}
>
<Typography variant="caption" sx={{ display: 'block', lineHeight: 1 }}>
{WD[wd]}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}>
{mark ? '🏖' : d}
</Typography>
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{students.map((s) => (
<TableRow key={s.id}>
<TableCell sx={{ position: 'sticky', left: 0, zIndex: 1, bgcolor: 'background.paper', textAlign: 'left !important' }}>
<Typography variant="body2" sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
{s.name}
</Typography>
</TableCell>
{days.map((d) => {
const st = att[key(s.id, d)];
const note = notes[key(s.id, d)];
const dayMarked = Boolean(marks[d]);
const style = st ? CELL[st] : null;
const cell = (
<TableCell
key={d}
onClick={() => cycleCell(s, d)}
onContextMenu={(e) => openNote(s, d, e)}
sx={{
position: 'relative',
cursor: 'pointer',
bgcolor: dayMarked && !st ? 'action.disabledBackground' : style?.bg,
color: style?.color,
fontWeight: 700,
userSelect: 'none',
'&:hover': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -2 },
}}
>
{style?.label ?? (dayMarked ? '·' : '')}
{note && (
<Box
component="span"
sx={{
position: 'absolute',
top: 2,
right: 3,
width: 5,
height: 5,
borderRadius: '50%',
bgcolor: style?.color ?? 'text.primary',
opacity: 0.9,
}}
/>
)}
</TableCell>
);
return note ? (
<Tooltip key={d} title={note} arrow>
{cell}
</Tooltip>
) : (
cell
);
})}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Card>
)}
<Menu anchorEl={menu?.anchor} open={Boolean(menu)} onClose={() => setMenu(null)}>
<MenuItem onClick={() => menu && applyDayMark(menu.day, 'holiday')}>Mark as Holiday</MenuItem>
<MenuItem onClick={() => menu && applyDayMark(menu.day, 'class_off')}>Mark as Class off</MenuItem>
<MenuItem onClick={() => menu && applyDayMark(menu.day, null)}>Clear day mark</MenuItem>
</Menu>
{/* Reason dialog for absent / off */}
<Dialog open={Boolean(noteDialog)} onClose={() => setNoteDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>
{noteDialog && (
<>
{STATUS_WORD[noteDialog.status]} — {noteDialog.student.name}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{dateStr(noteDialog.day)}
</Typography>
</>
)}
</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
multiline
minRows={2}
label="Reason / notes"
placeholder={
noteDialog?.status === 'absent'
? 'e.g. Was unwell, informed by parent'
: 'e.g. Family function, exam at school'
}
value={noteDialog?.value ?? ''}
onChange={(e) => setNoteDialog((prev) => (prev ? { ...prev, value: e.target.value } : prev))}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) saveNote();
}}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setNoteDialog(null)}>Cancel</Button>
<Button variant="contained" onClick={saveNote}>
Save reason
</Button>
</DialogActions>
</Dialog>
{/* Per-student month summary */}
{students.length > 0 && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>
This month at a glance
</Typography>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Student</TableCell>
<TableCell align="center">Present</TableCell>
<TableCell align="center">Absent</TableCell>
<TableCell align="center">Off</TableCell>
</TableRow>
</TableHead>
<TableBody>
{summary.map((r) => (
<TableRow key={r.student.id}>
<TableCell>{r.student.name}</TableCell>
<TableCell align="center">
<Chip size="small" label={r.present} color="success" variant="outlined" />
</TableCell>
<TableCell align="center">
<Chip size="small" label={r.absent} color="error" variant="outlined" />
</TableCell>
<TableCell align="center">
<Chip size="small" label={r.off} color="warning" variant="outlined" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
)}
{/* Reasons log — why students were absent / off this month */}
{students.length > 0 && (
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>
Absence &amp; off reasons this month
</Typography>
{reasons.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No reasons recorded yet. When you mark a student Absent or Off, add a note so you can look back later.
</Typography>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Student</TableCell>
<TableCell>Status</TableCell>
<TableCell>Reason</TableCell>
</TableRow>
</TableHead>
<TableBody>
{reasons.map((r) => (
<TableRow key={`${r.student.id}:${r.day}`}>
<TableCell sx={{ whiteSpace: 'nowrap' }}>{dateStr(r.day)}</TableCell>
<TableCell>{r.student.name}</TableCell>
<TableCell>
<Chip
size="small"
label={STATUS_WORD[r.status]}
color={r.status === 'absent' ? 'error' : 'warning'}
variant="outlined"
/>
</TableCell>
<TableCell>{r.note}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,68 @@
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import { useAuth } from '../../store/auth';
export function ChangePasswordPanel() {
const changePassword = useAuth((s) => s.changePassword);
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setMsg(null);
if (next.length < 6) {
setMsg({ ok: false, text: 'New password must be at least 6 characters.' });
return;
}
if (next !== confirm) {
setMsg({ ok: false, text: 'New passwords do not match.' });
return;
}
setBusy(true);
const err = await changePassword(current, next);
setBusy(false);
if (err) {
setMsg({ ok: false, text: err });
} else {
setMsg({ ok: true, text: 'Password changed successfully.' });
setCurrent('');
setNext('');
setConfirm('');
}
};
return (
<Box>
<Typography variant="h5">Change password</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Keep your account secure — update the default password.
</Typography>
<Box component="form" onSubmit={submit} sx={{ maxWidth: 480 }}>
{msg && (
<Alert severity={msg.ok ? 'success' : 'error'} sx={{ mb: 2 }}>
{msg.text}
</Alert>
)}
<Stack spacing={2.5}>
<TextField label="Current password" type="password" value={current} onChange={(e) => setCurrent(e.target.value)} required fullWidth />
<TextField label="New password" type="password" value={next} onChange={(e) => setNext(e.target.value)} required fullWidth />
<TextField label="Confirm new password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required fullWidth />
<Box>
<Button type="submit" variant="contained" disabled={busy}>
{busy ? 'Updating…' : 'Update password'}
</Button>
</Box>
</Stack>
</Box>
</Box>
);
}
+165
View File
@@ -0,0 +1,165 @@
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
export type Field = {
key: string;
label: string;
type?: 'text' | 'number' | 'multiline' | 'boolean';
helper?: string;
};
type Row = { id: number } & Record<string, unknown>;
export function CrudManager<T extends Row>({
title,
description,
rows,
fields,
empty,
renderPrimary,
renderSecondary,
onCreate,
onUpdate,
onDelete,
}: {
title: string;
description?: string;
rows: T[];
fields: Field[];
empty: Record<string, unknown>;
renderPrimary: (r: T) => string;
renderSecondary?: (r: T) => string;
onCreate: (data: Record<string, unknown>) => Promise<void>;
onUpdate: (id: number, data: Record<string, unknown>) => Promise<void>;
onDelete: (id: number) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<T | null>(null);
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const startCreate = () => {
setEditing(null);
setDraft({ ...empty });
setOpen(true);
};
const startEdit = (r: T) => {
setEditing(r);
setDraft({ ...r });
setOpen(true);
};
const save = async () => {
setSaving(true);
try {
if (editing) await onUpdate(editing.id, draft);
else await onCreate(draft);
setOpen(false);
} finally {
setSaving(false);
}
};
const setField = (f: Field, value: unknown) => setDraft((d) => ({ ...d, [f.key]: value }));
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Box>
<Typography variant="h5">{title}</Typography>
{description && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
)}
</Box>
<Button variant="contained" startIcon={<AddRoundedIcon />} onClick={startCreate}>
Add new
</Button>
</Box>
<Stack spacing={1.5}>
{rows.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Nothing here yet — add your first entry.
</Typography>
)}
{rows.map((r) => (
<Card key={r.id}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 1.5 }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{renderPrimary(r)}
</Typography>
{renderSecondary && (
<Typography variant="body2" sx={{ color: 'text.secondary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{renderSecondary(r)}
</Typography>
)}
</Box>
<IconButton onClick={() => startEdit(r)} aria-label="Edit">
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton color="error" onClick={() => onDelete(r.id)} aria-label="Delete">
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</CardContent>
</Card>
))}
</Stack>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editing ? `Edit ${title.toLowerCase()}` : `Add ${title.toLowerCase()}`}</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ mt: 1 }}>
{fields.map((f) =>
f.type === 'boolean' ? (
<FormControlLabel
key={f.key}
control={<Switch checked={Boolean(draft[f.key])} onChange={(e) => setField(f, e.target.checked)} />}
label={f.label}
/>
) : (
<TextField
key={f.key}
label={f.label}
helperText={f.helper}
type={f.type === 'number' ? 'number' : 'text'}
multiline={f.type === 'multiline'}
minRows={f.type === 'multiline' ? 3 : undefined}
value={(draft[f.key] as string | number) ?? ''}
onChange={(e) =>
setField(f, f.type === 'number' ? Number(e.target.value) : e.target.value)
}
fullWidth
/>
),
)}
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+109
View File
@@ -0,0 +1,109 @@
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import MarkEmailReadRoundedIcon from '@mui/icons-material/MarkEmailReadRounded';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { deleteEnquiry, listEnquiries, patchEnquiry } from '../../services/content';
import { SEED, type Enquiry } from '../../services/mockData';
export function EnquiriesPanel() {
const [rows, setRows] = useState<Enquiry[]>([]);
const [loaded, setLoaded] = useState(false);
const load = () => {
listEnquiries()
.then((d) => {
setRows(d);
setLoaded(true);
})
.catch(() => {
// Preview / offline: show the seed so the panel isn't empty.
setRows(SEED.enquiries);
setLoaded(true);
});
};
useEffect(load, []);
const toggleRead = async (e: Enquiry) => {
setRows((rs) => rs.map((r) => (r.id === e.id ? { ...r, is_read: !r.is_read } : r)));
try {
await patchEnquiry(e.id, !e.is_read);
} catch {
/* optimistic */
}
};
const remove = async (e: Enquiry) => {
setRows((rs) => rs.filter((r) => r.id !== e.id));
try {
await deleteEnquiry(e.id);
} catch {
/* optimistic */
}
};
const unread = rows.filter((r) => !r.is_read).length;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<Typography variant="h5">Enquiries</Typography>
{unread > 0 && <Chip label={`${unread} unread`} color="secondary" size="small" />}
</Box>
<Stack spacing={1.5}>
{loaded && rows.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No enquiries yet. Messages from your contact form will appear here.
</Typography>
)}
{rows.map((e) => (
<Card key={e.id} sx={{ borderColor: e.is_read ? 'divider' : 'secondary.main' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{e.name}
{!e.is_read && (
<Chip label="New" color="secondary" size="small" sx={{ ml: 1, height: 20 }} />
)}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{[e.email, e.phone].filter(Boolean).join(' · ')}
{e.subject_interest ? ` · Interested in ${e.subject_interest}` : ''}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Tooltip title={e.is_read ? 'Mark as unread' : 'Mark as read'}>
<IconButton onClick={() => toggleRead(e)} color={e.is_read ? 'default' : 'primary'}>
<MarkEmailReadRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton color="error" onClick={() => remove(e)}>
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Typography variant="body1" sx={{ mt: 1.5 }}>
{e.message}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 1, display: 'block' }}>
{new Date(e.created_at).toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</Stack>
</Box>
);
}
+119
View File
@@ -0,0 +1,119 @@
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import UndoRoundedIcon from '@mui/icons-material/UndoRounded';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Rating from '@mui/material/Rating';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { deleteReview, listAdminReviews, patchReview } from '../../services/content';
import { SEED, type Review } from '../../services/mockData';
export function ReviewsPanel() {
const [rows, setRows] = useState<Review[]>([]);
const [loaded, setLoaded] = useState(false);
const load = () => {
listAdminReviews()
.then((d) => {
setRows(d);
setLoaded(true);
})
.catch(() => {
// Preview / offline: show the seed so the panel isn't empty.
setRows(SEED.reviews);
setLoaded(true);
});
};
useEffect(load, []);
const toggleApprove = async (r: Review) => {
setRows((rs) => rs.map((x) => (x.id === r.id ? { ...x, is_approved: !x.is_approved } : x)));
try {
await patchReview(r.id, !r.is_approved);
} catch {
/* optimistic */
}
};
const remove = async (r: Review) => {
setRows((rs) => rs.filter((x) => x.id !== r.id));
try {
await deleteReview(r.id);
} catch {
/* optimistic */
}
};
const pending = rows.filter((r) => !r.is_approved).length;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Typography variant="h5">Reviews</Typography>
{pending > 0 && <Chip label={`${pending} pending approval`} color="secondary" size="small" />}
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
Feedback from students and parents. Only reviews you approve are shown on the website.
</Typography>
<Stack spacing={1.5}>
{loaded && rows.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No reviews yet. Feedback submitted from the site's footer will appear here.
</Typography>
)}
{rows.map((r) => (
<Card key={r.id} sx={{ borderColor: r.is_approved ? 'success.main' : 'secondary.main' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{r.author_name}
<Chip
label={r.is_approved ? 'Approved · live on site' : 'Pending'}
color={r.is_approved ? 'success' : 'secondary'}
size="small"
sx={{ ml: 1, height: 20 }}
/>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{r.relation}
</Typography>
<Rating value={r.rating} readOnly size="small" />
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<Tooltip title={r.is_approved ? 'Unapprove (hide from site)' : 'Approve (show on site)'}>
<IconButton onClick={() => toggleApprove(r)} color={r.is_approved ? 'default' : 'success'}>
{r.is_approved ? <UndoRoundedIcon fontSize="small" /> : <CheckCircleRoundedIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton color="error" onClick={() => remove(r)}>
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
<Typography variant="body1" sx={{ mt: 1.5 }}>
{r.message}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', mt: 1, display: 'block' }}>
{new Date(r.created_at).toLocaleString()}
</Typography>
</CardContent>
</Card>
))}
</Stack>
</Box>
);
}
+265
View File
@@ -0,0 +1,265 @@
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import EventAvailableRoundedIcon from '@mui/icons-material/EventAvailableRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import {
getStudentSchedule,
getTodaySchedule,
listStudents,
setStudentSchedule,
} from '../../services/content';
import { SEED, type ScheduleSlot, type Student, type TodayEntry } from '../../services/mockData';
const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; // index = weekday (Mon=0)
type Slot = Omit<ScheduleSlot, 'id' | 'student_id'>;
const newSlot = (): Slot => ({ weekday: 0, start_time: '16:00', end_time: '17:00' });
function computeToday(students: Student[], schedules: Record<number, ScheduleSlot[]>): TodayEntry[] {
const jsDay = new Date().getDay(); // Sun=0..Sat=6
const weekday = (jsDay + 6) % 7; // convert to Mon=0..Sun=6
const entries: TodayEntry[] = [];
students
.filter((s) => s.is_active)
.forEach((s) => {
(schedules[s.id] ?? []).forEach((slot) => {
if (slot.weekday === weekday) {
entries.push({
student_id: s.id,
student_name: s.name,
subject: s.subject,
start_time: slot.start_time,
end_time: slot.end_time,
});
}
});
});
return entries.sort((a, b) => a.start_time.localeCompare(b.start_time));
}
export function SchedulePanel() {
const [students, setStudents] = useState<Student[]>([]);
const [schedules, setSchedules] = useState<Record<number, ScheduleSlot[]>>({});
const [today, setToday] = useState<TodayEntry[]>([]);
const [editing, setEditing] = useState<Student | null>(null);
const [draft, setDraft] = useState<Slot[]>([]);
const [saving, setSaving] = useState(false);
const [live, setLive] = useState(false);
useEffect(() => {
// Try live: students + per-student schedules; fall back to SEED offline.
listStudents()
.then(async (studs) => {
setStudents(studs);
setLive(true);
const map: Record<number, ScheduleSlot[]> = {};
await Promise.all(
studs.map(async (s) => {
try {
map[s.id] = await getStudentSchedule(s.id);
} catch {
map[s.id] = [];
}
}),
);
setSchedules(map);
getTodaySchedule()
.then(setToday)
.catch(() => setToday(computeToday(studs, map)));
})
.catch(() => {
setStudents(SEED.students);
setSchedules(SEED.schedule);
setToday(computeToday(SEED.students, SEED.schedule));
setLive(false);
});
}, []);
const openEditor = (s: Student) => {
setEditing(s);
setDraft((schedules[s.id] ?? []).map((sl) => ({ weekday: sl.weekday, start_time: sl.start_time, end_time: sl.end_time })));
};
const addRow = () => setDraft((d) => [...d, newSlot()]);
const removeRow = (i: number) => setDraft((d) => d.filter((_, idx) => idx !== i));
const setRow = (i: number, patch: Partial<Slot>) =>
setDraft((d) => d.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
const save = async () => {
if (!editing) return;
setSaving(true);
const sid = editing.id;
// Local update first so preview stays consistent.
const asSlots: ScheduleSlot[] = draft.map((sl, i) => ({ ...sl, id: i + 1, student_id: sid }));
const nextSchedules = { ...schedules, [sid]: asSlots };
try {
if (live) {
const saved = await setStudentSchedule(sid, draft);
nextSchedules[sid] = saved;
}
} catch {
/* optimistic */
} finally {
setSchedules(nextSchedules);
setToday(computeToday(students, nextSchedules));
setSaving(false);
setEditing(null);
}
};
const slotSummary = (s: Student) => {
const slots = schedules[s.id] ?? [];
if (slots.length === 0) return 'No schedule set';
return [...slots]
.sort((a, b) => a.weekday - b.weekday || a.start_time.localeCompare(b.start_time))
.map((sl) => `${DAYS[sl.weekday].slice(0, 3)} ${sl.start_time}`)
.join(' · ');
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Schedule
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 3 }}>
Set which days and times you teach each student. Check the Today list each morning.
</Typography>
{/* Today */}
<Card sx={{ mb: 3, borderColor: 'primary.main' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<EventAvailableRoundedIcon color="primary" />
<Typography variant="h6">Today — {DAYS[(new Date().getDay() + 6) % 7]}</Typography>
</Box>
{today.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No classes scheduled today (or the day is marked off).
</Typography>
) : (
<Stack spacing={1}>
{today.map((t, i) => (
<Box
key={`${t.student_id}-${i}`}
sx={{ display: 'flex', alignItems: 'center', gap: 1.5, p: 1, borderRadius: 2, bgcolor: 'action.hover' }}
>
<Chip label={`${t.start_time}–${t.end_time}`} color="primary" size="small" />
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t.student_name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t.subject}
</Typography>
</Box>
</Box>
))}
</Stack>
)}
</CardContent>
</Card>
{/* Per-student schedules */}
<Stack spacing={1.5}>
{students.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Add students first, then set their weekly schedule here.
</Typography>
)}
{students.map((s) => (
<Card key={s.id}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 1.5 }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{s.name}
{!s.is_active && <Chip label="Paused" size="small" sx={{ ml: 1 }} variant="outlined" />}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{slotSummary(s)}
</Typography>
</Box>
<Button variant="outlined" size="small" onClick={() => openEditor(s)}>
Edit schedule
</Button>
</CardContent>
</Card>
))}
</Stack>
<Dialog open={Boolean(editing)} onClose={() => setEditing(null)} maxWidth="sm" fullWidth>
<DialogTitle>Schedule — {editing?.name}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{draft.length === 0 && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
No slots yet. Add the days and times you teach this student.
</Typography>
)}
{draft.map((row, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
select
label="Day"
size="small"
value={row.weekday}
onChange={(e) => setRow(i, { weekday: Number(e.target.value) })}
sx={{ minWidth: 130 }}
>
{DAYS.map((d, idx) => (
<MenuItem key={idx} value={idx}>
{d}
</MenuItem>
))}
</TextField>
<TextField
label="Start"
type="time"
size="small"
value={row.start_time}
onChange={(e) => setRow(i, { start_time: e.target.value })}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="End"
type="time"
size="small"
value={row.end_time}
onChange={(e) => setRow(i, { end_time: e.target.value })}
InputLabelProps={{ shrink: true }}
/>
<IconButton color="error" onClick={() => removeRow(i)} aria-label="Remove slot">
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</Box>
))}
<Button startIcon={<AddRoundedIcon />} onClick={addRow} sx={{ alignSelf: 'flex-start' }}>
Add a slot
</Button>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setEditing(null)}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save schedule'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
@@ -0,0 +1,95 @@
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Snackbar from '@mui/material/Snackbar';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useState } from 'react';
import type { Field } from './CrudManager';
// A form for a single record (profile / settings). Two-column responsive grid.
export function SingletonEditor<T extends Record<string, unknown>>({
title,
description,
value,
fields,
onSave,
}: {
title: string;
description?: string;
value: T;
fields: Field[];
onSave: (data: T) => Promise<void>;
}) {
const [draft, setDraft] = useState<T>(value);
const [saving, setSaving] = useState(false);
const [toast, setToast] = useState<{ open: boolean; msg: string; ok: boolean }>({ open: false, msg: '', ok: true });
const set = (key: string, v: unknown) => setDraft((d) => ({ ...d, [key]: v }));
const save = async () => {
setSaving(true);
try {
await onSave(draft);
setToast({ open: true, msg: 'Saved successfully', ok: true });
} catch (e) {
setToast({ open: true, msg: e instanceof Error ? e.message : 'Could not save', ok: false });
} finally {
setSaving(false);
}
};
return (
<Box>
<Typography variant="h5">{title}</Typography>
{description && (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{description}
</Typography>
)}
<Stack spacing={2.5} sx={{ maxWidth: 720 }}>
{fields.map((f) =>
f.type === 'number' ? (
<TextField
key={f.key}
label={f.label}
type="number"
value={(draft[f.key] as number) ?? 0}
onChange={(e) => set(f.key, Number(e.target.value))}
helperText={f.helper}
fullWidth
/>
) : (
<TextField
key={f.key}
label={f.label}
value={(draft[f.key] as string) ?? ''}
onChange={(e) => set(f.key, e.target.value)}
helperText={f.helper}
multiline={f.type === 'multiline'}
minRows={f.type === 'multiline' ? 3 : undefined}
fullWidth
/>
),
)}
<Box>
<Button variant="contained" onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save changes'}
</Button>
</Box>
</Stack>
<Snackbar
open={toast.open}
autoHideDuration={3000}
onClose={() => setToast((t) => ({ ...t, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={toast.ok ? 'success' : 'error'} variant="filled">
{toast.msg}
</Alert>
</Snackbar>
</Box>
);
}
+271
View File
@@ -0,0 +1,271 @@
import AddRoundedIcon from '@mui/icons-material/AddRounded';
import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import GroupsRoundedIcon from '@mui/icons-material/GroupsRounded';
import PaymentsRoundedIcon from '@mui/icons-material/PaymentsRounded';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import {
createStudent,
deleteStudent,
listStudents,
updateStudent,
} from '../../services/content';
import { SEED, type Student } from '../../services/mockData';
export const rupees = (n: number) => `₹${(n || 0).toLocaleString('en-IN')}`;
const todayStr = () => new Date().toISOString().slice(0, 10);
const emptyStudent = (): Partial<Student> => ({
name: '',
phone: '',
parent_name: '',
parent_phone: '',
address: '',
subject: '',
monthly_fee: 0,
start_date: todayStr(),
is_active: true,
notes: '',
});
export function StudentsPanel() {
const [rows, setRows] = useState<Student[]>([]);
const [loaded, setLoaded] = useState(false);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Student | null>(null);
const [draft, setDraft] = useState<Partial<Student>>(emptyStudent());
const [saving, setSaving] = useState(false);
const load = () => {
listStudents()
.then((d) => {
setRows(d);
setLoaded(true);
})
.catch(() => {
setRows(SEED.students);
setLoaded(true);
});
};
useEffect(load, []);
const startCreate = () => {
setEditing(null);
setDraft(emptyStudent());
setOpen(true);
};
const startEdit = (s: Student) => {
setEditing(s);
setDraft({ ...s });
setOpen(true);
};
const save = async () => {
setSaving(true);
try {
if (editing) await updateStudent(editing.id, draft);
else await createStudent(draft);
setOpen(false);
load();
} catch {
// Offline / preview: optimistic local update so the panel stays usable.
if (editing) {
setRows((rs) => rs.map((r) => (r.id === editing.id ? ({ ...r, ...draft } as Student) : r)));
} else {
const id = Math.max(0, ...rows.map((r) => r.id)) + 1;
setRows((rs) => [...rs, { ...(emptyStudent() as Student), ...draft, id }]);
}
setOpen(false);
} finally {
setSaving(false);
}
};
const remove = async (s: Student) => {
setRows((rs) => rs.filter((r) => r.id !== s.id));
try {
await deleteStudent(s.id);
} catch {
/* optimistic */
}
};
const set = <K extends keyof Student>(k: K, v: Student[K]) => setDraft((d) => ({ ...d, [k]: v }));
const activeStudents = rows.filter((r) => r.is_active);
const expectedIncome = activeStudents.reduce((sum, r) => sum + (r.monthly_fee || 0), 0);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
<Box>
<Typography variant="h5">Students</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Every student and parent record. Fees add up to your expected monthly income.
</Typography>
</Box>
<Button variant="contained" startIcon={<AddRoundedIcon />} onClick={startCreate}>
Add student
</Button>
</Box>
{/* Summary cards */}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<Card sx={{ flex: 1 }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<GroupsRoundedIcon color="primary" fontSize="large" />
<Box>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{activeStudents.length}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Active students
</Typography>
</Box>
</CardContent>
</Card>
<Card sx={{ flex: 1, bgcolor: 'primary.main', color: 'primary.contrastText' }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<PaymentsRoundedIcon fontSize="large" />
<Box>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
{rupees(expectedIncome)}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Expected this month
</Typography>
</Box>
</CardContent>
</Card>
</Stack>
<Card>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Student</TableCell>
<TableCell>Subject / Class</TableCell>
<TableCell>Parent</TableCell>
<TableCell align="right">Fee</TableCell>
<TableCell align="center">Status</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loaded && rows.length === 0 && (
<TableRow>
<TableCell colSpan={6}>
<Typography variant="body2" sx={{ color: 'text.secondary', py: 2 }}>
No students yet — add your first student.
</Typography>
</TableCell>
</TableRow>
)}
{rows.map((s) => (
<TableRow key={s.id} hover>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{s.name}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{s.phone}
</Typography>
</TableCell>
<TableCell>{s.subject}</TableCell>
<TableCell>
<Typography variant="body2">{s.parent_name}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{s.parent_phone}
</Typography>
</TableCell>
<TableCell align="right">{rupees(s.monthly_fee)}</TableCell>
<TableCell align="center">
<Chip
size="small"
label={s.is_active ? 'Active' : 'Paused'}
color={s.is_active ? 'success' : 'default'}
variant={s.is_active ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => startEdit(s)} aria-label="Edit">
<EditRoundedIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => remove(s)} aria-label="Delete">
<DeleteRoundedIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Card>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{editing ? 'Edit student' : 'Add student'}</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ mt: 1 }}>
<TextField label="Student name" value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} fullWidth autoFocus />
<TextField label="Student phone" value={draft.phone ?? ''} onChange={(e) => set('phone', e.target.value)} fullWidth />
<TextField label="Subject / Class" helperText="e.g. Mathematics · Class 10" value={draft.subject ?? ''} onChange={(e) => set('subject', e.target.value)} fullWidth />
<TextField label="Parent name" value={draft.parent_name ?? ''} onChange={(e) => set('parent_name', e.target.value)} fullWidth />
<TextField label="Parent phone" value={draft.parent_phone ?? ''} onChange={(e) => set('parent_phone', e.target.value)} fullWidth />
<TextField label="Address" multiline minRows={2} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} fullWidth />
<TextField
label="Monthly fee (₹)"
type="number"
value={draft.monthly_fee ?? 0}
onChange={(e) => set('monthly_fee', Number(e.target.value))}
fullWidth
/>
<TextField
label="Start date"
type="date"
value={(draft.start_date ?? todayStr()).slice(0, 10)}
onChange={(e) => set('start_date', e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField label="Notes" multiline minRows={2} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} fullWidth />
<FormControlLabel
control={<Switch checked={Boolean(draft.is_active)} onChange={(e) => set('is_active', e.target.checked)} />}
label="Active (uncheck to pause / stop counting fee)"
/>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={save} disabled={saving || !draft.name}>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
+55
View File
@@ -0,0 +1,55 @@
/**
* The HTTP client. One place that knows the base URL.
*
* Deliberately dependency-free: the scaffold installs no axios, so an `api.ts`
* that imported it would fail to build. The shape (`.get(...).then(r => r.data)`)
* matches what the fullstack skill's examples use.
*
* VITE_API_URL is the backend BASE, wired at deploy time. Endpoints live under
* `/api`. Never hardcode localhost — it works in dev and breaks on deploy.
*/
const BASE = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
export type Res<T> = { data: T; status: number };
// Set by an auth store (the `auth` block) — every request then carries it as a
// Bearer header. Nothing else in the app needs to know how auth travels.
let authToken: string | null = null;
export const setAuthToken = (token: string | null): void => {
authToken = token;
};
async function request<T>(method: string, path: string, body?: unknown): Promise<Res<T>> {
const headers: Record<string, string> = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let detail = `${res.status} ${res.statusText}`;
try {
const j = (await res.json()) as { detail?: unknown };
if (typeof j.detail === 'string') detail = j.detail;
} catch {
/* keep the status line */
}
throw new Error(detail);
}
// 204 has no body — reading it as JSON throws, and a delete that "fails"
// because it succeeded is a confusing bug to chase.
const data = res.status === 204 ? (undefined as T) : ((await res.json()) as T);
return { data, status: res.status };
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body ?? {}),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body ?? {}),
delete: <T>(path: string) => request<T>('DELETE', path),
};
export const apiBaseUrl = () => BASE;
+131
View File
@@ -0,0 +1,131 @@
// Data access for the public site + admin. Public reads go through `resolve()`
// so Preview (no backend) still renders the seed. Admin writes always hit the
// real API (they only run when logged in against a live backend).
import { api } from './api';
import { resolve } from './dataSource';
import {
SEED,
type Attendance,
type Course,
type DayMark,
type Enquiry,
type FeePlan,
type Profile,
type Review,
type ScheduleSlot,
type SiteSettings,
type Student,
type Subject,
type Summary,
type Testimonial,
type TodayEntry,
} from './mockData';
export const getProfile = () =>
resolve(() => api.get<Profile>('/api/profile').then((r) => r.data), SEED.profile);
export const getSubjects = () =>
resolve(() => api.get<Subject[]>('/api/subjects').then((r) => r.data), SEED.subjects);
export const getCourses = () =>
resolve(() => api.get<Course[]>('/api/courses').then((r) => r.data), SEED.courses);
export const getFees = () =>
resolve(() => api.get<FeePlan[]>('/api/fees').then((r) => r.data), SEED.fees);
export const getTestimonials = () =>
resolve(() => api.get<Testimonial[]>('/api/testimonials').then((r) => r.data), SEED.testimonials);
export const getSettings = () =>
resolve(() => api.get<SiteSettings>('/api/settings').then((r) => r.data), SEED.settings);
// Public reviews: only approved ones come back from the API.
export const getReviews = () =>
resolve(
() => api.get<Review[]>('/api/reviews').then((r) => r.data),
SEED.reviews.filter((r) => r.is_approved),
);
export const submitReview = (body: {
author_name: string;
relation: string;
rating: number;
message: string;
}) => api.post<Review>('/api/reviews', body).then((r) => r.data);
export const submitEnquiry = (body: {
name: string;
email: string;
phone: string;
subject_interest: string;
message: string;
}) => api.post<Enquiry>('/api/enquiries', body).then((r) => r.data);
// ---- Admin writes (live backend only) ----
export const updateProfile = (body: Profile) =>
api.put<Profile>('/api/admin/profile', body).then((r) => r.data);
export const updateSettings = (body: SiteSettings) =>
api.put<SiteSettings>('/api/admin/settings', body).then((r) => r.data);
export const createSubject = (b: Partial<Subject>) => api.post<Subject>('/api/admin/subjects', b).then((r) => r.data);
export const updateSubject = (id: number, b: Partial<Subject>) => api.put<Subject>(`/api/admin/subjects/${id}`, b).then((r) => r.data);
export const deleteSubject = (id: number) => api.delete(`/api/admin/subjects/${id}`);
export const createCourse = (b: Partial<Course>) => api.post<Course>('/api/admin/courses', b).then((r) => r.data);
export const updateCourse = (id: number, b: Partial<Course>) => api.put<Course>(`/api/admin/courses/${id}`, b).then((r) => r.data);
export const deleteCourse = (id: number) => api.delete(`/api/admin/courses/${id}`);
export const createFee = (b: Partial<FeePlan>) => api.post<FeePlan>('/api/admin/fees', b).then((r) => r.data);
export const updateFee = (id: number, b: Partial<FeePlan>) => api.put<FeePlan>(`/api/admin/fees/${id}`, b).then((r) => r.data);
export const deleteFee = (id: number) => api.delete(`/api/admin/fees/${id}`);
export const createTestimonial = (b: Partial<Testimonial>) => api.post<Testimonial>('/api/admin/testimonials', b).then((r) => r.data);
export const updateTestimonial = (id: number, b: Partial<Testimonial>) => api.put<Testimonial>(`/api/admin/testimonials/${id}`, b).then((r) => r.data);
export const deleteTestimonial = (id: number) => api.delete(`/api/admin/testimonials/${id}`);
export const listEnquiries = () => api.get<Enquiry[]>('/api/admin/enquiries').then((r) => r.data);
export const patchEnquiry = (id: number, is_read: boolean) => api.patch<Enquiry>(`/api/admin/enquiries/${id}`, { is_read }).then((r) => r.data);
export const deleteEnquiry = (id: number) => api.delete(`/api/admin/enquiries/${id}`);
export const listAdminReviews = () => api.get<Review[]>('/api/admin/reviews').then((r) => r.data);
export const patchReview = (id: number, is_approved: boolean) => api.patch<Review>(`/api/admin/reviews/${id}`, { is_approved }).then((r) => r.data);
export const deleteReview = (id: number) => api.delete(`/api/admin/reviews/${id}`);
// ---- Student management (live backend only; panels fall back to SEED offline) ----
export const listStudents = () => api.get<Student[]>('/api/admin/students').then((r) => r.data);
export const createStudent = (b: Partial<Student>) => api.post<Student>('/api/admin/students', b).then((r) => r.data);
export const updateStudent = (id: number, b: Partial<Student>) => api.put<Student>(`/api/admin/students/${id}`, b).then((r) => r.data);
export const deleteStudent = (id: number) => api.delete(`/api/admin/students/${id}`);
export const getStudentSchedule = (id: number) =>
api.get<ScheduleSlot[]>(`/api/admin/students/${id}/schedule`).then((r) => r.data);
export const setStudentSchedule = (id: number, slots: Omit<ScheduleSlot, 'id' | 'student_id'>[]) =>
api.put<ScheduleSlot[]>(`/api/admin/students/${id}/schedule`, slots).then((r) => r.data);
export const getTodaySchedule = () =>
api.get<TodayEntry[]>('/api/admin/schedule/today').then((r) => r.data);
export const getAttendance = (month: string) =>
api
.get<{ attendance: Attendance[]; day_marks: DayMark[] }>(
`/api/admin/attendance?month=${encodeURIComponent(month)}`,
)
.then((r) => r.data);
export const setAttendance = (body: {
student_id: number;
date: string;
status: string;
note?: string;
}) => api.put<Attendance>('/api/admin/attendance', body).then((r) => r.data);
export const clearAttendance = (body: { student_id: number; date: string }) =>
api.post('/api/admin/attendance/clear', body);
export const setDayMark = (body: { date: string; kind: string; note?: string }) =>
api.post<DayMark>('/api/admin/day-marks', body).then((r) => r.data);
export const deleteDayMark = (onDate: string) => api.delete(`/api/admin/day-marks/${onDate}`);
export const getStudentSummary = (month: string) =>
api
.get<Summary>(`/api/admin/students/summary?month=${encodeURIComponent(month)}`)
.then((r) => r.data);
+74
View File
@@ -0,0 +1,74 @@
/**
* Mock-first data resolution.
*
* The Preview tab runs this frontend ALONE, in a browser sandbox, with no
* backend behind it. A UI whose every screen starts with a fetch shows an
* error or an empty list there — which is what the app is judged by, seconds
* after the build says it finished.
*
* So every data call goes through `resolve(real, mock)`: it serves the mock
* until a health check says the backend is answering, then serves the real
* call. No code change, no reload, no flag to flip.
*
* const todos = await resolve(
* () => api.get<Todo[]>('/api/todos').then((r) => r.data),
* SEED.todos,
* );
*
* The seed rows live in ./mockData.ts. src/App.tsx (React) / src/views (Vue)
* already do this — copy that call, do not invent a second way.
*/
import { api } from './api';
/** Re-checked rather than cached forever: the backend appears mid-session, and
* a resolver that decided "no backend" once would never notice. */
const TTL_MS = 5_000;
let liveUntil = 0;
let live = false;
let inFlight: Promise<boolean> | null = null;
async function probe(): Promise<boolean> {
try {
const res = await api.get<unknown>('/api/health');
return res.status >= 200 && res.status < 300;
} catch {
return false;
}
}
/** Whether the backend is currently answering. Shared across callers, so ten
* components mounting together make one health request, not ten. */
export async function backendIsLive(): Promise<boolean> {
if (Date.now() < liveUntil) return live;
if (!inFlight) {
inFlight = probe().then((ok) => {
live = ok;
liveUntil = Date.now() + TTL_MS;
inFlight = null;
return ok;
});
}
return inFlight;
}
/**
* The real call when the backend is up, the mock otherwise.
*
* A real call that THROWS also falls back, rather than propagating: a backend
* that is up but broken should still leave a usable screen, and the caller
* cannot tell the difference anyway.
*/
export async function resolve<T>(real: () => Promise<T>, mock: T): Promise<T> {
if (!(await backendIsLive())) return mock;
try {
return await real();
} catch {
return mock;
}
}
/** For a status chip: "Demo data" vs "Live". Never leave the user guessing
* which one they are looking at. */
export const dataSourceLabel = async (): Promise<'live' | 'demo'> =>
(await backendIsLive()) ? 'live' : 'demo';
+277
View File
@@ -0,0 +1,277 @@
// Seed data mirrors the backend's DEVTeacher placeholder content, so Preview
// (which has no backend) renders a complete, real-looking site.
export type Profile = {
id: number;
name: string;
tagline: string;
bio: string;
photo_url: string;
qualifications: string;
experience_years: number;
students_taught: number;
achievements: string;
hero_cta: string;
};
export type Subject = {
id: number;
name: string;
level: string;
description: string;
mode: string;
icon: string;
sort_order: number;
};
export type Course = {
id: number;
title: string;
subject: string;
description: string;
duration: string;
mode: string;
highlights: string;
sort_order: number;
};
export type FeePlan = {
id: number;
name: string;
price: string;
period: string;
features: string;
highlighted: boolean;
sort_order: number;
};
export type Testimonial = {
id: number;
author_name: string;
relation: string;
quote: string;
rating: number;
sort_order: number;
};
export type SiteSettings = {
id: number;
address: string;
phone: string;
email: string;
hours: string;
whatsapp: string;
instagram: string;
youtube: string;
facebook: string;
footer_text: string;
};
export type Review = {
id: number;
author_name: string;
relation: string;
rating: number;
message: string;
is_approved: boolean;
created_at: string;
};
export type Enquiry = {
id: number;
name: string;
email: string;
phone: string;
subject_interest: string;
message: string;
created_at: string;
is_read: boolean;
};
// ---- Student management ----
export type Student = {
id: number;
name: string;
phone: string;
parent_name: string;
parent_phone: string;
address: string;
subject: string;
monthly_fee: number;
start_date: string; // YYYY-MM-DD
is_active: boolean;
notes: string;
};
export type ScheduleSlot = {
id: number;
student_id: number;
weekday: number; // 0=Mon .. 6=Sun
start_time: string; // HH:MM
end_time: string;
};
export type AttendanceStatus = 'present' | 'absent' | 'off';
export type Attendance = {
id: number;
student_id: number;
date: string; // YYYY-MM-DD
status: AttendanceStatus;
note: string; // why absent / off
};
export type DayMarkKind = 'holiday' | 'class_off';
export type DayMark = {
id: number;
date: string; // YYYY-MM-DD
kind: DayMarkKind;
note: string;
};
export type TodayEntry = {
student_id: number;
student_name: string;
subject: string;
start_time: string;
end_time: string;
};
export type StudentSummary = {
student_id: number;
student_name: string;
monthly_fee: number;
present: number;
absent: number;
off: number;
};
export type Summary = {
month: string;
active_students: number;
expected_income: number;
students: StudentSummary[];
};
const _today = new Date();
const _pad = (n: number) => String(n).padStart(2, '0');
const _ymd = (y: number, m: number, d: number) => `${y}-${_pad(m)}-${_pad(d)}`;
const _thisMonth = { y: _today.getFullYear(), m: _today.getMonth() + 1 };
export const SEED = {
profile: {
id: 1,
name: 'Swati Yadav',
tagline: 'Making learning joyful, clear and exam-ready',
bio:
"Hello! I'm Swati Yadav, a passionate tuition teacher with years of experience helping students build strong fundamentals and real confidence. I focus on concept clarity, regular practice and personal attention so every student can reach their full potential — whether the goal is topping the class, cracking board exams, or simply falling in love with a subject.",
photo_url: '',
qualifications: 'M.Sc. Mathematics · B.Ed. · Certified Online Educator',
experience_years: 10,
students_taught: 500,
achievements:
'95% of students improved by 2+ grades\nMentored 50+ board-exam toppers\nPersonalised study plans for every learner',
hero_cta: 'Book a free demo class',
} as Profile,
subjects: [
{ id: 1, name: 'Mathematics', level: 'Class 6–12', description: 'Algebra, geometry, calculus and problem-solving made simple with step-by-step methods.', mode: 'Online & Offline', icon: '📐', sort_order: 1 },
{ id: 2, name: 'Physics', level: 'Class 9–12', description: 'Concept-first physics with real-life examples, numericals and exam techniques.', mode: 'Online & Offline', icon: '🔬', sort_order: 2 },
{ id: 3, name: 'Chemistry', level: 'Class 9–12', description: 'Organic, inorganic and physical chemistry taught with clarity and easy recall tricks.', mode: 'Online & Offline', icon: '⚗️', sort_order: 3 },
{ id: 4, name: 'English', level: 'Class 6–10', description: 'Grammar, comprehension, writing skills and confident communication.', mode: 'Online & Offline', icon: '📖', sort_order: 4 },
] as Subject[],
courses: [
{ id: 1, title: 'Board Exam Crash Course', subject: 'Mathematics & Science', description: 'An intensive revision programme covering the complete syllabus with mock tests, doubt-clearing and previous-year papers.', duration: '3 months', mode: 'Online & Offline', highlights: 'Practice tests every week\nPersonal doubt sessions\nComplete syllabus revision', sort_order: 1 },
{ id: 2, title: 'Foundation Builder', subject: 'All Subjects', description: 'A steady long-term course for younger students that builds strong basics and study habits from the ground up.', duration: 'Full academic year', mode: 'Online & Offline', highlights: 'Concept-first teaching\nRegular homework & feedback\nMonthly progress reports', sort_order: 2 },
{ id: 3, title: 'One-on-One Personal Tuition', subject: 'Chosen Subject', description: "Fully personalised private tuition tailored to a single student's pace, goals and weak areas.", duration: 'Flexible', mode: 'Online & Offline', highlights: 'Completely customised plan\nFlexible timings\nDedicated attention', sort_order: 3 },
] as Course[],
fees: [
{ id: 1, name: 'Group Batch', price: '₹1,500', period: 'per month', features: 'Small batches (max 8)\n3 classes a week\nWeekly tests\nStudy material included', highlighted: false, sort_order: 1 },
{ id: 2, name: 'Premium Batch', price: '₹2,500', period: 'per month', features: 'Mini batches (max 4)\n5 classes a week\nWeekly tests + doubt sessions\nMonthly progress report', highlighted: true, sort_order: 2 },
{ id: 3, name: '1-on-1 Private', price: '₹4,000', period: 'per month', features: 'Fully personal tuition\nFlexible timings\nCustom study plan\nUnlimited doubt support', highlighted: false, sort_order: 3 },
] as FeePlan[],
testimonials: [
{ id: 1, author_name: 'Aarav Sharma', relation: 'Student', quote: "Swati ma'am made maths so easy to understand. My marks jumped from 62 to 91 in just one year!", rating: 5, sort_order: 1 },
{ id: 2, author_name: 'Meena Gupta', relation: 'Parent', quote: 'My daughter actually looks forward to her classes now. The personal attention is wonderful.', rating: 5, sort_order: 2 },
{ id: 3, author_name: 'Rohan Verma', relation: 'Student', quote: 'The best teacher I\'ve ever had. She explains every concept until it truly clicks.', rating: 5, sort_order: 3 },
{ id: 4, author_name: 'Kavita Nair', relation: 'Parent', quote: 'Very disciplined, caring and result-oriented. Highly recommend to every parent.', rating: 5, sort_order: 4 },
] as Testimonial[],
settings: {
id: 1,
address: '123 Vidya Nagar, Near City Library, Your City, 000000',
phone: '+91 90000 00000',
email: 'hello@swatiyadav.com',
hours: 'Mon–Sat, 9:00 AM – 8:00 PM',
whatsapp: 'https://wa.me/919000000000',
instagram: 'https://instagram.com/',
youtube: 'https://youtube.com/',
facebook: 'https://facebook.com/',
footer_text: 'Empowering students with knowledge, confidence and care.',
} as SiteSettings,
enquiries: [
{ id: 1, name: 'Sample Parent', email: 'parent@example.com', phone: '+91 98765 43210', subject_interest: 'Mathematics', message: "Hi, I'd like to know the timings for the Class 10 maths batch. Thank you!", created_at: new Date().toISOString(), is_read: false },
] as Enquiry[],
reviews: [
{ id: 1, author_name: 'Priya Deshmukh', relation: 'Parent', rating: 5, message: 'The weekly progress updates keep me informed and my son is far more confident now.', is_approved: true, created_at: new Date().toISOString() },
{ id: 2, author_name: 'Ananya Rao', relation: 'Student', rating: 5, message: 'Doubts are always cleared patiently. Highly recommend her classes!', is_approved: false, created_at: new Date().toISOString() },
] as Review[],
students: [
{ id: 1, name: 'Aarav Sharma', phone: '+91 98111 22233', parent_name: 'Rakesh Sharma', parent_phone: '+91 98111 00011', address: '12 Green Park, Your City', subject: 'Mathematics · Class 10', monthly_fee: 1500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
{ id: 2, name: 'Isha Verma', phone: '+91 98222 33344', parent_name: 'Sunita Verma', parent_phone: '+91 98222 00022', address: '45 Lake View, Your City', subject: 'Physics · Class 12', monthly_fee: 2500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
{ id: 3, name: 'Rohan Gupta', phone: '+91 98333 44455', parent_name: 'Manoj Gupta', parent_phone: '+91 98333 00033', address: '78 Rose Villa, Your City', subject: 'Chemistry · Class 11', monthly_fee: 2500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
{ id: 4, name: 'Ananya Nair', phone: '+91 98444 55566', parent_name: 'Kavita Nair', parent_phone: '+91 98444 00044', address: '9 Sunrise Apartments, Your City', subject: 'English · Class 8', monthly_fee: 1500, start_date: _ymd(_thisMonth.y, _thisMonth.m, 1), is_active: true, notes: '' },
] as Student[],
schedule: {
1: [
{ id: 1, student_id: 1, weekday: 0, start_time: '16:00', end_time: '17:00' },
{ id: 2, student_id: 1, weekday: 2, start_time: '16:00', end_time: '17:00' },
{ id: 3, student_id: 1, weekday: 4, start_time: '16:00', end_time: '17:00' },
],
2: [
{ id: 4, student_id: 2, weekday: 1, start_time: '17:30', end_time: '18:30' },
{ id: 5, student_id: 2, weekday: 3, start_time: '17:30', end_time: '18:30' },
],
3: [
{ id: 6, student_id: 3, weekday: 0, start_time: '18:00', end_time: '19:00' },
{ id: 7, student_id: 3, weekday: 3, start_time: '18:00', end_time: '19:00' },
{ id: 8, student_id: 3, weekday: 5, start_time: '10:00', end_time: '11:00' },
],
4: [
{ id: 9, student_id: 4, weekday: 1, start_time: '16:00', end_time: '17:00' },
{ id: 10, student_id: 4, weekday: 4, start_time: '16:00', end_time: '17:00' },
],
} as Record<number, ScheduleSlot[]>,
attendance: (() => {
const rows: Attendance[] = [];
const statuses: AttendanceStatus[] = ['present', 'present', 'absent', 'present', 'off'];
let id = 1;
const maxDay = Math.min(5, _today.getDate());
for (let day = 2; day <= maxDay; day += 1) {
[1, 2, 3, 4].forEach((sid, i) => {
const status = statuses[(day + i) % statuses.length];
rows.push({
id: id++,
student_id: sid,
date: _ymd(_thisMonth.y, _thisMonth.m, day),
status,
note: status === 'absent' ? 'Was unwell' : status === 'off' ? 'Family function' : '',
});
});
}
return rows;
})() as Attendance[],
dayMarks: [
{ id: 1, date: _ymd(_thisMonth.y, _thisMonth.m, 1), kind: 'holiday', note: 'Month start holiday' },
] as DayMark[],
};
View File
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { api, setAuthToken } from '../services/api';
const TOKEN_KEY = 'swatiyadav-token';
const USER_KEY = 'swatiyadav-user';
type AuthState = {
token: string | null;
username: string | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
changePassword: (current: string, next: string) => Promise<string | null>;
};
// Restore any saved session at module load so refreshing keeps you logged in.
const savedToken = typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_KEY) : null;
const savedUser = typeof window !== 'undefined' ? window.localStorage.getItem(USER_KEY) : null;
if (savedToken) setAuthToken(savedToken);
export const useAuth = create<AuthState>((set) => ({
token: savedToken,
username: savedUser,
loading: false,
error: null,
login: async (username, password) => {
set({ loading: true, error: null });
try {
const { data } = await api.post<{ access_token: string; username: string }>('/api/auth/login', {
username,
password,
});
setAuthToken(data.access_token);
window.localStorage.setItem(TOKEN_KEY, data.access_token);
window.localStorage.setItem(USER_KEY, data.username);
set({ token: data.access_token, username: data.username, loading: false });
return true;
} catch (e) {
const msg = e instanceof Error ? e.message : 'Login failed';
set({ loading: false, error: msg });
return false;
}
},
logout: () => {
setAuthToken(null);
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
set({ token: null, username: null, error: null });
},
changePassword: async (current, next) => {
try {
await api.post('/api/auth/change-password', { current_password: current, new_password: next });
return null;
} catch (e) {
return e instanceof Error ? e.message : 'Could not change password';
}
},
}));
+88
View File
@@ -0,0 +1,88 @@
import { createTheme, type Theme } from '@mui/material/styles';
// Design tokens for SwatiYadav — a warm, modern, feminine palette (violet +
// rose gradients) that reads well in both light and dark. The ONLY file with
// raw colour literals; everywhere else uses tokens (`color: 'text.secondary'`).
export type Mode = 'light' | 'dark';
const shared = {
spacing: 8,
shape: { borderRadius: 16 },
typography: {
fontFamily: '"Poppins", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif',
h1: { fontSize: '3rem', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.1 },
h2: { fontSize: '2.25rem', fontWeight: 800, letterSpacing: '-0.02em', lineHeight: 1.15 },
h3: { fontSize: '1.6rem', fontWeight: 700, letterSpacing: '-0.01em' },
h4: { fontSize: '1.3rem', fontWeight: 700 },
h5: { fontSize: '1.125rem', fontWeight: 600 },
h6: { fontSize: '1rem', fontWeight: 600 },
subtitle1: { fontSize: '1.05rem', lineHeight: 1.6 },
body1: { fontSize: '1rem', lineHeight: 1.7 },
body2: { fontSize: '0.9rem', lineHeight: 1.6 },
button: { textTransform: 'none' as const, fontWeight: 600 },
},
} as const;
const light = {
primary: { main: '#8b3fd6', light: '#a86ee2', dark: '#6a24ad', contrastText: '#ffffff' },
secondary: { main: '#e0457b', light: '#ec6f9a', dark: '#b12a5c', contrastText: '#ffffff' },
success: { main: '#1f9d6b' },
warning: { main: '#c07a10' },
error: { main: '#d13b4d' },
info: { main: '#2b7fb8' },
background: { default: '#faf7fd', paper: '#ffffff' },
text: { primary: '#221933', secondary: '#6a6280' },
divider: '#ece6f4',
};
const dark = {
primary: { main: '#b98cf0', light: '#cdaef5', dark: '#8b5fd0', contrastText: '#1a1024' },
secondary: { main: '#f47ba6', light: '#f89dbd', dark: '#c85583', contrastText: '#1a1024' },
success: { main: '#4cc794' },
warning: { main: '#e0a94a' },
error: { main: '#f16274' },
info: { main: '#5aa9dd' },
background: { default: '#140f1c', paper: '#1e1830' },
text: { primary: '#f4f0fb', secondary: '#b3aac6' },
divider: '#302743',
};
export function makeTheme(mode: Mode): Theme {
const palette = mode === 'light' ? light : dark;
return createTheme({
...shared,
palette: { mode, ...palette },
components: {
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: { borderRadius: 999, paddingLeft: 22, paddingRight: 22 },
sizeLarge: { paddingTop: 12, paddingBottom: 12, fontSize: '1rem' },
},
},
MuiPaper: {
defaultProps: { elevation: 0 },
styleOverrides: { root: { backgroundImage: 'none' } },
},
MuiCard: {
defaultProps: { variant: 'outlined' },
styleOverrides: {
root: {
borderColor: palette.divider,
transition: 'transform .25s ease, box-shadow .25s ease, border-color .25s ease',
},
},
},
MuiTextField: { defaultProps: { size: 'medium' } },
MuiAppBar: { defaultProps: { elevation: 0, color: 'transparent' } },
MuiChip: { styleOverrides: { root: { fontWeight: 600 } } },
},
});
}
// Reusable gradient string for hero / accents, mode-aware.
export const brandGradient = (mode: Mode) =>
mode === 'light'
? 'linear-gradient(135deg, #8b3fd6 0%, #e0457b 100%)'
: 'linear-gradient(135deg, #b98cf0 0%, #f47ba6 100%)';
+51
View File
@@ -0,0 +1,51 @@
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
import { makeTheme, type Mode } from '../theme';
type Ctx = { mode: Mode; toggle: () => void };
const ColorModeContext = createContext<Ctx>({ mode: 'light', toggle: () => {} });
const STORAGE_KEY = 'swatiyadav-theme';
function initialMode(): Mode {
if (typeof window === 'undefined') return 'light';
const saved = window.localStorage.getItem(STORAGE_KEY);
if (saved === 'light' || saved === 'dark') return saved;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ColorModeProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<Mode>(initialMode);
const ctx = useMemo<Ctx>(
() => ({
mode,
toggle: () =>
setMode((m) => {
const next = m === 'light' ? 'dark' : 'light';
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
/* ignore */
}
return next;
}),
}),
[mode],
);
const theme = useMemo(() => makeTheme(mode), [mode]);
return (
<ColorModeContext.Provider value={ctx}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</ColorModeContext.Provider>
);
}
export const useColorMode = () => useContext(ColorModeContext);
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+25
View File
@@ -0,0 +1,25 @@
// DevVibeCoder Design-mode inspector: a Babel visitor that stamps every JSX
// element with `data-vc-loc="src/App.tsx:41:7"` (path relative to this
// folder, 1-based line and column). vite.config.ts hands it to
// @vitejs/plugin-react only for `vite dev`, so a build carries no stamps.
// The Design pane maps a click in the preview back to that source position.
/* eslint-disable @typescript-eslint/no-explicit-any */
import path from 'node:path';
export const vcBabelLoc = () => ({
name: 'vc-loc',
visitor: {
JSXOpeningElement(p: any, state: any) {
const loc = p.node.loc;
const file: string | undefined = state.filename || state.file?.opts?.filename;
if (!loc || !file) return;
if (p.node.attributes.some((a: any) => a.type === 'JSXAttribute' && a.name?.name === 'data-vc-loc')) return;
const rel = path.relative(state.cwd || process.cwd(), file).split(path.sep).join('/');
p.node.attributes.push({
type: 'JSXAttribute',
name: { type: 'JSXIdentifier', name: 'data-vc-loc' },
value: { type: 'StringLiteral', value: `${rel}:${loc.start.line}:${loc.start.column + 1}` },
});
},
},
});
+257
View File
@@ -0,0 +1,257 @@
// DevVibeCoder Design-mode runtime. Dev only: main.* imports it behind
// `import.meta.env.DEV`, so it never reaches a production bundle.
//
// It never touches the source. It answers the host's handshake, reports which
// stamped element (`data-vc-loc`, see inspector.ts) is under the pointer, was
// clicked, or was dragged where, and keeps the host supplied with element
// rectangles so the host can draw outlines itself. Messages only flow to and
// from the ONE origin that said hello first.
const V = 1;
type Rect = { x: number; y: number; width: number; height: number };
type Node = { id: string; loc: string; tag: string; children: Node[] };
type Position = 'before' | 'after' | 'into';
let parentOrigin: string | null = null;
let active = false;
let raf = 0;
const post = (msg: Record<string, unknown>) => {
if (parentOrigin) window.parent.postMessage({ vc: V, ...msg }, parentOrigin);
};
const plain = (r: DOMRectReadOnly): Rect => ({ x: r.x, y: r.y, width: r.width, height: r.height });
const stamped = (): HTMLElement[] => Array.from(document.querySelectorAll<HTMLElement>('[data-vc-loc]'));
// The same source element renders many times inside a list, so the identity
// the host uses is `loc#n`: the nth stamped element in document order with
// that loc. Recomputed on every report, never stored on the element.
const idsOf = (all: HTMLElement[]): Map<HTMLElement, string> => {
const seen = new Map<string, number>();
const ids = new Map<HTMLElement, string>();
for (const el of all) {
const loc = el.dataset.vcLoc || '';
const n = seen.get(loc) ?? 0;
seen.set(loc, n + 1);
ids.set(el, `${loc}#${n}`);
}
return ids;
};
const idOf = (el: HTMLElement): string => idsOf(stamped()).get(el) || '';
// Stamped children of `el`, skipping unstamped wrappers in between.
const stampedChildren = (el: Element): HTMLElement[] => {
const out: HTMLElement[] = [];
const walk = (n: Element) => {
for (const c of Array.from(n.children)) {
if ((c as HTMLElement).dataset?.vcLoc !== undefined) out.push(c as HTMLElement);
else walk(c);
}
};
walk(el);
return out;
};
const tree = (): Node[] => {
const all = stamped();
const ids = idsOf(all);
const build = (el: HTMLElement): Node => ({
id: ids.get(el) || '',
loc: el.dataset.vcLoc || '',
tag: el.tagName.toLowerCase(),
children: stampedChildren(el).map(build),
});
return all.filter((el) => !el.parentElement?.closest('[data-vc-loc]')).map(build);
};
const sendRects = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const rects: Record<string, Rect> = {};
const all = stamped();
const ids = idsOf(all);
for (const el of all) rects[ids.get(el) || ''] = plain(el.getBoundingClientRect());
post({ type: 'rects', rects });
});
};
const sendTree = () => post({ type: 'tree', tree: tree(), route: location.pathname });
const closestStamped = (n: Element | null): HTMLElement | null =>
(n?.closest?.('[data-vc-loc]') as HTMLElement | null) ?? null;
const target = (e: Event): HTMLElement | null => closestStamped(e.target as Element | null);
// --- drag ---------------------------------------------------------------------
// Pointer-driven, no HTML5 DnD: it works on every element, including ones with
// their own click handlers, and never starts the app's own drag behaviour.
let press: { el: HTMLElement; x: number; y: number } | null = null;
let dragging: HTMLElement | null = null;
let over: { el: HTMLElement; position: Position } | null = null;
const isRow = (el: HTMLElement): boolean => {
const p = el.parentElement;
if (!p) return false;
const cs = getComputedStyle(p);
if (cs.display.includes('flex')) return cs.flexDirection.startsWith('row');
if (cs.display.includes('grid')) return true;
return cs.display === 'inline' || getComputedStyle(el).display.startsWith('inline');
};
const dropAt = (x: number, y: number): { el: HTMLElement; position: Position } | null => {
const hit = closestStamped(document.elementFromPoint(x, y));
if (!hit || !dragging || hit === dragging || dragging.contains(hit)) return null;
const r = hit.getBoundingClientRect();
if (!stampedChildren(hit).length && !hit.textContent?.trim()) {
// An empty container: the middle band drops inside it.
const fx = (x - r.x) / r.width;
const fy = (y - r.y) / r.height;
if (fx > 0.25 && fx < 0.75 && fy > 0.25 && fy < 0.75) return { el: hit, position: 'into' };
}
const half = isRow(hit) ? x < r.x + r.width / 2 : y < r.y + r.height / 2;
return { el: hit, position: half ? 'before' : 'after' };
};
const endDrag = (commit: boolean) => {
if (dragging) {
if (commit && over) post({ type: 'drop', id: idOf(dragging), targetId: idOf(over.el), position: over.position });
else post({ type: 'dragover', targetId: null });
}
press = null;
dragging = null;
over = null;
document.documentElement.style.cursor = active ? 'crosshair' : '';
};
const onDown = (e: PointerEvent) => {
if (e.button !== 0) return;
const el = target(e);
press = el ? { el, x: e.clientX, y: e.clientY } : null;
};
const onMove = (e: PointerEvent) => {
if (press && !dragging && Math.hypot(e.clientX - press.x, e.clientY - press.y) > 4) {
dragging = press.el;
document.documentElement.style.cursor = 'grabbing';
post({ type: 'dragstart', id: idOf(dragging) });
}
if (dragging) {
e.preventDefault();
const next = dropAt(e.clientX, e.clientY);
if (next?.el !== over?.el || next?.position !== over?.position) {
over = next;
post({
type: 'dragover',
targetId: over ? idOf(over.el) : null,
position: over?.position ?? null,
rect: over ? plain(over.el.getBoundingClientRect()) : null,
row: over ? isRow(over.el) : false,
});
}
return;
}
const el = target(e);
post({ type: 'hover', id: el ? idOf(el) : null, rect: el ? plain(el.getBoundingClientRect()) : null });
};
const onUp = () => {
if (dragging) endDrag(true);
press = null;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && dragging) endDrag(false);
};
const onClick = (e: MouseEvent) => {
// Design mode: a click selects, it does not navigate or submit.
e.preventDefault();
e.stopPropagation();
const el = target(e);
if (!el) {
post({ type: 'select', id: null });
return;
}
post({
type: 'select',
id: idOf(el),
loc: el.dataset.vcLoc,
tag: el.tagName.toLowerCase(),
rect: plain(el.getBoundingClientRect()),
});
};
const observer = new MutationObserver(() => {
sendRects();
sendTree();
});
const setActive = (on: boolean) => {
if (on === active) return;
active = on;
const opts = { capture: true } as const;
if (on) {
document.addEventListener('pointerdown', onDown, opts);
document.addEventListener('pointermove', onMove, opts);
document.addEventListener('pointerup', onUp, opts);
document.addEventListener('keydown', onKey, opts);
document.addEventListener('click', onClick, opts);
window.addEventListener('scroll', sendRects, opts);
window.addEventListener('resize', sendRects);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
document.documentElement.style.cursor = 'crosshair';
sendTree();
sendRects();
} else {
endDrag(false);
document.removeEventListener('pointerdown', onDown, opts);
document.removeEventListener('pointermove', onMove, opts);
document.removeEventListener('pointerup', onUp, opts);
document.removeEventListener('keydown', onKey, opts);
document.removeEventListener('click', onClick, opts);
window.removeEventListener('scroll', sendRects, opts);
window.removeEventListener('resize', sendRects);
observer.disconnect();
document.documentElement.style.cursor = '';
}
};
// Route changes: the host's page picker and the Layers tree follow them.
for (const m of ['pushState', 'replaceState'] as const) {
const orig = history[m];
history[m] = function (this: History, ...args: Parameters<History['pushState']>) {
const r = orig.apply(this, args);
queueMicrotask(sendTree);
return r;
};
}
window.addEventListener('popstate', () => queueMicrotask(sendTree));
window.addEventListener('message', (e: MessageEvent) => {
const d = e.data;
if (!d || d.vc !== V || window.parent === window) return;
if (d.type === 'hello') {
// The first hello pins the host; any other origin is ignored for the
// life of the page.
if (parentOrigin && e.origin !== parentOrigin) return;
parentOrigin = e.origin;
post({ type: 'ready', route: location.pathname });
setActive(d.mode === 'design');
return;
}
if (e.origin !== parentOrigin) return;
if (d.type === 'mode') setActive(d.mode === 'design');
else if (d.type === 'navigate' && typeof d.path === 'string') {
history.pushState(null, '', d.path);
dispatchEvent(new PopStateEvent('popstate'));
} else if (d.type === 'refresh') {
sendTree();
sendRects();
}
});
export {};
+19
View File
@@ -0,0 +1,19 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { vcBabelLoc } from './vc/inspector';
// Deploy owns `base` and the /api proxy. Changing them here breaks the deployed
// app while dev keeps working — the worst kind of change to make.
// `vc/` is DevVibeCoder's Design mode: source-location stamps, dev server only.
export default defineConfig(({ command }) => ({
plugins: [react(command === 'serve' ? { babel: { plugins: [vcBabelLoc] } } : {})],
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true,
},
},
},
}));
+159
View File
@@ -0,0 +1,159 @@
# One origin, two halves: static frontend at /, the API proxied behind it.
#
# This is what makes a generated app a single container on a single port. The
# browser sees one origin, so there is no CORS to configure, no second hostname
# to route, and no preflight on every request. The backend never learns it is
# behind anything.
#
# This is a COMPLETE nginx.conf, not a conf.d snippet, because the websocket
# `Connection` header needs a `map` and a map is only legal at http level. A
# drop-in under conf.d cannot declare one, and the usual workaround — passing
# the client's Connection header straight through — is wrong for a proxy that
# also keeps upstream connections alive.
worker_processes auto;
error_log /dev/stderr warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logs go to the container's streams. A generated app that logs to a file
# inside its own filesystem is logging into a volume nobody reads and
# filling a disk nobody is watching.
access_log /dev/stdout;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml image/svg+xml;
# "upgrade" when the client is opening a websocket, "close" otherwise.
# Forwarding the client's own Connection header instead would tear down the
# keepalive to the upstream on every ordinary request.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream app_backend {
# The application server, bound to loopback inside this container. It is
# NOT published: the only way in is through this nginx, which is what
# makes "the exposed port is 8000" true rather than conventional.
server 127.0.0.1:8001;
keepalive 16;
}
server {
listen 8000;
server_name _;
root /usr/share/nginx/html;
index index.html;
# An upload limit belongs here rather than in the app: nginx refuses an
# oversized body before it is buffered, where the app would read it
# first and then complain.
client_max_body_size 25m;
# --- the API -----------------------------------------------------
# Longest-prefix wins in nginx, so this is matched before the SPA
# fallback regardless of the order the blocks appear in.
location /api {
# Whether the prefix is STRIPPED before the backend sees it depends
# on the framework, so the recipe supplies the rewrite (or none):
#
# Django keeps /api/... and strips it itself via
# FORCE_SCRIPT_NAME, which is also what makes its
# redirects and admin URLs carry the prefix.
# FastAPI needs it stripped. uvicorn's --root-path does NOT
# strip; it only affects URL generation.
#
# An explicit `rewrite`, not `proxy_pass http://app_backend/`. The
# trailing-slash form looks equivalent and is not: with a prefix
# location that has no trailing slash, nginx replaces the matched
# "/api" with "/" and the backend receives "//ping" — a double
# slash that matches no route, so every endpoint 404s while nginx,
# the frontend and the healthcheck all report perfectly fine.
proxy_pass http://app_backend;
proxy_http_version 1.1;
# FastAPI recipes: a 404 with the prefix kept is retried with it
# stripped (@api_stripped below), so an app that mounts at the root
# and one that mounts under /api both answer. Django: empty.
proxy_intercept_errors on; error_page 404 = @api_stripped;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Without this a Django or FastAPI app behind TLS builds http://
# URLs in redirects and password-reset links, and the browser
# refuses them as mixed content — a failure that only shows up once
# there is TLS in front, which is to say in production.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Upgrade is hop-by-hop, so it has to be forwarded explicitly or a
# websocket handshake degrades to a plain request that the app
# answers 200 with no socket, and the client waits forever.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
# Streaming responses — server-sent events, a slow export — must
# not be held in a buffer until they finish, which is exactly what
# makes them look like a hang.
proxy_buffering off;
}
# --- the frontend -------------------------------------------------
# The retry target for proxy_intercept_errors on; error_page 404 = @api_stripped;: the same upstream, prefix
# stripped. Internal only — a client cannot request it by name.
location @api_stripped {
rewrite ^/api/?(.*)$ /$1 break;
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.html must NOT be cached: it names the hashed asset files, so a
# cached copy keeps pointing at the previous deploy's bundles.
location = /index.html {
add_header Cache-Control "no-store, must-revalidate";
}
# nginx's own liveness, answered without waking the app. The container
# healthcheck uses this, so a backend that is slow to start reads as
# starting rather than as a dead container.
location = /_nginx_health {
access_log off;
return 200 "ok\n";
}
}
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Exit the container when either half of the app has failed for good.
A container running two processes has a failure mode a single-process one does
not: supervisord keeps running after it gives up on a child, so the container
stays "up" with half an app in it. From the outside that is a healthy
container. From a browser it is a total outage, and nothing restarts it because
nothing has reported anything wrong.
This subscribes to ``PROCESS_STATE_FATAL`` — the state supervisord puts a
program in once it has stopped retrying — and shuts supervisord down, so the
container stops instead.
**The container exits 0, not non-zero**, and that is worth knowing before
choosing a restart policy. SIGTERM asks supervisord for an orderly shutdown, it
stops the surviving child properly, and it reports success at having done so.
The signal to an orchestrator is therefore *the container stopped*, not the
exit code. With ``restart: unless-stopped`` or ``always`` it comes back. With
``restart: on-failure`` it will NOT, because 0 is not a failure — which is a
defensible choice for an app that cannot start, but it should be a choice
rather than a surprise.
Making it exit non-zero would mean not running supervisord as PID 1, which
costs correct signal handling on ``docker stop`` — a worse trade for a rarer
event.
Why a script and not a one-line ``sh -c`` in the config: the event listener
protocol is a length-prefixed exchange on stdin/stdout, and getting it wrong
does not raise. It leaves a listener that sits reading forever while the thing
it was meant to watch stays broken — which is worse than not having it, because
it looks like it is working. The shell version of this really did do that.
"""
from __future__ import annotations
import os
import signal
import sys
def main() -> None:
while True:
# 1. Say we are ready for one event.
sys.stdout.write("READY\n")
sys.stdout.flush()
# 2. The header is a single line of `key:value` pairs, and it tells us
# how many bytes of payload follow. Reading the payload matters even
# though it is unused: leaving it in the pipe desynchronises every
# later exchange.
header = sys.stdin.readline()
if not header:
return # supervisord closed the pipe; it is going away anyway
fields = dict(
pair.split(":", 1) for pair in header.split() if ":" in pair
)
length = int(fields.get("len", 0) or 0)
payload = sys.stdin.read(length) if length else ""
if fields.get("eventname") == "PROCESS_STATE_FATAL":
name = dict(
pair.split(":", 1) for pair in payload.split() if ":" in pair
).get("processname", "a process")
sys.stderr.write(
f"quit_on_failure: {name} is FATAL — supervisord gave up "
f"restarting it. Stopping the container so this is visible as a "
f"failure rather than as a half-working app.\n"
)
sys.stderr.flush()
# 3. Answer before dying, so supervisord does not log a protocol
# error on top of the real one.
sys.stdout.write("RESULT 2\nOK")
sys.stdout.flush()
# SIGTERM, not SIGQUIT: supervisord treats it as an orderly
# shutdown and stops the surviving child properly, so nginx gets a
# chance to finish in-flight requests.
os.kill(os.getppid(), signal.SIGTERM)
return
sys.stdout.write("RESULT 2\nOK")
sys.stdout.flush()
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
# Two processes in one container, and the rules that make that survivable.
#
# A container with two processes needs something that reaps them, restarts
# them, and — most importantly — dies when either one dies for good. Without
# that last part the container stays "up" with half an app in it, the
# orchestrator sees a healthy container, and the only symptom is that every
# request 502s.
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
# Without these three, `supervisorctl` inside the container refuses to run at
# all — "ini file does not include supervisorctl section" — so the one tool for
# asking which half of the app is down, or restarting it without killing the
# container, is unavailable exactly when it is needed. The socket is a file in
# the container with no port and no network listener, so this costs nothing.
[unix_http_server]
file=/tmp/supervisor.sock
chmod=0700
[supervisorctl]
serverurl=unix:///tmp/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[program:backend]
command=uvicorn main:app --host 127.0.0.1 --port 8001 --root-path /api
directory=/app/backend
autostart=true
autorestart=true
# Three failures in a row is a real failure, not a slow start. Retrying forever
# hides a crash-looping app behind a container that never exits.
startretries=3
startsecs=5
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
# Python buffers stdout when it is a pipe, and supervisord gives it a pipe.
# Without this the app's logs appear only when the buffer fills — which for a
# crash means never, and the traceback that would explain it is lost.
environment=PYTHONUNBUFFERED="1"
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
startretries=3
# Started after the backend so the first proxied request has somewhere to go.
# It is only ordering, not waiting: nginx starts whether or not the backend is
# ready, which is correct — it serves the frontend meanwhile.
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[eventlistener:quit_on_failure]
# The part that matters. When either program enters FATAL — supervisord has
# given up restarting it — this stops supervisord, so the container exits and
# whatever supervises it can react. Otherwise the container lives on with one
# half of the app missing: healthy from outside, a total outage from a browser.
#
# A real script rather than an inline `sh -c`, because the listener protocol is
# a length-prefixed exchange and getting it wrong does not raise — it leaves a
# listener reading forever while the thing it watches stays broken. See the
# script's own docstring; the shell version of this genuinely did that.
command=python3 -u /app/quit_on_failure.py
events=PROCESS_STATE_FATAL
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+14
View File
@@ -0,0 +1,14 @@
# Tasks
- [x] backend: add Student, ScheduleSlot, Attendance, DayMark models to models.py
- [x] backend: add schemas for students, schedule, attendance, day-marks, summary
- [x] backend: create routes/students.py router (students CRUD, schedule, attendance, day-marks, today, summary)
- [x] backend: mount students router in routes/__init__.py
- [x] backend: seed sample students, schedule slots and attendance in seed.py
- [x] frontend: add types + SEED rows for students/schedule/attendance/day-marks in mockData.ts
- [x] frontend: add service functions in content.ts for the new admin endpoints
- [x] frontend: build StudentsPanel.tsx (table + add/edit dialog + monthly income total)
- [x] frontend: build AttendancePanel.tsx (month grid, cell cycling, holiday/class-off, per-student summary)
- [x] frontend: build SchedulePanel.tsx (per-student weekday/time editor + Today list)
- [x] frontend: add Students, Attendance, Schedule tabs to AdminDashboard.tsx
- [x] verify project and fix any errors
+8
View File
@@ -0,0 +1,8 @@
[template]
slug = "tutoring-admin-dashboard"
title = "Tutoring Admin Dashboard with Student Management"
scope = "full"
stack = ""
version = "v1"
entry_frontend = "frontend"
entry_backend = "backend"