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