added sign language library CRUD, improved existed functionality

This commit is contained in:
Dmitri 2026-06-22 13:52:35 +02:00
parent 13d87f695b
commit 9531ea34f2
43 changed files with 1559 additions and 273 deletions

View File

@ -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:

View File

@ -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`.

View File

@ -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",

View File

@ -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<typeof selectConnecti
const connection = selectConnection();
validateProductionDbConfig(connection);
const logSql = process.env.LOG_SQL === 'true';
const sequelize = new Sequelize(
connection.database ?? DEFAULT_DEV_DB_NAME,
@ -100,7 +102,9 @@ const sequelize = new Sequelize(
dialect: 'postgres',
host: connection.host ?? DEFAULT_DEV_DB_HOST,
port: connection.port ? Number(connection.port) : undefined,
logging: console.log,
logging: logSql
? (sql) => logger.debug('SQL query', { sql })
: false,
},
);

View File

@ -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: [
{

View File

@ -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);

View File

@ -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();
}

View File

@ -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(

View File

@ -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
) {

View File

@ -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<Record<string, unknown>> = [];

View File

@ -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<string> = 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',

View File

@ -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<string, unknown>;
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
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) : {}),

View File

@ -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 {

View File

@ -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

View File

@ -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/`.

View File

@ -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/`.

View File

@ -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).

View File

@ -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.

View File

@ -7,6 +7,12 @@
<title>FRAMEworks School Manager</title>
<meta name="description" content="A role-based app for schools with modules focusing on autism support, emotional intelligence, and effective communication." />
<link rel="icon" type="image/svg+xml" href="/placeholder.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<meta property="og:title" content="FRAMEworks School Manager" />
<meta property="og:description" content="A role-based app for schools with modules focusing on autism support, emotional intelligence, and effective communication." />

View File

@ -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",

View File

@ -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",

View File

@ -1,6 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
}

View File

@ -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<readonly SignItem[]>(
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,

View File

@ -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;

View File

@ -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<string>();
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<readonly SignItem[]>(
CONTENT_CATALOG_TYPES.signLanguageItems,
[],
);
const signOfWeekQuery = useContentCatalogPayload<DashboardSignOfWeek | null>(
CONTENT_CATALOG_TYPES.dashboardSignOfWeek,
null,
);
const pageContentQuery = useContentCatalogPayload<SignLanguagePageContent>(
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<SignLanguageCategoryFilter>('all');
const [selectedSignId, setSelectedSignId] = useState<string | null>(null);
const signs = signsQuery.payload;
const [signDraft, setSignDraft] = useState<SignLanguageDraft | null>(null);
const [signDraftMode, setSignDraftMode] = useState<'create' | 'edit' | null>(null);
const [signDraftError, setSignDraftError] = useState<string | null>(null);
const [signSaveMessage, setSignSaveMessage] = useState<string | null>(null);
const [pendingDeleteSign, setPendingDeleteSign] = useState<SignItem | null>(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<readonly SignItem[]>(
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<DashboardSignOfWeek>(
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<SignLanguageDraft>) {
setSignDraft((current) => current ? { ...current, ...updates } : current);
setSignDraftError(null);
setSignSaveMessage(null);
}
function updateSignDraftStep(index: number, updates: Partial<SignLanguageStepDraft>) {
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,
};
}

View File

@ -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',
});
});
});

View File

@ -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`,
};
}

View File

@ -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<void>;
readonly startCreateSign: () => void;
readonly startEditSign: (sign: SignItem) => void;
readonly updateSignDraft: (updates: Partial<SignLanguageDraft>) => void;
readonly updateSignDraftStep: (index: number, updates: Partial<SignLanguageStepDraft>) => 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;
}

View File

