417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
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,
|
|
paginateSignLanguageItems,
|
|
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,
|
|
} from '@/shared/constants/signLanguage';
|
|
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
|
|
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 { 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,
|
|
);
|
|
const progress = useLearnedSignsProgress({ enabled: canPersistProgress });
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [categoryFilter, setCategoryFilter] = useState<SignLanguageCategoryFilter>('all');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [selectedSignId, setSelectedSignId] = useState<string | null>(null);
|
|
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,
|
|
categoryFilter,
|
|
}),
|
|
[categoryFilter, searchQuery],
|
|
);
|
|
const categories = useMemo(
|
|
() => buildSignLanguageCategories(signs),
|
|
[signs],
|
|
);
|
|
const filteredSigns = useMemo(
|
|
() => filterSignLanguageItems(signs, filters),
|
|
[filters, signs],
|
|
);
|
|
const pagination = useMemo(
|
|
() => paginateSignLanguageItems(filteredSigns, currentPage),
|
|
[currentPage, filteredSigns],
|
|
);
|
|
const selectedSign = useMemo(
|
|
() => signs.find((sign) => sign.id === selectedSignId) ?? null,
|
|
[selectedSignId, signs],
|
|
);
|
|
const learnedSignIds = canPersistProgress ? progress.learnedSignIds : EMPTY_LEARNED_SIGN_IDS;
|
|
const progressPercent = useMemo(
|
|
() => getSignLanguageProgressPercent(signs, learnedSignIds),
|
|
[learnedSignIds, signs],
|
|
);
|
|
|
|
async function toggleLearned(id: string) {
|
|
if (!canPersistProgress) {
|
|
return;
|
|
}
|
|
|
|
const sign = signs.find((item) => item.id === id);
|
|
|
|
if (!sign) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function updateSearchQuery(value: string) {
|
|
setSearchQuery(value);
|
|
setCurrentPage(1);
|
|
}
|
|
|
|
function updateCategoryFilter(value: SignLanguageCategoryFilter) {
|
|
setCategoryFilter(value);
|
|
setCurrentPage(1);
|
|
}
|
|
|
|
function updatePage(page: number) {
|
|
setCurrentPage(paginateSignLanguageItems(filteredSigns, page).currentPage);
|
|
}
|
|
|
|
return {
|
|
signs,
|
|
filteredSigns,
|
|
visibleSigns: pagination.items,
|
|
pagination,
|
|
categories,
|
|
filters,
|
|
learnedSignIds,
|
|
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 || 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: updateSearchQuery,
|
|
clearSearch: () => updateSearchQuery(''),
|
|
setCategoryFilter: updateCategoryFilter,
|
|
setPage: updatePage,
|
|
selectSign: setSelectedSignId,
|
|
closeSign: () => setSelectedSignId(null),
|
|
toggleLearned,
|
|
startCreateSign,
|
|
startEditSign,
|
|
updateSignDraft,
|
|
updateSignDraftStep,
|
|
addSignDraftStep,
|
|
removeSignDraftStep,
|
|
cancelSignDraft,
|
|
saveSignDraft,
|
|
requestDeleteSign,
|
|
cancelDeleteSign,
|
|
confirmDeleteSign,
|
|
selectSignOfWeek,
|
|
};
|
|
}
|
|
|
|
export function useSignLanguageVideoModalState(): SignLanguageVideoModalState {
|
|
const [showSteps, setShowSteps] = useState(false);
|
|
const [viewMode, setViewMode] = useState<SignLanguageViewMode>('gif');
|
|
const [gifLoaded, setGifLoaded] = useState(false);
|
|
const [gifError, setGifError] = useState(false);
|
|
|
|
return {
|
|
showSteps,
|
|
viewMode,
|
|
gifLoaded,
|
|
gifError,
|
|
showStepGuide: () => setShowSteps(true),
|
|
hideStepGuide: () => setShowSteps(false),
|
|
toggleStepGuide: () => setShowSteps((current) => !current),
|
|
setViewMode,
|
|
markGifLoaded: () => setGifLoaded(true),
|
|
markGifFailed: () => {
|
|
setGifError(true);
|
|
setGifLoaded(true);
|
|
},
|
|
};
|
|
}
|