From 9531ea34f2f48a3876c7e2113abd1f27111d348a Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 22 Jun 2026 13:52:35 +0200 Subject: [PATCH] added sign language library CRUD, improved existed functionality --- backend/docs/backend-architecture.md | 12 + backend/docs/content-catalog.md | 6 +- backend/package.json | 1 + backend/src/db/models/index.ts | 6 +- .../content-catalog-seed-payloads.ts | 22 +- backend/src/index.ts | 14 +- backend/src/middlewares/request-logger.ts | 28 ++ backend/src/services/content_catalog.test.ts | 24 ++ backend/src/services/content_catalog.ts | 2 + .../src/services/content_catalog_seed.test.ts | 14 + .../src/shared/constants/content-catalog.ts | 5 +- backend/src/shared/logger.ts | 93 +++++- backend/watcher.ts | 3 +- frontend/docs/content-catalog-integration.md | 6 +- frontend/docs/dashboard-integration.md | 3 + frontend/docs/sign-language-integration.md | 18 +- frontend/docs/top-bar-integration.md | 7 +- frontend/docs/user-progress-integration.md | 1 + frontend/index.html | 6 + frontend/package-lock.json | 295 +++++++----------- frontend/package.json | 4 +- frontend/postcss.config.js | 6 - frontend/src/business/dashboard/hooks.ts | 27 +- frontend/src/business/dashboard/types.ts | 4 +- frontend/src/business/sign-language/hooks.ts | 269 +++++++++++++++- .../business/sign-language/selectors.test.ts | 88 +++++- .../src/business/sign-language/selectors.ts | 190 ++++++++++- frontend/src/business/sign-language/types.ts | 42 +++ frontend/src/business/top-bar/hooks.ts | 117 ++++++- .../src/business/top-bar/selectors.test.ts | 48 +++ frontend/src/business/top-bar/selectors.ts | 48 +++ .../dashboard/DashboardSignOfWeek.tsx | 13 +- .../sign-language/SignLanguageCard.tsx | 43 ++- .../sign-language/SignLanguageGrid.tsx | 9 + .../SignLanguageManagementPanel.tsx | 291 +++++++++++++++++ .../sign-language/SignLanguageVideoModal.tsx | 15 +- .../sign-language/SignLanguageView.tsx | 24 ++ frontend/src/components/ui/button.tsx | 15 + frontend/src/index.css | 3 - frontend/src/shared/constants/signLanguage.ts | 3 + frontend/src/shared/types/app.ts | 1 + frontend/src/shared/types/dashboard.ts | 2 + frontend/vite.config.ts | 4 +- 43 files changed, 1559 insertions(+), 273 deletions(-) create mode 100644 backend/src/middlewares/request-logger.ts delete mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/components/sign-language/SignLanguageManagementPanel.tsx diff --git a/backend/docs/backend-architecture.md b/backend/docs/backend-architecture.md index 3a14fcb..36bc936 100644 --- a/backend/docs/backend-architecture.md +++ b/backend/docs/backend-architecture.md @@ -104,6 +104,18 @@ Location: `src/shared/` (+ ambient types in `src/types/`). Cross-cutting code depends on no layer and may be imported by any layer. +### Logging + +Server logs go through `shared/logger.ts`. + +- Local development defaults to pretty, colorized logs and `LOG_LEVEL=debug`. +- `production` and `dev_stage` default to JSON logs and `LOG_LEVEL=info`. +- Override with `LOG_FORMAT=pretty|json` and `LOG_LEVEL=debug|info|warn|error`. +- Use `npm run start:debug` to start local migrate/seed/watch with debug request + logs and SQL query logs enabled. +- Sequelize SQL logging is disabled by default. Enable it only when needed with + `LOG_SQL=true`; SQL is emitted as `logger.debug('SQL query', { sql })`. + ## Import direction Allowed: diff --git a/backend/docs/content-catalog.md b/backend/docs/content-catalog.md index 33ec35e..e1a772b 100644 --- a/backend/docs/content-catalog.md +++ b/backend/docs/content-catalog.md @@ -30,6 +30,7 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`. - `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library. - `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz. - `emotional-intelligence-assessment-questions` and `emotional-intelligence-personality-quiz` are organization-scoped catalogs. Organization users with `MANAGE_CONTENT_CATALOG` manage the active quiz content once for the organization; descendant scopes read and complete that organization-owned content. +- `sign-language-items` is organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the sign card library once for the organization; descendant staff read the library and save personal learned progress in `user_progress`. ## Tenant Scope Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns: @@ -55,7 +56,7 @@ Management list excludes tenant-scoped content because those records are edited - `update` and `delete` run inside `withTransaction` and throw `ValidationError('contentCatalogNotFound')` when the active row is missing. `delete` sets `active = false` then soft-deletes (`destroy`). - Versioned quiz content types (`safety-qbs-quiz`, `emotional-intelligence-assessment-questions`, and `emotional-intelligence-personality-quiz`) preserve history on update: the current active row is marked inactive and a new active row is created. Reads and management fetches return only the active version, so old quiz versions remain stored but hidden from UI. - `findManagedByType` is the authenticated variant of `findByType`: it enforces manage access and then returns the active record. -- For `classroom-strategies`, `safety-qbs-quiz`, `emotional-intelligence-assessment-questions`, and `emotional-intelligence-personality-quiz`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage shared content and descendant scopes remain read-only. +- For `classroom-strategies`, `safety-qbs-quiz`, `emotional-intelligence-assessment-questions`, `emotional-intelligence-personality-quiz`, and `sign-language-items`, `findManagedByType`, `create`, `update`, and `delete` also require organization effective scope so organization leaders manage shared content and descendant scopes remain read-only. - Managed `classroom-strategies` images are uploaded through the file subsystem first. The content payload stores the returned private URL; production uploads use the configured GCloud bucket path from `file.md`. - Missing/inactive content types fail explicitly with `ValidationError` rather than returning empty payloads. @@ -70,9 +71,10 @@ New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant` - Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. - Frontend constants stay limited to UI config, labels, query keys, timing values, presentation tokens, and product-static catalogs. - If a catalog needs complex workflow state, approvals, or per-campus variants, replace the generic catalog entry with typed, tenant-scoped backend tables and CRUD APIs. +- `dashboard-sign-of-week` stores the selected sign card id, current Sunday-start `weekOf`, and fallback word, description, alt text, and image URL. Frontend dashboard and notification code resolve the id against `sign-language-items`; the fallback fields preserve compatibility with older seeded payloads. ## Tests -Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, and organization-only seeding for preset organization-owned content, including Zones of Regulation content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking. +Coverage lives in `src/services/content_catalog.test.ts` and `src/services/content_catalog_seed.test.ts`. It covers tenant read scoping, organization-only management for organization-owned content, organization-only sign library management, organization-only seeding for preset organization-owned content, seeded Sign of the Week selector alignment with the Help sign card, and Zones of Regulation content. Personality result tests cover active quiz consumption, reporting, and parent-drill save blocking. ## Related - Frontend: `frontend/docs/content-catalog-integration.md`. diff --git a/backend/package.json b/backend/package.json index 98fd290..e24a19a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "start": "npm run db:migrate && npm run db:seed && npm run watch", + "start:debug": "cross-env LOG_SQL=true LOG_LEVEL=debug LOG_FORMAT=pretty npm run start", "start:prod": "node --enable-source-maps dist/index.js", "start:production": "npm run db:migrate:prod && npm run db:seed:prod && npm run start:prod", "db:migrate:prod": "node dist/db/umzug.js migrate:up", diff --git a/backend/src/db/models/index.ts b/backend/src/db/models/index.ts index 8b2d1b3..8adf9a7 100644 --- a/backend/src/db/models/index.ts +++ b/backend/src/db/models/index.ts @@ -1,5 +1,6 @@ import { Sequelize } from 'sequelize'; import dbConfig from '@/db/db.config'; +import logger from '@/shared/logger'; import { DEFAULT_DEV_DB_HOST, DEFAULT_DEV_DB_NAME, @@ -91,6 +92,7 @@ function validateProductionDbConfig(connection: ReturnType logger.debug('SQL query', { sql }) + : false, }, ); diff --git a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts index d013c1d..ec2ad05 100644 --- a/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts +++ b/backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts @@ -3,6 +3,12 @@ const CLASSROOM_STRATEGY_IMPLEMENTATION_TIP = 'Start with one student or one transition period. Once comfortable, expand to the full classroom. Consistency is key: use the same visual and verbal cues each time.'; +const SIGN_LANGUAGE_HELP_DESCRIPTION = + 'Flat hand on top of fist, lift both up together'; + +const SIGN_LANGUAGE_HELP_IMAGE_URL = + 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037791874_e080660e.png'; + const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ classroomStrategies: [ { @@ -135,8 +141,8 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ signLanguageItems: [ { id: '1', word: 'Help', category: 'basic-needs', - description: 'Flat hand on top of fist, lift both up together', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037791874_e080660e.png', + description: SIGN_LANGUAGE_HELP_DESCRIPTION, + image: SIGN_LANGUAGE_HELP_IMAGE_URL, tip: 'Teach this sign first — it reduces frustration-based behaviors immediately.', videoUrl: 'https://www.youtube.com/embed/Euz1g9E-Mrw', gifUrl: 'https://www.lifeprint.com/asl101/gifs/h/help.gif', @@ -181,7 +187,7 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037847928_f8f28226.png', tip: 'Teaching "break" proactively prevents escalation. Honor the request when possible.', videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM', - gifUrl: 'https://www.lifeprint.com/asl101/images-signs/break.gif', + gifUrl: '', videoSteps: [ { step: 1, instruction: 'Hold both fists together in front of your chest, touching', duration: 3 }, { step: 2, instruction: 'Grip as if holding a stick or twig between both hands', duration: 3 }, @@ -209,7 +215,7 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037891102_4977dae4.png', tip: 'Model this sign while taking deep breaths. Others mirror the regulation.', videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA', - gifUrl: 'https://www.lifeprint.com/asl101/images-signs/calm.gif', + gifUrl: '', videoSteps: [ { step: 1, instruction: 'Hold both hands at chest level, palms facing downward', duration: 3 }, { step: 2, instruction: 'Take a slow, deep breath in as you hold position', duration: 3 }, @@ -279,7 +285,7 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037998820_245a2c77.jpg', tip: 'Pair with visual "ears listening" icon on the board.', videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA', - gifUrl: 'https://www.lifeprint.com/asl101/images-signs/listen.gif', + gifUrl: '', videoSteps: [ { step: 1, instruction: 'Raise your dominant hand up to the side of your head', duration: 3 }, { step: 2, instruction: 'Cup your hand with fingers together, like catching sound', duration: 3 }, @@ -429,10 +435,12 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({ }, ], dashboardSignOfWeek: { + signId: '1', + weekOf: '2026-06-21', word: 'Help', - image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656618075_ea1c15a9.png', + image: SIGN_LANGUAGE_HELP_IMAGE_URL, alt: 'Help sign', - description: 'Flat hand on top of fist, lift both up together', + description: SIGN_LANGUAGE_HELP_DESCRIPTION, }, communityOrganizations: [ { diff --git a/backend/src/index.ts b/backend/src/index.ts index 6d83f7b..9845387 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ import swaggerUI from 'swagger-ui-express'; import swaggerJsDoc from 'swagger-jsdoc'; import config from '@/shared/config'; import csrfOrigin from '@/middlewares/csrf-origin'; +import requestLogger from '@/middlewares/request-logger'; import { resolveActiveScope } from '@/middlewares/resolve-active-scope'; import { AUTH_COOKIE_NAME, @@ -22,20 +23,12 @@ import '@/auth/auth'; // Global error handlers to prevent server crashes from unhandled errors process.on('uncaughtException', (error: Error) => { - logger.error('Uncaught Exception - server continues running:', { - message: error.message, - stack: error.stack, - name: error.name, - }); + logger.error('Uncaught Exception - server continues running:', error); }); process.on('unhandledRejection', (reason: unknown) => { const error = reason instanceof Error ? reason : new Error(String(reason)); - logger.error('Unhandled Promise Rejection - server continues running:', { - message: error.message, - stack: error.stack, - name: error.name, - }); + logger.error('Unhandled Promise Rejection - server continues running:', error); }); import authRoutes from '@/routes/auth'; @@ -241,6 +234,7 @@ app.use( app.use(express.json()); app.use('/api', csrfOrigin); +app.use('/api', requestLogger); app.use('/api/auth', authRoutes); app.use('/api/file', fileRoutes); diff --git a/backend/src/middlewares/request-logger.ts b/backend/src/middlewares/request-logger.ts new file mode 100644 index 0000000..822748a --- /dev/null +++ b/backend/src/middlewares/request-logger.ts @@ -0,0 +1,28 @@ +import type { + NextFunction, + Request, + Response, +} from 'express'; +import logger from '@/shared/logger'; + +export default function requestLogger( + req: Request, + res: Response, + next: NextFunction, +): void { + const startedAt = performance.now(); + + res.on('finish', () => { + logger.debug('HTTP request', { + method: req.method, + path: req.originalUrl, + statusCode: res.statusCode, + durationMs: Math.round(performance.now() - startedAt), + userId: req.currentUser?.id, + role: req.currentUser?.app_role?.name, + activeScope: req.currentUser?.activeScope, + }); + }); + + next(); +} diff --git a/backend/src/services/content_catalog.test.ts b/backend/src/services/content_catalog.test.ts index 80a9922..9b63f66 100644 --- a/backend/src/services/content_catalog.test.ts +++ b/backend/src/services/content_catalog.test.ts @@ -173,6 +173,30 @@ describe('ContentCatalogService tenant scoping', () => { ); }); + test('rejects sign library management outside organization scope', async () => { + await assert.rejects( + () => ContentCatalogService.findManagedByType( + 'sign-language-items', + createGlobalAccessUser({ + app_role: { + name: ROLE_NAMES.SUPER_ADMIN, + scope: ROLE_SCOPES.SYSTEM, + globalAccess: true, + permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG }], + }, + activeScope: { + level: ROLE_SCOPES.CAMPUS, + organizationId: 'org-1', + schoolId: 'school-1', + campusId: 'campus-1', + classId: null, + }, + }), + ), + { name: 'ForbiddenError' }, + ); + }); + test('rejects QBS quiz management outside organization scope', async () => { await assert.rejects( () => ContentCatalogService.findManagedByType( diff --git a/backend/src/services/content_catalog.ts b/backend/src/services/content_catalog.ts index ebcee3e..6ffcb43 100644 --- a/backend/src/services/content_catalog.ts +++ b/backend/src/services/content_catalog.ts @@ -17,6 +17,7 @@ import { CLASSROOM_SUPPORT_CONTENT_TYPE, EI_ASSESSMENT_CONTENT_TYPE, PERSONALITY_QUIZ_CONTENT_TYPE, + SIGN_LANGUAGE_ITEMS_CONTENT_TYPE, SAFETY_QUIZ_CONTENT_TYPE, PER_TENANT_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES, @@ -128,6 +129,7 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo || contentType === SAFETY_QUIZ_CONTENT_TYPE || contentType === EI_ASSESSMENT_CONTENT_TYPE || contentType === PERSONALITY_QUIZ_CONTENT_TYPE + || contentType === SIGN_LANGUAGE_ITEMS_CONTENT_TYPE ) && getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION ) { diff --git a/backend/src/services/content_catalog_seed.test.ts b/backend/src/services/content_catalog_seed.test.ts index 7d4a908..5d1a244 100644 --- a/backend/src/services/content_catalog_seed.test.ts +++ b/backend/src/services/content_catalog_seed.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, mock, test } from 'node:test'; import assert from 'node:assert/strict'; import db from '@/db/models'; +import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads'; import { seedDefaultContentForTenant } from '@/services/content_catalog_seed'; afterEach(() => { @@ -9,6 +10,19 @@ afterEach(() => { }); describe('seedDefaultContentForTenant', () => { + test('keeps dashboard sign of the week aligned with the seeded Help sign card', () => { + const helpSign = CONTENT_CATALOG_SEED_PAYLOADS.signLanguageItems.find((sign) => + sign.word === 'Help' + ); + + assert.ok(helpSign); + assert.equal(CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek.signId, helpSign.id); + assert.match(CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek.weekOf, /^\d{4}-\d{2}-\d{2}$/); + assert.equal(CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek.word, 'Help'); + assert.equal(CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek.description, helpSign.description); + assert.equal(CONTENT_CATALOG_SEED_PAYLOADS.dashboardSignOfWeek.image, helpSign.image); + }); + test('seeds dashboard content at organization scope', async () => { const createdRows: Array> = []; diff --git a/backend/src/shared/constants/content-catalog.ts b/backend/src/shared/constants/content-catalog.ts index 5c2698b..4240e84 100644 --- a/backend/src/shared/constants/content-catalog.ts +++ b/backend/src/shared/constants/content-catalog.ts @@ -10,6 +10,9 @@ export const EI_ASSESSMENT_CONTENT_TYPE = 'emotional-intelligence-assessment-que /** Personality type quiz content, owned and managed at organization scope. */ export const PERSONALITY_QUIZ_CONTENT_TYPE = 'emotional-intelligence-personality-quiz'; +/** Sign card library, owned and managed at organization scope. */ +export const SIGN_LANGUAGE_ITEMS_CONTENT_TYPE = 'sign-language-items'; + /** ESA funding content — school-scoped (rules depend on the school's locale). */ export const ESA_CONTENT_TYPE = 'esa-funding-content'; @@ -38,7 +41,7 @@ export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet = new Set([ PERSONALITY_QUIZ_CONTENT_TYPE, 'regulation-zones', 'zones-of-regulation-page-content', - 'sign-language-items', + SIGN_LANGUAGE_ITEMS_CONTENT_TYPE, 'sign-language-page-content', 'emotional-intelligence-weekly-topics', 'emotional-intelligence-growth-tips', diff --git a/backend/src/shared/logger.ts b/backend/src/shared/logger.ts index 37272f4..ab21528 100644 --- a/backend/src/shared/logger.ts +++ b/backend/src/shared/logger.ts @@ -1,26 +1,88 @@ /** - * Minimal centralized logger. A thin wrapper over `console` so all server-side - * logging goes through one place (timestamp + level + optional structured - * context) without pulling in a heavyweight logging dependency. Swap the - * implementation here if a transport (file/JSON/external) is ever needed. + * Minimal centralized logger. Keeps one app-facing API while supporting + * readable local logs and machine-readable production logs. */ type LogContext = Record; +type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LOG_LEVEL_PRIORITY: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +const LOG_LEVEL_COLORS: Record = { + debug: '\x1b[36m', + info: '\x1b[32m', + warn: '\x1b[33m', + error: '\x1b[31m', +}; + +const RESET_COLOR = '\x1b[0m'; + +function readLogLevel(): LogLevel { + const value = process.env.LOG_LEVEL?.toLowerCase(); + + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + + return process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage' + ? 'info' + : 'debug'; +} + +function shouldUseJsonLogs(): boolean { + if (process.env.LOG_FORMAT === 'json') { + return true; + } + + if (process.env.LOG_FORMAT === 'pretty') { + return false; + } + + return process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev_stage'; +} + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[readLogLevel()]; +} + +function serializeContext(context?: LogContext): string { + if (!context || Object.keys(context).length === 0) { + return ''; + } + + return ` ${JSON.stringify(context)}`; +} function emit( - level: 'error' | 'warn' | 'info', + level: LogLevel, message: string, context?: LogContext, ): void { - const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${message}`; - const payload = context && Object.keys(context).length > 0 ? [context] : []; - - if (level === 'error') { - console.error(line, ...payload); - } else if (level === 'warn') { - console.warn(line, ...payload); - } else { - console.log(line, ...payload); + if (!shouldLog(level)) { + return; } + + const time = new Date().toISOString(); + const stream = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + + if (shouldUseJsonLogs()) { + stream(JSON.stringify({ + time, + level, + message, + ...(context && Object.keys(context).length > 0 ? context : {}), + })); + return; + } + + const color = LOG_LEVEL_COLORS[level]; + const line = `${color}${time} ${level.toUpperCase().padEnd(5)}${RESET_COLOR} ${message}${serializeContext(context)}`; + + stream(line); } /** Serializes an unknown thrown value into a stable, loggable shape. */ @@ -32,6 +94,9 @@ function describeError(error: unknown): LogContext { } const logger = { + debug(message: string, context?: LogContext): void { + emit('debug', message, context); + }, error(message: string, error?: unknown, context?: LogContext): void { emit('error', message, { ...(error !== undefined ? describeError(error) : {}), diff --git a/backend/watcher.ts b/backend/watcher.ts index e824414..0fcc1e6 100644 --- a/backend/watcher.ts +++ b/backend/watcher.ts @@ -2,7 +2,8 @@ import { exec } from 'child_process'; import chokidar from 'chokidar'; import nodemon from 'nodemon'; -const nodeEnv = process.env.NODE_ENV || 'dev_stage'; +// Local `npm run start` should use dev defaults; staging/VM passes NODE_ENV explicitly. +const nodeEnv = process.env.NODE_ENV || 'development'; const childEnv = { ...process.env, NODE_ENV: nodeEnv }; function runOnAdd(label: string, dir: string, script: string): void { diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index b9af53e..bee10f9 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -40,7 +40,7 @@ catalog only once the user types (see `top-bar-integration.md`). - sign language page content - regulation zones - zones of regulation page content -- dashboard quote, compliance items, and sign of the week +- dashboard quotes, compliance items, and Sign of the Week selector - community organizations - vocational opportunities - emotional intelligence assessment content, personality quiz questions, weekly focus, and team content @@ -116,13 +116,15 @@ Classroom strategy titles, descriptions, images, categories, age groups, regulat Sign records, teaching tips, video URLs, GIF URLs, step instructions, and page-level teaching reminders are part of the `sign-language-items` and `sign-language-page-content` content catalog payloads. The frontend renders these payloads and does not keep sign content or reminder copy in shared constants. +`sign-language-items` is organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` can add, edit, delete, upload media for, and select Sign of the Week from the shared organization sign library. School, campus, and classroom users read the same organization-owned sign cards and persist only personal learned progress when they are in their own scope. + ## Editable Zones Of Regulation Content Regulation zone records, behaviors, strategies, matching signs, and QBS safety connection copy are part of the `regulation-zones` and `zones-of-regulation-page-content` content catalog payloads. The frontend renders these payloads and keeps the stable Quick Behavior Management Flow as static component content because it is not tenant-editable. ## Editable Dashboard Content -Dashboard quotes, compliance items, and sign-of-week content are part of the `dashboard-encouraging-quotes`, `dashboard-compliance-items`, and `dashboard-sign-of-week` content catalog payloads. The dashboard business layer renders these payloads and does not keep dashboard content records in shared constants. +Dashboard quotes and compliance items are part of the `dashboard-encouraging-quotes` and `dashboard-compliance-items` content catalog payloads. `dashboard-sign-of-week` stores the selected sign card id, current Sunday-start `weekOf`, and fallback display fields; the dashboard business layer resolves it against `sign-language-items` instead of duplicating sign-card content. These dashboard catalog rows are seeded per tenant at organization, school, and campus scope so owner/superintendent/principal/registrar/director dashboards and platform-admin drill-down views all read backend-owned content. ## Editable ESA Funding Content diff --git a/frontend/docs/dashboard-integration.md b/frontend/docs/dashboard-integration.md index 7708537..e9ebf9c 100644 --- a/frontend/docs/dashboard-integration.md +++ b/frontend/docs/dashboard-integration.md @@ -44,6 +44,7 @@ Content catalog: - `GET /api/content-catalog/read/dashboard-encouraging-quotes` - `GET /api/content-catalog/read/dashboard-compliance-items` - `GET /api/content-catalog/read/dashboard-sign-of-week` +- `GET /api/content-catalog/read/sign-language-items` Feature APIs: @@ -55,6 +56,7 @@ Feature APIs: ## Behavior - `useDashboardPage` composes all dashboard data sources into one page model. +- Dashboard Sign of the Week resolves the `dashboard-sign-of-week` selector payload against `sign-language-items`, so card edits/deletes on the sign language page are reflected on the main dashboard. Older seeded payloads without `signId` fall back to matching by sign word. - The hero's "Week of …" uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — the same canonicalization as the F.R.A.M.E. week picker and the safety-quiz week. - Selectors handle greeting calculation, day-based quote selection, zone normalization, event limiting, and quick actions filtered by the current scoped module set. - View components receive prepared props and do not call API/data access modules. @@ -64,5 +66,6 @@ Feature APIs: ## Data Ownership Rules - Do not add dashboard quote, compliance, sign-of-week, FRAME, event, or zone progress records to frontend constants. +- Do not store standalone dashboard sign-card content; select from the org sign library. - Keep frontend constants limited to quick-action navigation config, zone button style config, and display limits. - Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/docs/sign-language-integration.md b/frontend/docs/sign-language-integration.md index a12d266..35864dd 100644 --- a/frontend/docs/sign-language-integration.md +++ b/frontend/docs/sign-language-integration.md @@ -18,6 +18,7 @@ View: - `frontend/src/components/frameworks/SignLanguageVideoModal.tsx` - `frontend/src/components/sign-language/SignLanguageView.tsx` - `frontend/src/components/sign-language/SignLanguageHeader.tsx` +- `frontend/src/components/sign-language/SignLanguageManagementPanel.tsx` - `frontend/src/components/sign-language/SignLanguageRememberPanel.tsx` - `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` - `frontend/src/components/sign-language/SignLanguageFilters.tsx` @@ -43,6 +44,9 @@ The page reads content from: - `GET /api/content-catalog/read/sign-language-items` - `GET /api/content-catalog/read/sign-language-page-content` +- `GET /api/content-catalog/read/dashboard-sign-of-week` +- `PUT /api/content-catalog/sign-language-items` +- `PUT /api/content-catalog/dashboard-sign-of-week` Learned progress uses: @@ -52,16 +56,25 @@ Learned progress uses: Content payloads are seeded in: -- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` +- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts` ## Behavior - `useSignLanguagePage` loads sign items, page content, and learned sign progress. +- Organization-scope users with `MANAGE_CONTENT_CATALOG` can create, edit, and delete sign cards from the sign language page. The management panel writes the full org-scoped `sign-language-items` payload through the managed content catalog endpoint. +- The sign editor supports title, description, category, step-by-step guide, teaching tip, preview image URL or upload, GIF URL or upload, YouTube video URL, and optional YouTube search URL. +- The Sign of the Week selector stores the selected card in `dashboard-sign-of-week` by `signId`, current Sunday-start `weekOf`, and display fallback fields. Dashboard and notification rendering resolve that selector back to the live sign card, with word-based fallback for older seeded payloads. - Learned progress is a personal persisted state. When a parent-scope user is drilled into a child tenant, the page still shows sign content, but it does not load/write learned-sign progress or render "Progress Saved" / "Learned" affordances. -- Selectors handle category counts, search/category filtering, progress percentage, video duration, filter normalization, and YouTube search URL construction. +- Selectors handle category counts, search/category filtering, progress percentage, video duration, filter normalization, media URL normalization, and YouTube search URL construction. +- Sign item media is normalized before rendering: + - image URLs are trimmed; + - YouTube watch, short, embed, shorts, and raw video IDs are normalized to embed URLs; + - Lifeprint GIF absolute and relative paths are normalized to the canonical `/asl101/gifs` catalog; + - legacy Lifeprint `/asl101/images-signs` entries are treated as missing animated demos, so the modal shows the reference image fallback; + - YouTube search terms are trimmed and URL-encoded. - View components receive a prepared page model and do not call API/data access modules. - The video modal uses `useSignLanguageVideoModalState` for GIF/video mode, GIF loading state, and step-guide expansion. - Loading, empty, and error states are explicit through `StatePanel`. @@ -71,4 +84,5 @@ Content payloads are seeded in: - Do not add sign records, teaching tips, page reminders, video URLs, GIF URLs, or step instructions to frontend constants. - Do not add frontend fallback sign payloads. - Keep frontend constants limited to filter options, category style classes, external URL templates, and UI view modes. +- Sign of the Week is selected from existing sign cards; do not duplicate dashboard-only sign content. - Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. diff --git a/frontend/docs/top-bar-integration.md b/frontend/docs/top-bar-integration.md index 8cb821d..7543a38 100644 --- a/frontend/docs/top-bar-integration.md +++ b/frontend/docs/top-bar-integration.md @@ -44,6 +44,11 @@ Shared config: reminder is a one-time completion nudge because that quiz does not reset weekly. These reminders are derived from backend-backed status queries, not frontend constants. +- Manager reminders are permission-gated and derived from current status queries: + `MANAGE_CONTENT_CATALOG` organization users are nudged to select the current + week's Sign of the Week, `MANAGE_FRAME` users are nudged when the current + week has no F.R.A.M.E. entry, and `FILL_ATTENDANCE` users are nudged when no + attendance row exists for today in their current attendance scope. - Selectors handle initials, campus label fallback, shared role labels, and unread notification count. - **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (permission- and scope-filtered via `getScopedModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for modules available in the current effective scope) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here. - View components receive a prepared page model and do not call API/data access modules. @@ -51,7 +56,7 @@ Shared config: ## Tests -- `business/top-bar/selectors.test.ts` (notification builder, QBS/EI/personality quiz reminders + zones `href`), +- `business/top-bar/selectors.test.ts` (notification builder, QBS/EI/personality quiz reminders, manager content reminders + zones `href`), `business/top-bar/search.test.ts` (module permission-filtering + content matching + combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal). diff --git a/frontend/docs/user-progress-integration.md b/frontend/docs/user-progress-integration.md index 8699bd5..25baa40 100644 --- a/frontend/docs/user-progress-integration.md +++ b/frontend/docs/user-progress-integration.md @@ -53,6 +53,7 @@ Constants: - Saving a classroom strategy favorite uses `POST /api/user_progress`; removing it uses `DELETE /api/user_progress/by-item`. - The sign language page combines user progress with backend content catalog records in `useSignLanguagePage`. +- The notification dropdown also reads the selected Sign of the Week and current learned-sign progress. Staff who can access Signs receive a weekly reminder until the selected sign's `sign_learned` row exists for their user. - The Classroom Support page combines favorite progress with backend content catalog records in `useClassroomSupportPage`. - Views render explicit backend errors from React Query state. - User progress ownership is derived by the backend from the authenticated session. diff --git a/frontend/index.html b/frontend/index.html index 140c2fd..51e471d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,6 +7,12 @@ FRAMEworks School Manager + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ac638b..0af4539 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -65,8 +65,8 @@ "@axe-core/playwright": "^4.11.3", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", - "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -74,13 +74,11 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", - "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "typescript-eslint": "^8.60.1", @@ -98,19 +96,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -2623,50 +2608,78 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.1" } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -2680,10 +2693,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -2697,10 +2710,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -2714,10 +2727,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -2731,10 +2744,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -2748,10 +2761,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], @@ -2765,10 +2778,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], @@ -2782,10 +2795,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], @@ -2799,10 +2812,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], @@ -2816,10 +2829,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2839,17 +2852,17 @@ "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -2863,10 +2876,10 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -2880,32 +2893,26 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", - "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "node_modules/@tailwindcss/vite/node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "postcss": "^8.5.10", - "tailwindcss": "4.3.0" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" } }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.20", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", - "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" - } + "license": "MIT" }, "node_modules/@tanstack/query-core": { "version": "5.101.0", @@ -3632,43 +3639,6 @@ "node": ">=12" } }, - "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/axe-core": { "version": "4.11.4", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", @@ -4155,20 +4125,6 @@ "embla-carousel": "8.6.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", - "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -4520,20 +4476,6 @@ "dev": true, "license": "ISC" }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5504,13 +5446,6 @@ "node": ">=4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6a05db1..99018cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,8 +76,8 @@ "@axe-core/playwright": "^4.11.3", "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", - "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/typography": "^0.5.20", + "@tailwindcss/vite": "^4.3.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -85,13 +85,11 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", - "postcss": "^8.5.15", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", "typescript-eslint": "^8.60.1", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 14502dc..0000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - "@tailwindcss/postcss": {}, - autoprefixer: {}, - }, -} diff --git a/frontend/src/business/dashboard/hooks.ts b/frontend/src/business/dashboard/hooks.ts index 623a589..935c8ce 100644 --- a/frontend/src/business/dashboard/hooks.ts +++ b/frontend/src/business/dashboard/hooks.ts @@ -9,6 +9,10 @@ import { selectDashboardQuote, selectDashboardUpcomingEvents, } from '@/business/dashboard/selectors'; +import { + normalizeSignLanguageItems, + selectSignLanguageSignOfWeek, +} from '@/business/sign-language/selectors'; import type { DashboardPage, DashboardProps, @@ -24,7 +28,10 @@ import { MODULES } from '@/shared/constants/appData'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; -import type { ZoneColor } from '@/shared/types/app'; +import type { + SignItem, + ZoneColor, +} from '@/shared/types/app'; import type { DashboardComplianceItem, DashboardEncouragingQuote, @@ -53,6 +60,10 @@ export function useDashboardPage({ CONTENT_CATALOG_TYPES.dashboardSignOfWeek, null, ); + const signsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.signLanguageItems, + [], + ); const frameEntriesQuery = useFrameEntries(); const canUseZoneCheckIn = canPersistPersonalScopeResults(ownTenant, selectedTenant) && canZoneCheckIn(user); const zoneCheckInState = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); @@ -71,6 +82,14 @@ export function useDashboardPage({ () => selectDashboardQuote(quotesQuery.payload, dashboardDate), [dashboardDate, quotesQuery.payload], ); + const signs = useMemo( + () => normalizeSignLanguageItems(signsQuery.payload), + [signsQuery.payload], + ); + const signOfWeek = useMemo( + () => selectSignLanguageSignOfWeek(signs, signOfWeekQuery.payload), + [signOfWeekQuery.payload, signs], + ); const effectiveTier = selectedTenant ? selectedTenant.level : tier; const scopedModuleIds = useMemo( () => new Set( @@ -116,10 +135,10 @@ export function useDashboardPage({ isLoading: complianceItemsQuery.isLoading, isError: Boolean(complianceItemsQuery.error), }, - signOfWeek: signOfWeekQuery.payload, + signOfWeek, signOfWeekState: { - isLoading: signOfWeekQuery.isLoading, - isError: Boolean(signOfWeekQuery.error), + isLoading: signOfWeekQuery.isLoading || signsQuery.isLoading, + isError: Boolean(signOfWeekQuery.error || signsQuery.error), }, quickActions: selectDashboardQuickActions(scopedModuleIds), goToModule: setCurrentModule, diff --git a/frontend/src/business/dashboard/types.ts b/frontend/src/business/dashboard/types.ts index 43b940a..fd058c0 100644 --- a/frontend/src/business/dashboard/types.ts +++ b/frontend/src/business/dashboard/types.ts @@ -5,6 +5,7 @@ import type { } from '@/shared/constants/dashboard'; import type { ModuleId, + SignItem, UserRole, ZoneColor, } from '@/shared/types/app'; @@ -12,7 +13,6 @@ import type { CommunicationEventDto } from '@/shared/types/communications'; import type { DashboardComplianceItem, DashboardEncouragingQuote, - DashboardSignOfWeek, } from '@/shared/types/dashboard'; export interface DashboardProps { @@ -40,7 +40,7 @@ export interface DashboardPage { readonly eventsState: DashboardContentState; readonly complianceItems: readonly DashboardComplianceItem[]; readonly complianceState: DashboardContentState; - readonly signOfWeek: DashboardSignOfWeek | null; + readonly signOfWeek: SignItem | null; readonly signOfWeekState: DashboardContentState; readonly quickActions: readonly DashboardQuickAction[]; readonly goToModule: (id: ModuleId) => void; diff --git a/frontend/src/business/sign-language/hooks.ts b/frontend/src/business/sign-language/hooks.ts index d32534d..ca6a46a 100644 --- a/frontend/src/business/sign-language/hooks.ts +++ b/frontend/src/business/sign-language/hooks.ts @@ -1,19 +1,28 @@ import { useMemo, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { + buildDashboardSignOfWeekPayload, buildSignLanguageCategories, filterSignLanguageItems, getSignLanguageProgressPercent, + normalizeSignLanguageItems, + selectSignLanguageSignOfWeek, } from '@/business/sign-language/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import type { + SignLanguageDraft, + SignLanguageStepDraft, SignLanguagePage, SignLanguageVideoModalState, } from '@/business/sign-language/types'; import { useLearnedSignsProgress } from '@/business/user-progress/hooks'; +import { updateManagedContentCatalog } from '@/shared/api/contentCatalog'; import { useScopeContext } from '@/shared/app/scope-context'; +import { usePermissions } from '@/shared/app/usePermissions'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog'; import type { SignLanguageCategoryFilter, SignLanguageViewMode, @@ -23,20 +32,102 @@ import type { SignItem, SignLanguagePageContent, } from '@/shared/types/app'; +import type { DashboardSignOfWeek } from '@/shared/types/dashboard'; const EMPTY_SIGN_LANGUAGE_PAGE_CONTENT: SignLanguagePageContent = { rememberTitle: '', rememberDescription: '', }; const EMPTY_LEARNED_SIGN_IDS = new Set(); +const DEFAULT_SIGN_STEP: SignLanguageStepDraft = { + step: 1, + instruction: '', + duration: 3, +}; +const DEFAULT_SIGN_DRAFT: SignLanguageDraft = { + id: null, + word: '', + category: 'basic-needs', + description: '', + image: '', + tip: '', + videoUrl: '', + gifUrl: '', + youtubeSearchUrl: '', + videoSteps: [DEFAULT_SIGN_STEP], +}; + +function createSignId(): string { + return `sign-${crypto.randomUUID()}`; +} + +function draftFromSign(sign: SignItem): SignLanguageDraft { + return { + id: sign.id, + word: sign.word, + category: sign.category, + description: sign.description, + image: sign.image, + tip: sign.tip, + videoUrl: sign.videoUrl, + gifUrl: sign.gifUrl, + youtubeSearchUrl: sign.youtubeSearchUrl ?? '', + videoSteps: sign.videoSteps.map((step, index) => ({ + step: step.step || index + 1, + instruction: step.instruction, + duration: step.duration, + })), + }; +} + +function signFromDraft(draft: SignLanguageDraft): SignItem { + return { + id: draft.id ?? createSignId(), + word: draft.word.trim(), + category: draft.category, + description: draft.description.trim(), + image: draft.image.trim(), + tip: draft.tip.trim(), + videoUrl: draft.videoUrl.trim(), + gifUrl: draft.gifUrl.trim(), + youtubeSearchUrl: draft.youtubeSearchUrl.trim() || undefined, + videoSteps: draft.videoSteps + .map((step, index) => ({ + step: index + 1, + instruction: step.instruction.trim(), + duration: Number.isFinite(step.duration) && step.duration > 0 ? step.duration : 3, + })) + .filter((step) => step.instruction.length > 0), + }; +} + +function validateSignDraft(draft: SignLanguageDraft): string | null { + if (!draft.word.trim()) return 'Title is required.'; + if (!draft.description.trim()) return 'Description is required.'; + if (!draft.image.trim()) return 'Preview image URL or upload is required.'; + if (!draft.tip.trim()) return 'Teaching tip is required.'; + if (!draft.videoUrl.trim()) return 'YouTube video URL is required.'; + if (draft.videoSteps.every((step) => !step.instruction.trim())) { + return 'At least one guide step is required.'; + } + return null; +} export function useSignLanguagePage(): SignLanguagePage { - const { ownTenant, selectedTenant } = useScopeContext(); + const { effectiveTenant, ownTenant, selectedTenant } = useScopeContext(); + const permissions = usePermissions(); + const queryClient = useQueryClient(); const canPersistProgress = canPersistPersonalScopeResults(ownTenant, selectedTenant); + const canManageSigns = + effectiveTenant?.level === 'organization' && permissions.has('MANAGE_CONTENT_CATALOG'); const signsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.signLanguageItems, [], ); + const signOfWeekQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.dashboardSignOfWeek, + null, + ); const pageContentQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.signLanguagePageContent, EMPTY_SIGN_LANGUAGE_PAGE_CONTENT, @@ -45,7 +136,45 @@ export function useSignLanguagePage(): SignLanguagePage { const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [selectedSignId, setSelectedSignId] = useState(null); - const signs = signsQuery.payload; + const [signDraft, setSignDraft] = useState(null); + const [signDraftMode, setSignDraftMode] = useState<'create' | 'edit' | null>(null); + const [signDraftError, setSignDraftError] = useState(null); + const [signSaveMessage, setSignSaveMessage] = useState(null); + const [pendingDeleteSign, setPendingDeleteSign] = useState(null); + const signs = useMemo( + () => normalizeSignLanguageItems(signsQuery.payload), + [signsQuery.payload], + ); + const signOfWeek = useMemo( + () => selectSignLanguageSignOfWeek(signs, signOfWeekQuery.payload), + [signOfWeekQuery.payload, signs], + ); + const saveSigns = useMutation({ + mutationFn: async (nextSigns: readonly SignItem[]) => + updateManagedContentCatalog( + CONTENT_CATALOG_TYPES.signLanguageItems, + { payload: nextSigns }, + ), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.signLanguageItems], + }); + setSignSaveMessage('Sign cards saved.'); + }, + }); + const saveSignOfWeek = useMutation({ + mutationFn: async (sign: SignItem) => + updateManagedContentCatalog( + CONTENT_CATALOG_TYPES.dashboardSignOfWeek, + { payload: buildDashboardSignOfWeekPayload(sign) }, + ), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.dashboardSignOfWeek], + }); + setSignSaveMessage('Sign of the week saved.'); + }, + }); const filters = useMemo( () => ({ searchQuery, @@ -85,6 +214,115 @@ export function useSignLanguagePage(): SignLanguagePage { await progress.toggleLearnedSign(id, sign.word); } + function startCreateSign() { + if (!canManageSigns) return; + setSignDraft(DEFAULT_SIGN_DRAFT); + setSignDraftMode('create'); + setSignDraftError(null); + setSignSaveMessage(null); + } + + function startEditSign(sign: SignItem) { + if (!canManageSigns) return; + setSignDraft(draftFromSign(sign)); + setSignDraftMode('edit'); + setSignDraftError(null); + setSignSaveMessage(null); + } + + function updateSignDraft(updates: Partial) { + setSignDraft((current) => current ? { ...current, ...updates } : current); + setSignDraftError(null); + setSignSaveMessage(null); + } + + function updateSignDraftStep(index: number, updates: Partial) { + setSignDraft((current) => current ? { + ...current, + videoSteps: current.videoSteps.map((step, stepIndex) => + stepIndex === index ? { ...step, ...updates } : step, + ), + } : current); + setSignDraftError(null); + setSignSaveMessage(null); + } + + function addSignDraftStep() { + setSignDraft((current) => current ? { + ...current, + videoSteps: [ + ...current.videoSteps, + { ...DEFAULT_SIGN_STEP, step: current.videoSteps.length + 1 }, + ], + } : current); + } + + function removeSignDraftStep(index: number) { + setSignDraft((current) => current ? { + ...current, + videoSteps: current.videoSteps + .filter((_, stepIndex) => stepIndex !== index) + .map((step, stepIndex) => ({ ...step, step: stepIndex + 1 })), + } : current); + } + + function cancelSignDraft() { + setSignDraft(null); + setSignDraftMode(null); + setSignDraftError(null); + } + + function saveSignDraft() { + if (!canManageSigns || !signDraft || !signDraftMode) return; + + const validationError = validateSignDraft(signDraft); + if (validationError) { + setSignDraftError(validationError); + return; + } + + const nextSign = signFromDraft(signDraft); + const nextSigns = signDraftMode === 'create' + ? [...signs, nextSign] + : signs.map((sign) => sign.id === nextSign.id ? nextSign : sign); + + void saveSigns.mutateAsync(nextSigns).then(() => { + setSignDraft(null); + setSignDraftMode(null); + setSignDraftError(null); + }).catch(() => undefined); + } + + function requestDeleteSign(sign: SignItem) { + if (!canManageSigns) return; + setPendingDeleteSign(sign); + } + + function cancelDeleteSign() { + setPendingDeleteSign(null); + } + + function confirmDeleteSign() { + if (!canManageSigns || !pendingDeleteSign) return; + + const id = pendingDeleteSign.id; + const nextSigns = signs.filter((sign) => sign.id !== id); + void saveSigns.mutateAsync(nextSigns).then(() => { + setPendingDeleteSign(null); + if (signDraft?.id === id) cancelSignDraft(); + if (selectedSignId === id) setSelectedSignId(null); + }).catch(() => undefined); + } + + function selectSignOfWeek(signId: string) { + if (!canManageSigns) return; + + const sign = signs.find((item) => item.id === signId); + if (!sign) return; + + void saveSignOfWeek.mutateAsync(sign).catch(() => undefined); + } + return { signs, filteredSigns, @@ -94,11 +332,20 @@ export function useSignLanguagePage(): SignLanguagePage { learnedCount: learnedSignIds.size, progressPercent, canPersistProgress, + canManageSigns, selectedSign, + signOfWeekId: signOfWeek?.id ?? null, + signDraft, + signDraftMode, + signDraftError, + signManagementError: saveSigns.error || saveSignOfWeek.error, + signSaveMessage, + isSavingSigns: saveSigns.isPending || saveSignOfWeek.isPending, + pendingDeleteSign, pageContent: pageContentQuery.payload, - isLoading: signsQuery.isLoading || pageContentQuery.isLoading || (canPersistProgress && progress.isLoading), - isSaving: canPersistProgress && progress.isSaving, - signsError: signsQuery.error, + isLoading: signsQuery.isLoading || signOfWeekQuery.isLoading || pageContentQuery.isLoading || (canPersistProgress && progress.isLoading), + isSaving: (canPersistProgress && progress.isSaving) || saveSigns.isPending || saveSignOfWeek.isPending, + signsError: signsQuery.error || signOfWeekQuery.error, pageContentError: pageContentQuery.error, progressErrorMessage: getOptionalErrorMessage(progress.error), setSearchQuery, @@ -107,6 +354,18 @@ export function useSignLanguagePage(): SignLanguagePage { selectSign: setSelectedSignId, closeSign: () => setSelectedSignId(null), toggleLearned, + startCreateSign, + startEditSign, + updateSignDraft, + updateSignDraftStep, + addSignDraftStep, + removeSignDraftStep, + cancelSignDraft, + saveSignDraft, + requestDeleteSign, + cancelDeleteSign, + confirmDeleteSign, + selectSignOfWeek, }; } diff --git a/frontend/src/business/sign-language/selectors.test.ts b/frontend/src/business/sign-language/selectors.test.ts index b7117e6..e66c614 100644 --- a/frontend/src/business/sign-language/selectors.test.ts +++ b/frontend/src/business/sign-language/selectors.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from 'vitest'; import { + buildDashboardSignOfWeekPayload, buildSignLanguageCategories, + buildSignLanguageLifeprintGifUrl, buildSignLanguageYoutubeSearchUrl, filterSignLanguageItems, getSignLanguageProgressPercent, getSignLanguageVideoDurationSeconds, + normalizeSignLanguageGifUrl, + normalizeSignLanguageItems, + normalizeSignLanguageYoutubeVideoUrl, + selectSignLanguageSignOfWeek, toSignLanguageCategoryFilter, } from '@/business/sign-language/selectors'; import type { SignItem } from '@/shared/types/app'; @@ -87,8 +93,88 @@ describe('sign language selectors', () => { }); it('builds encoded YouTube search URLs', () => { - expect(buildSignLanguageYoutubeSearchUrl('All Done')).toBe( + expect(buildSignLanguageYoutubeSearchUrl(' All Done ')).toBe( 'https://www.youtube.com/results?search_query=ASL%20sign%20language%20All%20Done%20tutorial', ); }); + + it('normalizes YouTube URLs to embeddable video URLs', () => { + expect(normalizeSignLanguageYoutubeVideoUrl('https://www.youtube.com/watch?v=abc123_XYZ')).toBe( + 'https://www.youtube.com/embed/abc123_XYZ', + ); + expect(normalizeSignLanguageYoutubeVideoUrl('https://youtu.be/abc123_XYZ')).toBe( + 'https://www.youtube.com/embed/abc123_XYZ', + ); + expect(normalizeSignLanguageYoutubeVideoUrl('abc123_XYZ')).toBe( + 'https://www.youtube.com/embed/abc123_XYZ', + ); + }); + + it('normalizes Lifeprint GIF paths to the canonical GIF catalog', () => { + expect(buildSignLanguageLifeprintGifUrl('/asl101/gifs/h/help.gif')).toBe( + 'https://www.lifeprint.com/asl101/gifs/h/help.gif', + ); + expect(normalizeSignLanguageGifUrl('gifs/m/more')).toBe( + 'https://www.lifeprint.com/asl101/gifs/m/more.gif', + ); + }); + + it('suppresses legacy Lifeprint image-sign URLs as animated demos', () => { + expect(normalizeSignLanguageGifUrl('https://www.lifeprint.com/asl101/images-signs/listen.gif')).toBe(''); + expect(normalizeSignLanguageGifUrl('images-signs/listen.gif')).toBe(''); + }); + + it('normalizes sign media fields before rendering', () => { + const normalizedSigns = normalizeSignLanguageItems([{ + ...signs[0], + image: ' help.jpg ', + videoUrl: 'https://www.youtube.com/watch?v=abc123_XYZ', + gifUrl: 'gifs/h/help', + }]); + + expect(normalizedSigns[0]).toMatchObject({ + image: 'help.jpg', + videoUrl: 'https://www.youtube.com/embed/abc123_XYZ', + gifUrl: 'https://www.lifeprint.com/asl101/gifs/h/help.gif', + }); + }); + + it('uses explicit YouTube search URLs when provided', () => { + const normalizedSigns = normalizeSignLanguageItems([{ + ...signs[0], + youtubeSearchUrl: ' https://www.youtube.com/results?search_query=help ', + }]); + + expect(normalizedSigns[0].youtubeSearchUrl).toBe( + 'https://www.youtube.com/results?search_query=help', + ); + }); + + it('selects sign of the week by selector id with seeded word fallback', () => { + expect(selectSignLanguageSignOfWeek(signs, { + signId: 'calm', + word: 'Help', + description: 'Old payload', + image: 'old.jpg', + alt: 'Old', + })).toEqual(signs[1]); + + expect(selectSignLanguageSignOfWeek(signs, { + word: 'Help', + description: 'Old payload', + image: 'old.jpg', + alt: 'Old', + })).toEqual(signs[0]); + }); + + it('builds dashboard sign-of-week selector payloads from sign cards', () => { + expect(buildDashboardSignOfWeekPayload(signs[0], new Date('2026-06-22T12:00:00Z'))).toEqual({ + signId: 'help', + weekOf: '2026-06-21', + word: 'Help', + description: 'Help description', + image: 'help.jpg', + alt: 'Help sign', + }); + }); }); diff --git a/frontend/src/business/sign-language/selectors.ts b/frontend/src/business/sign-language/selectors.ts index e69153c..2ccf84b 100644 --- a/frontend/src/business/sign-language/selectors.ts +++ b/frontend/src/business/sign-language/selectors.ts @@ -1,16 +1,83 @@ import { SIGN_LANGUAGE_CATEGORY_FILTERS, + SIGN_LANGUAGE_LIFEPRINT_GIF_BASE_URL, + SIGN_LANGUAGE_LIFEPRINT_LEGACY_IMAGE_SIGNS_PATH, + SIGN_LANGUAGE_LIFEPRINT_URL, + SIGN_LANGUAGE_YOUTUBE_EMBED_URL, SIGN_LANGUAGE_YOUTUBE_QUERY_PREFIX, SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX, SIGN_LANGUAGE_YOUTUBE_SEARCH_URL, } from '@/shared/constants/signLanguage'; import type { SignLanguageCategoryFilter } from '@/shared/constants/signLanguage'; import type { SignItem } from '@/shared/types/app'; +import type { DashboardSignOfWeek } from '@/shared/types/dashboard'; +import { toWeekStartIso } from '@/shared/business/week'; import type { SignLanguageCategoryOption, SignLanguageFilters, } from '@/business/sign-language/types'; +const LIFEPRINT_HOST = new URL(SIGN_LANGUAGE_LIFEPRINT_URL).hostname; +const YOUTUBE_HOST = 'youtube.com'; +const YOUTUBE_SHORT_HOST = 'youtu.be'; +const RAW_YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/; +const RELATIVE_LIFEPRINT_GIF_PATH_PATTERN = /^[A-Za-z0-9-]+\/[A-Za-z0-9-]+(?:\.gif)?$/; + +function trimPathSlashes(value: string): string { + return value.replace(/^\/+|\/+$/g, ''); +} + +function isHostOrSubdomain(hostname: string, expectedHost: string): boolean { + return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`); +} + +function extractYoutubeVideoId(videoUrl: string): string | null { + const value = videoUrl.trim(); + + if (!value) { + return null; + } + + if (RAW_YOUTUBE_VIDEO_ID_PATTERN.test(value) && !value.includes('/')) { + return value; + } + + try { + const url = new URL(value); + const hostname = url.hostname.replace(/^www\./, ''); + + if (hostname === YOUTUBE_SHORT_HOST) { + return url.pathname.split('/').filter(Boolean)[0] ?? null; + } + + if (!isHostOrSubdomain(hostname, YOUTUBE_HOST)) { + return null; + } + + const [firstSegment, secondSegment] = url.pathname.split('/').filter(Boolean); + + if (firstSegment === 'embed' || firstSegment === 'shorts') { + return secondSegment ?? null; + } + + return url.searchParams.get('v'); + } catch { + return null; + } +} + +function toLifeprintGifPath(path: string): string { + const trimmedPath = trimPathSlashes(path) + .replace(/^asl101\/gifs\//, '') + .replace(/^gifs\//, ''); + + if (!trimmedPath) { + return ''; + } + + return trimmedPath.endsWith('.gif') ? trimmedPath : `${trimmedPath}.gif`; +} + export function buildSignLanguageCategories( signs: readonly SignItem[], ): readonly SignLanguageCategoryOption[] { @@ -61,8 +128,129 @@ export function toSignLanguageCategoryFilter(value: string): SignLanguageCategor return category?.value ?? 'all'; } +export function normalizeSignLanguageImageUrl(imageUrl: string): string { + return imageUrl.trim(); +} + +export function buildSignLanguageLifeprintGifUrl(path: string): string { + const gifPath = toLifeprintGifPath(path); + + return gifPath ? `${SIGN_LANGUAGE_LIFEPRINT_GIF_BASE_URL}/${gifPath}` : ''; +} + +export function normalizeSignLanguageGifUrl(gifUrl: string): string { + const value = gifUrl.trim(); + + if (!value) { + return ''; + } + + try { + const url = new URL(value); + + if (!isHostOrSubdomain(url.hostname, LIFEPRINT_HOST)) { + return value; + } + + if (url.pathname.startsWith(SIGN_LANGUAGE_LIFEPRINT_LEGACY_IMAGE_SIGNS_PATH)) { + return ''; + } + + const normalizedPath = trimPathSlashes(url.pathname); + + if ( + normalizedPath.startsWith('asl101/gifs/') + || normalizedPath.startsWith('gifs/') + || RELATIVE_LIFEPRINT_GIF_PATH_PATTERN.test(normalizedPath) + ) { + return buildSignLanguageLifeprintGifUrl(normalizedPath); + } + + return value; + } catch { + const normalizedPath = trimPathSlashes(value); + + if ( + normalizedPath.startsWith(trimPathSlashes(SIGN_LANGUAGE_LIFEPRINT_LEGACY_IMAGE_SIGNS_PATH)) + || normalizedPath.startsWith('images-signs/') + ) { + return ''; + } + + if ( + normalizedPath.startsWith('asl101/gifs/') + || normalizedPath.startsWith('gifs/') + || RELATIVE_LIFEPRINT_GIF_PATH_PATTERN.test(normalizedPath) + ) { + return buildSignLanguageLifeprintGifUrl(normalizedPath); + } + + return value; + } +} + +export function normalizeSignLanguageYoutubeVideoUrl(videoUrl: string): string { + const value = videoUrl.trim(); + const videoId = extractYoutubeVideoId(value); + + if (!videoId) { + return value; + } + + return `${SIGN_LANGUAGE_YOUTUBE_EMBED_URL}/${encodeURIComponent(videoId)}`; +} + +export function normalizeSignLanguageYoutubeSearchUrl(searchUrl: string | undefined): string | undefined { + const value = searchUrl?.trim() ?? ''; + + return value || undefined; +} + +export function normalizeSignLanguageItem(sign: SignItem): SignItem { + return { + ...sign, + image: normalizeSignLanguageImageUrl(sign.image), + gifUrl: normalizeSignLanguageGifUrl(sign.gifUrl), + videoUrl: normalizeSignLanguageYoutubeVideoUrl(sign.videoUrl), + youtubeSearchUrl: normalizeSignLanguageYoutubeSearchUrl(sign.youtubeSearchUrl), + }; +} + +export function normalizeSignLanguageItems(signs: readonly SignItem[]): readonly SignItem[] { + return signs.map(normalizeSignLanguageItem); +} + export function buildSignLanguageYoutubeSearchUrl(word: string): string { - const searchQuery = `${SIGN_LANGUAGE_YOUTUBE_QUERY_PREFIX} ${word} ${SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX}`; + const searchQuery = `${SIGN_LANGUAGE_YOUTUBE_QUERY_PREFIX} ${word.trim()} ${SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX}`; return `${SIGN_LANGUAGE_YOUTUBE_SEARCH_URL}?search_query=${encodeURIComponent(searchQuery)}`; } + +export function selectSignLanguageSignOfWeek( + signs: readonly SignItem[], + payload: DashboardSignOfWeek | null, +): SignItem | null { + if (!payload) { + return null; + } + + if (payload.signId) { + return signs.find((sign) => sign.id === payload.signId) ?? null; + } + + return signs.find((sign) => sign.word === payload.word) ?? null; +} + +export function buildDashboardSignOfWeekPayload( + sign: SignItem, + now: Date = new Date(), +): DashboardSignOfWeek { + return { + signId: sign.id, + weekOf: toWeekStartIso(now), + word: sign.word, + description: sign.description, + image: sign.image, + alt: `${sign.word} sign`, + }; +} diff --git a/frontend/src/business/sign-language/types.ts b/frontend/src/business/sign-language/types.ts index 8299d59..ece5efd 100644 --- a/frontend/src/business/sign-language/types.ts +++ b/frontend/src/business/sign-language/types.ts @@ -18,6 +18,27 @@ export interface SignLanguageFilters { readonly categoryFilter: SignLanguageCategoryFilter; } +export interface SignLanguageStepDraft { + readonly step: number; + readonly instruction: string; + readonly duration: number; +} + +export interface SignLanguageDraft { + readonly id: string | null; + readonly word: string; + readonly category: SignItem['category']; + readonly description: string; + readonly image: string; + readonly tip: string; + readonly videoUrl: string; + readonly gifUrl: string; + readonly youtubeSearchUrl: string; + readonly videoSteps: readonly SignLanguageStepDraft[]; +} + +export type SignLanguageDraftMode = 'create' | 'edit'; + export interface SignLanguageVideoModalState { readonly showSteps: boolean; readonly viewMode: SignLanguageViewMode; @@ -40,7 +61,16 @@ export interface SignLanguagePage { readonly learnedCount: number; readonly progressPercent: number; readonly canPersistProgress: boolean; + readonly canManageSigns: boolean; readonly selectedSign: SignItem | null; + readonly signOfWeekId: string | null; + readonly signDraft: SignLanguageDraft | null; + readonly signDraftMode: SignLanguageDraftMode | null; + readonly signDraftError: string | null; + readonly signManagementError: unknown; + readonly signSaveMessage: string | null; + readonly isSavingSigns: boolean; + readonly pendingDeleteSign: SignItem | null; readonly pageContent: SignLanguagePageContent; readonly isLoading: boolean; readonly isSaving: boolean; @@ -53,4 +83,16 @@ export interface SignLanguagePage { readonly selectSign: (id: string) => void; readonly closeSign: () => void; readonly toggleLearned: (id: string) => Promise; + readonly startCreateSign: () => void; + readonly startEditSign: (sign: SignItem) => void; + readonly updateSignDraft: (updates: Partial) => void; + readonly updateSignDraftStep: (index: number, updates: Partial) => void; + readonly addSignDraftStep: () => void; + readonly removeSignDraftStep: (index: number) => void; + readonly cancelSignDraft: () => void; + readonly saveSignDraft: () => void; + readonly requestDeleteSign: (sign: SignItem) => void; + readonly cancelDeleteSign: () => void; + readonly confirmDeleteSign: () => void; + readonly selectSignOfWeek: (signId: string) => void; } diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index 69e2f8c..eb9b139 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -1,5 +1,8 @@ import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { listCampusAttendanceSummaries } from '@/shared/api/campusAttendance'; +import { listFrameEntries } from '@/shared/api/frame'; import { buildTopBarNotifications, countUnreadTopBarNotifications, @@ -17,6 +20,10 @@ import { } from '@/business/communications/hooks'; import { getScopedModules } from '@/business/app-shell/selectors'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { + normalizeSignLanguageItems, + selectSignLanguageSignOfWeek, +} from '@/business/sign-language/selectors'; import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks'; import { useCurrentPersonalityResult } from '@/business/personality/queryHooks'; import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks'; @@ -40,6 +47,9 @@ import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; import { useScopeContext } from '@/shared/app/scope-context'; import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; +import { useLearnedSignsProgress } from '@/business/user-progress/hooks'; +import type { DashboardSignOfWeek } from '@/shared/types/dashboard'; +import { toWeekStartIso } from '@/shared/business/week'; const EMPTY_STRATEGIES: readonly Strategy[] = []; const EMPTY_SIGNS: readonly SignItem[] = []; @@ -83,6 +93,7 @@ export function useTopBarPage({ && accessibleModuleIds.has('qbs') && (effectiveTier === 'school' || effectiveTier === 'campus' || effectiveTier === 'class'); const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); + const today = new Date().toISOString().split('T')[0] ?? ''; const safetyQuizStatus = useMySafetyQuizStatus( safetyQuizWeek, canReceiveSafetyQuizNotification, @@ -90,6 +101,52 @@ export function useTopBarPage({ const needsSafetyQuiz = canReceiveSafetyQuizNotification && !safetyQuizStatus.isLoading && safetyQuizStatus.data?.completed !== true; + const canReceiveSignOfWeekNotification = canPersistPersonalResults + && accessibleModuleIds.has('signs'); + const canManageWeeklySignSelection = hasPermission(user, 'MANAGE_CONTENT_CATALOG') + && accessibleModuleIds.has('signs') + && effectiveTier === 'organization'; + const signOfWeekQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.dashboardSignOfWeek, + null, + { enabled: canReceiveSignOfWeekNotification || canManageWeeklySignSelection }, + ); + const learnedSigns = useLearnedSignsProgress({ enabled: canReceiveSignOfWeekNotification }); + const canManageWeeklyFrame = hasPermission(user, 'MANAGE_FRAME') + && accessibleModuleIds.has('frame'); + const frameContentQuery = useQuery({ + queryKey: ['top-bar-weekly-frame-content', safetyQuizWeek], + enabled: canManageWeeklyFrame, + queryFn: async () => { + const response = await listFrameEntries({ + startDate: safetyQuizWeek, + endDate: safetyQuizWeek, + }); + return response.rows; + }, + }); + const canManageDailyAttendance = hasPermission(user, 'FILL_ATTENDANCE') + && accessibleModuleIds.has('attendance'); + const attendanceCampusKey = + effectiveTier === 'campus' || effectiveTier === 'class' + ? campusInfo?.id + : undefined; + const attendanceContentQuery = useQuery({ + queryKey: ['top-bar-daily-attendance-content', today, attendanceCampusKey ?? null, effectiveTier], + enabled: canManageDailyAttendance && Boolean(today) && ( + effectiveTier === 'organization' + || effectiveTier === 'school' + || Boolean(attendanceCampusKey) + ), + queryFn: async () => { + const response = await listCampusAttendanceSummaries({ + ...(attendanceCampusKey ? { campusKey: attendanceCampusKey } : {}), + startDate: today, + endDate: today, + }); + return response.rows; + }, + }); const canReceiveEmotionalIntelligenceNotifications = canPersistPersonalResults && hasPermission(user, 'TAKE_QUIZ') && accessibleModuleIds.has('ei'); @@ -117,18 +174,6 @@ export function useTopBarPage({ )); const handbookPolicies = usePolicies(canReadHandbook); const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols); - const notifications = buildTopBarNotifications({ - needsZoneCheckIn, - needsSafetyQuiz, - needsEiSelfAssessment, - needsPersonalityQuiz, - communicationEvents: communicationEvents.data ?? [], - acknowledgedCommunicationEventIds, - handbookPolicies: handbookPolicies.data ?? [], - safetyProtocols: safetyProtocols.data ?? [], - policyAcknowledgments: policyAcknowledgments.data ?? [], - }); - // Header search = accessible modules (local) + their product content from the // content catalog. Content is fetched lazily — only once the user types, and // only for modules the user can access. @@ -146,7 +191,12 @@ export function useTopBarPage({ const signsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.signLanguageItems, EMPTY_SIGNS, - { enabled: hasQuery && accessibleModuleIds.has('signs') }, + { + enabled: + (hasQuery && accessibleModuleIds.has('signs')) + || canReceiveSignOfWeekNotification + || canManageWeeklySignSelection, + }, ); const zonesQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.regulationZones, @@ -164,6 +214,47 @@ export function useTopBarPage({ zonesQuery.payload.forEach((z) => add('zones', `zone-${z.color}`, z.name)); return items; }, [strategiesQuery.payload, signsQuery.payload, zonesQuery.payload, moduleNameById]); + const normalizedSigns = useMemo( + () => normalizeSignLanguageItems(signsQuery.payload), + [signsQuery.payload], + ); + const signOfWeek = useMemo( + () => selectSignLanguageSignOfWeek(normalizedSigns, signOfWeekQuery.payload), + [normalizedSigns, signOfWeekQuery.payload], + ); + const needsSignOfWeek = canReceiveSignOfWeekNotification + && !signOfWeekQuery.isLoading + && !signsQuery.isLoading + && !learnedSigns.isLoading + && Boolean(signOfWeek) + && !learnedSigns.learnedSignIds.has(signOfWeek?.id ?? ''); + const needsWeeklySignSelection = canManageWeeklySignSelection + && !signOfWeekQuery.isLoading + && !signsQuery.isLoading + && (!signOfWeek || signOfWeekQuery.payload?.weekOf !== toWeekStartIso(new Date())); + const needsWeeklyFrameContent = canManageWeeklyFrame + && !frameContentQuery.isLoading + && !frameContentQuery.error + && (frameContentQuery.data?.length ?? 0) === 0; + const needsDailyAttendanceContent = canManageDailyAttendance + && !attendanceContentQuery.isLoading + && !attendanceContentQuery.error + && (attendanceContentQuery.data?.length ?? 0) === 0; + const notifications = buildTopBarNotifications({ + needsZoneCheckIn, + needsSafetyQuiz, + needsSignOfWeek, + needsWeeklySignSelection, + needsWeeklyFrameContent, + needsDailyAttendanceContent, + needsEiSelfAssessment, + needsPersonalityQuiz, + communicationEvents: communicationEvents.data ?? [], + acknowledgedCommunicationEventIds, + handbookPolicies: handbookPolicies.data ?? [], + safetyProtocols: safetyProtocols.data ?? [], + policyAcknowledgments: policyAcknowledgments.data ?? [], + }); const searchResults = useMemo( () => buildTopBarSearchResults(scopedModules, user, searchQuery, contentItems), diff --git a/frontend/src/business/top-bar/selectors.test.ts b/frontend/src/business/top-bar/selectors.test.ts index 00ab7b6..9aeb60f 100644 --- a/frontend/src/business/top-bar/selectors.test.ts +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -79,6 +79,54 @@ describe('top bar selectors', () => { }]); }); + it('surfaces an unread sign-of-week reminder when the weekly sign is not learned', () => { + const withReminder = buildTopBarNotifications({ + needsZoneCheckIn: false, + needsSignOfWeek: true, + }); + + expect(withReminder).toEqual([{ + id: 'sign-of-week', + text: "You haven't learned this week's sign", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.signs, + }]); + }); + + it('surfaces manager reminders for missing weekly and daily content', () => { + const reminders = buildTopBarNotifications({ + needsZoneCheckIn: false, + needsWeeklySignSelection: true, + needsWeeklyFrameContent: true, + needsDailyAttendanceContent: true, + }); + + expect(reminders).toEqual([ + { + id: 'weekly-sign-selection', + text: "Select this week's Sign of the Week", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.signs, + }, + { + id: 'weekly-frame-content', + text: "Publish this week's F.R.A.M.E. entry", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.frame, + }, + { + id: 'daily-attendance-content', + text: "Submit today's attendance", + time: 'Today', + unread: true, + href: APP_ROUTE_PATHS.attendance, + }, + ]); + }); + it('surfaces EI self-assessment and personality quiz completion reminders', () => { const reminders = buildTopBarNotifications({ needsZoneCheckIn: false, diff --git a/frontend/src/business/top-bar/selectors.ts b/frontend/src/business/top-bar/selectors.ts index fac4dfb..15a5cda 100644 --- a/frontend/src/business/top-bar/selectors.ts +++ b/frontend/src/business/top-bar/selectors.ts @@ -39,6 +39,10 @@ export function countUnreadTopBarNotifications( const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly'; +const SIGN_OF_WEEK_NOTIFICATION_ID = 'sign-of-week'; +const WEEKLY_SIGN_SELECTION_NOTIFICATION_ID = 'weekly-sign-selection'; +const WEEKLY_FRAME_NOTIFICATION_ID = 'weekly-frame-content'; +const DAILY_ATTENDANCE_NOTIFICATION_ID = 'daily-attendance-content'; const EI_SELF_ASSESSMENT_NOTIFICATION_ID = 'ei-self-assessment'; const EI_PERSONALITY_QUIZ_NOTIFICATION_ID = 'ei-personality-quiz'; @@ -50,6 +54,10 @@ const EI_PERSONALITY_QUIZ_NOTIFICATION_ID = 'ei-personality-quiz'; export function buildTopBarNotifications(input: { readonly needsZoneCheckIn: boolean; readonly needsSafetyQuiz?: boolean; + readonly needsSignOfWeek?: boolean; + readonly needsWeeklySignSelection?: boolean; + readonly needsWeeklyFrameContent?: boolean; + readonly needsDailyAttendanceContent?: boolean; readonly needsEiSelfAssessment?: boolean; readonly needsPersonalityQuiz?: boolean; readonly communicationEvents?: readonly CommunicationEventDto[]; @@ -80,6 +88,46 @@ export function buildTopBarNotifications(input: { }); } + if (input.needsSignOfWeek) { + notifications.push({ + id: SIGN_OF_WEEK_NOTIFICATION_ID, + text: "You haven't learned this week's sign", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.signs, + }); + } + + if (input.needsWeeklySignSelection) { + notifications.push({ + id: WEEKLY_SIGN_SELECTION_NOTIFICATION_ID, + text: "Select this week's Sign of the Week", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.signs, + }); + } + + if (input.needsWeeklyFrameContent) { + notifications.push({ + id: WEEKLY_FRAME_NOTIFICATION_ID, + text: "Publish this week's F.R.A.M.E. entry", + time: 'This week', + unread: true, + href: APP_ROUTE_PATHS.frame, + }); + } + + if (input.needsDailyAttendanceContent) { + notifications.push({ + id: DAILY_ATTENDANCE_NOTIFICATION_ID, + text: "Submit today's attendance", + time: 'Today', + unread: true, + href: APP_ROUTE_PATHS.attendance, + }); + } + if (input.needsEiSelfAssessment) { notifications.push({ id: EI_SELF_ASSESSMENT_NOTIFICATION_ID, diff --git a/frontend/src/components/dashboard/DashboardSignOfWeek.tsx b/frontend/src/components/dashboard/DashboardSignOfWeek.tsx index bfadeb5..1646f48 100644 --- a/frontend/src/components/dashboard/DashboardSignOfWeek.tsx +++ b/frontend/src/components/dashboard/DashboardSignOfWeek.tsx @@ -1,12 +1,15 @@ import { HandMetal } from 'lucide-react'; import type { DashboardContentState } from '@/business/dashboard/types'; +import { fileAssetUrl } from '@/business/files/api'; import { Button } from '@/components/ui/button'; -import type { ModuleId } from '@/shared/types/app'; -import type { DashboardSignOfWeek as DashboardSignOfWeekPayload } from '@/shared/types/dashboard'; +import type { + ModuleId, + SignItem, +} from '@/shared/types/app'; interface DashboardSignOfWeekProps { - readonly sign: DashboardSignOfWeekPayload | null; + readonly sign: SignItem | null; readonly state: DashboardContentState; readonly onNavigate: (module: ModuleId) => void; } @@ -29,8 +32,8 @@ export function DashboardSignOfWeek({