@ -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<DashboardSignOfWeek | null>(
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<readonly SignItem[]>(
CONTENT_CATALOG_TYPES.signLanguageItems,
EMPTY_SIGNS,
{ enabled: hasQuery && accessibleModuleIds.has('signs') },
{
enabled:
(hasQuery && accessibleModuleIds.has('signs'))
|| canReceiveSignOfWeekNotification
|| canManageWeeklySignSelection,
},
);
const zonesQuery = useContentCatalogPayload<readonly ZoneInfo[]>(
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),

View File

@ -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,

View File

@ -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,

View File

@ -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({
<p className="text-xs text-red-300">Sign of the week could not be loaded.</p>
) : sign ? (
<>
<div className="w-20 h-20 mx-auto rounded-2xl overflow-hidden mb-2 border-2 border-indigo-500/30 shadow-lg shadow-indigo-500/10">
<img src={sign.image} alt={sign.alt} className="w-full h-full object-cover" />
<div className="mx-auto mb-4 h-32 w-32 overflow-hidden rounded-2xl border-2 border-indigo-500/30 shadow-lg shadow-indigo-500/10 sm:h-36 sm:w-36">
<img src={fileAssetUrl(sign.image)} alt={`${sign.word} sign`} className="w-full h-full object-cover" />
</div>
<p className="font-bold text-indigo-400 text-lg">"{sign.word}"</p>
<p className="text-xs text-slate-400 mt-1">{sign.description}</p>

View File

@ -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<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
@ -38,7 +46,7 @@ export function SignLanguageCard({
>
<div className="aspect-[4/5] overflow-hidden relative bg-gradient-to-br from-slate-100 to-slate-50">
<img
src={sign.image}
src={fileAssetUrl(sign.image)}
alt={`Sign language for "${sign.word}"`}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
@ -55,6 +63,37 @@ export function SignLanguageCard({
</div>
)}
{canManage && (
<div className="absolute top-2 left-2 flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={(event) => {
event.stopPropagation();
onEdit(sign);
}}
aria-label={`Edit ${sign.word}`}
className="h-7 w-7 rounded-full bg-slate-950/60 text-white hover:bg-slate-900"
>
<Pencil size={13} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={(event) => {
event.stopPropagation();
onDelete(sign);
}}
aria-label={`Delete ${sign.word}`}
className="h-7 w-7 rounded-full bg-red-950/70 text-red-100 hover:bg-red-900"
>
<Trash2 size={13} />
</Button>
</div>
)}
<span className={cn(
'absolute bottom-2 left-2 px-2 py-0.5 rounded-lg text-[10px] font-semibold',
SIGN_LANGUAGE_CATEGORY_BADGE_CLASSES[sign.category],

View File

@ -7,13 +7,19 @@ import type { SignItem } from '@/shared/types/app';
interface SignLanguageGridProps {
readonly signs: readonly SignItem[];
readonly learnedSignIds: ReadonlySet<string>;
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}
/>
))}
</section>

View File

@ -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 (
<fieldset className="rounded-xl border border-slate-700/60 bg-slate-900/45 p-4">
<legend className="px-1 text-sm font-semibold text-slate-100">{title}</legend>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<label className="space-y-2 text-sm font-medium text-slate-300">
{urlLabel}
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</label>
<div className="md:min-w-64">
<ImageUpload
value={value || null}
onChange={(privateUrl) => onChange(privateUrl ?? '')}
table="sign-language"
field={uploadField}
label={uploadLabel}
/>
</div>
</div>
</fieldset>
);
}
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 (
<section className="rounded-2xl border border-slate-700/50 bg-slate-900/45 overflow-hidden">
<div className="flex flex-col gap-3 border-b border-slate-700/50 px-5 py-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="text-lg font-semibold text-white">Manage Sign Cards</h3>
<p className="text-sm text-slate-400">Organization-level sign library and weekly focus</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<label className="flex w-full flex-col gap-1.5 text-xs font-medium text-slate-300 sm:w-64 lg:w-72">
<span>Sign of the Week</span>
<NativeSelect
value={page.signOfWeekId ?? ''}
onChange={(event) => page.selectSignOfWeek(event.target.value)}
disabled={page.isSavingSigns || page.signs.length === 0}
className="h-10 rounded-lg border-slate-700 bg-slate-950/70 text-sm"
>
<option value="" disabled>Select a sign</option>
{page.signs.map((sign) => (
<option key={sign.id} value={sign.id}>
{sign.word}
</option>
))}
</NativeSelect>
</label>
<Button
type="button"
onClick={page.startCreateSign}
disabled={page.isSavingSigns}
leadingIcon={<Plus size={16} />}
className="h-10 shrink-0 rounded-lg bg-emerald-600 px-4 text-sm text-white hover:bg-emerald-500"
>
Add Sign
</Button>
</div>
</div>
<div className="space-y-5 p-5">
{draft && (
<div className="rounded-xl border border-slate-700/60 bg-slate-950/35 p-4">
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="text-base font-semibold text-white">
{page.signDraftMode === 'create' ? 'New Sign' : 'Edit Sign'}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={page.cancelSignDraft}
leadingIcon={<X size={16} />}
>
Cancel
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<label className="space-y-2 text-sm font-medium text-slate-300">
Title
<Input
value={draft.word}
onChange={(event) => updateTextField('word', event.target.value)}
placeholder="Help"
/>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300">
Category
<NativeSelect
value={draft.category}
onChange={(event) => page.updateSignDraft({ category: toSignCategory(event.target.value) })}
>
{SIGN_LANGUAGE_CATEGORY_FILTERS
.filter((category) => category.value !== 'all')
.map((category) => (
<option key={category.value} value={category.value}>
{category.label}
</option>
))}
</NativeSelect>
</label>
<div className="lg:col-span-2">
<SignAssetSection
title="Preview Image"
urlLabel="Image URL"
uploadLabel="Upload preview image"
value={draft.image}
placeholder="https://..."
uploadField="preview-images"
onChange={(value) => page.updateSignDraft({ image: value })}
/>
</div>
<div className="lg:col-span-2">
<SignAssetSection
title="Animated GIF"
urlLabel="GIF URL"
uploadLabel="Upload GIF"
value={draft.gifUrl}
placeholder="https://..."
uploadField="gif-demos"
onChange={(value) => page.updateSignDraft({ gifUrl: value })}
/>
</div>
<label className="space-y-2 text-sm font-medium text-slate-300">
YouTube Video URL
<Input
value={draft.videoUrl}
onChange={(event) => updateTextField('videoUrl', event.target.value)}
placeholder="https://youtube.com/watch?v=..."
/>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300">
YouTube Search URL
<Input
value={draft.youtubeSearchUrl}
onChange={(event) => updateTextField('youtubeSearchUrl', event.target.value)}
placeholder="https://youtube.com/results?search_query=..."
/>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300 lg:col-span-2">
Description
<Textarea
value={draft.description}
onChange={(event) => updateTextField('description', event.target.value)}
placeholder="When and why staff should use this sign"
/>
</label>
<label className="space-y-2 text-sm font-medium text-slate-300 lg:col-span-2">
Teaching Tip
<Textarea
value={draft.tip}
onChange={(event) => updateTextField('tip', event.target.value)}
placeholder="Practical coaching detail"
/>
</label>
</div>
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between gap-3">
<h5 className="text-sm font-semibold text-slate-200">Step-by-Step Guide</h5>
<Button type="button" variant="ghost" size="sm" onClick={page.addSignDraftStep}>
Add Step
</Button>
</div>
{draft.videoSteps.map((step, index) => (
<div key={step.step} className="grid gap-3 rounded-xl border border-slate-700/60 bg-slate-900/55 p-3 lg:grid-cols-[1fr_120px_auto]">
<Input
value={step.instruction}
onChange={(event) => page.updateSignDraftStep(index, { instruction: event.target.value })}
placeholder={`Step ${index + 1}`}
/>
<Input
type="number"
min={1}
value={step.duration}
onChange={(event) => page.updateSignDraftStep(index, { duration: Number(event.target.value) })}
aria-label={`Step ${index + 1} duration`}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => page.removeSignDraftStep(index)}
aria-label={`Remove step ${index + 1}`}
disabled={draft.videoSteps.length === 1}
className="text-red-300 hover:text-red-200"
>
<Trash2 size={16} />
</Button>
</div>
))}
</div>
{page.signDraftError && (
<p className="mt-4 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm font-medium text-red-300">
{page.signDraftError}
</p>
)}
<div className="mt-5 flex justify-end">
<Button
type="button"
onClick={page.saveSignDraft}
loading={page.isSavingSigns}
leadingIcon={<Save size={16} />}
className="bg-blue-600 hover:bg-blue-500 text-white"
>
Save Sign
</Button>
</div>
</div>
)}
{Boolean(page.signManagementError) && (
<p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm font-medium text-red-300">
Sign cards could not be saved.
</p>
)}
{page.signSaveMessage && (
<p className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm font-medium text-emerald-300">
{page.signSaveMessage}
</p>
)}
</div>
</section>
);
}

