diff --git a/docs/technical.html b/docs/technical.html index 5462dee..f452e39 100644 --- a/docs/technical.html +++ b/docs/technical.html @@ -1224,25 +1224,220 @@

5. Application Installation

+

+ The application is composed of three moving parts — an Angular 20 SPA (ui/winstudentgoaltracker), + a .NET 9 Web API (api/), and a MySQL 8 database initialised from the SQL objects in + db/Objects/. Configuration for every tier is read from a single .env file at the + repository root. Two workflows are supported: running the stack directly on a developer workstation, or + deploying the full stack to a server with docker compose behind an existing Traefik reverse + proxy. +

-

Prerequisites

-

Node.js, .NET 9 SDK, MySQL, Docker

+

5.1 Local Development Environment

+

+ The recommended local workflow runs MySQL in Docker (so the schema initialises itself from + db/docker-init/01-schema.sh) while the API and UI run on the host for fast iteration and + debugging. +

-

Frontend

-
cd frontend
-npm install
-npm start
+

Prerequisites

+ -

Backend

-
cd backend
+        

Step 1 — Clone and create .env

+

+ Clone the repository and create a .env file at the project root. The API reads it via + DotNetEnv (which traverses parent directories), the UI/API containers read it through + docker-compose.yml's env_file directive, and the MySQL container consumes the + MYSQL_* variables natively. +

+
git clone <repo-url> WinStudentGoalTracker
+cd WinStudentGoalTracker
+

Create .env with the following keys (replace the secrets before sharing or deploying):

+
# MySQL container bootstrap
+MYSQL_ROOT_PASSWORD=change_me_root
+MYSQL_DATABASE=winstudentgoaltracker
+MYSQL_USER=appuser
+MYSQL_PASSWORD=change_me_app
+
+# Connection used by the .NET API
+MYSQL_HOST=127.0.0.1
+MYSQL_PORT=3309
+# (the api falls back to root/no-password if MYSQL_USER/PASSWORD are omitted)
+
+# JWT signing key — must be >= 32 bytes
+JWT_KEY=replace_with_a_long_random_string_at_least_32_chars
+

+ .env is listed in .gitignore; never commit it. For local dev, point + MYSQL_HOST at 127.0.0.1 so the host-side API can reach the containerised MySQL + through the published port 3309. +

+ +

Step 2 — Start MySQL

+

+ Bring up only the database service from the root compose file. On first run it creates the volume, creates + the database and application user, and executes db/docker-init/01-schema.sh which loads tables, + functions, views, and stored procedures from db/Objects/ in the correct order. +

+
docker compose up -d mysql
+docker compose logs -f mysql   # watch for "Schema initialization complete"
+

+ MySQL is exposed on host port 3309 (mapped to container port 3306). To reset the + database during development, stop the container and remove the named volume: + docker compose down && docker volume rm winstudentgoaltracker_win_mysql_data. +

+ +

Step 3 — Run the API

+
cd api
 dotnet restore
 dotnet run
+

+ The API listens on http://localhost:5000 (and https://localhost:5001) by default + via Kestrel. Swagger UI is available at /swagger when running in the + Development environment (the default for dotnet run). On startup the console + prints the resolved MySQL connection string — verify it points at 127.0.0.1:3309 and the + database name from your .env. +

-

Database

-

Create DB, import schema, update .env.

+

Step 4 — Run the UI

+
cd ui/winstudentgoaltracker
+npm install
+npm start
+

+ The Angular dev server starts on http://localhost:4200. The base URL the UI calls is defined in + src/environments/environment.development.ts. For a fully local stack, edit that file so + apiBaseUrl points at your locally running API (e.g. http://localhost:5000) instead + of the deployed https://winapi.opelly.me. The API's CORS policy already allows any origin in + development. +

-

Docker

-
docker-compose up --build
+

Step 5 — Seed a user

+

+ The schema initialises empty. To sign in, insert at least one user, one + school_district, one program, and a user_program row linking the user + to the program with a role. Passwords are hashed with PBKDF2 by PasswordHasher; the simplest + path is to use an existing seeded dump (ask a team member for seed.sql) or register via a + super_admin account using the admin endpoints. +

+ +

5.2 Production Deployment (Docker Compose + Traefik)

+

