template tutoring-admin-dashboard v1
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
verify ok: True
|
||||
verify bad: False
|
||||
@@ -0,0 +1,4 @@
|
||||
[db] using Postgres
|
||||
admin: 1
|
||||
profile name: Swati Yadav
|
||||
subjects: 4
|
||||
@@ -0,0 +1,3 @@
|
||||
$ tsc --noEmit
|
||||
/usr/bin/bash: line 1: tsc: command not found
|
||||
error: script "typecheck" exited with code 127
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(' ')
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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="")
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Unused. Replaced by content.py / admin.py. Kept empty to avoid a dangling
|
||||
import; not mounted in the router barrel."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Generated
+1153
File diff suppressed because it is too large
Load Diff
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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[],
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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%)';
|
||||
@@ -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);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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}` },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user