View File

@ -3,6 +3,7 @@ import { BookOpen, ChevronDown, ExternalLink, Play, RefreshCw, Star, X } from 'l
import { useSignLanguageVideoModalState } from '@/business/sign-language/hooks';
import { buildSignLanguageYoutubeSearchUrl } from '@/business/sign-language/selectors';
import { Button } from '@/components/ui/button';
import { fileAssetUrl } from '@/business/files/api';
import {
DEFAULT_SIGN_LANGUAGE_CATEGORY,
SIGN_LANGUAGE_CATEGORY_COLORS,
@ -31,7 +32,9 @@ export function SignLanguageVideoModal({
const state = useSignLanguageVideoModalState();
const categoryColor =
SIGN_LANGUAGE_CATEGORY_COLORS[sign.category] ?? SIGN_LANGUAGE_CATEGORY_COLORS[DEFAULT_SIGN_LANGUAGE_CATEGORY];
const youtubeSearchUrl = buildSignLanguageYoutubeSearchUrl(sign.word);
const youtubeSearchUrl = sign.youtubeSearchUrl ?? buildSignLanguageYoutubeSearchUrl(sign.word);
const hasGifUrl = sign.gifUrl.trim().length > 0;
const shouldShowGifFallback = !hasGifUrl || state.gifError;
return (
<div
@ -94,7 +97,7 @@ export function SignLanguageVideoModal({
<div className="relative bg-gradient-to-br from-slate-900 to-slate-800 flex-shrink-0 min-h-[300px]">
{state.viewMode === 'gif' ? (
<div className="flex items-center justify-center p-6 min-h-[300px]">
{!state.gifLoaded && !state.gifError && (
{hasGifUrl && !state.gifLoaded && !state.gifError && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<div className="text-center space-y-3">
<RefreshCw size={28} className="animate-spin text-indigo-400 mx-auto" />
@ -103,9 +106,9 @@ export function SignLanguageVideoModal({
</div>
)}
{!state.gifError ? (
{!shouldShowGifFallback ? (
<img
src={sign.gifUrl}
src={fileAssetUrl(sign.gifUrl)}
alt={`Animated demonstration of ASL sign for "${sign.word}"`}
className={cn(
'max-h-[280px] object-contain rounded-lg transition-opacity duration-300',
@ -117,7 +120,7 @@ export function SignLanguageVideoModal({
) : (
<div className="flex flex-col items-center justify-center space-y-4">
<img
src={sign.image}
src={fileAssetUrl(sign.image)}
alt={`Sign for ${sign.word}`}
className="max-h-[240px] object-contain rounded-lg"
/>
@ -125,7 +128,7 @@ export function SignLanguageVideoModal({
</div>
)}
{state.gifLoaded && !state.gifError && (
{hasGifUrl && state.gifLoaded && !state.gifError && (
<div className="absolute bottom-3 left-3 flex items-center gap-1.5 px-2.5 py-1 bg-black/50 backdrop-blur-sm rounded-lg">
<RefreshCw size={10} className="text-emerald-400 animate-spin [animation-duration:3s]" />
<span className="text-[10px] text-white/80 font-medium">Looping</span>

View File

@ -1,9 +1,11 @@
import { AlertTriangle, HandMetal } from 'lucide-react';
import type { SignLanguagePage } from '@/business/sign-language/types';
import { ConfirmationDialog } from '@/components/common/ConfirmationDialog';
import { SignLanguageFilters } from '@/components/sign-language/SignLanguageFilters';
import { SignLanguageGrid } from '@/components/sign-language/SignLanguageGrid';
import { SignLanguageHeader } from '@/components/sign-language/SignLanguageHeader';
import { SignLanguageManagementPanel } from '@/components/sign-language/SignLanguageManagementPanel';
import { SignLanguageProgressPanel } from '@/components/sign-language/SignLanguageProgressPanel';
import { SignLanguageRememberPanel } from '@/components/sign-language/SignLanguageRememberPanel';
import { SignLanguageVideoModal } from '@/components/sign-language/SignLanguageVideoModal';
@ -22,6 +24,8 @@ export function SignLanguageView({ page }: SignLanguageViewProps) {
<SignLanguageRememberPanel content={page.pageContent} />
<SignLanguageManagementPanel page={page} />
{page.canPersistProgress && (
<SignLanguageProgressPanel
learnedCount={page.learnedCount}
@ -63,7 +67,10 @@ export function SignLanguageView({ page }: SignLanguageViewProps) {
<SignLanguageGrid
signs={page.filteredSigns}
learnedSignIds={page.learnedSignIds}
canManageSigns={page.canManageSigns}
onSelectSign={page.selectSign}
onEditSign={page.startEditSign}
onDeleteSign={page.requestDeleteSign}
/>
)}
@ -77,6 +84,23 @@ export function SignLanguageView({ page }: SignLanguageViewProps) {
onToggleLearned={page.toggleLearned}
/>
)}
<ConfirmationDialog
open={Boolean(page.pendingDeleteSign)}
title="Delete sign card?"
description={
page.pendingDeleteSign
? `This will remove "${page.pendingDeleteSign.word}" from the organization sign library.`
: 'This will remove the sign card.'
}
confirmLabel="Delete sign"
loadingLabel="Deleting..."
loading={page.isSavingSigns}
disabled={!page.pendingDeleteSign}
tone="danger"
onCancel={page.cancelDeleteSign}
onConfirm={page.confirmDeleteSign}
/>
</div>
);
}

View File

@ -31,6 +31,21 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
...props
}, ref) => {
const Comp = asChild ? Slot : "button"
if (asChild) {
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
aria-busy={loading || undefined}
aria-disabled={disabled || loading || undefined}
ref={ref}
{...props}
>
{children}
</Comp>
)
}
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}

View File

@ -1,6 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
@import "tailwindcss";
@config "../tailwind.config.ts";

View File

@ -27,9 +27,12 @@ export const SIGN_LANGUAGE_CATEGORY_COLORS: Record<
export const DEFAULT_SIGN_LANGUAGE_CATEGORY: SignItem['category'] = 'basic-needs';
export const SIGN_LANGUAGE_YOUTUBE_SEARCH_URL = 'https://www.youtube.com/results';
export const SIGN_LANGUAGE_YOUTUBE_EMBED_URL = 'https://www.youtube.com/embed';
export const SIGN_LANGUAGE_YOUTUBE_QUERY_PREFIX = 'ASL sign language';
export const SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX = 'tutorial';
export const SIGN_LANGUAGE_LIFEPRINT_URL = 'https://www.lifeprint.com';
export const SIGN_LANGUAGE_LIFEPRINT_GIF_BASE_URL = `${SIGN_LANGUAGE_LIFEPRINT_URL}/asl101/gifs`;
export const SIGN_LANGUAGE_LIFEPRINT_LEGACY_IMAGE_SIGNS_PATH = '/asl101/images-signs/';
export const SIGN_LANGUAGE_VIEW_MODES = ['gif', 'video'] as const;
export type SignLanguageViewMode = (typeof SIGN_LANGUAGE_VIEW_MODES)[number];

View File

@ -108,6 +108,7 @@ export interface SignItem {
readonly tip: string;
readonly videoUrl: string;
readonly gifUrl: string;
readonly youtubeSearchUrl?: string;
readonly videoSteps: readonly SignVideoStep[];
}

View File

@ -12,6 +12,8 @@ export interface DashboardComplianceItem {
}
export interface DashboardSignOfWeek {
readonly signId?: string;
readonly weekOf?: string;
readonly word: string;
readonly description: string;
readonly image: string;

View File

@ -1,5 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vitejs.dev/config/
@ -22,7 +23,8 @@ export default defineConfig(({ mode }) => ({
allowedHosts: true,
},
plugins: [
react()
react(),
tailwindcss(),
].filter(Boolean),
resolve: {
alias: {