Desktop UI updates

This commit is contained in:
ivan-pelly
2026-02-28 09:51:53 -08:00
parent b6b058f05e
commit 592c7324a1
39 changed files with 907 additions and 363 deletions
+4
View File
@@ -3,3 +3,7 @@
/prototype/RolesAssignments/.vs /prototype/RolesAssignments/.vs
/prototype/RolesAssignments/bin /prototype/RolesAssignments/bin
/prototype/RolesAssignments/obj /prototype/RolesAssignments/obj
/.vs/WinStudentGoalTracker/CopilotIndices
/.vs/WinStudentGoalTracker/FileContentIndex
/.vs/WinStudentGoalTracker/DesignTimeBuild
/.vs/WinStudentGoalTracker/v17
@@ -1,6 +1,8 @@
DELIMITER ;; DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36)) CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_Delete`(IN p_id_student CHAR(36))
BEGIN BEGIN
DELETE FROM user_student
WHERE id_student = p_id_student;
DELETE FROM student DELETE FROM student
WHERE id_student = p_id_student; WHERE id_student = p_id_student;
SELECT ROW_COUNT() AS rows_affected; SELECT ROW_COUNT() AS rows_affected;
@@ -4,7 +4,6 @@ BEGIN
SELECT SELECT
id_student, id_student,
id_program, id_program,
id_user,
identifier, identifier,
program_year, program_year,
enrollment_date, enrollment_date,
@@ -4,7 +4,6 @@ BEGIN
SELECT SELECT
id_student, id_student,
id_program, id_program,
id_user,
identifier, identifier,
program_year, program_year,
enrollment_date, enrollment_date,
@@ -1,19 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByProgram`(
IN p_id_program CHAR(36)
)
BEGIN
SELECT
id_student,
id_program,
id_user,
identifier,
program_year,
enrollment_date,
expected_grad,
created_at
FROM student
WHERE id_program = p_id_program
ORDER BY id_student;
END;;
DELIMITER ;
@@ -1,21 +0,0 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetByUserAndProgram`(
IN p_id_user CHAR(36),
IN p_id_program CHAR(36)
)
BEGIN
SELECT
s.id_student,
s.id_program,
s.id_user,
s.identifier,
s.program_year,
s.enrollment_date,
s.expected_grad,
s.created_at
FROM student s
WHERE s.id_user = p_id_user
AND s.id_program = p_id_program
ORDER BY s.id_student;
END;;
DELIMITER ;
@@ -0,0 +1,30 @@
DELIMITER ;;
CREATE DEFINER=`root`@`%` PROCEDURE `sp_Student_GetWithAssignments`(
IN p_id_program CHAR(36),
IN p_id_user CHAR(36)
)
BEGIN
-- Result set 1: All students in the program
SELECT
s.id_student,
s.id_program,
s.identifier,
s.program_year,
s.enrollment_date,
s.expected_grad,
s.created_at
FROM student s
WHERE s.id_program = p_id_program
ORDER BY s.id_student;
-- Result set 2: user_student assignments for the requesting user in this program
SELECT
us.id_user_student,
us.id_user,
us.id_student,
us.is_primary
FROM user_student us
INNER JOIN student s ON s.id_student = us.id_student
WHERE us.id_user = p_id_user
AND s.id_program = p_id_program;
END;;
DELIMITER ;
+2 -3
View File
@@ -13,7 +13,6 @@ BEGIN
( (
id_student, id_student,
id_program, id_program,
id_user,
identifier, identifier,
program_year, program_year,
enrollment_date, enrollment_date,
@@ -24,17 +23,17 @@ BEGIN
( (
p_id_student, p_id_student,
p_id_program, p_id_program,
p_id_user,
p_identifier, p_identifier,
p_program_year, p_program_year,
p_enrollment_date, p_enrollment_date,
p_expected_grad, p_expected_grad,
UTC_TIMESTAMP() UTC_TIMESTAMP()
); );
INSERT INTO user_student (id_user_student, id_user, id_student, is_primary)
VALUES (UUID(), p_id_user, p_id_student, 1);
SELECT SELECT
id_student, id_student,
id_program, id_program,
id_user,
identifier, identifier,
program_year, program_year,
enrollment_date, enrollment_date,
+1 -4
View File
@@ -1,7 +1,6 @@
CREATE TABLE `student` ( CREATE TABLE `student` (
`id_student` char(36) NOT NULL, `id_student` char(36) NOT NULL,
`id_program` char(36) DEFAULT NULL, `id_program` char(36) DEFAULT NULL,
`id_user` char(36) DEFAULT NULL,
`identifier` varchar(50) DEFAULT NULL, `identifier` varchar(50) DEFAULT NULL,
`program_year` int DEFAULT NULL, `program_year` int DEFAULT NULL,
`enrollment_date` date DEFAULT NULL, `enrollment_date` date DEFAULT NULL,
@@ -9,7 +8,5 @@ CREATE TABLE `student` (
`created_at` timestamp NULL DEFAULT NULL, `created_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id_student`), PRIMARY KEY (`id_student`),
KEY `student_ibfk_1` (`id_program`), KEY `student_ibfk_1` (`id_program`),
KEY `student_ibfk_2` (`id_user`), CONSTRAINT `student_ibfk_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`)
CONSTRAINT `student_ibfk_1` FOREIGN KEY (`id_program`) REFERENCES `program` (`id_program`),
CONSTRAINT `student_ibfk_2` FOREIGN KEY (`id_user`) REFERENCES `user` (`id_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -3,7 +3,7 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor'; import { authInterceptor } from './shared/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
+12 -2
View File
@@ -1,7 +1,17 @@
import { inject } from '@angular/core';
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { Login } from './pages/login/login'; import { Login } from './shared/pages/login/login';
import { PlatformService } from './shared/services/platform.service';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: Login },
{ path: 'login', component: Login }, { path: 'login', component: Login },
{
path: '',
canMatch: [() => inject(PlatformService).formFactor() === 'mobile'],
loadChildren: () => import('./mobile/mobile.routes'),
},
{
path: '',
loadChildren: () => import('./desktop/desktop.routes'),
},
]; ];
@@ -0,0 +1,14 @@
import { Routes } from '@angular/router';
import { Home } from './pages/home/home';
import { StudentCardList } from './pages/components/student-card-list/student-card-list';
export default [
{
path: '',
component: Home,
children: [
{ path: '', redirectTo: 'students', pathMatch: 'full' },
{ path: 'students', component: StudentCardList },
],
},
] satisfies Routes;
@@ -0,0 +1,14 @@
<div class="toolbar">
<button class="toolbar-btn" (click)="onAddStudent()">+ Add a Student</button>
</div>
@if (displayMode() === 'card') {
<div class="card-grid">
@for (student of students(); track student.studentId) {
<app-student-card [student]="student" />
}
</div>
} @else {
<!-- List mode — to be implemented -->
<p>List view coming soon.</p>
}
@@ -0,0 +1,41 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
height: 40px;
padding-right: 0.5rem;
border-radius: 8px;
background: #fff;
border-bottom: 1px solid #ddd;
margin-bottom: 1rem;
flex-shrink: 0;
}
.toolbar-btn {
padding: 0.375rem 0.75rem;
background: transparent;
color: #4f46e5;
border: 1px solid #4f46e5;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.toolbar-btn:hover {
background: #eef2ff;
}
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
overflow-y: auto;
flex: 1;
}
@@ -0,0 +1,48 @@
import { Component, inject, signal } from '@angular/core';
import { StudentCard } from '../student-card/student-card';
import { StudentService } from '../../../../shared/services/student.service';
import { StudentCardDto } from '../../../../shared/models/dto/student-card.dto';
export type DisplayMode = 'card' | 'list';
@Component({
selector: 'app-student-card-list',
imports: [StudentCard],
templateUrl: './student-card-list.html',
styleUrl: './student-card-list.scss',
})
export class StudentCardList {
// ************************** Constructor **************************
constructor() {
this.loadStudents();
}
// ************************** Declarations *************************
private readonly studentService = inject(StudentService);
protected readonly students = signal<StudentCardDto[]>([]);
protected readonly displayMode = signal<DisplayMode>('card');
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
onAddStudent() {
// TODO: navigate to add-student form
}
// ********************** Support Procedures ***********************
// *****************************************************************
// Loads students from the service and populates the students signal.
// *****************************************************************
private loadStudents() {
this.studentService.getStudentCards().subscribe(data => {
this.students.set(data);
});
}
}
@@ -0,0 +1,19 @@
<div class="card">
<h2 class="identifier">🎓 {{ student().identifier }}</h2>
<div class="meta">
<span class="badge">Age: {{ student().age }}</span>
<span class="last-entry">
@if (student().lastEntryDate) {
Last entry: {{ student().lastEntryDate | date:'M/d/yy' }}
} @else {
No entries yet
}
</span>
</div>
<div class="stats">
<span>Goals: {{ student().goalCount }}</span>
<span>Events: {{ student().progressEventCount }}</span>
</div>
</div>
@@ -0,0 +1,42 @@
:host {
display: block;
width: 280px;
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
}
.identifier {
margin: 0 0 0.75rem;
font-size: 1.5rem;
text-align: center;
}
.meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-size: 0.875rem;
color: #666;
}
.badge {
padding: 0.25rem 0.5rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.8125rem;
color: #333;
}
.stats {
display: flex;
justify-content: space-around;
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
@@ -0,0 +1,26 @@
import { Component, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { StudentCardDto } from '../../../../shared/models/dto/student-card.dto';
@Component({
selector: 'app-student-card',
imports: [DatePipe],
templateUrl: './student-card.html',
styleUrl: './student-card.scss',
})
export class StudentCard {
// ************************** Constructor **************************
// ************************** Declarations *************************
readonly student = input.required<StudentCardDto>();
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,24 @@
<div class="shell">
<!-- Header -->
<header class="header">
<button class="menu-toggle" (click)="onToggleSidebar()"></button>
<span class="header-title">WinStudentGoalTracker</span>
</header>
<!-- Body: Sidebar + Main -->
<div class="body">
<nav class="sidebar" [class.expanded]="sidebarExpanded()">
<a class="nav-item" routerLink="/students">Home</a>
<a class="nav-item sub" routerLink="/students" routerLinkActive="active">My Students</a>
</nav>
<main class="content">
<router-outlet />
</main>
</div>
<!-- Footer -->
<footer class="footer">
<span>&copy; 2026 WinStudentGoalTracker</span>
</footer>
</div>
@@ -0,0 +1,110 @@
:host {
display: block;
height: 100%;
}
.shell {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem;
height: 48px;
background: #fff;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
}
.menu-toggle {
background: none;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
color: #333;
}
.menu-toggle:hover {
background: #f5f5f5;
}
.header-title {
font-weight: 600;
font-size: 1rem;
color: #333;
}
/* Body: Sidebar + Content */
.body {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 0;
background: #fff;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.2s ease;
flex-shrink: 0;
}
.sidebar.expanded {
width: 220px;
}
.nav-item {
display: block;
padding: 0.75rem 1rem;
color: #333;
text-decoration: none;
white-space: nowrap;
font-size: 0.875rem;
}
.nav-item:hover {
background: #f5f5f5;
}
.nav-item.sub {
padding-left: 2rem;
font-size: 0.8125rem;
}
.nav-item.active {
font-weight: 600;
color: #4f46e5;
background: #eef2ff;
}
/* Main content */
.content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
background: #f5f5f5;
}
/* Footer */
.footer {
display: flex;
align-items: center;
padding: 0 1rem;
height: 32px;
background: #fff;
border-top: 1px solid #ddd;
color: #666;
font-size: 0.75rem;
flex-shrink: 0;
}
@@ -0,0 +1,29 @@
import { Component, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-home',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './home.html',
styleUrl: './home.scss',
})
export class Home {
// ************************** Constructor **************************
// ************************** Declarations *************************
protected readonly sidebarExpanded = signal(false);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
onToggleSidebar() {
this.sidebarExpanded.update(v => !v);
}
// ********************** Support Procedures ***********************
}
@@ -0,0 +1,6 @@
import { Routes } from '@angular/router';
import { Home } from './pages/home/home';
export default [
{ path: '', component: Home },
] satisfies Routes;
@@ -0,0 +1 @@
<p>Mobile home works!</p>
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
imports: [],
templateUrl: './home.html',
styleUrl: './home.scss',
})
export class Home {
}
@@ -1,310 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Auth } from '../../services/auth';
@Component({
selector: 'app-login',
imports: [FormsModule],
template: `
<!-- Phase 1: Email & Password -->
@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
<div class="card">
<h2>Login</h2>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<form (ngSubmit)="onLogin()">
<label>
Email
<input type="email" [(ngModel)]="email" name="email" required />
</label>
<label>
Password
<input type="password" [(ngModel)]="password" name="password" required />
</label>
<button type="submit" [disabled]="loading()">
{{ loading() ? 'Signing in...' : 'Sign in' }}
</button>
</form>
</div>
}
<!-- Phase 2: Program Selection -->
@if (auth.isSelectingProgram()) {
<div class="card">
<h2>Select a Program</h2>
<p class="subtitle">Choose which program to log into.</p>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<div class="program-list">
@for (program of auth.programs(); track program.programId) {
<button
class="program-button"
[disabled]="loading()"
(click)="onSelectProgram(program.programId)"
>
<span class="program-name">{{ program.programName }}</span>
<span class="program-meta">{{ program.roleDisplayName }}{{ program.isPrimary ? ' (Primary)' : '' }}</span>
</button>
}
</div>
<button class="link-button" (click)="onBackToLogin()">Back to login</button>
</div>
}
<!-- Authenticated: User Info -->
@if (auth.isAuthenticated()) {
<div class="card">
<h2>Authenticated</h2>
@if (auth.user(); as user) {
<dl>
<dt>User ID</dt>
<dd class="mono">{{ user.userId }}</dd>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Program ID</dt>
<dd class="mono">{{ user.programId }}</dd>
<dt>Role</dt>
<dd>{{ user.role }}</dd>
</dl>
}
<button (click)="onLogout()">Logout</button>
</div>
}
`,
styles: `
:host {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
padding: 1rem;
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 2rem;
width: 100%;
max-width: 420px;
}
h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.subtitle {
margin: 0 0 1rem;
color: #666;
font-size: 0.875rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
input {
padding: 0.625rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.15s;
}
input:focus {
border-color: #4f46e5;
}
button[type='submit'],
button:not(.program-button):not(.link-button) {
padding: 0.625rem 1rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
margin-top: 0.5rem;
}
button[type='submit']:hover,
button:not(.program-button):not(.link-button):hover {
background: #4338ca;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background: #fef2f2;
color: #dc2626;
padding: 0.625rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
margin: 0.5rem 0;
}
.program-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.program-button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
text-align: left;
width: 100%;
}
.program-button:hover {
background: #eef2ff;
border-color: #4f46e5;
}
.program-name {
font-weight: 500;
font-size: 0.9375rem;
color: #111;
}
.program-meta {
font-size: 0.8125rem;
color: #666;
}
.link-button {
background: none;
border: none;
color: #4f46e5;
cursor: pointer;
font-size: 0.875rem;
padding: 0.5rem 0;
margin-top: 0.75rem;
}
.link-button:hover {
text-decoration: underline;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 1rem 0;
font-size: 0.9375rem;
}
dt {
font-weight: 500;
color: #555;
}
dd {
margin: 0;
color: #111;
word-break: break-all;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8125rem;
}
`,
})
export class Login {
protected readonly auth = inject(Auth);
protected email = '';
protected password = '';
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
onLogin() {
this.error.set(null);
this.loading.set(true);
this.auth.login(this.email, this.password).subscribe({
next: (res) => {
this.loading.set(false);
if (!res.success) {
this.error.set(res.message);
}
},
error: () => {
this.loading.set(false);
this.error.set('Unable to reach the server.');
},
});
}
onSelectProgram(programId: string) {
this.error.set(null);
this.loading.set(true);
this.auth.selectProgram(programId).subscribe({
next: (res) => {
this.loading.set(false);
if (!res.success) {
this.error.set(res.message);
}
},
error: () => {
this.loading.set(false);
this.error.set('Unable to reach the server.');
},
});
}
onLogout() {
this.auth.logout().subscribe();
}
onBackToLogin() {
this.error.set(null);
// Clear session state so we go back to the login form
this.auth.logout().subscribe();
}
}
@@ -0,0 +1,8 @@
export interface StudentCardDto {
studentId: string;
identifier: string;
age: number;
lastEntryDate: string | null;
goalCount: number;
progressEventCount: number;
}
@@ -0,0 +1,166 @@
:host {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
padding: 1rem;
}
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 2rem;
width: 100%;
max-width: 420px;
}
h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.subtitle {
margin: 0 0 1rem;
color: #666;
font-size: 0.875rem;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
input {
padding: 0.625rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.15s;
}
input:focus {
border-color: #4f46e5;
}
button[type='submit'],
button:not(.program-button):not(.link-button) {
padding: 0.625rem 1rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
margin-top: 0.5rem;
}
button[type='submit']:hover,
button:not(.program-button):not(.link-button):hover {
background: #4338ca;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background: #fef2f2;
color: #dc2626;
padding: 0.625rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
margin: 0.5rem 0;
}
.program-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.program-button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
text-align: left;
width: 100%;
}
.program-button:hover {
background: #eef2ff;
border-color: #4f46e5;
}
.program-name {
font-weight: 500;
font-size: 0.9375rem;
color: #111;
}
.program-meta {
font-size: 0.8125rem;
color: #666;
}
.link-button {
background: none;
border: none;
color: #4f46e5;
cursor: pointer;
font-size: 0.875rem;
padding: 0.5rem 0;
margin-top: 0.75rem;
}
.link-button:hover {
text-decoration: underline;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 1rem 0;
font-size: 0.9375rem;
}
dt {
font-weight: 500;
color: #555;
}
dd {
margin: 0;
color: #111;
word-break: break-all;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8125rem;
}
@@ -0,0 +1,78 @@
<!-- Phase 1: Email & Password -->
@if (!auth.isAuthenticated() && !auth.isSelectingProgram()) {
<div class="card">
<h2>Login</h2>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<form (ngSubmit)="onLogin()">
<label>
Email
<input type="email" [(ngModel)]="email" name="email" required />
</label>
<label>
Password
<input type="password" [(ngModel)]="password" name="password" required />
</label>
<button type="submit" [disabled]="loading()">
{{ loading() ? 'Signing in...' : 'Sign in' }}
</button>
</form>
</div>
}
<!-- Phase 2: Program Selection -->
@if (auth.isSelectingProgram()) {
<div class="card">
<h2>Select a Program</h2>
<p class="subtitle">Choose which program to log into.</p>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<div class="program-list">
@for (program of auth.programs(); track program.programId) {
<button
class="program-button"
[disabled]="loading()"
(click)="onSelectProgram(program.programId)"
>
<span class="program-name">{{ program.programName }}</span>
<span class="program-meta">{{ program.roleDisplayName }}{{ program.isPrimary ? ' (Primary)' : '' }}</span>
</button>
}
</div>
<button class="link-button" (click)="onBackToLogin()">Back to login</button>
</div>
}
<!-- Authenticated: User Info -->
@if (auth.isAuthenticated()) {
<div class="card">
<h2>Authenticated</h2>
@if (auth.user(); as user) {
<dl>
<dt>User ID</dt>
<dd class="mono">{{ user.userId }}</dd>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Program ID</dt>
<dd class="mono">{{ user.programId }}</dd>
<dt>Role</dt>
<dd>{{ user.role }}</dd>
</dl>
}
<button (click)="onHome()">Continue</button>
<button (click)="onLogout()">Logout</button>
</div>
}
@@ -0,0 +1,82 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Auth } from '../../services/auth';
@Component({
selector: 'app-login',
imports: [FormsModule],
templateUrl: './login.html',
styleUrl: './login.css',
})
export class Login {
// ************************** Constructor **************************
// ************************** Declarations *************************
private readonly router = inject(Router);
protected readonly auth = inject(Auth);
protected email = '';
protected password = '';
protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null);
// ************************** Properties ***************************
// ************************ Public Methods *************************
// ************************ Event Handlers *************************
onLogin() {
this.error.set(null);
this.loading.set(true);
this.auth.login(this.email, this.password).subscribe({
next: (res) => {
this.loading.set(false);
if (!res.success) {
this.error.set(res.message);
}
},
error: () => {
this.loading.set(false);
this.error.set('Unable to reach the server.');
},
});
}
onSelectProgram(programId: string) {
this.error.set(null);
this.loading.set(true);
this.auth.selectProgram(programId).subscribe({
next: (res) => {
this.loading.set(false);
if (!res.success) {
this.error.set(res.message);
}
},
error: () => {
this.loading.set(false);
this.error.set('Unable to reach the server.');
},
});
}
onHome() {
this.router.navigateByUrl('/');
}
onLogout() {
this.auth.logout().subscribe();
}
onBackToLogin() {
this.error.set(null);
// Clear session state so we go back to the login form
this.auth.logout().subscribe();
}
// ********************** Support Procedures ***********************
}
@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../../environments/environment';
import { import {
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
@@ -0,0 +1,73 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
export type FormFactor = 'mobile' | 'desktop';
@Injectable({
providedIn: 'root',
})
export class PlatformService {
private readonly router = inject(Router);
// ──── Raw Hardware Signals ────
private readonly isCoarsePointer =
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
private readonly screenWidth =
typeof window !== 'undefined' ? window.innerWidth : 1920;
private readonly screenHeight =
typeof window !== 'undefined' ? window.innerHeight : 1080;
private readonly minDimension = Math.min(this.screenWidth, this.screenHeight);
private readonly maxDimension = Math.max(this.screenWidth, this.screenHeight);
// ──── Override Layer ────
/** When non-null, overrides auto-detection. */
private readonly formFactorOverride = signal<FormFactor | null>(null);
// ──── Public API ────
/** The resolved form factor — auto-detected or overridden. */
readonly formFactor = computed<FormFactor>(() =>
this.formFactorOverride() ?? this.resolveFormFactor(),
);
/** True when the user has manually toggled away from auto-detection. */
readonly isOverridden = computed(() => this.formFactorOverride() !== null);
/**
* Switch to a specific form factor at runtime.
* Forces a route re-evaluation so the user immediately sees the other experience.
*/
switchTo(target: FormFactor): void {
this.formFactorOverride.set(target);
this.router.navigateByUrl(this.router.url);
}
/** Clear the override and return to auto-detected form factor. */
resetToAuto(): void {
this.formFactorOverride.set(null);
this.router.navigateByUrl(this.router.url);
}
// ──── Device Classification Policy ────
private resolveFormFactor(): FormFactor {
// Non-touch devices are always desktop
if (!this.isCoarsePointer) return 'desktop';
// Large tablets (iPad Pro 12.9" portrait = 1024px) → desktop
if (this.minDimension >= 1024) return 'desktop';
// Medium tablets in landscape with sufficient width → desktop
if (this.maxDimension >= 1180 && this.screenWidth > this.screenHeight) {
return 'desktop';
}
// Phones and small/portrait tablets → mobile
return 'mobile';
}
}
@@ -0,0 +1,54 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { StudentCardDto } from '../models/dto/student-card.dto';
@Injectable({
providedIn: 'root',
})
export class StudentService {
// ************************** Constructor **************************
// ************************** Declarations *************************
// ************************** Properties ***************************
// ************************ Public Methods *************************
// *****************************************************************
// Returns student card summaries. Currently returns dummy data
// until the API endpoint is available.
// *****************************************************************
getStudentCards(): Observable<StudentCardDto[]> {
return of([
{
studentId: '1',
identifier: 'J.B',
age: 21,
lastEntryDate: '2026-02-21',
goalCount: 3,
progressEventCount: 5,
},
{
studentId: '2',
identifier: 'M.K',
age: 19,
lastEntryDate: '2026-02-25',
goalCount: 4,
progressEventCount: 8,
},
{
studentId: '3',
identifier: 'A.R',
age: 22,
lastEntryDate: null,
goalCount: 2,
progressEventCount: 0,
},
]);
}
// ************************ Event Handlers *************************
// ********************** Support Procedures ***********************
}
+8
View File
@@ -1 +1,9 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}