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. 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 ## Import direction
Allowed: 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. - `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. - `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. - `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 ## Tenant Scope
Content records can be tenant-scoped through nullable `organizationId`, `schoolId`, `campusId`, and `classId` columns: 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`). - `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. - 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. - `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`. - 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. - 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. - 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. - 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. - 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 ## 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 ## Related
- Frontend: `frontend/docs/content-catalog-integration.md`. - Frontend: `frontend/docs/content-catalog-integration.md`.

View File

@ -4,6 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch", "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:prod": "node --enable-source-maps dist/index.js",
"start:production": "npm run db:migrate:prod && npm run db:seed:prod && npm run start:prod", "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", "db:migrate:prod": "node dist/db/umzug.js migrate:up",

View File

@ -1,5 +1,6 @@
import { Sequelize } from 'sequelize'; import { Sequelize } from 'sequelize';
import dbConfig from '@/db/db.config'; import dbConfig from '@/db/db.config';
import logger from '@/shared/logger';
import { import {
DEFAULT_DEV_DB_HOST, DEFAULT_DEV_DB_HOST,
DEFAULT_DEV_DB_NAME, DEFAULT_DEV_DB_NAME,
@ -91,6 +92,7 @@ function validateProductionDbConfig(connection: ReturnType<typeof selectConnecti
const connection = selectConnection(); const connection = selectConnection();
validateProductionDbConfig(connection); validateProductionDbConfig(connection);
const logSql = process.env.LOG_SQL === 'true';
const sequelize = new Sequelize( const sequelize = new Sequelize(
connection.database ?? DEFAULT_DEV_DB_NAME, connection.database ?? DEFAULT_DEV_DB_NAME,
@ -100,7 +102,9 @@ const sequelize = new Sequelize(
dialect: 'postgres', dialect: 'postgres',
host: connection.host ?? DEFAULT_DEV_DB_HOST, host: connection.host ?? DEFAULT_DEV_DB_HOST,
port: connection.port ? Number(connection.port) : undefined, 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 = 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.'; '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({ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
classroomStrategies: [ classroomStrategies: [
{ {
@ -135,8 +141,8 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
signLanguageItems: [ signLanguageItems: [
{ {
id: '1', word: 'Help', category: 'basic-needs', id: '1', word: 'Help', category: 'basic-needs',
description: 'Flat hand on top of fist, lift both up together', description: SIGN_LANGUAGE_HELP_DESCRIPTION,
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037791874_e080660e.png', image: SIGN_LANGUAGE_HELP_IMAGE_URL,
tip: 'Teach this sign first — it reduces frustration-based behaviors immediately.', tip: 'Teach this sign first — it reduces frustration-based behaviors immediately.',
videoUrl: 'https://www.youtube.com/embed/Euz1g9E-Mrw', videoUrl: 'https://www.youtube.com/embed/Euz1g9E-Mrw',
gifUrl: 'https://www.lifeprint.com/asl101/gifs/h/help.gif', 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', image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037847928_f8f28226.png',
tip: 'Teaching "break" proactively prevents escalation. Honor the request when possible.', tip: 'Teaching "break" proactively prevents escalation. Honor the request when possible.',
videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM', videoUrl: 'https://www.youtube.com/embed/q6LuW4Sp_XM',
gifUrl: 'https://www.lifeprint.com/asl101/images-signs/break.gif', gifUrl: '',
videoSteps: [ videoSteps: [
{ step: 1, instruction: 'Hold both fists together in front of your chest, touching', duration: 3 }, { 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 }, { 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', image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037891102_4977dae4.png',
tip: 'Model this sign while taking deep breaths. Others mirror the regulation.', tip: 'Model this sign while taking deep breaths. Others mirror the regulation.',
videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA', videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA',
gifUrl: 'https://www.lifeprint.com/asl101/images-signs/calm.gif', gifUrl: '',
videoSteps: [ videoSteps: [
{ step: 1, instruction: 'Hold both hands at chest level, palms facing downward', duration: 3 }, { 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 }, { 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', image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1774037998820_245a2c77.jpg',
tip: 'Pair with visual "ears listening" icon on the board.', tip: 'Pair with visual "ears listening" icon on the board.',
videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA', videoUrl: 'https://www.youtube.com/embed/RhQvlq-mZtA',
gifUrl: 'https://www.lifeprint.com/asl101/images-signs/listen.gif', gifUrl: '',
videoSteps: [ videoSteps: [
{ step: 1, instruction: 'Raise your dominant hand up to the side of your head', duration: 3 }, { 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 }, { 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: { dashboardSignOfWeek: {
signId: '1',
weekOf: '2026-06-21',
word: 'Help', word: 'Help',
image: 'https://d64gsuwffb70l.cloudfront.net/698a11e863624924473cfb17_1770656618075_ea1c15a9.png', image: SIGN_LANGUAGE_HELP_IMAGE_URL,
alt: 'Help sign', alt: 'Help sign',
description: 'Flat hand on top of fist, lift both up together', description: SIGN_LANGUAGE_HELP_DESCRIPTION,
}, },
communityOrganizations: [ communityOrganizations: [
{ {

View File

@ -7,6 +7,7 @@ import swaggerUI from 'swagger-ui-express';
import swaggerJsDoc from 'swagger-jsdoc'; import swaggerJsDoc from 'swagger-jsdoc';
import config from '@/shared/config'; import config from '@/shared/config';
import csrfOrigin from '@/middlewares/csrf-origin'; import csrfOrigin from '@/middlewares/csrf-origin';
import requestLogger from '@/middlewares/request-logger';
import { resolveActiveScope } from '@/middlewares/resolve-active-scope'; import { resolveActiveScope } from '@/middlewares/resolve-active-scope';
import { import {
AUTH_COOKIE_NAME, AUTH_COOKIE_NAME,
@ -22,20 +23,12 @@ import '@/auth/auth';
// Global error handlers to prevent server crashes from unhandled errors // Global error handlers to prevent server crashes from unhandled errors
process.on('uncaughtException', (error: Error) => { process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught Exception - server continues running:', { logger.error('Uncaught Exception - server continues running:', error);
message: error.message,
stack: error.stack,
name: error.name,
});
}); });
process.on('unhandledRejection', (reason: unknown) => { process.on('unhandledRejection', (reason: unknown) => {
const error = reason instanceof Error ? reason : new Error(String(reason)); const error = reason instanceof Error ? reason : new Error(String(reason));
logger.error('Unhandled Promise Rejection - server continues running:', { logger.error('Unhandled Promise Rejection - server continues running:', error);
message: error.message,
stack: error.stack,
name: error.name,
});
}); });
import authRoutes from '@/routes/auth'; import authRoutes from '@/routes/auth';
@ -241,6 +234,7 @@ app.use(
app.use(express.json()); app.use(express.json());
app.use('/api', csrfOrigin); app.use('/api', csrfOrigin);
app.use('/api', requestLogger);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes); 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 () => { test('rejects QBS quiz management outside organization scope', async () => {
await assert.rejects( await assert.rejects(
() => ContentCatalogService.findManagedByType( () => ContentCatalogService.findManagedByType(

View File

@ -17,6 +17,7 @@ import {
CLASSROOM_SUPPORT_CONTENT_TYPE, CLASSROOM_SUPPORT_CONTENT_TYPE,
EI_ASSESSMENT_CONTENT_TYPE, EI_ASSESSMENT_CONTENT_TYPE,
PERSONALITY_QUIZ_CONTENT_TYPE, PERSONALITY_QUIZ_CONTENT_TYPE,
SIGN_LANGUAGE_ITEMS_CONTENT_TYPE,
SAFETY_QUIZ_CONTENT_TYPE, SAFETY_QUIZ_CONTENT_TYPE,
PER_TENANT_CONTENT_TYPES, PER_TENANT_CONTENT_TYPES,
SCHOOL_SCOPED_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES,
@ -128,6 +129,7 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo
|| contentType === SAFETY_QUIZ_CONTENT_TYPE || contentType === SAFETY_QUIZ_CONTENT_TYPE
|| contentType === EI_ASSESSMENT_CONTENT_TYPE || contentType === EI_ASSESSMENT_CONTENT_TYPE
|| contentType === PERSONALITY_QUIZ_CONTENT_TYPE || contentType === PERSONALITY_QUIZ_CONTENT_TYPE
|| contentType === SIGN_LANGUAGE_ITEMS_CONTENT_TYPE
) )
&& getRoleScope(currentUser) !== ROLE_SCOPES.ORGANIZATION && 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 assert from 'node:assert/strict';
import db from '@/db/models'; 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'; import { seedDefaultContentForTenant } from '@/services/content_catalog_seed';
afterEach(() => { afterEach(() => {
@ -9,6 +10,19 @@ afterEach(() => {
}); });
describe('seedDefaultContentForTenant', () => { 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 () => { test('seeds dashboard content at organization scope', async () => {
const createdRows: Array<Record<string, unknown>> = []; 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. */ /** Personality type quiz content, owned and managed at organization scope. */
export const PERSONALITY_QUIZ_CONTENT_TYPE = 'emotional-intelligence-personality-quiz'; 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). */ /** ESA funding content — school-scoped (rules depend on the school's locale). */
export const ESA_CONTENT_TYPE = 'esa-funding-content'; 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, PERSONALITY_QUIZ_CONTENT_TYPE,
'regulation-zones', 'regulation-zones',
'zones-of-regulation-page-content', 'zones-of-regulation-page-content',
'sign-language-items', SIGN_LANGUAGE_ITEMS_CONTENT_TYPE,
'sign-language-page-content', 'sign-language-page-content',
'emotional-intelligence-weekly-topics', 'emotional-intelligence-weekly-topics',
'emotional-intelligence-growth-tips', 'emotional-intelligence-growth-tips',

View File

@ -1,26 +1,88 @@
/** /**
* Minimal centralized logger. A thin wrapper over `console` so all server-side * Minimal centralized logger. Keeps one app-facing API while supporting
* logging goes through one place (timestamp + level + optional structured * readable local logs and machine-readable production logs.
* context) without pulling in a heavyweight logging dependency. Swap the
* implementation here if a transport (file/JSON/external) is ever needed.
*/ */
type LogContext = Record<string, unknown>; 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( function emit(
level: 'error' | 'warn' | 'info', level: LogLevel,
message: string, message: string,
context?: LogContext, context?: LogContext,
): void { ): void {
const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${message}`; if (!shouldLog(level)) {
const payload = context && Object.keys(context).length > 0 ? [context] : []; return;
if (level === 'error') {
console.error(line, ...payload);
} else if (level === 'warn') {
console.warn(line, ...payload);
} else {
console.log(line, ...payload);
} }
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. */ /** Serializes an unknown thrown value into a stable, loggable shape. */
@ -32,6 +94,9 @@ function describeError(error: unknown): LogContext {
} }
const logger = { const logger = {
debug(message: string, context?: LogContext): void {
emit('debug', message, context);
},
error(message: string, error?: unknown, context?: LogContext): void { error(message: string, error?: unknown, context?: LogContext): void {
emit('error', message, { emit('error', message, {
...(error !== undefined ? describeError(error) : {}), ...(error !== undefined ? describeError(error) : {}),

View File

@ -2,7 +2,8 @@ import { exec } from 'child_process';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
import nodemon from 'nodemon'; 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 }; const childEnv = { ...process.env, NODE_ENV: nodeEnv };
function runOnAdd(label: string, dir: string, script: string): void { 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 - sign language page content
- regulation zones - regulation zones
- zones of regulation page content - 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 - community organizations
- vocational opportunities - vocational opportunities
- emotional intelligence assessment content, personality quiz questions, weekly focus, and team content - 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 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 ## 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. 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 ## 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. 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 ## 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-encouraging-quotes`
- `GET /api/content-catalog/read/dashboard-compliance-items` - `GET /api/content-catalog/read/dashboard-compliance-items`
- `GET /api/content-catalog/read/dashboard-sign-of-week` - `GET /api/content-catalog/read/dashboard-sign-of-week`
- `GET /api/content-catalog/read/sign-language-items`
Feature APIs: Feature APIs:
@ -55,6 +56,7 @@ Feature APIs:
## Behavior ## Behavior
- `useDashboardPage` composes all dashboard data sources into one page model. - `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. - 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. - 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. - View components receive prepared props and do not call API/data access modules.
@ -64,5 +66,6 @@ Feature APIs:
## Data Ownership Rules ## Data Ownership Rules
- Do not add dashboard quote, compliance, sign-of-week, FRAME, event, or zone progress records to frontend constants. - 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. - 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/`. - 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/frameworks/SignLanguageVideoModal.tsx`
- `frontend/src/components/sign-language/SignLanguageView.tsx` - `frontend/src/components/sign-language/SignLanguageView.tsx`
- `frontend/src/components/sign-language/SignLanguageHeader.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/SignLanguageRememberPanel.tsx`
- `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` - `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx`
- `frontend/src/components/sign-language/SignLanguageFilters.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-items`
- `GET /api/content-catalog/read/sign-language-page-content` - `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: Learned progress uses:
@ -52,16 +56,25 @@ Learned progress uses:
Content payloads are seeded in: 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 ## Behavior
- `useSignLanguagePage` loads sign items, page content, and learned sign progress. - `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 - 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 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" not load/write learned-sign progress or render "Progress Saved" / "Learned"
affordances. 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. - 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. - 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`. - 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 sign records, teaching tips, page reminders, video URLs, GIF URLs, or step instructions to frontend constants.
- Do not add frontend fallback sign payloads. - Do not add frontend fallback sign payloads.
- Keep frontend constants limited to filter options, category style classes, external URL templates, and UI view modes. - 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/`. - 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 reminder is a one-time completion nudge because that quiz does not reset
weekly. These reminders are derived from backend-backed status queries, not weekly. These reminders are derived from backend-backed status queries, not
frontend constants. 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. - 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. - **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. - View components receive a prepared page model and do not call API/data access modules.
@ -51,7 +56,7 @@ Shared config:
## Tests ## 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 + `business/top-bar/search.test.ts` (module permission-filtering + content matching +
combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal). 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 - Saving a classroom strategy favorite uses `POST /api/user_progress`; removing it uses
`DELETE /api/user_progress/by-item`. `DELETE /api/user_progress/by-item`.
- The sign language page combines user progress with backend content catalog records in `useSignLanguagePage`. - 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`. - The Classroom Support page combines favorite progress with backend content catalog records in `useClassroomSupportPage`.
- Views render explicit backend errors from React Query state. - Views render explicit backend errors from React Query state.
- User progress ownership is derived by the backend from the authenticated session. - User progress ownership is derived by the backend from the authenticated session.

View File

@ -7,6 +7,12 @@
<title>FRAMEworks School Manager</title> <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." /> <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="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: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." /> <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", "@axe-core/playwright": "^4.11.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@ -74,13 +74,11 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.60.1", "typescript-eslint": "^8.60.1",
@ -98,19 +96,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@asamuzakjp/css-color": {
"version": "5.1.11", "version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
@ -2623,50 +2608,78 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/typography": {
"version": "4.3.0", "version": "0.5.20",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz",
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "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, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.21.0", "enhanced-resolve": "5.21.6",
"jiti": "^2.6.1", "jiti": "^2.7.0",
"lightningcss": "1.32.0", "lightningcss": "1.32.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "4.3.0" "tailwindcss": "4.3.1"
} }
}, },
"node_modules/@tailwindcss/oxide": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz",
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20" "node": ">= 20"
}, },
"optionalDependencies": { "optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-android-arm64": "4.3.1",
"@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.1",
"@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.1",
"@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.1",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1",
"@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.1",
"@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.1",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0" "@tailwindcss/oxide-win32-x64-msvc": "4.3.1"
} }
}, },
"node_modules/@tailwindcss/oxide-android-arm64": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz",
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2680,10 +2693,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-arm64": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz",
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2697,10 +2710,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-darwin-x64": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz",
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2714,10 +2727,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-freebsd-x64": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz",
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2731,10 +2744,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz",
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -2748,10 +2761,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz",
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2765,10 +2778,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-linux-arm64-musl": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz",
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2782,10 +2795,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-gnu": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz",
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2799,10 +2812,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-linux-x64-musl": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz",
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2816,10 +2829,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz",
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==",
"bundleDependencies": [ "bundleDependencies": [
"@napi-rs/wasm-runtime", "@napi-rs/wasm-runtime",
"@emnapi/core", "@emnapi/core",
@ -2839,17 +2852,17 @@
"@emnapi/runtime": "^1.10.0", "@emnapi/runtime": "^1.10.0",
"@emnapi/wasi-threads": "^1.2.1", "@emnapi/wasi-threads": "^1.2.1",
"@napi-rs/wasm-runtime": "^1.1.4", "@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.1", "@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz",
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -2863,10 +2876,10 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/oxide-win32-x64-msvc": { "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.0", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz",
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2880,32 +2893,26 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/postcss": { "node_modules/@tailwindcss/vite/node_modules/enhanced-resolve": {
"version": "4.3.0", "version": "5.21.6",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "graceful-fs": "^4.2.4",
"@tailwindcss/node": "4.3.0", "tapable": "^2.3.3"
"@tailwindcss/oxide": "4.3.0", },
"postcss": "^8.5.10", "engines": {
"tailwindcss": "4.3.0" "node": ">=10.13.0"
} }
}, },
"node_modules/@tailwindcss/typography": { "node_modules/@tailwindcss/vite/node_modules/tailwindcss": {
"version": "0.5.20", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz",
"integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
}
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.101.0", "version": "5.101.0",
@ -3632,43 +3639,6 @@
"node": ">=12" "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": { "node_modules/axe-core": {
"version": "4.11.4", "version": "4.11.4",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
@ -4155,20 +4125,6 @@
"embla-carousel": "8.6.0" "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": { "node_modules/entities": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@ -4520,20 +4476,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -5504,13 +5446,6 @@
"node": ">=4" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "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", "@axe-core/playwright": "^4.11.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@ -85,13 +85,11 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.60.1", "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, selectDashboardQuote,
selectDashboardUpcomingEvents, selectDashboardUpcomingEvents,
} from '@/business/dashboard/selectors'; } from '@/business/dashboard/selectors';
import {
normalizeSignLanguageItems,
selectSignLanguageSignOfWeek,
} from '@/business/sign-language/selectors';
import type { import type {
DashboardPage, DashboardPage,
DashboardProps, DashboardProps,
@ -24,7 +28,10 @@ import { MODULES } from '@/shared/constants/appData';
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard'; import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard';
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
import type { ZoneColor } from '@/shared/types/app'; import type {
SignItem,
ZoneColor,
} from '@/shared/types/app';
import type { import type {
DashboardComplianceItem, DashboardComplianceItem,
DashboardEncouragingQuote, DashboardEncouragingQuote,
@ -53,6 +60,10 @@ export function useDashboardPage({
CONTENT_CATALOG_TYPES.dashboardSignOfWeek, CONTENT_CATALOG_TYPES.dashboardSignOfWeek,
null, null,
); );
const signsQuery = useContentCatalogPayload<readonly SignItem[]>(
CONTENT_CATALOG_TYPES.signLanguageItems,
[],
);
const frameEntriesQuery = useFrameEntries(); const frameEntriesQuery = useFrameEntries();
const canUseZoneCheckIn = canPersistPersonalScopeResults(ownTenant, selectedTenant) && canZoneCheckIn(user); const canUseZoneCheckIn = canPersistPersonalScopeResults(ownTenant, selectedTenant) && canZoneCheckIn(user);
const zoneCheckInState = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn }); const zoneCheckInState = useTodayZoneCheckIn({ enabled: canUseZoneCheckIn });
@ -71,6 +82,14 @@ export function useDashboardPage({
() => selectDashboardQuote(quotesQuery.payload, dashboardDate), () => selectDashboardQuote(quotesQuery.payload, dashboardDate),
[dashboardDate, quotesQuery.payload], [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 effectiveTier = selectedTenant ? selectedTenant.level : tier;
const scopedModuleIds = useMemo( const scopedModuleIds = useMemo(
() => new Set( () => new Set(
@ -116,10 +135,10 @@ export function useDashboardPage({
isLoading: complianceItemsQuery.isLoading, isLoading: complianceItemsQuery.isLoading,
isError: Boolean(complianceItemsQuery.error), isError: Boolean(complianceItemsQuery.error),
}, },
signOfWeek: signOfWeekQuery.payload, signOfWeek,
signOfWeekState: { signOfWeekState: {
isLoading: signOfWeekQuery.isLoading, isLoading: signOfWeekQuery.isLoading || signsQuery.isLoading,
isError: Boolean(signOfWeekQuery.error), isError: Boolean(signOfWeekQuery.error || signsQuery.error),
}, },
quickActions: selectDashboardQuickActions(scopedModuleIds), quickActions: selectDashboardQuickActions(scopedModuleIds),
goToModule: setCurrentModule, goToModule: setCurrentModule,

View File

@ -5,6 +5,7 @@ import type {
} from '@/shared/constants/dashboard'; } from '@/shared/constants/dashboard';
import type { import type {
ModuleId, ModuleId,
SignItem,
UserRole, UserRole,
ZoneColor, ZoneColor,
} from '@/shared/types/app'; } from '@/shared/types/app';
@ -12,7 +13,6 @@ import type { CommunicationEventDto } from '@/shared/types/communications';
import type { import type {
DashboardComplianceItem, DashboardComplianceItem,
DashboardEncouragingQuote, DashboardEncouragingQuote,
DashboardSignOfWeek,
} from '@/shared/types/dashboard'; } from '@/shared/types/dashboard';
export interface DashboardProps { export interface DashboardProps {
@ -40,7 +40,7 @@ export interface DashboardPage {
readonly eventsState: DashboardContentState; readonly eventsState: DashboardContentState;
readonly complianceItems: readonly DashboardComplianceItem[]; readonly complianceItems: readonly DashboardComplianceItem[];
readonly complianceState: DashboardContentState; readonly complianceState: DashboardContentState;
readonly signOfWeek: DashboardSignOfWeek | null; readonly signOfWeek: SignItem | null;
readonly signOfWeekState: DashboardContentState; readonly signOfWeekState: DashboardContentState;
readonly quickActions: readonly DashboardQuickAction[]; readonly quickActions: readonly DashboardQuickAction[];
readonly goToModule: (id: ModuleId) => void; readonly goToModule: (id: ModuleId) => void;

View File

@ -1,19 +1,28 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
import { import {
buildDashboardSignOfWeekPayload,
buildSignLanguageCategories, buildSignLanguageCategories,
filterSignLanguageItems, filterSignLanguageItems,
getSignLanguageProgressPercent, getSignLanguageProgressPercent,
normalizeSignLanguageItems,
selectSignLanguageSignOfWeek,
} from '@/business/sign-language/selectors'; } from '@/business/sign-language/selectors';
import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
import type { import type {
SignLanguageDraft,
SignLanguageStepDraft,
SignLanguagePage, SignLanguagePage,
SignLanguageVideoModalState, SignLanguageVideoModalState,
} from '@/business/sign-language/types'; } from '@/business/sign-language/types';
import { useLearnedSignsProgress } from '@/business/user-progress/hooks'; import { useLearnedSignsProgress } from '@/business/user-progress/hooks';
import { updateManagedContentCatalog } from '@/shared/api/contentCatalog';
import { useScopeContext } from '@/shared/app/scope-context'; import { useScopeContext } from '@/shared/app/scope-context';
import { usePermissions } from '@/shared/app/usePermissions';
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog';
import type { import type {
SignLanguageCategoryFilter, SignLanguageCategoryFilter,
SignLanguageViewMode, SignLanguageViewMode,
@ -23,20 +32,102 @@ import type {
SignItem, SignItem,
SignLanguagePageContent, SignLanguagePageContent,
} from '@/shared/types/app'; } from '@/shared/types/app';
import type { DashboardSignOfWeek } from '@/shared/types/dashboard';
const EMPTY_SIGN_LANGUAGE_PAGE_CONTENT: SignLanguagePageContent = { const EMPTY_SIGN_LANGUAGE_PAGE_CONTENT: SignLanguagePageContent = {
rememberTitle: '', rememberTitle: '',
rememberDescription: '', rememberDescription: '',
}; };
const EMPTY_LEARNED_SIGN_IDS = new Set<string>(); 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 { 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 canPersistProgress = canPersistPersonalScopeResults(ownTenant, selectedTenant);
const canManageSigns =
effectiveTenant?.level === 'organization' && permissions.has('MANAGE_CONTENT_CATALOG');
const signsQuery = useContentCatalogPayload<readonly SignItem[]>( const signsQuery = useContentCatalogPayload<readonly SignItem[]>(
CONTENT_CATALOG_TYPES.signLanguageItems, CONTENT_CATALOG_TYPES.signLanguageItems,
[], [],
); );
const signOfWeekQuery = useContentCatalogPayload<DashboardSignOfWeek | null>(
CONTENT_CATALOG_TYPES.dashboardSignOfWeek,
null,
);
const pageContentQuery = useContentCatalogPayload<SignLanguagePageContent>( const pageContentQuery = useContentCatalogPayload<SignLanguagePageContent>(
CONTENT_CATALOG_TYPES.signLanguagePageContent, CONTENT_CATALOG_TYPES.signLanguagePageContent,
EMPTY_SIGN_LANGUAGE_PAGE_CONTENT, EMPTY_SIGN_LANGUAGE_PAGE_CONTENT,
@ -45,7 +136,45 @@ export function useSignLanguagePage(): SignLanguagePage {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<SignLanguageCategoryFilter>('all'); const [categoryFilter, setCategoryFilter] = useState<SignLanguageCategoryFilter>('all');
const [selectedSignId, setSelectedSignId] = useState<string | null>(null); 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( const filters = useMemo(
() => ({ () => ({
searchQuery, searchQuery,
@ -85,6 +214,115 @@ export function useSignLanguagePage(): SignLanguagePage {
await progress.toggleLearnedSign(id, sign.word); 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 { return {
signs, signs,
filteredSigns, filteredSigns,
@ -94,11 +332,20 @@ export function useSignLanguagePage(): SignLanguagePage {
learnedCount: learnedSignIds.size, learnedCount: learnedSignIds.size,
progressPercent, progressPercent,
canPersistProgress, canPersistProgress,
canManageSigns,
selectedSign, selectedSign,
signOfWeekId: signOfWeek?.id ?? null,
signDraft,
signDraftMode,
signDraftError,
signManagementError: saveSigns.error || saveSignOfWeek.error,
signSaveMessage,
isSavingSigns: saveSigns.isPending || saveSignOfWeek.isPending,
pendingDeleteSign,
pageContent: pageContentQuery.payload, pageContent: pageContentQuery.payload,
isLoading: signsQuery.isLoading || pageContentQuery.isLoading || (canPersistProgress && progress.isLoading), isLoading: signsQuery.isLoading || signOfWeekQuery.isLoading || pageContentQuery.isLoading || (canPersistProgress && progress.isLoading),
isSaving: canPersistProgress && progress.isSaving, isSaving: (canPersistProgress && progress.isSaving) || saveSigns.isPending || saveSignOfWeek.isPending,
signsError: signsQuery.error, signsError: signsQuery.error || signOfWeekQuery.error,
pageContentError: pageContentQuery.error, pageContentError: pageContentQuery.error,
progressErrorMessage: getOptionalErrorMessage(progress.error), progressErrorMessage: getOptionalErrorMessage(progress.error),
setSearchQuery, setSearchQuery,
@ -107,6 +354,18 @@ export function useSignLanguagePage(): SignLanguagePage {
selectSign: setSelectedSignId, selectSign: setSelectedSignId,
closeSign: () => setSelectedSignId(null), closeSign: () => setSelectedSignId(null),
toggleLearned, 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 { describe, expect, it } from 'vitest';
import { import {
buildDashboardSignOfWeekPayload,
buildSignLanguageCategories, buildSignLanguageCategories,
buildSignLanguageLifeprintGifUrl,
buildSignLanguageYoutubeSearchUrl, buildSignLanguageYoutubeSearchUrl,
filterSignLanguageItems, filterSignLanguageItems,
getSignLanguageProgressPercent, getSignLanguageProgressPercent,
getSignLanguageVideoDurationSeconds, getSignLanguageVideoDurationSeconds,
normalizeSignLanguageGifUrl,
normalizeSignLanguageItems,
normalizeSignLanguageYoutubeVideoUrl,
selectSignLanguageSignOfWeek,
toSignLanguageCategoryFilter, toSignLanguageCategoryFilter,
} from '@/business/sign-language/selectors'; } from '@/business/sign-language/selectors';
import type { SignItem } from '@/shared/types/app'; import type { SignItem } from '@/shared/types/app';
@ -87,8 +93,88 @@ describe('sign language selectors', () => {
}); });
it('builds encoded YouTube search URLs', () => { 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', '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 { import {
SIGN_LANGUAGE_CATEGORY_FILTERS, 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_PREFIX,
SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX, SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX,
SIGN_LANGUAGE_YOUTUBE_SEARCH_URL, SIGN_LANGUAGE_YOUTUBE_SEARCH_URL,
} from '@/shared/constants/signLanguage'; } from '@/shared/constants/signLanguage';
import type { SignLanguageCategoryFilter } from '@/shared/constants/signLanguage'; import type { SignLanguageCategoryFilter } from '@/shared/constants/signLanguage';
import type { SignItem } from '@/shared/types/app'; import type { SignItem } from '@/shared/types/app';
import type { DashboardSignOfWeek } from '@/shared/types/dashboard';
import { toWeekStartIso } from '@/shared/business/week';
import type { import type {
SignLanguageCategoryOption, SignLanguageCategoryOption,
SignLanguageFilters, SignLanguageFilters,
} from '@/business/sign-language/types'; } 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( export function buildSignLanguageCategories(
signs: readonly SignItem[], signs: readonly SignItem[],
): readonly SignLanguageCategoryOption[] { ): readonly SignLanguageCategoryOption[] {
@ -61,8 +128,129 @@ export function toSignLanguageCategoryFilter(value: string): SignLanguageCategor
return category?.value ?? 'all'; 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 { 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)}`; 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; 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 { export interface SignLanguageVideoModalState {
readonly showSteps: boolean; readonly showSteps: boolean;
readonly viewMode: SignLanguageViewMode; readonly viewMode: SignLanguageViewMode;
@ -40,7 +61,16 @@ export interface SignLanguagePage {
readonly learnedCount: number; readonly learnedCount: number;
readonly progressPercent: number; readonly progressPercent: number;
readonly canPersistProgress: boolean; readonly canPersistProgress: boolean;
readonly canManageSigns: boolean;
readonly selectedSign: SignItem | null; 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 pageContent: SignLanguagePageContent;
readonly isLoading: boolean; readonly isLoading: boolean;
readonly isSaving: boolean; readonly isSaving: boolean;
@ -53,4 +83,16 @@ export interface SignLanguagePage {
readonly selectSign: (id: string) => void; readonly selectSign: (id: string) => void;
readonly closeSign: () => void; readonly closeSign: () => void;
readonly toggleLearned: (id: string) => Promise<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 { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { listCampusAttendanceSummaries } from '@/shared/api/campusAttendance';
import { listFrameEntries } from '@/shared/api/frame';
import { import {
buildTopBarNotifications, buildTopBarNotifications,
countUnreadTopBarNotifications, countUnreadTopBarNotifications,
@ -17,6 +20,10 @@ import {
} from '@/business/communications/hooks'; } from '@/business/communications/hooks';
import { getScopedModules } from '@/business/app-shell/selectors'; import { getScopedModules } from '@/business/app-shell/selectors';
import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
import {
normalizeSignLanguageItems,
selectSignLanguageSignOfWeek,
} from '@/business/sign-language/selectors';
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks'; import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks';
import { useCurrentPersonalityResult } from '@/business/personality/queryHooks'; import { useCurrentPersonalityResult } from '@/business/personality/queryHooks';
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks'; 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 { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors';
import { useScopeContext } from '@/shared/app/scope-context'; import { useScopeContext } from '@/shared/app/scope-context';
import { getCurrentSafetyQuizWeek } from '@/business/safety-quiz/selectors'; 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_STRATEGIES: readonly Strategy[] = [];
const EMPTY_SIGNS: readonly SignItem[] = []; const EMPTY_SIGNS: readonly SignItem[] = [];
@ -83,6 +93,7 @@ export function useTopBarPage({
&& accessibleModuleIds.has('qbs') && accessibleModuleIds.has('qbs')
&& (effectiveTier === 'school' || effectiveTier === 'campus' || effectiveTier === 'class'); && (effectiveTier === 'school' || effectiveTier === 'campus' || effectiveTier === 'class');
const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date()); const safetyQuizWeek = getCurrentSafetyQuizWeek(new Date());
const today = new Date().toISOString().split('T')[0] ?? '';
const safetyQuizStatus = useMySafetyQuizStatus( const safetyQuizStatus = useMySafetyQuizStatus(
safetyQuizWeek, safetyQuizWeek,
canReceiveSafetyQuizNotification, canReceiveSafetyQuizNotification,
@ -90,6 +101,52 @@ export function useTopBarPage({
const needsSafetyQuiz = canReceiveSafetyQuizNotification const needsSafetyQuiz = canReceiveSafetyQuizNotification
&& !safetyQuizStatus.isLoading && !safetyQuizStatus.isLoading
&& safetyQuizStatus.data?.completed !== true; && 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 const canReceiveEmotionalIntelligenceNotifications = canPersistPersonalResults
&& hasPermission(user, 'TAKE_QUIZ') && hasPermission(user, 'TAKE_QUIZ')
&& accessibleModuleIds.has('ei'); && accessibleModuleIds.has('ei');
@ -117,18 +174,6 @@ export function useTopBarPage({
)); ));
const handbookPolicies = usePolicies(canReadHandbook); const handbookPolicies = usePolicies(canReadHandbook);
const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols); 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 // Header search = accessible modules (local) + their product content from the
// content catalog. Content is fetched lazily — only once the user types, and // content catalog. Content is fetched lazily — only once the user types, and
// only for modules the user can access. // only for modules the user can access.
@ -146,7 +191,12 @@ export function useTopBarPage({
const signsQuery = useContentCatalogPayload<readonly SignItem[]>( const signsQuery = useContentCatalogPayload<readonly SignItem[]>(
CONTENT_CATALOG_TYPES.signLanguageItems, CONTENT_CATALOG_TYPES.signLanguageItems,
EMPTY_SIGNS, EMPTY_SIGNS,
{ enabled: hasQuery && accessibleModuleIds.has('signs') }, {
enabled:
(hasQuery && accessibleModuleIds.has('signs'))
|| canReceiveSignOfWeekNotification
|| canManageWeeklySignSelection,
},
); );
const zonesQuery = useContentCatalogPayload<readonly ZoneInfo[]>( const zonesQuery = useContentCatalogPayload<readonly ZoneInfo[]>(
CONTENT_CATALOG_TYPES.regulationZones, CONTENT_CATALOG_TYPES.regulationZones,
@ -164,6 +214,47 @@ export function useTopBarPage({
zonesQuery.payload.forEach((z) => add('zones', `zone-${z.color}`, z.name)); zonesQuery.payload.forEach((z) => add('zones', `zone-${z.color}`, z.name));
return items; return items;
}, [strategiesQuery.payload, signsQuery.payload, zonesQuery.payload, moduleNameById]); }, [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( const searchResults = useMemo(
() => buildTopBarSearchResults(scopedModules, user, searchQuery, contentItems), () => 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', () => { it('surfaces EI self-assessment and personality quiz completion reminders', () => {
const reminders = buildTopBarNotifications({ const reminders = buildTopBarNotifications({
needsZoneCheckIn: false, needsZoneCheckIn: false,

View File

@ -39,6 +39,10 @@ export function countUnreadTopBarNotifications(
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
const SAFETY_QUIZ_NOTIFICATION_ID = 'safety-quiz-weekly'; 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_SELF_ASSESSMENT_NOTIFICATION_ID = 'ei-self-assessment';
const EI_PERSONALITY_QUIZ_NOTIFICATION_ID = 'ei-personality-quiz'; 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: { export function buildTopBarNotifications(input: {
readonly needsZoneCheckIn: boolean; readonly needsZoneCheckIn: boolean;
readonly needsSafetyQuiz?: boolean; readonly needsSafetyQuiz?: boolean;
readonly needsSignOfWeek?: boolean;
readonly needsWeeklySignSelection?: boolean;
readonly needsWeeklyFrameContent?: boolean;
readonly needsDailyAttendanceContent?: boolean;
readonly needsEiSelfAssessment?: boolean; readonly needsEiSelfAssessment?: boolean;
readonly needsPersonalityQuiz?: boolean; readonly needsPersonalityQuiz?: boolean;
readonly communicationEvents?: readonly CommunicationEventDto[]; 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) { if (input.needsEiSelfAssessment) {
notifications.push({ notifications.push({
id: EI_SELF_ASSESSMENT_NOTIFICATION_ID, id: EI_SELF_ASSESSMENT_NOTIFICATION_ID,

View File

@ -1,12 +1,15 @@
import { HandMetal } from 'lucide-react'; import { HandMetal } from 'lucide-react';
import type { DashboardContentState } from '@/business/dashboard/types'; import type { DashboardContentState } from '@/business/dashboard/types';
import { fileAssetUrl } from '@/business/files/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { ModuleId } from '@/shared/types/app'; import type {
import type { DashboardSignOfWeek as DashboardSignOfWeekPayload } from '@/shared/types/dashboard'; ModuleId,
SignItem,
} from '@/shared/types/app';
interface DashboardSignOfWeekProps { interface DashboardSignOfWeekProps {
readonly sign: DashboardSignOfWeekPayload | null; readonly sign: SignItem | null;
readonly state: DashboardContentState; readonly state: DashboardContentState;
readonly onNavigate: (module: ModuleId) => void; 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> <p className="text-xs text-red-300">Sign of the week could not be loaded.</p>
) : sign ? ( ) : 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"> <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={sign.image} alt={sign.alt} className="w-full h-full object-cover" /> <img src={fileAssetUrl(sign.image)} alt={`${sign.word} sign`} className="w-full h-full object-cover" />
</div> </div>
<p className="font-bold text-indigo-400 text-lg">"{sign.word}"</p> <p className="font-bold text-indigo-400 text-lg">"{sign.word}"</p>
<p className="text-xs text-slate-400 mt-1">{sign.description}</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 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 { getSignLanguageVideoDurationSeconds } from '@/business/sign-language/selectors';
import { SIGN_LANGUAGE_CATEGORY_BADGE_CLASSES } from '@/shared/constants/signLanguage'; import { SIGN_LANGUAGE_CATEGORY_BADGE_CLASSES } from '@/shared/constants/signLanguage';
import type { SignItem } from '@/shared/types/app'; import type { SignItem } from '@/shared/types/app';
@ -9,13 +11,19 @@ import { cn } from '@/lib/utils';
interface SignLanguageCardProps { interface SignLanguageCardProps {
readonly sign: SignItem; readonly sign: SignItem;
readonly isLearned: boolean; readonly isLearned: boolean;
readonly canManage: boolean;
readonly onSelect: (id: string) => void; readonly onSelect: (id: string) => void;
readonly onEdit: (sign: SignItem) => void;
readonly onDelete: (sign: SignItem) => void;
} }
export function SignLanguageCard({ export function SignLanguageCard({
sign, sign,
isLearned, isLearned,
canManage,
onSelect, onSelect,
onEdit,
onDelete,
}: SignLanguageCardProps) { }: SignLanguageCardProps) {
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') { 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"> <div className="aspect-[4/5] overflow-hidden relative bg-gradient-to-br from-slate-100 to-slate-50">
<img <img
src={sign.image} src={fileAssetUrl(sign.image)}
alt={`Sign language for "${sign.word}"`} alt={`Sign language for "${sign.word}"`}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/> />
@ -55,6 +63,37 @@ export function SignLanguageCard({
</div> </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( <span className={cn(
'absolute bottom-2 left-2 px-2 py-0.5 rounded-lg text-[10px] font-semibold', 'absolute bottom-2 left-2 px-2 py-0.5 rounded-lg text-[10px] font-semibold',
SIGN_LANGUAGE_CATEGORY_BADGE_CLASSES[sign.category], SIGN_LANGUAGE_CATEGORY_BADGE_CLASSES[sign.category],

View File

@ -7,13 +7,19 @@ import type { SignItem } from '@/shared/types/app';
interface SignLanguageGridProps { interface SignLanguageGridProps {
readonly signs: readonly SignItem[]; readonly signs: readonly SignItem[];
readonly learnedSignIds: ReadonlySet<string>; readonly learnedSignIds: ReadonlySet<string>;
readonly canManageSigns: boolean;
readonly onSelectSign: (id: string) => void; readonly onSelectSign: (id: string) => void;
readonly onEditSign: (sign: SignItem) => void;
readonly onDeleteSign: (sign: SignItem) => void;
} }
export function SignLanguageGrid({ export function SignLanguageGrid({
signs, signs,
learnedSignIds, learnedSignIds,
canManageSigns,
onSelectSign, onSelectSign,
onEditSign,
onDeleteSign,
}: SignLanguageGridProps) { }: SignLanguageGridProps) {
if (signs.length === 0) { if (signs.length === 0) {
return ( return (
@ -35,7 +41,10 @@ export function SignLanguageGrid({
key={sign.id} key={sign.id}
sign={sign} sign={sign}
isLearned={learnedSignIds.has(sign.id)} isLearned={learnedSignIds.has(sign.id)}
canManage={canManageSigns}
onSelect={onSelectSign} onSelect={onSelectSign}
onEdit={onEditSign}
onDelete={onDeleteSign}
/> />
))} ))}
</section> </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 { useSignLanguageVideoModalState } from '@/business/sign-language/hooks';
import { buildSignLanguageYoutubeSearchUrl } from '@/business/sign-language/selectors'; import { buildSignLanguageYoutubeSearchUrl } from '@/business/sign-language/selectors';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { fileAssetUrl } from '@/business/files/api';
import { import {
DEFAULT_SIGN_LANGUAGE_CATEGORY, DEFAULT_SIGN_LANGUAGE_CATEGORY,
SIGN_LANGUAGE_CATEGORY_COLORS, SIGN_LANGUAGE_CATEGORY_COLORS,
@ -31,7 +32,9 @@ export function SignLanguageVideoModal({
const state = useSignLanguageVideoModalState(); const state = useSignLanguageVideoModalState();
const categoryColor = const categoryColor =
SIGN_LANGUAGE_CATEGORY_COLORS[sign.category] ?? SIGN_LANGUAGE_CATEGORY_COLORS[DEFAULT_SIGN_LANGUAGE_CATEGORY]; 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 ( return (
<div <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]"> <div className="relative bg-gradient-to-br from-slate-900 to-slate-800 flex-shrink-0 min-h-[300px]">
{state.viewMode === 'gif' ? ( {state.viewMode === 'gif' ? (
<div className="flex items-center justify-center p-6 min-h-[300px]"> <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="absolute inset-0 flex items-center justify-center z-10">
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<RefreshCw size={28} className="animate-spin text-indigo-400 mx-auto" /> <RefreshCw size={28} className="animate-spin text-indigo-400 mx-auto" />
@ -103,9 +106,9 @@ export function SignLanguageVideoModal({
</div> </div>
)} )}
{!state.gifError ? ( {!shouldShowGifFallback ? (
<img <img
src={sign.gifUrl} src={fileAssetUrl(sign.gifUrl)}
alt={`Animated demonstration of ASL sign for "${sign.word}"`} alt={`Animated demonstration of ASL sign for "${sign.word}"`}
className={cn( className={cn(
'max-h-[280px] object-contain rounded-lg transition-opacity duration-300', '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"> <div className="flex flex-col items-center justify-center space-y-4">
<img <img
src={sign.image} src={fileAssetUrl(sign.image)}
alt={`Sign for ${sign.word}`} alt={`Sign for ${sign.word}`}
className="max-h-[240px] object-contain rounded-lg" className="max-h-[240px] object-contain rounded-lg"
/> />
@ -125,7 +128,7 @@ export function SignLanguageVideoModal({
</div> </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"> <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]" /> <RefreshCw size={10} className="text-emerald-400 animate-spin [animation-duration:3s]" />
<span className="text-[10px] text-white/80 font-medium">Looping</span> <span className="text-[10px] text-white/80 font-medium">Looping</span>

View File

@ -1,9 +1,11 @@
import { AlertTriangle, HandMetal } from 'lucide-react'; import { AlertTriangle, HandMetal } from 'lucide-react';
import type { SignLanguagePage } from '@/business/sign-language/types'; import type { SignLanguagePage } from '@/business/sign-language/types';
import { ConfirmationDialog } from '@/components/common/ConfirmationDialog';
import { SignLanguageFilters } from '@/components/sign-language/SignLanguageFilters'; import { SignLanguageFilters } from '@/components/sign-language/SignLanguageFilters';
import { SignLanguageGrid } from '@/components/sign-language/SignLanguageGrid'; import { SignLanguageGrid } from '@/components/sign-language/SignLanguageGrid';
import { SignLanguageHeader } from '@/components/sign-language/SignLanguageHeader'; import { SignLanguageHeader } from '@/components/sign-language/SignLanguageHeader';
import { SignLanguageManagementPanel } from '@/components/sign-language/SignLanguageManagementPanel';
import { SignLanguageProgressPanel } from '@/components/sign-language/SignLanguageProgressPanel'; import { SignLanguageProgressPanel } from '@/components/sign-language/SignLanguageProgressPanel';
import { SignLanguageRememberPanel } from '@/components/sign-language/SignLanguageRememberPanel'; import { SignLanguageRememberPanel } from '@/components/sign-language/SignLanguageRememberPanel';
import { SignLanguageVideoModal } from '@/components/sign-language/SignLanguageVideoModal'; import { SignLanguageVideoModal } from '@/components/sign-language/SignLanguageVideoModal';
@ -22,6 +24,8 @@ export function SignLanguageView({ page }: SignLanguageViewProps) {
<SignLanguageRememberPanel content={page.pageContent} /> <SignLanguageRememberPanel content={page.pageContent} />
<SignLanguageManagementPanel page={page} />
{page.canPersistProgress && ( {page.canPersistProgress && (
<SignLanguageProgressPanel <SignLanguageProgressPanel
learnedCount={page.learnedCount} learnedCount={page.learnedCount}
@ -63,7 +67,10 @@ export function SignLanguageView({ page }: SignLanguageViewProps) {
<SignLanguageGrid <SignLanguageGrid
signs={page.filteredSigns} signs={page.filteredSigns}
learnedSignIds={page.learnedSignIds} learnedSignIds={page.learnedSignIds}
canManageSigns={page.canManageSigns}
onSelectSign={page.selectSign} onSelectSign={page.selectSign}
onEditSign={page.startEditSign}
onDeleteSign={page.requestDeleteSign}
/> />
)} )}
@ -77,6 +84,23 @@ export function SignLanguageView({ page }: SignLanguageViewProps) {
onToggleLearned={page.toggleLearned} 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> </div>
); );
} }

View File

@ -31,6 +31,21 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
...props ...props
}, ref) => { }, ref) => {
const Comp = asChild ? Slot : "button" 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 ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} 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"; @import "tailwindcss";
@config "../tailwind.config.ts"; @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 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_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_PREFIX = 'ASL sign language';
export const SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX = 'tutorial'; export const SIGN_LANGUAGE_YOUTUBE_QUERY_SUFFIX = 'tutorial';
export const SIGN_LANGUAGE_LIFEPRINT_URL = 'https://www.lifeprint.com'; 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 const SIGN_LANGUAGE_VIEW_MODES = ['gif', 'video'] as const;
export type SignLanguageViewMode = (typeof SIGN_LANGUAGE_VIEW_MODES)[number]; export type SignLanguageViewMode = (typeof SIGN_LANGUAGE_VIEW_MODES)[number];

View File

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

View File

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

View File

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