+ Production deploys all four tiers — MySQL, .NET API, Nginx-served Angular build, and the Traefik reverse + proxy — as Docker containers on a single VPS. The repository's docker-compose.yml brings up + MySQL, the API, and the UI; Traefik runs in a separate compose stack on the same host and + publishes ports 80/443 for the whole server. The two stacks communicate through an external Docker network + named web. +

+ +

Prerequisites on the server

+
    +
  • Linux VPS with Docker Engine and the Compose plugin installed
  • +
  • Ports 80 and 443 open on the server's firewall
  • +
  • Two DNS A records pointing at the server's public IP: + win.<your-domain> (UI) and winapi.<your-domain> (API)
  • +
  • A working Traefik stack on the host (see next section)
  • +
+ +

Setting up Traefik (one-time)

+

+ Traefik is the edge router that terminates TLS, requests Let's Encrypt certificates, and forwards traffic to + the correct container based on the Host() rule. The WIN compose file assumes Traefik is already + running on the host and configured with: +

+
    +
  • An external Docker network named web that Traefik is attached to. The + application's UI and API containers join this network so Traefik can reach them. +
    docker network create web
    +
  • +
  • An entrypoint named websecure listening on :443 (and typically + a redirect from :80).
  • +
  • A certificate resolver named letsencrypt (ACME, HTTP or DNS challenge) + configured with an email and a persistent acme.json volume.
  • +
  • Two file-provider middlewares referenced by the labels in docker-compose.yml: + gzip@file (compression) and + security-headers@file (HSTS, frame-deny, etc.).
  • +
+

+ A minimal Traefik docker-compose.yml typically lives in /opt/traefik/ alongside a + traefik.yml static config (declaring the entrypoints, providers, and ACME resolver) and a + dynamic/ directory containing the gzip and security-headers + middleware definitions. Traefik's official documentation at + doc.traefik.io covers this setup in detail — the WIN stack + does not prescribe a specific Traefik configuration, it only requires that the network, entrypoint, cert + resolver, and middleware names above exist. +

+ +

Step 1 — Prepare the repository on the server

+
git clone <repo-url> /opt/winstudentgoaltracker
+cd /opt/winstudentgoaltracker
+ +

Step 2 — Populate .env

+