Sign of the week could not be loaded.

) : sign ? ( <> -
- {sign.alt} +
+ {`${sign.word}

"{sign.word}"

{sign.description}

diff --git a/frontend/src/components/sign-language/SignLanguageCard.tsx b/frontend/src/components/sign-language/SignLanguageCard.tsx index 21b4636..ae40ba3 100644 --- a/frontend/src/components/sign-language/SignLanguageCard.tsx +++ b/frontend/src/components/sign-language/SignLanguageCard.tsx @@ -1,6 +1,8 @@ import type { KeyboardEvent } from 'react'; -import { Play, Star } from 'lucide-react'; +import { Pencil, Play, Star, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { fileAssetUrl } from '@/business/files/api'; import { getSignLanguageVideoDurationSeconds } from '@/business/sign-language/selectors'; import { SIGN_LANGUAGE_CATEGORY_BADGE_CLASSES } from '@/shared/constants/signLanguage'; import type { SignItem } from '@/shared/types/app'; @@ -9,13 +11,19 @@ import { cn } from '@/lib/utils'; interface SignLanguageCardProps { readonly sign: SignItem; readonly isLearned: boolean; + readonly canManage: boolean; readonly onSelect: (id: string) => void; + readonly onEdit: (sign: SignItem) => void; + readonly onDelete: (sign: SignItem) => void; } export function SignLanguageCard({ sign, isLearned, + canManage, onSelect, + onEdit, + onDelete, }: SignLanguageCardProps) { function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Enter' || event.key === ' ') { @@ -38,7 +46,7 @@ export function SignLanguageCard({ >
{`Sign @@ -55,6 +63,37 @@ export function SignLanguageCard({
)} + {canManage && ( +
+ + +
+ )} + ; + readonly canManageSigns: boolean; readonly onSelectSign: (id: string) => void; + readonly onEditSign: (sign: SignItem) => void; + readonly onDeleteSign: (sign: SignItem) => void; } export function SignLanguageGrid({ signs, learnedSignIds, + canManageSigns, onSelectSign, + onEditSign, + onDeleteSign, }: SignLanguageGridProps) { if (signs.length === 0) { return ( @@ -35,7 +41,10 @@ export function SignLanguageGrid({ key={sign.id} sign={sign} isLearned={learnedSignIds.has(sign.id)} + canManage={canManageSigns} onSelect={onSelectSign} + onEdit={onEditSign} + onDelete={onDeleteSign} /> ))} diff --git a/frontend/src/components/sign-language/SignLanguageManagementPanel.tsx b/frontend/src/components/sign-language/SignLanguageManagementPanel.tsx new file mode 100644 index 0000000..fa9c6b6 --- /dev/null +++ b/frontend/src/components/sign-language/SignLanguageManagementPanel.tsx @@ -0,0 +1,291 @@ +import { Plus, Save, Trash2, X } from 'lucide-react'; + +import type { + SignLanguageDraft, + SignLanguagePage, +} from '@/business/sign-language/types'; +import { ImageUpload } from '@/components/common/ImageUpload'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; +import { Textarea } from '@/components/ui/textarea'; +import { SIGN_LANGUAGE_CATEGORY_FILTERS } from '@/shared/constants/signLanguage'; +import type { SignItem } from '@/shared/types/app'; + +interface SignLanguageManagementPanelProps { + readonly page: SignLanguagePage; +} + +type DraftField = keyof SignLanguageDraft; + +interface SignAssetSectionProps { + readonly title: string; + readonly urlLabel: string; + readonly uploadLabel: string; + readonly value: string; + readonly placeholder: string; + readonly uploadField: string; + readonly onChange: (value: string) => void; +} + +function toSignCategory(value: string): SignItem['category'] { + switch (value) { + case 'emotional': + case 'classroom': + return value; + case 'basic-needs': + default: + return 'basic-needs'; + } +} + +function SignAssetSection({ + title, + urlLabel, + uploadLabel, + value, + placeholder, + uploadField, + onChange, +}: SignAssetSectionProps) { + return ( +
+ {title} +
+ +
+ onChange(privateUrl ?? '')} + table="sign-language" + field={uploadField} + label={uploadLabel} + /> +
+
+
+ ); +} + +export function SignLanguageManagementPanel({ page }: SignLanguageManagementPanelProps) { + if (!page.canManageSigns) { + return null; + } + + const draft = page.signDraft; + + function updateTextField(field: DraftField, value: string) { + page.updateSignDraft({ [field]: value }); + } + + return ( +
+
+
+

Manage Sign Cards

+

Organization-level sign library and weekly focus

+
+
+ + +
+
+ +
+ {draft && ( +
+
+

+ {page.signDraftMode === 'create' ? 'New Sign' : 'Edit Sign'} +

+ +
+ +
+ + +
+ page.updateSignDraft({ image: value })} + /> +
+
+ page.updateSignDraft({ gifUrl: value })} + /> +
+ + +