diff --git a/ui/all-translations-het-archief.json b/ui/all-translations-het-archief.json index 87537bac2..55a1ce444 100644 --- a/ui/all-translations-het-archief.json +++ b/ui/all-translations-het-archief.json @@ -17859,6 +17859,386 @@ "value": "Achtergrondkleur tekstvak is verplicht", "value_type": "TEXT" }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "driekeuzespeler", + "language": "nl", + "value": "Driekeuzespeler", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "driekeuzespeler", + "language": "en", + "value": "Three-choice player", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel", + "language": "nl", + "value": "Titel", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel", + "language": "en", + "value": "Title", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel-is-verplicht", + "language": "nl", + "value": "Titel is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "titel-is-verplicht", + "language": "en", + "value": "Title is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop", + "language": "nl", + "value": "Label van de shuffleknop", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop", + "language": "en", + "value": "Shuffle button label", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop-is-verplicht", + "language": "nl", + "value": "Label van de shuffleknop is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "label-van-de-shuffle-knop-is-verplicht", + "language": "en", + "value": "Shuffle button label is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-tegel", + "language": "nl", + "value": "Tegel", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-tegel", + "language": "en", + "value": "Tile", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-interesse-op-tegel", + "language": "nl", + "value": "Kleuren van de interesse op tegel", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "kleuren-van-de-interesse-op-tegel", + "language": "en", + "value": "Colours of the interest on tile", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "interesse", + "language": "nl", + "value": "Interesse", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "interesse", + "language": "en", + "value": "Interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse", + "language": "nl", + "value": "Naam van de interesse", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse", + "language": "en", + "value": "Name of the interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse-is-verplicht", + "language": "nl", + "value": "Naam van de interesse is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "naam-van-de-interesse-is-verplicht", + "language": "en", + "value": "Name of the interest is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "gerelateerd-thema", + "language": "nl", + "value": "Gerelateerd thema", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "gerelateerd-thema", + "language": "en", + "value": "Related theme", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "een-thema-is-verplicht", + "language": "nl", + "value": "Een thema is verplicht", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "een-thema-is-verplicht", + "language": "en", + "value": "A theme is required", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "voeg-een-interesse-toe", + "language": "nl", + "value": "Voeg een interesse toe", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "voeg-een-interesse-toe", + "language": "en", + "value": "Add an interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "verwijder-deze-interesse", + "language": "nl", + "value": "Verwijder deze interesse", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "verwijder-deze-interesse", + "language": "en", + "value": "Delete this interest", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "toon-me-iets-anders", + "language": "nl", + "value": "Toon me iets anders", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler", + "key": "toon-me-iets-anders", + "language": "en", + "value": "Show me something else", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/const/get-content-block-type-options", + "key": "driekeuzespeler", + "language": "nl", + "value": "Driekeuzespeler", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "modules/content-page/const/get-content-block-type-options", + "key": "driekeuzespeler", + "language": "en", + "value": "Three-choice player", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "kies-een-thema", + "language": "nl", + "value": "Kies een thema", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "kies-een-thema", + "language": "en", + "value": "Choose a theme", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "geen-themas-gevonden", + "language": "nl", + "value": "Geen thema's gevonden", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "geen-themas-gevonden", + "language": "en", + "value": "No themes found", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "bezig-met-laden", + "language": "nl", + "value": "Bezig met laden", + "value_type": "TEXT" + }, + { + "id": "", + "app": "HET_ARCHIEF", + "component": "ADMIN_CORE", + "location": "react-admin/modules/shared/components/theme-select/theme-select", + "key": "bezig-met-laden", + "language": "en", + "value": "Loading", + "value_type": "TEXT" + }, { "id": "", "app": "HET_ARCHIEF", diff --git a/ui/all-translations-het-archief.sql b/ui/all-translations-het-archief.sql index a2dc0945e..c6da24dfe 100644 --- a/ui/all-translations-het-archief.sql +++ b/ui/all-translations-het-archief.sql @@ -1385,6 +1385,44 @@ INSERT INTO app.translations ("component", "location", "key", "value", "value_ty INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-double-banner/block-double-banner', 'tekstkleur', 'Tekstkleur', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tekstkleur', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-double-banner/block-double-banner', 'tekstkleur-is-verplicht', 'Tekstkleur is verplicht', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tekstkleur is verplicht', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-double-banner/block-double-banner', 'tekstkleur-is-verplicht', 'Tekstkleur is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tekstkleur is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'driekeuzespeler', 'Driekeuzespeler', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Driekeuzespeler', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'driekeuzespeler', 'Three-choice player', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Three-choice player', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel', 'Titel', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Titel', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel', 'Title', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Title', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel-is-verplicht', 'Titel is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Titel is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'titel-is-verplicht', 'Title is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Title is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop', 'Label van de shuffleknop', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Label van de shuffleknop', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop', 'Shuffle button label', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Shuffle button label', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop-is-verplicht', 'Label van de shuffleknop is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Label van de shuffleknop is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'label-van-de-shuffle-knop-is-verplicht', 'Shuffle button label is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Shuffle button label is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-tegel', 'Tegel', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tegel', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-tegel', 'Tile', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Tile', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-interesse-op-tegel', 'Kleuren van de interesse op tegel', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Kleuren van de interesse op tegel', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'kleuren-van-de-interesse-op-tegel', 'Colours of the interest on tile', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Colours of the interest on tile', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'interesse', 'Interesse', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Interesse', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'interesse', 'Interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse', 'Naam van de interesse', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Naam van de interesse', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse', 'Name of the interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Name of the interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse-is-verplicht', 'Naam van de interesse is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Naam van de interesse is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'naam-van-de-interesse-is-verplicht', 'Name of the interest is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Name of the interest is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'gerelateerd-thema', 'Gerelateerd thema', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Gerelateerd thema', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'gerelateerd-thema', 'Related theme', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Related theme', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'een-thema-is-verplicht', 'Een thema is verplicht', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Een thema is verplicht', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'een-thema-is-verplicht', 'A theme is required', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'A theme is required', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'voeg-een-interesse-toe', 'Voeg een interesse toe', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Voeg een interesse toe', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'voeg-een-interesse-toe', 'Add an interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Add an interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'verwijder-deze-interesse', 'Verwijder deze interesse', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Verwijder deze interesse', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'verwijder-deze-interesse', 'Delete this interest', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Delete this interest', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'toon-me-iets-anders', 'Toon me iets anders', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Toon me iets anders', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler', 'toon-me-iets-anders', 'Show me something else', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Show me something else', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/get-content-block-type-options', 'driekeuzespeler', 'Driekeuzespeler', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Driekeuzespeler', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/const/get-content-block-type-options', 'driekeuzespeler', 'Three-choice player', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Three-choice player', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'kies-een-thema', 'Kies een thema', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Kies een thema', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'kies-een-thema', 'Choose a theme', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Choose a theme', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'geen-themas-gevonden', 'Geen thema''s gevonden', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Geen thema''s gevonden', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'geen-themas-gevonden', 'No themes found', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'No themes found', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'bezig-met-laden', 'Bezig met laden', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Bezig met laden', value_type = 'TEXT'; +INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'react-admin/modules/shared/components/theme-select/theme-select', 'bezig-met-laden', 'Loading', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Loading', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel', 'achtergrondafbeelding-krijgt-voorrang-op-achtergrondkleur', 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel', 'achtergrondafbeelding-krijgt-voorrang-op-achtergrondkleur', 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', 'TEXT', 'nl') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Achtergrondafbeelding (krijgt voorrang op achtergrondkleur)', value_type = 'TEXT'; INSERT INTO app.translations ("component", "location", "key", "value", "value_type", "language") VALUES ('ADMIN_CORE', 'modules/content-page/components/blocks/block-hero-carousel/block-hero-carousel', 'animatie-tekst', 'Animatie tekst', 'TEXT', 'en') ON CONFLICT (component, location, key, language) DO UPDATE SET value = 'Animatie tekst', value_type = 'TEXT'; diff --git a/ui/package-lock.json b/ui/package-lock.json index 3a4f1d1f7..90ba2a382 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -77,11 +77,11 @@ "@emotion/react": "11.14.0", "@floating-ui/react": "0.27.18", "@hookform/resolvers": "2.9.11", - "@meemoo/react-components": "6.0.12", + "@meemoo/react-components": "6.0.14", "@studiohyperdrive/pagination": "1.0.0", "@tanstack/react-query": "5.99.0", - "@viaa/avo2-components": "7.0.6", - "@viaa/avo2-types": "5.0.20", + "@viaa/avo2-components": "7.0.9", + "@viaa/avo2-types": "5.0.21", "autosize": "6.0.1", "blend-promise-utils": "1.29.2", "caniuse-lite": "1.0.30001712", @@ -2476,9 +2476,9 @@ "license": "MIT" }, "node_modules/@meemoo/react-components": { - "version": "6.0.12", - "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@meemoo/react-components/-/react-components-6.0.12.tgz", - "integrity": "sha512-/FpktIRHzwRx6jfMpVFRaxrXZuDmoNE9JRqEkDeE9bXjjBB3lyuUlGbxQeCCPOeFYYpaUHpu12A0iMi2uojyBQ==", + "version": "6.0.14", + "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@meemoo/react-components/-/react-components-6.0.14.tgz", + "integrity": "sha512-/7JLrHG8XWKQ9RR0b2fnkioGGzX+tpWUCTNYwF7HdyBwAowxF2o0pf7apFyyCQVzLvcUvStR1uFV6zUWs7PsBA==", "peer": true, "dependencies": { "use-sync-external-store": "1.4.0" @@ -2488,7 +2488,7 @@ "@floating-ui/react": "0.27.18", "@flowplayer/player": "3.32.1", "@tanstack/react-table": "8.21.3", - "@viaa/avo2-types": "5.0.20", + "@viaa/avo2-types": "5.0.21", "autosize": "6.0.1", "clsx": "2.0.0", "date-fns": "3.6.0", @@ -4569,13 +4569,13 @@ } }, "node_modules/@viaa/avo2-components": { - "version": "7.0.6", - "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-components/-/avo2-components-7.0.6.tgz", - "integrity": "sha512-GcHvtTrce6SmZ3mnpzXV0WmhprPE+Ek+3WhKehZYEH0VgyzbT+DKgbv21v5ZcQlPZnm/BU8svr+FIqEFdnyheg==", + "version": "7.0.9", + "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-components/-/avo2-components-7.0.9.tgz", + "integrity": "sha512-z1hLoj/eBQaSIXMiqtJAmkno4Q9tI6o/pnWRu743sLNgjk1QSpoaXMKhAEKg+vxP+iz60N/IG4M4HdGfhytSoA==", "peer": true, "peerDependencies": { "@popperjs/core": "2.11.6", - "@viaa/avo2-types": "5.0.20", + "@viaa/avo2-types": "5.0.21", "autosize": "6.0.1", "clsx": "2.0.0", "date-fns": "3.6.0", @@ -4592,9 +4592,9 @@ } }, "node_modules/@viaa/avo2-types": { - "version": "5.0.20", - "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-types/-/avo2-types-5.0.20.tgz", - "integrity": "sha512-ZllWhdGmO9XCITFpmQMliGs+G5gsM4L+b3SNnFhi7+9P3PJ3FXC4/HW0Oe6c5No2ho7rTHX8vcsfO/rM2HwKaA==", + "version": "5.0.21", + "resolved": "http://do-prd-mvn-01.do.viaa.be:8081/repository/npm-viaa/@viaa/avo2-types/-/avo2-types-5.0.21.tgz", + "integrity": "sha512-pXUqfcrB96gP0ZFjZ1+Hu6S0vZpo5wyJMp4im1gNgrYG/xKEQEQWRJ6S9iMwTLqJr0FK3HcMb10x7r7KkJEFig==", "peer": true }, "node_modules/@vitejs/plugin-react": { diff --git a/ui/package.json b/ui/package.json index 531ef00f4..fa6b0bc99 100644 --- a/ui/package.json +++ b/ui/package.json @@ -76,11 +76,11 @@ "@emotion/react": "11.14.0", "@floating-ui/react": "0.27.18", "@hookform/resolvers": "2.9.11", - "@meemoo/react-components": "6.0.12", + "@meemoo/react-components": "6.0.14", "@studiohyperdrive/pagination": "1.0.0", "@tanstack/react-query": "5.99.0", - "@viaa/avo2-components": "7.0.6", - "@viaa/avo2-types": "5.0.20", + "@viaa/avo2-components": "7.0.9", + "@viaa/avo2-types": "5.0.21", "autosize": "6.0.1", "blend-promise-utils": "1.29.2", "caniuse-lite": "1.0.30001712", diff --git a/ui/src/client.ts b/ui/src/client.ts index 17c093863..4a84b3915 100644 --- a/ui/src/client.ts +++ b/ui/src/client.ts @@ -38,7 +38,13 @@ export { BlockThreeClickableTiles } from '~content-blocks/BlockThreeClickableTil export { BlockUitgeklaard } from '~content-blocks/BlockUitgeklaard'; export { BlockVideoTitleTextButtonWrapper } from '~content-blocks/BlockVideoTitleTextButton/BlockVideoTitleTextButton.wrapper'; export { AdminConfigManager, AdminCoreIconName, ToastType } from '~core/config'; -export type { AdminConfig, IeObjectPlayInfo, LinkInfo, ToastInfo } from '~core/config/config.types'; +export type { + AdminConfig, + IeObjectPlayInfo, + IiifViewerConfigProps, + LinkInfo, + ToastInfo, +} from '~core/config/config.types'; export { ContentPageService } from '~modules/content-page/services/content-page.service'; export { ContentBlockType } from '~modules/content-page/types/content-block.types'; export type { DbContentPage } from '~modules/content-page/types/content-pages.types'; diff --git a/ui/src/react-admin/core/config/config.types.ts b/ui/src/react-admin/core/config/config.types.ts index 98fb7a048..433fe783b 100644 --- a/ui/src/react-admin/core/config/config.types.ts +++ b/ui/src/react-admin/core/config/config.types.ts @@ -3,6 +3,8 @@ import type { AvoCoreDatabaseType, AvoEducationOrganizationOrganization, AvoUserCommonUser, + HetArchiefIeObject as IeObject, + HetArchiefIeObjectType as IeObjectType, } from '@viaa/avo2-types'; import type { ComponentType, FC, FunctionComponent, MouseEvent, ReactNode } from 'react'; import type { OrderProperty } from '~content-blocks/BlockObjectsGrid/BlockObjectsGrid.types.ts'; @@ -14,7 +16,6 @@ import type { import type { App, Locale } from '~modules/translations/translations.core.types'; import type { UserBulkAction } from '~modules/user/user.types'; import type { FlowPlayerWrapperProps } from '~shared/components/FlowPlayerWrapper/FlowPlayerWrapper.types'; -import type { IeObjectType } from '~shared/helpers/map-format-to-type.ts'; /** * What a consuming app needs to log a play of an ie-object rendered by a content block. The @@ -129,6 +130,10 @@ export interface AdminConfig { schemaIdentifier: string, name: string | null | undefined ) => string; + // Path of the search page pre-filtered on a theme. Lives in the config for the same reason + // as getIeObjectDetailPath: the client's own advanced-filter url encoding is reused here, + // without the admin-core needing to depend on the client package. + getThemeSearchPath?: (locale: string, themeSlug: string) => string; }; components: { loader: { @@ -136,6 +141,8 @@ export interface AdminConfig { }; defaultAudioStill: string; flowplayer?: FC; // User by avo for + /** Injected by the host like `flowplayer`. Unset falls back to the flat IIIF detail image. */ + iiifViewer?: FC; buttonTypes: () => { label: string; value: string }[]; enableMultiLanguage: boolean; }; @@ -208,6 +215,16 @@ export interface AdminConfig { }; } +/** + * Page state and the per-page ticket stay with the host: a ticket is short-lived and access-checked + * at request time, so it cannot be handed over already resolved. + */ +export interface IiifViewerConfigProps { + ieObject: IeObject; + /** Names the viewer for assistive technology, since the object title lives outside it. */ + title?: string; +} + /** * The icons admin-core renders through the client config. Every client maps each of these onto one * of its own icons in `icon.componentProps`, so each client decides what eg: a warning looks like. @@ -233,6 +250,7 @@ export enum AdminCoreIconName { ChevronLeft = 'chevronLeft', Clock = 'clock', Collection = 'collection', + CollectionShuffle = 'collectionShuffle', Copy = 'copy', Delete = 'delete', Edit = 'edit', diff --git a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx index 839b7e0f8..164aa8ec9 100644 --- a/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx +++ b/ui/src/react-admin/modules/content-page/components/ContentBlockRenderer/ContentBlockRenderer.const.tsx @@ -8,6 +8,7 @@ import { BlockCardsWithoutDescription } from '~content-blocks/BlockCardsWithoutD import { BlockContentPageMeta } from '~content-blocks/BlockContentPageMeta'; import { BlockCTAsWrapper } from '~content-blocks/BlockCTAs'; import { BlockDoubleBanner } from '~content-blocks/BlockDoubleBanner'; +import { BlockDriekeuzespeler } from '~content-blocks/BlockDriekeuzespeler'; import { BlockEventbrite } from '~content-blocks/BlockEventbrite'; import { BlockHeading } from '~content-blocks/BlockHeading'; import { BlockHeroCarousel } from '~content-blocks/BlockHeroCarousel'; @@ -111,6 +112,7 @@ export function GET_BLOCK_COMPONENT( [ContentBlockType.Timeline]: BlockTimeline, [ContentBlockType.ImageCarousel]: BlockImageCarousel, [ContentBlockType.TitleWithParallax]: BlockTitleWithParallax, + [ContentBlockType.ThreeChoicesPlayer]: BlockDriekeuzespeler, // Avo specific blocks [ContentBlockType.MediaGrid]: loadComponentFromConfig(ContentBlockType.MediaGrid), diff --git a/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx b/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx index 4fa088111..3996da9e0 100644 --- a/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx +++ b/ui/src/react-admin/modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx @@ -1,15 +1,15 @@ import { Button } from '@meemoo/react-components'; import { Icon, type IconName } from '@viaa/avo2-components'; -import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import { + AvoCoreContentPickerType, + type HetArchiefIeObjectType as IeObjectType, + HetArchiefSimpleIeObjectType as SimpleIeObjectType, +} from '@viaa/avo2-types'; import { clsx } from 'clsx'; import type { FunctionComponent } from 'react'; import React from 'react'; import { SmartLink } from '~shared/components/SmartLink/SmartLink'; -import { - type IeObjectType, - mapDcTermsFormatToSimpleType, - SimpleIeObjectType, -} from '~shared/helpers/map-format-to-type.ts'; +import { mapDcTermsFormatToSimpleType } from '~shared/helpers/map-format-to-type.ts'; import { useIsMobileWidth } from '~shared/helpers/media-query.ts'; import { tText } from '~shared/helpers/translation-functions'; import type { PlayableDisplayIeObject } from '~shared/services/ie-objects-service/ie-objects.types.ts'; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.test.ts new file mode 100644 index 000000000..641da0830 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + ContentBlockConfig, + ContentBlockField, + ContentBlockFieldGroup, + DriekeuzespelerBlockComponentState, +} from '~modules/content-page/types/content-block.types'; +import { Color, ContentBlockEditor } from '~modules/content-page/types/content-block.types'; +import { + DRIEKEUZESPELER_CONFIG, + DRIEKEUZESPELER_MAX_INTERESTS, + DRIEKEUZESPELER_MIN_INTERESTS, + DRIEKEUZESPELER_TILE_COUNT, + INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE, +} from './BlockDriekeuzespeler.editorconfig'; + +vi.mock('~shared/helpers/translation-functions', () => ({ + tText: (key: string) => key, +})); + +vi.mock('~shared/helpers/is-avo', () => ({ + isAvo: () => false, +})); + +describe('DRIEKEUZESPELER_CONFIG', () => { + // Rebuilt per test, so nothing one test does to the config leaks into the next. + let config: ContentBlockConfig; + + beforeEach(() => { + config = DRIEKEUZESPELER_CONFIG(); + }); + + const group = (key: string): ContentBlockFieldGroup => + config.components.fields[key] as ContentBlockFieldGroup; + const field = (key: string): ContentBlockField => + config.components.fields[key] as ContentBlockField; + + it('starts with one colour entry per tile and the minimum number of interests', () => { + const state = INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(); + + expect(state.tileColors).toHaveLength(DRIEKEUZESPELER_TILE_COUNT); + expect(state.interests).toHaveLength(DRIEKEUZESPELER_MIN_INTERESTS); + }); + + it('defaults every tile to no background and black text, as the FA asks', () => { + const state = INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(); + + for (const entry of state.tileColors) { + expect(entry).toEqual({ + backgroundColor: Color.Transparent, + textColor: Color.Black, + }); + } + }); + + it('renders the fields in the order the FA lists them', () => { + // Object.keys order is the render order in ContentBlockFormGroup, so it is a real UI contract. + expect(Object.keys(config.components.fields)).toEqual([ + 'title', + 'tileColors', + 'shuffleButtonLabel', + 'interests', + ]); + }); + + it('gives every initial interest empty required fields', () => { + const state = INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(); + + for (const interest of state.interests) { + expect(interest).toEqual({ name: '', mediaItem: undefined, theme: undefined }); + } + }); + + it('holds the interests between 3 and 200', () => { + expect(group('interests').min).toBe(DRIEKEUZESPELER_MIN_INTERESTS); + expect(group('interests').max).toBe(DRIEKEUZESPELER_MAX_INTERESTS); + expect(DRIEKEUZESPELER_MAX_INTERESTS).toBe(200); + }); + + it('fixes the colour list at three, so the count cannot change', () => { + expect(group('tileColors').min).toBe(DRIEKEUZESPELER_TILE_COUNT); + expect(group('tileColors').max).toBe(DRIEKEUZESPELER_TILE_COUNT); + }); + + it('holds both colours of a tile in the same entry', () => { + // One group per tile, so the admin fills a tile in completely instead of jumping between lists. + expect(Object.keys(group('tileColors').fields ?? {})).toEqual(['backgroundColor', 'textColor']); + }); + + it('marks both repeated sets as field groups, so FieldGenerator iterates them', () => { + // A group without `type: 'fieldGroup'` falls through to the single-field branch, and one + // without `repeat` renders once instead of per entry. + for (const key of ['tileColors', 'interests']) { + expect(group(key).type).toBe('fieldGroup'); + expect(group(key).repeat).toBeDefined(); + } + }); + + it('requires a title and a shuffle label', () => { + expect(field('title').validator?.('')).toEqual(expect.arrayContaining([expect.any(String)])); + expect(field('shuffleButtonLabel').validator?.('')).toEqual( + expect.arrayContaining([expect.any(String)]) + ); + expect(field('title').validator?.('Ontdek')).toEqual([]); + }); + + it('requires a name and an object on every interest', () => { + const interestFields = group('interests').fields; + + // A plain ContentPicker field like every other block's, so it carries no validator of its own. + const filledValues: Record = { + name: 'Wielrennen', + mediaItem: { type: 'IE_OBJECT', value: '086348mc8s' }, + }; + + for (const [key, filled] of Object.entries(filledValues)) { + expect(interestFields[key].validator?.('')).toEqual( + expect.arrayContaining([expect.any(String)]) + ); + expect(interestFields[key].validator?.(filled)).toEqual([]); + } + }); + + it('picks the object with the shared object picker, so nobody types a pid by hand', () => { + // The `mediaItem` key is shared by every block pointing at an ie-object, and read by the proxy. + const mediaItem = group('interests').fields.mediaItem; + + expect(mediaItem.editorType).toBe(ContentBlockEditor.ContentPicker); + expect(mediaItem.editorProps).toMatchObject({ + allowedTypes: ['IE_OBJECT'], + hideTypeDropdown: true, + }); + }); + + it('picks the theme with the shared content picker, so an interest links to exactly one theme', () => { + // The same picker BlockThemeReels and BlockOverviewThemes use, locked to themes. + const theme = group('interests').fields.theme; + + expect(theme.editorType).toBe(ContentBlockEditor.ContentPicker); + expect(theme.editorProps).toMatchObject({ + allowedTypes: ['IE_OBJECT_THEME'], + hideTypeDropdown: true, + hideTargetSwitch: true, + }); + }); + + it('types its own initial state', () => { + // Compile-time check that the state the editor stores matches the state the block renders. + const state: DriekeuzespelerBlockComponentState = + INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE() as DriekeuzespelerBlockComponentState; + + expect(state.title).toBe(''); + expect(state.shuffleButtonLabel).toBe(''); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.ts new file mode 100644 index 000000000..f87fdf663 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.editorconfig.ts @@ -0,0 +1,189 @@ +import { AvoCoreContentPickerType } from '@viaa/avo2-types'; +import { + BACKGROUND_COLOR_FIELD, + BLOCK_FIELD_DEFAULTS, + BLOCK_STATE_DEFAULTS, + FOREGROUND_COLOR_FIELD, + IE_OBJECT_FIELD, + TEXT_FIELD, +} from '~content-blocks/defaults'; +import { + GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF, + GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF, +} from '~modules/content-page/const/get-color-options.ts'; +import { + Color, + type ContentBlockConfig, + ContentBlockEditor, + ContentBlockType, + type DefaultContentBlockState, + type DriekeuzespelerInterestState, + type DriekeuzespelerTileColors, +} from '~modules/content-page/types/content-block.types'; +import { tText } from '~shared/helpers/translation-functions'; +import { HET_ARCHIEF } from '~shared/types'; + +/** + * The block always renders three tiles, so the colour list is fixed at three entries and the + * interest list needs at least three to fill them. + */ +export const DRIEKEUZESPELER_TILE_COUNT = 3; +export const DRIEKEUZESPELER_MIN_INTERESTS = 3; +export const DRIEKEUZESPELER_MAX_INTERESTS = 200; + +const INITIAL_TILE_COLORS_STATE = (): DriekeuzespelerTileColors => ({ + backgroundColor: Color.Transparent, + textColor: Color.Black, +}); + +const INITIAL_DRIEKEUZESPELER_INTEREST_STATE = (): DriekeuzespelerInterestState => ({ + name: '', + mediaItem: undefined, + theme: undefined, +}); + +export const INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE = () => ({ + title: '', + tileColors: Array.from({ length: DRIEKEUZESPELER_TILE_COUNT }, () => INITIAL_TILE_COLORS_STATE()), + shuffleButtonLabel: '', + // Start at the minimum, so a freshly added block is one valid tile-set away from being usable. + interests: Array.from({ length: DRIEKEUZESPELER_MIN_INTERESTS }, () => + INITIAL_DRIEKEUZESPELER_INTEREST_STATE() + ), +}); + +export const INITIAL_DRIEKEUZESPELER_BLOCK_STATE = (): DefaultContentBlockState => ({ + ...BLOCK_STATE_DEFAULTS(), + fullWidth: true, +}); + +export const DRIEKEUZESPELER_CONFIG = (position = 0): ContentBlockConfig => ({ + position, + name: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___driekeuzespeler', + undefined, + [HET_ARCHIEF] + ), + type: ContentBlockType.ThreeChoicesPlayer, + components: { + state: INITIAL_DRIEKEUZESPELER_COMPONENTS_STATE(), + fields: { + title: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___titel', + undefined, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___titel-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ), + tileColors: { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___kleuren-van-de-interesse-op-tegel', + undefined, + [HET_ARCHIEF] + ), + type: 'fieldGroup', + min: DRIEKEUZESPELER_TILE_COUNT, + max: DRIEKEUZESPELER_TILE_COUNT, + repeat: { + defaultState: INITIAL_TILE_COLORS_STATE(), + }, + fields: { + backgroundColor: BACKGROUND_COLOR_FIELD( + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___achtergrondkleur', + undefined, + [HET_ARCHIEF] + ), + GET_BACKGROUND_COLOR_OPTIONS_ARCHIEF()[0] + ), + textColor: FOREGROUND_COLOR_FIELD( + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___tekstkleur', + undefined, + [HET_ARCHIEF] + ), + GET_FOREGROUND_COLOR_OPTIONS_ARCHIEF()[0] + ), + }, + }, + shuffleButtonLabel: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___label-van-de-shuffle-knop', + undefined, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___label-van-de-shuffle-knop-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ), + interests: { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___interesse', + undefined, + [HET_ARCHIEF] + ), + type: 'fieldGroup', + min: DRIEKEUZESPELER_MIN_INTERESTS, + max: DRIEKEUZESPELER_MAX_INTERESTS, + fields: { + name: TEXT_FIELD( + { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___naam-van-de-interesse', + undefined, + [HET_ARCHIEF] + ), + }, + tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___naam-van-de-interesse-is-verplicht', + undefined, + [HET_ARCHIEF] + ) + ), + mediaItem: IE_OBJECT_FIELD({ isRequired: true }), + theme: { + label: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___gerelateerd-thema', + undefined, + [HET_ARCHIEF] + ), + editorType: ContentBlockEditor.ContentPicker, + editorProps: { + allowedTypes: [AvoCoreContentPickerType.IE_OBJECT_THEME], + hideTypeDropdown: true, + hideTargetSwitch: true, + }, + }, + }, + repeat: { + defaultState: INITIAL_DRIEKEUZESPELER_INTEREST_STATE(), + addButtonLabel: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___voeg-een-interesse-toe', + undefined, + [HET_ARCHIEF] + ), + deleteButtonLabel: tText( + 'modules/content-page/components/blocks/block-driekeuzespeler/block-driekeuzespeler___verwijder-deze-interesse', + undefined, + [HET_ARCHIEF] + ), + }, + }, + }, + }, + block: { + state: INITIAL_DRIEKEUZESPELER_BLOCK_STATE(), + fields: BLOCK_FIELD_DEFAULTS(), + }, +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.test.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.test.ts new file mode 100644 index 000000000..973fdc4ef --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { pickNextSelection, pickRandomIndices } from './BlockDriekeuzespeler.helpers'; + +/** A deterministic stand-in for Math.random that walks the given values and then repeats the last. */ +const seededRandom = (values: number[]): (() => number) => { + let call = 0; + + return () => values[Math.min(call++, values.length - 1)]; +}; + +describe('pickRandomIndices', () => { + it('picks the requested number of indices', () => { + expect(pickRandomIndices(200, 3)).toHaveLength(3); + }); + + it('never repeats an index', () => { + for (let run = 0; run < 200; run++) { + const picked = pickRandomIndices(5, 3); + + expect(new Set(picked).size).toBe(picked.length); + } + }); + + it('only returns indices that exist', () => { + for (let run = 0; run < 200; run++) { + for (const index of pickRandomIndices(4, 3)) { + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(4); + } + } + }); + + it('returns every index, shuffled, when there are fewer interests than tiles', () => { + expect(pickRandomIndices(2, 3).sort()).toEqual([0, 1]); + }); + + it('returns nothing for an empty list or a zero count', () => { + expect(pickRandomIndices(0, 3)).toEqual([]); + expect(pickRandomIndices(5, 0)).toEqual([]); + }); + + it('can reach every index across many runs, so nothing is unreachable', () => { + const seen = new Set(); + + for (let run = 0; run < 500; run++) { + for (const index of pickRandomIndices(5, 3)) { + seen.add(index); + } + } + + expect([...seen].sort()).toEqual([0, 1, 2, 3, 4]); + }); + + it('is driven entirely by the injected random source', () => { + // All zeroes means "always swap with the first remaining index", which leaves the head of the + // list in place. + expect(pickRandomIndices(5, 3, seededRandom([0]))).toEqual([0, 1, 2]); + }); +}); + +describe('pickNextSelection', () => { + it('never hands back the arrangement the visitor is already looking at', () => { + for (const total of [3, 4, 10, 200]) { + for (let run = 0; run < 200; run++) { + expect(pickNextSelection(total, 3, [0, 1, 2])).not.toEqual([0, 1, 2]); + } + } + }); + + it('keeps working when the list holds exactly as many interests as there are tiles', () => { + // Only the tile each interest lands on can change, so it must still return a full selection. + const next = pickNextSelection(3, 3, [0, 1, 2]); + + expect([...next].sort()).toEqual([0, 1, 2]); + }); + + it('reorders the tiles when the list holds exactly as many interests as there are tiles', () => { + // Draws the previous order first, then a different one: the retry has to take the second draw. + expect(pickNextSelection(3, 3, [0, 1, 2], [], seededRandom([0, 0, 0, 0.99, 0, 0]))).toEqual([ + 2, 1, 0, + ]); + }); + + it('gives up when a single interest leaves nothing to reorder', () => { + expect(pickNextSelection(1, 3, [0])).toEqual([0]); + }); + + it('handles a first shuffle with no previous selection', () => { + expect(pickNextSelection(10, 3, [])).toHaveLength(3); + }); + + it('still returns a full selection when it retries', () => { + // A random source that first reproduces the previous set forces the retry path. + const next = pickNextSelection(4, 3, [0, 1, 2], [], seededRandom([0, 0, 0, 0.99, 0, 0])); + + expect(next).toHaveLength(3); + expect(new Set(next).size).toBe(3); + }); + + it('gives up after the retry cap instead of looping forever', () => { + // Always reproduces the previous set, so only the retry cap can end this. + const next = pickNextSelection(4, 3, [0, 1, 2], [], seededRandom([0])); + + expect(next).toEqual([0, 1, 2]); + }); + + it('never draws an interest that is in recentlyShown, when enough others are available', () => { + for (let run = 0; run < 200; run++) { + const next = pickNextSelection(10, 3, [0, 1, 2], [3, 4, 5, 6, 7]); + + for (const index of next) { + expect([3, 4, 5, 6, 7]).not.toContain(index); + } + } + }); + + it('falls back to every interest once too few are left outside recentlyShown', () => { + // Only one unseen interest is left, so the exclusion has to give way rather than come up short. + const next = pickNextSelection(6, 3, [0, 1, 2], [1, 2, 3, 4, 5]); + + expect(next).toHaveLength(3); + expect(new Set(next).size).toBe(3); + }); + + it('ignores recentlyShown entries past `total`, e.g. after the interest list shrinks', () => { + const next = pickNextSelection(4, 3, [0, 1, 2], [4, 5, 6, 7, 8, 9]); + + expect(next).toHaveLength(3); + expect(new Set(next).size).toBe(3); + }); + + it('is not tripped up by an empty recentlyShown', () => { + expect(pickNextSelection(10, 3, [0, 1, 2], [])).toHaveLength(3); + }); +}); diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.ts b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.ts new file mode 100644 index 000000000..27e6aa061 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.helpers.ts @@ -0,0 +1,81 @@ +/** Partial Fisher-Yates: shuffles only the positions it needs, so 3 out of 200 costs three swaps. */ +function pickRandomFrom(candidates: T[], count: number, random: () => number): T[] { + const items = candidates.slice(); + const picks = Math.min(count, items.length); + + for (let position = 0; position < picks; position++) { + const swapWith = position + Math.floor(random() * (items.length - position)); + + [items[position], items[swapWith]] = [items[swapWith], items[position]]; + } + + return items.slice(0, picks); +} + +/** + * When `total` is smaller than `count` every index comes back, so the caller renders fewer tiles + * rather than repeating an interest. The editor requires three, but a saved block can predate that. + */ +export function pickRandomIndices( + total: number, + count: number, + random: () => number = Math.random +): number[] { + if (total <= 0 || count <= 0) { + return []; + } + + return pickRandomFrom( + Array.from({ length: total }, (_unused, index) => index), + count, + random + ); +} + +// Capped because a degenerate list has only one arrangement, and an uncapped retry loop would never +// end. Once the cap is reached the last draw stands. +const MAX_SHUFFLE_RETRIES = 20; + +/** How far back a shuffle looks to avoid repeats: two shuffles' worth of tiles. */ +export const RECENTLY_SHOWN_LIMIT = 6; + +/** + * A shuffle must not leave the visitor looking at what they already saw, so it retries until the + * selection moves. Compared by position, not by set: colours and thumbnail belong to the position, + * so the same three in a different order is a visibly different block. + * + * `recentlyShown` is a soft preference. With too few unseen interests left it is dropped for that + * draw, so the result is always a full selection rather than a short one. + */ +export function pickNextSelection( + total: number, + count: number, + previous: number[], + recentlyShown: number[] = [], + random: () => number = Math.random +): number[] { + const excluded = new Set(recentlyShown); + const unseenIndices = Array.from({ length: total }, (_unused, index) => index).filter( + (index) => !excluded.has(index) + ); + const draw = () => + unseenIndices.length >= count + ? pickRandomFrom(unseenIndices, count, random) + : pickRandomIndices(total, count, random); + + let next = draw(); + + if (previous.length === 0) { + return next; + } + + const isUnchanged = (candidate: number[]) => + candidate.length === previous.length && + candidate.every((index, position) => index === previous[position]); + + for (let retry = 0; retry < MAX_SHUFFLE_RETRIES && isUnchanged(next); retry++) { + next = draw(); + } + + return next; +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss new file mode 100644 index 000000000..67ef1835a --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.scss @@ -0,0 +1,366 @@ +@use "sass:map"; +@use "sass:math"; +@use "../../../../shared/styles/settings/variables" as variables; +@use "../../../../shared/styles/settings/colors" as colors; +@use "../../../../shared/styles/mixins/focus" as focus; +@use "../../../../shared/styles/mixins/typography" as typography; +@use "./shapes" as shapes; + +// The three tiles are hand placed, so each is positioned by its own centre inside a container with +// the design's aspect ratio. Every size below is a percentage of that container, so the whole +// composition scales as one at any viewport width. Design frames: 1440 desktop, 402 mobile. + +$tiles-ratio-desktop: math.div(1222.013, 512); +$tiles-width-desktop: 84.862%; + +// Mobile: the tiles stack and overlap, spanning 402 x 842. +$tiles-ratio-mobile: math.div(402, 842); +$tile-tilt: 5deg; +$tile-active-tilt: 2deg; + +// Hovering decides which tile is active; without a hover it is the middle one. Sizes are one base +// width plus scales rather than fixed widths, which is what lets the active look travel between +// tiles. The base is derived so the scales land on the design's rendered sizes. +$tile-scale-inactive: 0.9; +$tile-width-desktop-base: math.div(29.46%, $tile-scale-inactive); // 32.733% +$tile-scale-active-desktop: math.div(41.898%, $tile-width-desktop-base); // 1.28 +$tile-width-mobile-base: math.div(78.109%, $tile-scale-inactive); // 86.788% +$tile-scale-active-mobile: math.div(90.05%, $tile-width-mobile-base); // 1.038 + +// A hovered side tile grows less than the middle tile does at rest, so it still fits the stage. +$tile-scale-hover-desktop: 1.1; +$tile-tucked-distance: variables.$g-spacer-unit * 7; +$tile-z-tucked: 1; +$tile-z-inactive: 3; +$tile-z-middle-inactive: 5; +$tile-z-active: 30; +$tile-shadow-inactive: 0 1.4rem 3.2rem rgb(0 0 0 / 10%); +$tile-shadow-active: + 0 2.8rem 6rem rgb(0 0 0 / 22%), + 0 0.8rem 2rem rgb(0 0 0 / 12%); + +// Quicker to become active than to return to rest, so the stack settles instead of snapping back. +$tile-hover-duration: 320ms; +$tile-hover-easing: cubic-bezier(0.22, 1, 0.36, 1); +$tile-rest-duration: 500ms; + +// A border width, not spacing, so not a spacer multiple. +$tile-border-outer: 0.6rem; + +// Clamped by CSS to half the pill's height, so it reads as fully rounded. +$pill-radius: variables.$g-spacer-unit * 8; + +// The type presets leave line-height unset at these sizes, but it is what gives the pill its height. +// A ratio on desktop, because the font size scales with the stage there. +$pill-line-height-ratio: math.div(2.6, 2.4); +$pill-line-height-mobile: 2rem; +$pill-height-mobile: variables.$g-spacer-unit * 6; + +// The type scales with the stage, or a two-word interest overflows its tile between the mobile +// breakpoint and 1440. The bounds are the mobile and desktop sizes of the design. +$pill-font-size: clamp(1.8rem, 1.964cqw, 2.4rem); + +.c-driekeuzespeler { + position: relative; + + // The shapes reach past the tile area on purpose and run off the left edge on mobile. Clipping + // keeps that from adding a horizontal scrollbar to the page. + overflow: hidden; + + &__title { + @include typography.sofia-pro-heading-2xl; + + // The shapes layer reaches into the title, so the title has to sit in front of it. + position: relative; + z-index: 1; + margin: 0 0 (variables.$g-spacer-unit * 8); + text-align: center; + + @media (max-width: variables.$g-bp2) { + @include typography.sofia-pro-heading-xl; + + margin-bottom: variables.$g-spacer-unit * 6; + } + } + + // The coordinate box every percentage resolves against. A box of its own because the shapes and + // the tiles share it, and a `ul` may only hold `li` children. + &__stage { + position: relative; + aspect-ratio: $tiles-ratio-desktop; + width: $tiles-width-desktop; + margin: 0 auto; + + // The pill scales against this box, not the viewport, so it keeps its share of a tile whether + // the block is full width or in a narrower column. + container-type: inline-size; + + @media (max-width: variables.$g-bp2) { + // The tiles fill the mobile frame, so no inset there. + aspect-ratio: $tiles-ratio-mobile; + width: 100%; + } + } + + &__tiles { + position: absolute; + z-index: 1; + inset: 0; + margin: 0; + padding: 0; + list-style: none; + } + + @include shapes.shapes; + + &__tile { + // Placed by its centre, so its size can change without moving it. + position: absolute; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + z-index: $tile-z-inactive; + width: $tile-width-desktop-base; + + // Custom properties so one rule can change one part without restating the rest. + transform: translate(calc(-50% + var(--tile-shift, 0px)), -50%) + rotate(var(--tile-tilt, 0deg)) scale(var(--tile-scale, #{$tile-scale-inactive})); + background-color: var(--tile-color, #{colors.$color-gray-100}); + box-shadow: $tile-shadow-inactive; + + // Transform and shadow only, so no layout work per frame. + transition: + transform $tile-rest-duration $tile-hover-easing, + box-shadow $tile-rest-duration $tile-hover-easing; + + // The white band on the active tile. Always present, revealed only when active, so it does not + // fade in halfway through the grow. + &::after { + content: ""; + box-sizing: border-box; + position: absolute; + inset: 0; + border: $tile-border-outer solid colors.$white; + opacity: 0; + pointer-events: none; + transition: opacity $tile-rest-duration $tile-hover-easing; + } + + &:nth-child(1) { + --tile-tilt: #{-$tile-tilt}; + + left: 15.958%; + top: 56.128%; + } + + &:nth-child(2) { + --tile-tilt: 0deg; + + z-index: $tile-z-middle-inactive; + left: 50%; + top: 50%; + } + + &:nth-child(3) { + --tile-tilt: #{$tile-tilt}; + + left: 86.61%; + top: 50%; + } + + // On mobile the three sit in a centred, overlapping column. `left` is repeated per tile because + // the desktop rules above use `:nth-child()`, which outranks a bare `&` whatever the source + // order, so a single `left: 50%` never lands. + @media (max-width: variables.$g-bp2) { + width: $tile-width-mobile-base; + + &:nth-child(1) { + left: 50%; + top: 18.646%; + } + + &:nth-child(2) { + left: 50%; + top: 50%; + } + + &:nth-child(3) { + left: 50%; + top: 81.354%; + } + } + } + + &__thumbnail { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + + // An inactive tile is desaturated slightly. On the thumbnail alone, because the pill carries + // colours the admin picked. + filter: saturate(0.9); + transition: filter $tile-rest-duration $tile-hover-easing; + } + + &__tile-button { + // The tile's tilt and scale on :focus-within is motion, not a focus indicator, and it is gone + // altogether under prefers-reduced-motion. The keyboard user needs a real outline. + @include focus.focus-outline; + + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: none; + cursor: pointer; + } + + &__pill { + @include typography.sofia-pro-heading-lg; + + position: relative; + z-index: 1; + padding: (variables.$g-spacer-unit * 2) (variables.$g-spacer-unit * 3); + border-radius: $pill-radius; + font-size: $pill-font-size; + line-height: $pill-line-height-ratio; + text-align: center; + + @media (max-width: variables.$g-bp2) { + @include typography.sofia-pro-heading-md; + + display: flex; + align-items: center; + height: $pill-height-mobile; + padding: 0 (variables.$g-spacer-unit * 3); + line-height: $pill-line-height-mobile; + } + } + + // Only the button's place under the tile area is set here; the look is the shared black pill. The + // positioned shapes layer reaches below that area, so an unpositioned button would paint under it. + // The compound selector is not decoration: the client's `.c-content-page-preview [class*="c-button--"]` + // margin and `.c-button`'s own `display` both beat or tie a lone `&__shuffle`, dropping the CTA onto + // the tiles. Three classes put this above both in either app. + & &__shuffle.c-button { + position: relative; + z-index: 1; + + // `.c-button` is inline-block, and an auto margin only centres a block level box. + display: flex; + width: fit-content; + margin: (variables.$g-spacer-unit * 6) auto 0; + } + + // Collapsed rather than removed, so only the movement goes away. + @media (prefers-reduced-motion: reduce) { + &__tile, + &__tile::after, + &__thumbnail { + transition-duration: 1ms; + } + } +} + +// On a dark block background the black pill has no edge at all, which fails the 3:1 WCAG 1.4.11 asks +// of a control's boundary. ARC-3848 +.c-content-block-preview--dark .c-driekeuzespeler__shuffle { + background-color: colors.$white; + color: colors.$color-gray-1000; +} + +// The active state travels between tiles, so it cannot live on the middle one. Written with full +// class names rather than `&`: inside a nested block `&` is the entire parent selector, which +// silently compiles to a descendant selector that matches nothing. +@mixin tile-active($scale-desktop: $tile-scale-active-desktop) { + --tile-scale: #{$scale-desktop}; + + z-index: $tile-z-active; + box-shadow: $tile-shadow-active; + transition-duration: $tile-hover-duration; + + &::after { + opacity: 1; + transition-duration: $tile-hover-duration; + } + + .c-driekeuzespeler__thumbnail { + filter: saturate(1); + transition-duration: $tile-hover-duration; + } +} + +// No hover or focus anywhere in the stack: the middle tile is the active one. `:has` drives the +// siblings with no javascript, as BlockHeroCarousel does. +.c-driekeuzespeler__tiles:not(:has(.c-driekeuzespeler__tile:is(:hover, :focus-within))) + .c-driekeuzespeler__tile:nth-child(2) { + @include tile-active; +} + +.c-driekeuzespeler__tile:is(:hover, :focus-within) { + @include tile-active($tile-scale-hover-desktop); +} + +// The middle tile is already at the active size at rest, so hovering it must not shrink it -- that +// reads as the focal tile stepping backwards under the cursor. +.c-driekeuzespeler__tile:nth-child(2):is(:hover, :focus-within) { + --tile-scale: #{$tile-scale-active-desktop}; +} + +// One active scale on mobile. Every desktop scale above needs its selector repeated here, matched +// for specificity: source order alone loses to the extra `:nth-child` on the middle tile's rule, +// which would hand a hovered middle tile the desktop scale and burst the stage. +@media (max-width: variables.$g-bp2) { + .c-driekeuzespeler__tiles:not(:has(.c-driekeuzespeler__tile:is(:hover, :focus-within))) + .c-driekeuzespeler__tile:nth-child(2), + .c-driekeuzespeler__tile:is(:hover, :focus-within), + .c-driekeuzespeler__tile:nth-child(2):is(:hover, :focus-within) { + --tile-scale: #{$tile-scale-active-mobile}; + } +} + +// A tilted tile straightens part of the way when active, rather than snapping upright. +.c-driekeuzespeler__tile:nth-child(1):is(:hover, :focus-within) { + --tile-tilt: #{-$tile-active-tilt}; +} + +.c-driekeuzespeler__tile:nth-child(3):is(:hover, :focus-within) { + --tile-tilt: #{$tile-active-tilt}; +} + +// When one side tile is active the opposite one slides inward and drops behind the middle tile. +.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(1):is(:hover, :focus-within)) + .c-driekeuzespeler__tile:nth-child(3) { + --tile-shift: #{-$tile-tucked-distance}; + + z-index: $tile-z-tucked; +} + +.c-driekeuzespeler__tiles:has(.c-driekeuzespeler__tile:nth-child(3):is(:hover, :focus-within)) + .c-driekeuzespeler__tile:nth-child(1) { + --tile-shift: #{$tile-tucked-distance}; + + z-index: $tile-z-tucked; +} + +// A touch device has no hover, so a tap must not leave a tile stuck as the active one. +@media (hover: none) { + .c-driekeuzespeler__tile:nth-child(1):hover, + .c-driekeuzespeler__tile:nth-child(3):hover { + --tile-scale: #{$tile-scale-inactive}; + --tile-shift: 0px; + + z-index: $tile-z-inactive; + box-shadow: $tile-shadow-inactive; + + &::after { + opacity: 0; + } + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx new file mode 100644 index 000000000..ca2990ca3 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespeler.tsx @@ -0,0 +1,194 @@ +import { Button } from '@meemoo/react-components'; +import clsx from 'clsx'; +import type { CSSProperties, FunctionComponent, ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { AdminCoreIconName } from '~core/config'; +import { useGetIeObjectsByIds } from '~modules/content-page/hooks/useGetIeObjectsByIds'; +import { useGetPlayableFileForIeObjects } from '~modules/content-page/hooks/useGetPlayableFileForIeObjects'; +import type { + DriekeuzespelerInterestState, + DriekeuzespelerTileColors, +} from '~modules/content-page/types/content-block.types'; +import { Icon } from '~shared/components/Icon/Icon'; +import type { DefaultComponentProps } from '~shared/types/components'; +import { useGetThemesByIds } from '../BlockOverviewThemes/hooks/useGetThemesByIds'; +import { DRIEKEUZESPELER_TILE_COUNT } from './BlockDriekeuzespeler.editorconfig'; +import { + pickNextSelection, + pickRandomIndices, + RECENTLY_SHOWN_LIMIT, +} from './BlockDriekeuzespeler.helpers'; +import { BlockDriekeuzespelerModal } from './BlockDriekeuzespelerModal'; + +import './BlockDriekeuzespeler.scss'; + +export interface BlockDriekeuzespelerProps extends DefaultComponentProps { + title: string; + tileColors: DriekeuzespelerTileColors[]; + shuffleButtonLabel: string; + interests: DriekeuzespelerInterestState[]; +} + +/** + * Shows three of the configured interests, picked at random. The shuffle CTA replaces all three. + */ +export const BlockDriekeuzespeler: FunctionComponent = ({ + title, + tileColors, + shuffleButtonLabel, + interests, + className, +}): ReactElement => { + // Selected after mount: randomising during render would break hydration, so the first paint shows + // the tile skeletons instead. + const [selection, setSelection] = useState(null); + + // A ref rather than state: bookkeeping pickNextSelection reads, not something the block renders. + const recentlyShownRef = useRef([]); + + useEffect(() => { + const initial = pickRandomIndices(interests.length, DRIEKEUZESPELER_TILE_COUNT); + recentlyShownRef.current = initial.slice(-RECENTLY_SHOWN_LIMIT); + setSelection(initial); + }, [interests.length]); + + const shuffle = useCallback(() => { + const next = pickNextSelection( + interests.length, + DRIEKEUZESPELER_TILE_COUNT, + selection || [], + recentlyShownRef.current + ); + recentlyShownRef.current = [...recentlyShownRef.current, ...next].slice(-RECENTLY_SHOWN_LIMIT); + setSelection(next); + }, [interests.length, selection]); + + // An index rather than the interest itself, so it survives the renderer handing us a rebuilt array. + const [openedIndex, setOpenedIndex] = useState(null); + + // Only the three on screen are resolved, not all two hundred a block may hold. The pids are part + // of the query key, so a shuffle back to a selection already seen is served from cache. + const selectedSchemaIdentifiers = (selection || []) + .map((index) => interests[index]?.mediaItem?.value || '') + .filter(Boolean); + + const { data: ieObjectsById, isFetching: isFetchingObjects } = + useGetIeObjectsByIds(selectedSchemaIdentifiers); + + // Ticketed with the selection, so the modal plays what is already there. + const { data: playableFileById, isFetching: isFetchingPlayableFile } = + useGetPlayableFileForIeObjects( + selectedSchemaIdentifiers.map((schemaIdentifier) => ieObjectsById?.[schemaIdentifier]) + ); + + // Deduplicated and sorted, so a shuffle back to a selection already seen hits the same query key. + const selectedThemeIds = Array.from( + new Set((selection || []).map((index) => interests[index]?.theme?.value || '').filter(Boolean)) + ).sort(); + + const { data: themes } = useGetThemesByIds(selectedThemeIds); + + const openedInterest = openedIndex === null ? null : interests[openedIndex]; + + const renderTile = (tileIndex: number): ReactElement => { + // Not compacted: colours are positional, so dropping a missing interest shifts every later tile + // onto the wrong colour. + const interestIndex = selection?.[tileIndex]; + // Index and interest travel together, so the render below needs one check rather than one per + // half: opening the modal needs the index, naming the button needs the interest. + const selected = + interestIndex === undefined + ? undefined + : { index: interestIndex, interest: interests[interestIndex] }; + const { backgroundColor, textColor } = tileColors[tileIndex] ?? {}; + const schemaIdentifier = selected?.interest?.mediaItem?.value; + const ieObject = schemaIdentifier ? ieObjectsById?.[schemaIdentifier] : undefined; + + return ( +
  • + {!!ieObject?.thumbnailUrl && ( + + )} + {!!selected?.interest && ( + // A real button, so Enter and Space work for free. The thumbnail is decorative, so the + // interest name is what names the control. + + )} +
  • + ); + }; + + return ( +
    + {!!title &&

    {title}

    } + +
    + {/* Each shape carries its own rotation and a background layer cannot be rotated on its own, so + the three mobile shapes need three boxes: this layer, its two pseudo elements draw two of + them, and this span the third. */} + + +
      + {Array.from({ length: DRIEKEUZESPELER_TILE_COUNT }, (_unused, tileIndex) => + renderTile(tileIndex) + )} +
    +
    + + {/* With exactly three interests every shuffle draws the same three, so the CTA only appears + once there is something else to draw. */} + {interests.length > DRIEKEUZESPELER_TILE_COUNT && ( +
    + ); +}; diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss new file mode 100644 index 000000000..4cc5178be --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.scss @@ -0,0 +1,121 @@ +@use "../../../../shared/styles/settings/colors" as colors; +@use "../../../../shared/styles/settings/variables" as variables; + +// Same ratio as the video player on an object's detail page. +$media-aspect-ratio: 16 / 9; +$card-radius: variables.$g-spacer-unit * 1.5; + +.c-driekeuzespeler-modal { + &__media { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: $media-aspect-ratio; + width: 100%; + + // Keeps a tall newspaper page inside the viewport instead of pushing the metadata panel and + // the close button off screen. + max-height: 70vh; + overflow: hidden; + } + + &__player, + &__newspaper, + &__iiif-viewer { + width: 100%; + height: 100%; + } + + // The viewer sizes itself to its container, so it needs one with a height of its own. + &__iiif-viewer { + position: relative; + overflow: hidden; + } + + // A newspaper page is portrait, so it fits inside the frame rather than filling it. + &__newspaper { + object-fit: contain; + } + + // Stands in for the essence when the visitor has no access to it. + &__inaccessible { + font-size: variables.$g-spacer-unit * 8; + } + + &__loading { + margin: 0; + } + + // The theme CTA sits in the same bar as the object metadata, so the modal owns the bar and the + // shared component renders inside it without its own chrome. + &__metadata-row { + display: flex; + align-items: center; + gap: variables.$g-spacer-unit * 3; + padding: variables.$g-spacer-unit * 2; + background-color: colors.$color-platinum; + } + + // The bar grows with its content instead of the shared component's fixed 6rem row, and the row + // above carries the background now. Chained onto the shared class so it wins whichever + // stylesheet loads last. + &__metadata.c-ie-object-metadata { + flex: 1 1 auto; + min-width: 0; + height: auto; + padding: 0; + margin-top: 0; + background-color: transparent; + } + + &__divider { + flex: 0 0 auto; + align-self: stretch; + width: 1px; + background-color: rgba(colors.$color-gray-1000, 0.12); + } + + &__theme-cta { + flex: 0 0 auto; + padding: variables.$g-spacer-unit variables.$g-spacer-unit * 2; + border-radius: 9.2rem; + background-color: colors.$color-silver; + font-weight: 700; + white-space: nowrap; + } + + // Mobile insets the media and stacks the metadata card under it, with the theme CTA on its own row. + @media (max-width: variables.$g-bp2) { + &__media { + width: calc(100% - #{variables.$g-spacer-unit * 5}); + margin: 0 auto; + border-radius: $card-radius $card-radius 0 0; + + // Tighter than desktop's 70vh: the stacked metadata card needs the room. + max-height: 45vh; + flex: 0 0 auto; + } + + // The theme CTA drops to its own row under the metadata; the rest stays side by side inside + // the shared component. + &__metadata-row { + flex-direction: column; + align-items: stretch; + background-color: colors.$white; + border-radius: 0 0 $card-radius $card-radius; + width: calc(100% - #{variables.$g-spacer-unit * 5}); + margin: 0 auto; + } + + &__divider { + width: 100%; + height: 1px; + align-self: auto; + } + + &__theme-cta { + width: fit-content; + } + + } +} diff --git a/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.tsx b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.tsx new file mode 100644 index 000000000..ca23754b9 --- /dev/null +++ b/ui/src/react-admin/modules/content-page/components/blocks/BlockDriekeuzespeler/BlockDriekeuzespelerModal.tsx @@ -0,0 +1,200 @@ +import { Modal, ModalBody } from '@viaa/avo2-components'; +import { + AvoCoreContentPickerType, + type HetArchiefIeObject as IeObject, + type HetArchiefIeObjectType as IeObjectType, +} from '@viaa/avo2-types'; +import type { FunctionComponent, ReactElement } from 'react'; +import React from 'react'; +import { AdminConfigManager } from '~core/config/config.class'; +import { IeObjectFlowPlayerWrapper } from '~modules/content-page/components/IeObjectFlowPlayerWrapper/IeObjectFlowPlayerWrapper.tsx'; +import { IeObjectMetadata } from '~modules/content-page/components/IeObjectMetadata/IeObjectMetadata.tsx'; +import type { PlayableFile } from '~modules/content-page/hooks/useGetPlayableFileForIeObjects'; +import { Locale } from '~modules/translations/translations.core.types.ts'; +import { Icon } from '~shared/components/Icon/Icon'; +import { SmartLink } from '~shared/components/SmartLink/SmartLink.tsx'; +import { getIconFromObjectType } from '~shared/helpers/get-icon-from-object-type.ts'; +import { isAudioVideoFormat, isNewspaperFormat } from '~shared/helpers/is-audio-video-format.ts'; +import { tText } from '~shared/helpers/translation-functions'; +import type { PlayableDisplayIeObject } from '~shared/services/ie-objects-service/ie-objects.types.ts'; +import { ThemesService } from '~shared/services/themes-service/themes.service.ts'; +import type { Theme } from '~shared/services/themes-service/themes.types'; +import { HET_ARCHIEF } from '~shared/types'; + +import './BlockDriekeuzespelerModal.scss'; + +export interface BlockDriekeuzespelerModalProps { + interest: { + name: string; + } | null; + ieObject?: IeObject; + /** Absent for a newspaper, which has nothing to ticket. */ + playableFile?: PlayableFile; + /** Resolved by the parent: the block config stores only an id, and the CTA needs name and slug. */ + theme?: Theme; + isFetching: boolean; + onClose: () => void; +} + +/** + * Plays the object behind one interest on top of the page: the visitor is never navigated anywhere, + * and closing returns to the same three tiles. + */ +export const BlockDriekeuzespelerModal: FunctionComponent = ({ + interest, + ieObject, + playableFile, + theme, + isFetching, + onClose, +}): ReactElement => { + const isOpen = !!interest; + // Read per render rather than at module load, so a config set up after this module is imported + // is still picked up. + const IiifViewer = AdminConfigManager.getConfig().components.iiifViewer; + + const locale = AdminConfigManager.getConfig().locale || Locale.Nl; + const themeName = locale === Locale.En ? theme?.nameEn : theme?.nameNl; + + const isPlayable = isAudioVideoFormat(ieObject?.dctermsFormat); + + // The shared player and metadata panel describe an object the way playable-display-data does. + const displayIeObject: PlayableDisplayIeObject | undefined = ieObject && { + schemaIdentifier: ieObject.schemaIdentifier, + name: ieObject.name || '', + thumbnailUrl: ieObject.thumbnailUrl || null, + dctermsFormat: ieObject.dctermsFormat as IeObjectType, + maintainerId: ieObject.maintainerId || '', + maintainerSlug: ieObject.maintainerSlug || '', + maintainerName: ieObject.maintainerName || '', + maintainerLogo: ieObject.maintainerLogo || undefined, + maintainerOverlay: !!ieObject.maintainerOverlay, + playableUrl: playableFile?.playableUrl, + mimeType: playableFile?.mimeType, + peakfileData: playableFile?.peakfileData, + }; + + // Only mount the player while the modal is open, so closing stops playback outright instead of + // leaving audio running behind the page. + const renderMedia = (): ReactElement | null => { + if (!ieObject || !displayIeObject) { + return null; + } + + // No thumbnail means the essence is not available to this visitor, so neither the player nor + // the viewer may be shown. + if (!ieObject.thumbnailUrl) { + return ( + + + + ); + } + + if (isPlayable) { + return ( + + ); + } + + // The viewer is injected rather than living here because the host resolves a ticket-service + // token per page. Only a newspaper has the pages it needs; anything else falls back to the + // thumbnail. + if (IiifViewer && isNewspaperFormat(ieObject.dctermsFormat)) { + return ( +
    + +
    + ); + } + + return ( + {ieObject.name + ); + }; + + // Sits at the end of the metadata row, after a divider that separates it from the primary CTA. + const renderThemeCta = (): ReactElement | undefined => { + if (!themeName || !theme) { + return undefined; + } + + return ( + <> +