Same keys as the local workflow, but with production-safe values. For the containerised deployment, + MYSQL_HOST must be the service name mysql (the API container resolves it over the + internal backend Docker network) and MYSQL_PORT must be 3306 (the + container's internal port, not the host-mapped 3309):

+
MYSQL_ROOT_PASSWORD=<strong-random>
+MYSQL_DATABASE=winstudentgoaltracker
+MYSQL_USER=appuser
+MYSQL_PASSWORD=<strong-random>
+MYSQL_HOST=mysql
+MYSQL_PORT=3306
+JWT_KEY=<32+ byte random secret>
+ +

Step 3 — Update domain labels (if not using opelly.me)

+

+ docker-compose.yml hard-codes the Traefik Host() rules + winapi.opelly.me and win.opelly.me. Edit those two labels to match your DNS + records before bringing the stack up. The UI also points at https://winapi.opelly.me via + src/environments/environment.ts; update that file and rebuild the UI image so the browser calls + your API host. +

+ +

Step 4 — Build and start the stack

+
docker compose up -d --build
+

This creates:

+
    +
  • winstudent-mysql — MySQL 8 on the internal backend network, volume + win_mysql_data, schema loaded on first start.
  • +
  • winstudent-api — .NET 9 API on port 8005, attached to both + backend (to reach MySQL) and web (to be reached by Traefik). Published at + winapi.<your-domain> over HTTPS.
  • +
  • winstudent-ui — Angular build served by Nginx on port 8006, attached to + web. Published at win.<your-domain> over HTTPS.
  • +
+

+ Traefik picks up the new containers automatically via its Docker provider, requests TLS certificates from + Let's Encrypt on first request, and begins routing traffic. Confirm with + docker compose ps (all services healthy/running) and + docker compose logs -f api (look for the Kestrel startup line and the printed connection + string). +

+ +

Step 5 — Verify

+
    +
  • Visit https://win.<your-domain> — the login page should load over HTTPS with a valid + Let's Encrypt certificate.
  • +
  • Visit https://winapi.<your-domain>/swagger — should return 404 in production (Swagger + is only enabled in Development); a 404 from the API itself confirms routing works.
  • +
  • Sign in with a seeded user and check docker compose logs api for successful + /api/Auth/Login requests.
  • +
+ +

Updating a running deployment

+
cd /opt/winstudentgoaltracker
+git pull
+docker compose up -d --build    # rebuilds changed images and recreates containers
+docker compose logs -f
+

+ MySQL is not rebuilt by --build (it uses the upstream image) and its volume + persists across up/down cycles, so data survives code deployments. Schema + migrations should be applied by running the appropriate SQL files from db/migrations/ against + the running MySQL container; see section 7 for backup procedures before running migrations. +

diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/confirm-modal/confirm-modal.scss b/ui/winstudentgoaltracker/src/app/desktop/components/confirm-modal/confirm-modal.scss index a25ac09..d09ab0c 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/confirm-modal/confirm-modal.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/confirm-modal/confirm-modal.scss @@ -6,7 +6,7 @@ } .second-confirm-message { - color: #DC2626; + color: var(--danger); font-weight: 600; } @@ -14,29 +14,29 @@ padding: 8px 20px; border-radius: var(--radius-md); border: none; - background: #DC2626; + background: var(--danger); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer; &:hover { - background: #B91C1C; + background: var(--danger-dark); } } :host ::ng-deep .btn-danger-confirm { padding: 8px 20px; border-radius: var(--radius-md); - border: 2px solid #DC2626; - background: #FEF2F2; - color: #DC2626; + border: 2px solid var(--danger); + background: var(--danger-bg-light); + color: var(--danger); font-size: 13px; font-weight: 600; cursor: pointer; &:hover { - background: #DC2626; + background: var(--danger); color: #fff; } } diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.scss b/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.scss index c8e1b1c..8ab4c66 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/edit-benchmark-modal/edit-benchmark-modal.scss @@ -6,16 +6,16 @@ .btn-ai { padding: 7px 14px; border-radius: var(--radius-md); - border: 1px solid #c4b5fd; - background: #f5f3ff; - color: #6d28d9; + border: 1px solid var(--accent-purple-border); + background: var(--accent-purple-bg); + color: var(--accent-purple); font-size: 13px; font-weight: 600; cursor: pointer; transition: background 0.15s; &:hover:not(:disabled) { - background: #ede9fe; + background: var(--accent-purple-hover); } &:disabled { @@ -26,7 +26,7 @@ .recommend-error { font-size: 12px; - color: #dc2626; + color: var(--danger); margin: 6px 0 0; } } diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/edit-event-modal/edit-event-modal.scss b/ui/winstudentgoaltracker/src/app/desktop/components/edit-event-modal/edit-event-modal.scss index 593aa21..758282d 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/edit-event-modal/edit-event-modal.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/edit-event-modal/edit-event-modal.scss @@ -8,9 +8,9 @@ padding: 4px 10px; border-radius: 5px; font-size: 12px; - border: 1.5px solid #D5D5D0; - background: #FFF; - color: #666; + border: 1.5px solid var(--border-muted); + background: var(--bg-surface); + color: var(--text-secondary); cursor: pointer; font-family: inherit; transition: all 0.15s ease; diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/modal-shell/modal-shell.scss b/ui/winstudentgoaltracker/src/app/desktop/components/modal-shell/modal-shell.scss index 00d310e..3e63bb8 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/modal-shell/modal-shell.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/modal-shell/modal-shell.scss @@ -64,7 +64,7 @@ display: block; font-size: 12px; font-weight: 600; - color: #666; + color: var(--text-secondary); margin-bottom: 4px; } @@ -102,7 +102,7 @@ cursor: pointer; &:hover:not(:disabled) { - background: #3730A3; + background: var(--accent-indigo-dark); } &:disabled { @@ -122,13 +122,13 @@ cursor: pointer; &:hover { - background: #e5e5e0; + background: var(--border-color); } } .error { font-size: 13px; - color: #dc2626; + color: var(--danger); margin: 0 0 8px; } } diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss index 3f238d6..0dc94c3 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/student-progress-report/student-progress-report.scss @@ -93,6 +93,6 @@ min-width: 6rem; &:hover { - background: #3730A3 !important; + background: var(--accent-indigo-dark) !important; } } diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.html b/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.html new file mode 100644 index 0000000..c24df0a --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.html @@ -0,0 +1,13 @@ +
+ @for (t of toast.toasts(); track t.id) { +
+ {{ t.message }} + +
+ } +
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.scss b/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.scss new file mode 100644 index 0000000..cda8495 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.scss @@ -0,0 +1,75 @@ +.toast-stack { + position: fixed; + top: 16px; + right: 16px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: var(--radius-lg); + font-size: 13px; + font-weight: 500; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + pointer-events: all; + animation: toast-in 0.15s ease; + min-width: 180px; + max-width: 300px; + + &--success { + background: var(--bg-surface); + border: 1px solid var(--border-color); + color: var(--text-primary); + } + + &--error { + background: var(--danger-bg-light); + border: 1px solid var(--danger); + color: var(--danger); + } + + &--info { + background: var(--accent-indigo-bg); + border: 1px solid var(--accent-indigo-border-light); + color: var(--accent-indigo); + } +} + +.toast-msg { + flex: 1; +} + +.toast-close { + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: inherit; + opacity: 0.45; + display: flex; + align-items: center; + flex-shrink: 0; + border-radius: var(--radius-sm); + + &:hover { + opacity: 1; + } +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(8px); + } + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.ts b/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.ts new file mode 100644 index 0000000..49e5208 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/desktop/components/toast-host/toast-host.ts @@ -0,0 +1,11 @@ +import { Component, inject } from '@angular/core'; +import { ToastService } from '../../../shared/services/toast.service'; + +@Component({ + selector: 'app-toast-host', + templateUrl: './toast-host.html', + styleUrl: './toast-host.scss', +}) +export class ToastHost { + protected readonly toast = inject(ToastService); +} diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html index 3f510ac..24ee2c7 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html +++ b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.html @@ -68,13 +68,14 @@

{{ student()!.identifier }}

IEP {{ formatDate(student()!.nextIepDate) }} -
diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss index 2004b44..d94b092 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.scss @@ -46,20 +46,24 @@ .delete-student-btn { display: flex; align-items: center; - justify-content: center; + gap: 6px; margin-left: auto; align-self: center; - padding: 4px; - border: none; + padding: 6px 12px; + border: 1px solid var(--border-muted); background: none; - color: var(--text-faint); + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + font-family: inherit; cursor: pointer; - border-radius: var(--radius-sm); - transition: color var(--transition-fast), background var(--transition-fast); + border-radius: var(--radius-md); + transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast); &:hover { - color: #DC2626; - background: #FEE2E2; + color: var(--danger); + background: var(--danger-bg); + border-color: var(--danger); } } @@ -76,7 +80,7 @@ border-radius: var(--radius-md); border: 1.5px solid var(--border-color); background: var(--bg-surface); - color: #666; + color: var(--text-secondary); font-weight: 400; font-size: 13px; cursor: pointer; @@ -85,9 +89,9 @@ &.active { font-weight: 600; - border-color: #818CF8; - background: #EEF2FF; - color: #4338CA; + border-color: var(--accent-indigo-border); + background: var(--accent-indigo-bg); + color: var(--accent-indigo); } &.add-goal { @@ -127,8 +131,8 @@ letter-spacing: 0.05em; padding: 3px 8px; border-radius: var(--radius-sm); - color: #4338CA; - background: #EEF2FF; + color: var(--accent-indigo); + background: var(--accent-indigo-bg); } .goal-due { @@ -172,8 +176,8 @@ transition: color var(--transition-fast), background var(--transition-fast); &:hover { - color: #DC2626; - background: #FEE2E2; + color: var(--danger); + background: var(--danger-bg); } } @@ -181,7 +185,7 @@ font-size: 15px; line-height: 1.55; margin: 0; - color: #333; + color: var(--text-primary); } /* ─── Sub Tabs ─── */ @@ -207,8 +211,8 @@ &.active { font-weight: 600; - color: #4338CA; - border-bottom-color: #6366F1; + color: var(--accent-indigo); + border-bottom-color: var(--accent-indigo-light); } } @@ -237,7 +241,7 @@ .benchmark-name { font-weight: 600; font-size: 14px; - color: #4338CA; + color: var(--accent-indigo); } .benchmark-events { @@ -267,8 +271,8 @@ transition: color var(--transition-fast), background var(--transition-fast); &:hover { - color: #DC2626; - background: #FEE2E2; + color: var(--danger); + background: var(--danger-bg); } } @@ -300,8 +304,8 @@ width: 12px; height: 12px; border-radius: 50%; - border: 2px solid #818CF8; - background: #EEF2FF; + border: 2px solid var(--accent-indigo-border); + background: var(--accent-indigo-bg); } .event-card { @@ -327,7 +331,7 @@ font-size: 13px; line-height: 1.55; margin: 0; - color: #333; + color: var(--text-primary); } .delete-event-btn { @@ -344,8 +348,8 @@ transition: color var(--transition-fast), background var(--transition-fast); &:hover { - color: #DC2626; - background: #FEE2E2; + color: var(--danger); + background: var(--danger-bg); } } @@ -359,11 +363,11 @@ .benchmark-tag { font-size: 11px; font-weight: 500; - color: #4338CA; - background: #EEF2FF; + color: var(--accent-indigo); + background: var(--accent-indigo-bg); padding: 3px 8px; border-radius: var(--radius-sm); - border: 1px solid #C7D2FE; + border: 1px solid var(--accent-indigo-border-light); } /* ─── Add Buttons ─── */ diff --git a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.ts b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.ts index ce28a65..dacde44 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/components/workspace/workspace.ts @@ -10,6 +10,7 @@ import { EditBenchmarkModal } from '../edit-benchmark-modal/edit-benchmark-modal import { EditEventModal } from '../edit-event-modal/edit-event-modal'; import { EditIcon } from '../edit-icon/edit-icon'; import { ConfirmModal } from '../confirm-modal/confirm-modal'; +import { ToastService } from '../../../shared/services/toast.service'; import { formatDate } from '../../../shared/utils/format-date'; type TabView = 'benchmarks' | 'progress'; @@ -66,6 +67,7 @@ export class Workspace { private readonly studentService = inject(StudentService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly toast = inject(ToastService); protected readonly studentId = signal(null); protected readonly student = signal(null); @@ -136,6 +138,7 @@ export class Workspace { onGoalSaved() { this.showGoalModal.set(null); this.refetchProfile(); + this.toast.show('Goal updated'); } onAddGoal() { @@ -162,6 +165,7 @@ export class Workspace { this.selectedGoalId.set(null); this.studentService.notifyDataChanged(); await this.refetchProfile(); + this.toast.show('Goal deleted'); } onGoalCreated(goal: StudentGoalItem) { @@ -170,6 +174,7 @@ export class Workspace { this.refetchProfile().then(() => { this.selectedGoalId.set(goal.goalId); }); + this.toast.show('Goal added'); } onEditBenchmark(b: BenchmarkDto) { @@ -179,6 +184,7 @@ export class Workspace { onEditBenchmarkSaved() { this.showEditBenchmarkModal.set(null); this.refetchProfile(); + this.toast.show('Benchmark saved'); } onAddBenchmark() { @@ -204,6 +210,7 @@ export class Workspace { if (!result.success) return; await this.refetchProfile(); + this.toast.show('Benchmark deleted'); } onNewEvent() { @@ -217,6 +224,7 @@ export class Workspace { onEventSaved() { this.showEditEventModal.set(null); this.refetchProfile(); + this.toast.show('Progress event saved'); } onDeleteEvent(ev: ProgressEventWithGoalDto) { @@ -238,6 +246,7 @@ export class Workspace { if (!result.success) return; await this.refetchProfile(); + this.toast.show('Progress event deleted'); } // ***************************************************************** @@ -274,6 +283,7 @@ export class Workspace { if (!result.success) return; this.studentService.notifyDataChanged(); + this.toast.show('Student deleted'); this.router.navigate(['/']); } diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html index 1ef22e2..4116729 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.html @@ -1,3 +1,4 @@ +
@if (showStudentModal()) { diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss index efd0f84..7a2c9ce 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.scss @@ -68,7 +68,7 @@ align-items: center; width: 30px; height: 16px; - background: #d1d5db; + background: var(--toggle-inactive); border-radius: 8px; padding: 2px; transition: background var(--transition-normal); @@ -102,11 +102,11 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: #4338CA; - background: #EEF2FF; + color: var(--accent-indigo); + background: var(--accent-indigo-bg); padding: 6px 12px; margin-top: 6px; - border-left: 3px solid #818CF8; + border-left: 3px solid var(--accent-indigo-border); } .student-item { diff --git a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts index cdd8fef..c3de319 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts +++ b/ui/winstudentgoaltracker/src/app/desktop/pages/home/home.ts @@ -5,11 +5,13 @@ import { StudentService } from '../../../shared/services/student.service'; import { StudentCardDto } from '../../../shared/classes/student-card.dto'; import { StudentModal } from '../../components/student-modal/student-modal'; import { EditIcon } from '../../components/edit-icon/edit-icon'; +import { ToastHost } from '../../components/toast-host/toast-host'; +import { ToastService } from '../../../shared/services/toast.service'; import { formatDate } from '../../../shared/utils/format-date'; @Component({ selector: 'app-home', - imports: [RouterOutlet, RouterLink, StudentModal, EditIcon], + imports: [RouterOutlet, RouterLink, StudentModal, EditIcon, ToastHost], templateUrl: './home.html', styleUrl: './home.scss', }) @@ -36,6 +38,7 @@ export class Home { protected readonly auth = inject(Auth); private readonly router = inject(Router); private readonly studentService = inject(StudentService); + private readonly toast = inject(ToastService); protected readonly students = signal([]); protected readonly selectedStudentId = signal(null); @@ -96,6 +99,7 @@ export class Home { this.studentService.notifyDataChanged(); this.selectedStudentId.set(student.studentId); this.router.navigate(['/students', student.studentId]); + this.toast.show('Student added'); } onEditStudent(student: StudentCardDto, event: Event) { @@ -106,6 +110,7 @@ export class Home { onStudentSaved() { this.showStudentModal.set(null); this.loadStudents(); + this.toast.show('Student updated'); } onLogout() { diff --git a/ui/winstudentgoaltracker/src/app/desktop/styles/_detail-page.scss b/ui/winstudentgoaltracker/src/app/desktop/styles/_detail-page.scss index a03d54a..74bbce6 100644 --- a/ui/winstudentgoaltracker/src/app/desktop/styles/_detail-page.scss +++ b/ui/winstudentgoaltracker/src/app/desktop/styles/_detail-page.scss @@ -25,7 +25,7 @@ font-family: inherit; &:hover { - background: #EEF2FF; + background: var(--accent-indigo-bg); } &:disabled { @@ -60,7 +60,7 @@ .field-label { font-size: 12px; font-weight: 600; - color: #666; + color: var(--text-secondary); margin-bottom: 4px; } @@ -80,7 +80,7 @@ .error { font-size: 13px; - color: #dc2626; + color: var(--danger); margin: 0 0 12px; } diff --git a/ui/winstudentgoaltracker/src/app/shared/services/toast.service.ts b/ui/winstudentgoaltracker/src/app/shared/services/toast.service.ts new file mode 100644 index 0000000..6092ed6 --- /dev/null +++ b/ui/winstudentgoaltracker/src/app/shared/services/toast.service.ts @@ -0,0 +1,25 @@ +import { Injectable, signal } from '@angular/core'; + +export type ToastType = 'success' | 'error' | 'info'; + +export interface Toast { + id: number; + message: string; + type: ToastType; +} + +@Injectable({ providedIn: 'root' }) +export class ToastService { + private nextId = 0; + readonly toasts = signal([]); + + show(message: string, type: ToastType = 'success') { + const id = ++this.nextId; + this.toasts.update(list => [...list, { id, message, type }]); + setTimeout(() => this.dismiss(id), 3000); + } + + dismiss(id: number) { + this.toasts.update(list => list.filter(t => t.id !== id)); + } +} diff --git a/ui/winstudentgoaltracker/src/styles.scss b/ui/winstudentgoaltracker/src/styles.scss index 967d081..bb39a90 100644 --- a/ui/winstudentgoaltracker/src/styles.scss +++ b/ui/winstudentgoaltracker/src/styles.scss @@ -15,6 +15,19 @@ --text-dim: #bbb; --accent-indigo: #4338CA; --accent-indigo-light: #6366F1; + --accent-indigo-dark: #3730A3; + --accent-indigo-bg: #EEF2FF; + --accent-indigo-border: #818CF8; + --accent-indigo-border-light: #C7D2FE; + --accent-purple: #6d28d9; + --accent-purple-bg: #f5f3ff; + --accent-purple-border: #c4b5fd; + --accent-purple-hover: #ede9fe; + --danger: #DC2626; + --danger-dark: #B91C1C; + --danger-bg: #FEE2E2; + --danger-bg-light: #FEF2F2; + --toggle-inactive: #d1d5db; --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px;