From 335346c94ff043778d35aa2c95839e523fd723e4 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:39:21 +0200 Subject: [PATCH 1/5] Fix public kid search and friends Load kid search results on demand, cache favorite kids locally, refresh friend passport progress in a single batch request, update kid gender icons, and stop deploying the public gh-pages demo from main pushes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/frontend-ci.yml | 26 +------ server/api.ts | 39 +++++++++- src/components/FriendPassportView.tsx | 8 +- src/components/FriendStarButton.tsx | 15 +--- src/components/KidFinder.tsx | 29 +++++-- src/components/KidGenderIcon.tsx | 38 ++++++++++ src/components/KidList.tsx | 8 +- src/components/KidsSection.tsx | 56 ++++++++++---- src/contexts/DataLayerContext.tsx | 94 +++++++++++++++++++++-- src/contexts/LocalDataLayerContext.tsx | 101 +++++++++++++++++++++++-- src/data/remote-data-client.ts | 43 ++++++++++- src/i18n/messages.ts | 2 + src/styles.css | 96 +++++++++++------------ 13 files changed, 416 insertions(+), 139 deletions(-) create mode 100644 src/components/KidGenderIcon.tsx diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 0f0e5f7..2471c47 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -2,15 +2,12 @@ name: Frontend CI on: pull_request: - push: - branches: - - main permissions: contents: read concurrency: - group: pages-${{ github.ref }} + group: frontend-ci-${{ github.ref }} cancel-in-progress: true jobs: @@ -34,24 +31,3 @@ jobs: - name: Build run: npm run build - - - name: Upload Pages artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v3 - with: - path: dist - - deploy: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/server/api.ts b/server/api.ts index 85eda1f..fc6d643 100644 --- a/server/api.ts +++ b/server/api.ts @@ -508,9 +508,23 @@ function parseAdminBackup(body: Record): AdminBackup { } async function handleKids(request: ApiRequest, url: URL): Promise { - void url; - if (request.method === 'GET') { + const searchedKid = url.searchParams.get('kid')?.trim(); + + if (searchedKid) { + const snapshot = await readSnapshot(); + const normalizedKidId = normalizeKidId(searchedKid, snapshot); + const kid = snapshot.kids.find( + (entry) => entry.id.toLowerCase() === normalizedKidId.toLowerCase(), + ); + + if (!kid) { + throw new HttpError(404, `Unknown kid: ${normalizedKidId}`); + } + + return jsonResponse(request, 200, kid); + } + await requireMagicLink(request, url, [...staffRoles]); const snapshot = await readSnapshot(); @@ -646,9 +660,26 @@ async function handlePassport( } if (request.method === 'GET') { - await requireMagicLink(request, url, [...staffRoles]); - const snapshot = await readSnapshot(); + const batchKidIds = url.searchParams + .getAll('kids') + .flatMap((kidIds) => kidIds.split(',')) + .map((kidId) => kidId.trim()) + .filter(Boolean); + + if (batchKidIds.length > 0) { + const passportsByKid = Object.fromEntries( + [...new Set(batchKidIds)] + .map((kidId) => normalizeKidId(kidId, snapshot)) + .map((kidId) => [ + kidId, + passportResponse(snapshot.passportActivitiesByKid, kidId), + ]), + ); + + return jsonResponse(request, 200, passportsByKid); + } + const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot); return jsonResponse( diff --git a/src/components/FriendPassportView.tsx b/src/components/FriendPassportView.tsx index d65c7d5..999b125 100644 --- a/src/components/FriendPassportView.tsx +++ b/src/components/FriendPassportView.tsx @@ -6,6 +6,7 @@ import { } from '../contexts/DataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; import { FriendStarButton } from './FriendStarButton'; +import { KidGenderIcon } from './KidGenderIcon'; import { PassportActivityMosaic } from './PassportActivityMosaic'; import { ProgressCounter } from './ProgressCounter'; @@ -30,10 +31,9 @@ export function FriendPassportView({ kid }: FriendPassportViewProps) {
-

{kid.name}

diff --git a/src/components/FriendStarButton.tsx b/src/components/FriendStarButton.tsx index ab169ed..a91ac5e 100644 --- a/src/components/FriendStarButton.tsx +++ b/src/components/FriendStarButton.tsx @@ -1,8 +1,5 @@ import { StarFillIcon, StarIcon } from '@primer/octicons-react'; -import { - useCurrentUser, - type Kid, -} from '../contexts/DataLayerContext'; +import type { Kid } from '../contexts/DataLayerContext'; import { useIsFriend, useToggleFriend } from '../contexts/LocalDataLayerContext'; import { useI18n } from '../i18n/I18nProvider'; @@ -11,22 +8,14 @@ type FriendStarButtonProps = { }; export function FriendStarButton({ kid }: FriendStarButtonProps) { - const currentUser = useCurrentUser(); const isFriend = useIsFriend(); const toggleFriend = useToggleFriend(); const { t } = useI18n(); - const canToggleFriend = - currentUser.role === 'guest' || - (currentUser.role === 'kid' && currentUser.id !== kid.id); const friendSelected = isFriend(kid.id); const label = friendSelected ? t('friends.star.remove').replace('{name}', kid.name) : t('friends.star.add').replace('{name}', kid.name); - if (!canToggleFriend) { - return null; - } - return ( + ) : null}
{friends.length > 0 ? (
    @@ -55,10 +84,9 @@ export function KidsSection({ type="button" onClick={() => onKidSelected(kid)} > - {kid.name} PrizeAward; conference: ConferenceData; currentUser: CurrentUser; - findKidByManualNumber: (rawSearchValue: string) => Kid | undefined; - findKidByQrIdData: (qrPayload: string) => Kid | undefined; + findKidById: (kidId: string) => Promise; + findKidByManualNumber: (rawSearchValue: string) => Promise; + findKidByQrIdData: (qrPayload: string) => Promise; getPassportForKid: (kidId: string) => PassportData; getWheelShotSummaryForKid: (kidId: string) => WheelShotSummary; kids: Kid[]; @@ -95,6 +98,7 @@ type DataLayerContextValue = { prizes: Prize[]; refreshPrizes: () => Prize[]; reloadPassportActivities: (kidId?: string) => void; + reloadPassportActivitiesForKids: (kidIds: string[]) => Promise; reloadPrizeAwardsForKid: (kidId: string) => void; users: User[]; resetCurrentUser: () => void; @@ -267,6 +271,19 @@ export function DataLayerProvider({ children }: PropsWithChildren) { [kidId]: activities.map((activity) => ({ ...activity })), })); }; + const applyRemotePassports = ( + passportsByKid: Record, + ) => { + setPassportActivitiesByUser((currentPassportActivities) => ({ + ...currentPassportActivities, + ...Object.fromEntries( + Object.entries(passportsByKid).map(([kidId, activities]) => [ + kidId, + activities.map((activity) => ({ ...activity })), + ]), + ), + })); + }; const applyRemotePrizeAwards = (kidId: string, awards: PrizeAward[]) => { setPrizeAwards((currentPrizeAwards) => [ ...currentPrizeAwards.filter((award) => award.kidId !== kidId), @@ -498,21 +515,54 @@ export function DataLayerProvider({ children }: PropsWithChildren) { usedShots, }; }; - const findKidByManualNumber = (rawSearchValue: string) => { + const rememberKid = (kid: Kid) => { + setKidList((currentKids) => mergeKid(currentKids, kid)); + + return kid; + }; + const findRemoteKid = async (rawKid: string) => { + const remoteKid = await fetchRemoteKid(rawKid); + + return remoteKid ? rememberKid(remoteKid) : undefined; + }; + const findKidById = async (kidId: string) => { + const trimmedKidId = kidId.trim(); + + if (!trimmedKidId) { + return undefined; + } + + const knownKid = kidList.find( + (kid) => kid.id.toLowerCase() === trimmedKidId.toLowerCase(), + ); + + if (knownKid) { + return knownKid; + } + + return isRemoteDataLayer ? findRemoteKid(trimmedKidId) : undefined; + }; + const findKidByManualNumber = async (rawSearchValue: string) => { const searchedNumber = Number(rawSearchValue); if (!Number.isInteger(searchedNumber)) { return undefined; } - return kidList.find((kid) => getKidSequenceNumber(kid.id) === searchedNumber); + const knownKid = kidList.find( + (kid) => getKidSequenceNumber(kid.id) === searchedNumber, + ); + + if (knownKid) { + return knownKid; + } + + return isRemoteDataLayer ? findRemoteKid(rawSearchValue) : undefined; }; - const findKidByQrIdData = (qrPayload: string) => { + const findKidByQrIdData = async (qrPayload: string) => { const kidId = parseKidQrPayload(qrPayload); - return kidId - ? kidList.find((kid) => kid.id.toLowerCase() === kidId.toLowerCase()) - : undefined; + return kidId ? findKidById(kidId) : undefined; }; const reloadPassportActivities = (kidId?: string) => { if (isRemoteDataLayer) { @@ -530,6 +580,24 @@ export function DataLayerProvider({ children }: PropsWithChildren) { clonePassportActivities(currentPassportActivities), ); }; + const reloadPassportActivitiesForKids = async (kidIds: string[]) => { + const uniqueKidIds = [...new Set(kidIds.map((kidId) => kidId.trim()))].filter( + Boolean, + ); + + if (uniqueKidIds.length === 0) { + return; + } + + if (isRemoteDataLayer) { + applyRemotePassports(await fetchRemotePassports(uniqueKidIds)); + return; + } + + setPassportActivitiesByUser((currentPassportActivities) => + clonePassportActivities(currentPassportActivities), + ); + }; const reloadPrizeAwardsForKid = (kidId: string) => { if (isRemoteDataLayer) { loadRemotePrizeAwardsForKid(kidId); @@ -767,6 +835,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { awardPrizeToKid, conference: conferenceJson, currentUser, + findKidById, findKidByManualNumber, findKidByQrIdData, getPassportForKid, @@ -782,6 +851,7 @@ export function DataLayerProvider({ children }: PropsWithChildren) { prizes, refreshPrizes, reloadPassportActivities, + reloadPassportActivitiesForKids, reloadPrizeAwardsForKid, resetCurrentUser, setCurrentUser, @@ -837,6 +907,10 @@ export function useFindKidByQrIdData() { return useDataLayer().findKidByQrIdData; } +export function useFindKidById() { + return useDataLayer().findKidById; +} + export function useKidsData() { return useDataLayer().kids; } @@ -849,6 +923,10 @@ export function useReloadPassportActivities() { return useDataLayer().reloadPassportActivities; } +export function useReloadPassportActivitiesForKids() { + return useDataLayer().reloadPassportActivitiesForKids; +} + export function useGetPassportForKid() { return useDataLayer().getPassportForKid; } diff --git a/src/contexts/LocalDataLayerContext.tsx b/src/contexts/LocalDataLayerContext.tsx index 536d070..8a76832 100644 --- a/src/contexts/LocalDataLayerContext.tsx +++ b/src/contexts/LocalDataLayerContext.tsx @@ -6,14 +6,17 @@ import { useState, type PropsWithChildren, } from 'react'; +import type { Kid } from '../data/data-model'; type LocalDataLayerContextValue = { getFriendIds: () => string[]; + getFriendKids: () => Kid[]; isFriend: (friendKidId: string) => boolean; - toggleFriend: (friendKidId: string) => void; + toggleFriend: (friendKid: Kid) => void; }; const friendsStorageKey = 'kid-a:local:friends'; +const friendKidsStorageKey = 'kid-a:local:friend-kids'; const LocalDataLayerContext = createContext< LocalDataLayerContextValue | undefined @@ -53,18 +56,82 @@ function writeStoredFriends(friends: string[]) { window.localStorage.setItem(friendsStorageKey, JSON.stringify(friends)); } +function isStoredKid(value: unknown): value is Kid { + return ( + typeof value === 'object' && + value !== null && + typeof (value as Kid).age === 'number' && + typeof (value as Kid).gender === 'string' && + typeof (value as Kid).id === 'string' && + typeof (value as Kid).language === 'string' && + typeof (value as Kid).name === 'string' + ); +} + +function readStoredFriendKids(): Record { + const storedFriendKids = window.localStorage.getItem(friendKidsStorageKey); + + if (!storedFriendKids) { + return {}; + } + + try { + const parsedFriendKids: unknown = JSON.parse(storedFriendKids); + + if (Array.isArray(parsedFriendKids)) { + return Object.fromEntries( + parsedFriendKids + .filter(isStoredKid) + .map((kid) => [kid.id, kid]), + ); + } + + if (parsedFriendKids && typeof parsedFriendKids === 'object') { + return Object.fromEntries( + Object.values(parsedFriendKids) + .filter(isStoredKid) + .map((kid) => [kid.id, kid]), + ); + } + + console.warn('Ignoring invalid local friend kids data.'); + return {}; + } catch (error) { + console.warn('Ignoring unreadable local friend kids data.', error); + return {}; + } +} + +function writeStoredFriendKids(friendKidsById: Record) { + window.localStorage.setItem( + friendKidsStorageKey, + JSON.stringify(Object.values(friendKidsById)), + ); +} + export function LocalDataLayerProvider({ children }: PropsWithChildren) { const [friendIds, setFriendIds] = useState(() => readStoredFriends()); + const [friendKidsById, setFriendKidsById] = useState>( + () => readStoredFriendKids(), + ); useEffect(() => { writeStoredFriends(friendIds); }, [friendIds]); + useEffect(() => { + writeStoredFriendKids(friendKidsById); + }, [friendKidsById]); + useEffect(() => { const readFriendsFromAnotherTab = (event: StorageEvent) => { if (event.key === friendsStorageKey) { setFriendIds(readStoredFriends()); } + + if (event.key === friendKidsStorageKey) { + setFriendKidsById(readStoredFriendKids()); + } }; window.addEventListener('storage', readFriendsFromAnotherTab); @@ -75,17 +142,35 @@ export function LocalDataLayerProvider({ children }: PropsWithChildren) { const value = useMemo( () => ({ getFriendIds: () => friendIds, + getFriendKids: () => + friendIds + .map((friendId) => friendKidsById[friendId]) + .filter((kid): kid is Kid => kid !== undefined), isFriend: (friendKidId) => friendIds.includes(friendKidId), - toggleFriend: (friendKidId) => { + toggleFriend: (friendKid) => { + const isCurrentFriend = friendIds.includes(friendKid.id); + setFriendIds((currentFriendIds) => { - const isCurrentFriend = currentFriendIds.includes(friendKidId); return isCurrentFriend - ? currentFriendIds.filter((friendId) => friendId !== friendKidId) - : [...currentFriendIds, friendKidId]; + ? currentFriendIds.filter((friendId) => friendId !== friendKid.id) + : dedupeFriendIds([...currentFriendIds, friendKid.id]); + }); + setFriendKidsById((currentFriendKids) => { + if (isCurrentFriend) { + const remainingFriendKids = { ...currentFriendKids }; + delete remainingFriendKids[friendKid.id]; + + return remainingFriendKids; + } + + return { + ...currentFriendKids, + [friendKid.id]: friendKid, + }; }); }, }), - [friendIds], + [friendIds, friendKidsById], ); return ( @@ -109,6 +194,10 @@ export function useGetFriendIds() { return useLocalDataLayer().getFriendIds; } +export function useGetFriendKids() { + return useLocalDataLayer().getFriendKids; +} + export function useIsFriend() { return useLocalDataLayer().isFriend; } diff --git a/src/data/remote-data-client.ts b/src/data/remote-data-client.ts index 4f7d44b..2388cce 100644 --- a/src/data/remote-data-client.ts +++ b/src/data/remote-data-client.ts @@ -14,6 +14,8 @@ export type RemoteDataSnapshot = { prizes: Prize[]; }; +export type RemotePassportsByKid = Record; + type RemotePrizeResponse = { prize?: Prize; prizes: Prize[]; @@ -54,13 +56,32 @@ export function isRemoteDataLayerEnabled() { return import.meta.env.VITE_DATA_LAYER === 'remote'; } +export async function fetchRemoteKid(rawKid: string): Promise { + const response = await fetch(buildApiUrl(`/kids?kid=${encodeURIComponent(rawKid)}`), { + cache: 'no-store', + }); + + if (response.status === 404) { + return undefined; + } + + return readJsonResponse(response); +} + +async function fetchRemoteKidList(headers: HeadersInit): Promise { + const response = await fetch(buildApiUrl('/kids'), { + cache: 'no-store', + headers, + }); + + return readJsonResponse(response); +} + export async function fetchRemoteDataSnapshot(): Promise { const headers = magicLinkRequestHeaders(); const requestInit = { cache: 'no-store', headers } satisfies RequestInit; const [kids, prizes] = await Promise.all([ - fetch(buildApiUrl('/kids'), requestInit).then((response) => - readJsonResponse(response), - ), + fetchRemoteKidList(headers), fetch(buildApiUrl('/wheel-prizes'), requestInit).then((response) => readJsonResponse(response), ), @@ -84,6 +105,22 @@ export async function fetchRemotePassport(kidId: string) { return readJsonResponse(response); } +export async function fetchRemotePassports(kidIds: string[]) { + const uniqueKidIds = [...new Set(kidIds.map((kidId) => kidId.trim()))].filter( + Boolean, + ); + const query = new URLSearchParams(); + + query.set('kids', uniqueKidIds.join(',')); + + const response = await fetch(buildApiUrl(`/passport?${query.toString()}`), { + cache: 'no-store', + headers: magicLinkRequestHeaders(), + }); + + return readJsonResponse(response); +} + export async function fetchRemotePrizeAwardsForKid(kidId: string) { const response = await fetch( buildApiUrl(`/prizes-kid?kid=${encodeURIComponent(kidId)}`), diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 396febd..b8dce96 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -153,6 +153,7 @@ export const messages = { 'friends.star.add': 'Add {name} as a friend', 'friends.star.remove': 'Remove {name} from friends', 'friends.dialog.close': 'Close friends', + 'friends.refresh': 'Refresh friends progress', 'friends.add.open': 'Find', 'friends.add.close': 'Close finder', 'friends.add.title': 'Find a friend', @@ -367,6 +368,7 @@ export const messages = { 'friends.star.add': 'Añadir a {name} como amistad', 'friends.star.remove': 'Quitar a {name} de amistades', 'friends.dialog.close': 'Cerrar amistades', + 'friends.refresh': 'Actualizar progreso de amistades', 'friends.add.open': 'Buscar', 'friends.add.close': 'Cerrar buscador', 'friends.add.title': 'Buscar una amistad', diff --git a/src/styles.css b/src/styles.css index f22f72c..781152d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -110,9 +110,34 @@ h2 { } .welcome-friends-header { + display: flex; + gap: 8px; + align-items: center; margin-bottom: 10px; } +.welcome-friends-refresh-button { + display: inline-grid; + width: 30px; + height: 30px; + place-items: center; + border: 0; + border-radius: 999px; + background: rgb(90 66 40 / 10%); + color: #5a4228; + cursor: pointer; +} + +.welcome-friends-refresh-button:hover, +.welcome-friends-refresh-button:focus-visible { + background: rgb(90 66 40 / 18%); +} + +.welcome-friends-refresh-button:disabled { + cursor: wait; + opacity: 0.55; +} + .welcome-activity-list h2 { margin-bottom: 10px; color: #5a4228; @@ -1498,67 +1523,32 @@ h2 { } .kid-gender-icon { - position: relative; - display: inline-block; - width: 30px; - height: 30px; + display: inline-grid; + width: 34px; + height: 34px; flex: 0 0 auto; + place-items: center; transform: translateY(6px); - overflow: hidden; - border: 2px solid #4e6f2b; - border-radius: 50%; - background: #e5f0d0; -} - -.kid-gender-icon::before { - position: absolute; - z-index: 1; - right: 6px; - bottom: 3px; - left: 6px; - height: 19px; - border-radius: 50% 50% 45% 45%; - background: #ccefb8; - content: ""; -} - -.kid-gender-icon::after { - position: absolute; - content: ""; + color: #5a4228; } -.kid-gender-icon.boy::after { - top: 4px; - right: 6px; - left: 6px; - height: 9px; - border-radius: 12px 12px 5px 5px; - background: - radial-gradient(circle at 28% 80%, #d9482f 0 3px, transparent 4px), - radial-gradient(circle at 50% 82%, #d9482f 0 3px, transparent 4px), - radial-gradient(circle at 72% 80%, #d9482f 0 3px, transparent 4px), - #d9482f; -} - -.kid-gender-icon.girl::after { - top: 3px; - right: 4px; - bottom: 2px; - left: 4px; - border-radius: 14px 14px 9px 9px; - background: - radial-gradient(ellipse at 18% 58%, #ffd84d 0 5px, transparent 6px), - radial-gradient(ellipse at 82% 58%, #ffd84d 0 5px, transparent 6px), - linear-gradient(#ffd84d 0 48%, transparent 49%); +.kid-gender-icon svg { + width: 100%; + height: 100%; + overflow: visible; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.8; } -.kid-gender-icon.preferNotToSay { - background: transparent; +.kid-gender-shoulders { + stroke-width: 3; } -.kid-gender-icon.preferNotToSay::before, -.kid-gender-icon.preferNotToSay::after { - display: none; +.kid-gender-hair { + stroke-width: 3.2; } .kid-count-card { From ced227a5cffb4cc305aa98aaa64b242309c45655 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:48:08 +0200 Subject: [PATCH 2/5] Hide home admin access Remove the home admin access button and guest avatar. Show a logged-in user avatar on the home page only when a kid or staff role is active, and make it navigate back to that role's page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/SampleAccessDialog.tsx | 12 ++--------- src/components/TopBar.tsx | 30 +++++++++++++++++++++++++-- src/pages/WelcomePage.tsx | 6 +++++- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/components/SampleAccessDialog.tsx b/src/components/SampleAccessDialog.tsx index 4a9bdf2..b6a0984 100644 --- a/src/components/SampleAccessDialog.tsx +++ b/src/components/SampleAccessDialog.tsx @@ -61,15 +61,7 @@ export function SampleAccessDialog() { return (
    - {isRemoteDataLayer ? ( - - ) : ( + {!isRemoteDataLayer ? ( - )} + ) : null} - {isUserMenuOpen ? ( + {isUserMenuOpen && !navigateOnAvatar ? (
    {t('user.nameLabel')} diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 05d3bcc..2476807 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -21,7 +21,11 @@ export function WelcomePage() { return ( <> - +

    {conference.shortName}

    From eae8c067f344b6c4f260c54a386a22b882d28fd1 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:51:03 +0200 Subject: [PATCH 3/5] Update initial activities Refresh the initial activities seed data from opensouthcode/2026#4 with the current 16 activity titles and their GitHub issue URLs, without lead names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/data/activities.json | 41 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/data/activities.json b/src/data/activities.json index 5216bf8..bbc99af 100644 --- a/src/data/activities.json +++ b/src/data/activities.json @@ -1,8 +1,7 @@ [ { "id": "1", - "title": "Micro:bit Luces", - "details": "Let's learn about Micro:bit.", + "title": "Micro:bit Coches", "issueUrl": "https://github.com/opensouthcode/2026/issues/38" }, { @@ -13,57 +12,57 @@ { "id": "3", "title": "Desenchufao", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/45" }, { "id": "4", "title": "Makey Makey Dance Dance", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/47" }, { "id": "5", "title": "Anima tu dibujo", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/48" }, { "id": "6", "title": "Aprende Git", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "issueUrl": "https://github.com/opensouthcode/2026/issues/49" }, { "id": "7", - "title": "Magnific workflow", + "title": "Magnific flow", "issueUrl": "https://github.com/opensouthcode/2026/issues/11" }, { "id": "8", - "title": "Comic AI - Elige tu aventura", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "ComicIA - Elige tu aventura", + "issueUrl": "https://github.com/opensouthcode/2026/issues/46" }, { "id": "9", - "title": "NFC Cinexin", + "title": "NFC Jukebox", "issueUrl": "https://github.com/opensouthcode/2026/issues/9" }, { "id": "10", - "title": "TinkerCAD", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "TinkerCAD 3D", + "issueUrl": "https://github.com/opensouthcode/2026/issues/44" }, { "id": "11", - "title": "YWT 1 y 2", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "YesWeTech - Detective digital", + "issueUrl": "https://github.com/opensouthcode/2026/issues/42" }, { "id": "12", - "title": "YWT 3 y 4", - "issueUrl": "https://github.com/opensouthcode/2026/issues/4" + "title": "YesWeTech - LEDs", + "issueUrl": "https://github.com/opensouthcode/2026/issues/43" }, { "id": "13", - "title": "Mural digital", - "issueUrl": "https://github.com/opensouthcode/2026/issues/6" + "title": "OpenSouthQuiz", + "issueUrl": "https://github.com/opensouthcode/2026/issues/10" }, { "id": "14", @@ -72,12 +71,12 @@ }, { "id": "15", - "title": "Ada & Zangemann: Manualidades", - "issueUrl": "https://github.com/opensouthcode/2026/issues/3" + "title": "Ada Kraft", + "issueUrl": "https://github.com/opensouthcode/2026/issues/41" }, { "id": "16", - "title": "Ada & Zangemann: Cine", + "title": "Cine Zangemann", "issueUrl": "https://github.com/opensouthcode/2026/issues/3" } ] From c001e394840889563b977ed2a937103c1ea42460 Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 10:57:36 +0200 Subject: [PATCH 4/5] Add activity descriptions Add short initial descriptions for all 16 activities from their GitHub issues and label issue links as more details in the activity UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/ActivityHero.tsx | 5 ++++- src/data/activities.json | 16 ++++++++++++++++ src/i18n/messages.ts | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/ActivityHero.tsx b/src/components/ActivityHero.tsx index a2cb56b..785e36b 100644 --- a/src/components/ActivityHero.tsx +++ b/src/components/ActivityHero.tsx @@ -1,4 +1,5 @@ import type { Activity } from '../contexts/DataLayerContext'; +import { useI18n } from '../i18n/I18nProvider'; type ActivityHeroProps = { activity: Activity; @@ -11,6 +12,8 @@ function getIssueLabel(issueUrl: string) { } export function ActivityHero({ activity, eyebrow, headingId }: ActivityHeroProps) { + const { t } = useI18n(); + return (

    {eyebrow}

    @@ -22,7 +25,7 @@ export function ActivityHero({ activity, eyebrow, headingId }: ActivityHeroProps rel="noreferrer" target="_blank" > - {getIssueLabel(activity.issueUrl)} + {t('activity.detail.moreDetails')} {getIssueLabel(activity.issueUrl)}

    {activity.details ? ( diff --git a/src/data/activities.json b/src/data/activities.json index bbc99af..b061375 100644 --- a/src/data/activities.json +++ b/src/data/activities.json @@ -2,81 +2,97 @@ { "id": "1", "title": "Micro:bit Coches", + "details": "Construye y programa un coche con Micro:bit para experimentar con movimiento, sensores y pequeñas pruebas de conducción.", "issueUrl": "https://github.com/opensouthcode/2026/issues/38" }, { "id": "2", "title": "Hackea el Zombie", + "details": "Modifica un videojuego de zombis hecho con Godot y cambia reglas, personajes o detalles para hacerlo a tu gusto.", "issueUrl": "https://github.com/opensouthcode/2026/issues/31" }, { "id": "3", "title": "Desenchufao", + "details": "Resuelve retos de pensamiento computacional sin pantallas, usando lógica, juego y colaboración.", "issueUrl": "https://github.com/opensouthcode/2026/issues/45" }, { "id": "4", "title": "Makey Makey Dance Dance", + "details": "Crea un mando de baile con Makey Makey y materiales cotidianos para controlar música o un juego con el cuerpo.", "issueUrl": "https://github.com/opensouthcode/2026/issues/47" }, { "id": "5", "title": "Anima tu dibujo", + "details": "Convierte un dibujo propio en una pequeña animación y descubre cómo dar vida digital a tus ideas.", "issueUrl": "https://github.com/opensouthcode/2026/issues/48" }, { "id": "6", "title": "Aprende Git", + "details": "Da tus primeros pasos con Git mediante una actividad práctica para guardar cambios y colaborar como en un proyecto real.", "issueUrl": "https://github.com/opensouthcode/2026/issues/49" }, { "id": "7", "title": "Magnific flow", + "details": "Crea imágenes con IA generativa combinando referencias visuales, texto y prompts para dirigir el resultado.", "issueUrl": "https://github.com/opensouthcode/2026/issues/11" }, { "id": "8", "title": "ComicIA - Elige tu aventura", + "details": "Añade una viñeta a un cómic colaborativo usando creatividad e imágenes generadas con IA.", "issueUrl": "https://github.com/opensouthcode/2026/issues/46" }, { "id": "9", "title": "NFC Jukebox", + "details": "Explora cómo las etiquetas NFC pueden activar canciones o sonidos y monta una pequeña jukebox interactiva.", "issueUrl": "https://github.com/opensouthcode/2026/issues/9" }, { "id": "10", "title": "TinkerCAD 3D", + "details": "Diseña una pieza sencilla en 3D con TinkerCAD y prepárala para poder imprimirla.", "issueUrl": "https://github.com/opensouthcode/2026/issues/44" }, { "id": "11", "title": "YesWeTech - Detective digital", + "details": "Conviértete en detective digital y descifra códigos aprendiendo conceptos básicos de criptografía.", "issueUrl": "https://github.com/opensouthcode/2026/issues/42" }, { "id": "12", "title": "YesWeTech - LEDs", + "details": "Programa señales luminosas y pequeñas secuencias con LEDs para entender cómo se controla la luz desde el código.", "issueUrl": "https://github.com/opensouthcode/2026/issues/43" }, { "id": "13", "title": "OpenSouthQuiz", + "details": "Participa en un juego de preguntas en tiempo real con rondas por edades, puntuación rápida y ranking final.", "issueUrl": "https://github.com/opensouthcode/2026/issues/10" }, { "id": "14", "title": "Si fueras un Octogato", + "details": "Diseña tu propio Octogato personalizado, ideal para imaginar un avatar divertido y compartirlo.", "issueUrl": "https://github.com/opensouthcode/2026/issues/7" }, { "id": "15", "title": "Ada Kraft", + "details": "Haz manualidades inspiradas en Ada & Zangemann y en la tecnología libre.", "issueUrl": "https://github.com/opensouthcode/2026/issues/41" }, { "id": "16", "title": "Cine Zangemann", + "details": "Disfruta la historia de Ada & Zangemann y descubre ideas sobre software libre, creatividad y tecnología.", "issueUrl": "https://github.com/opensouthcode/2026/issues/3" } ] diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index b8dce96..f0b15e9 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -16,6 +16,7 @@ export const messages = { 'app.access': 'Access', 'activity.list.title': 'Activities', 'activity.detail.eyebrow': 'Activity', + 'activity.detail.moreDetails': 'More details', 'activity.detail.notFound': 'Activity not found', 'access.title': 'Choose User', 'access.role.desk': 'Desk', @@ -230,6 +231,7 @@ export const messages = { 'app.access': 'Acceder', 'activity.list.title': 'Actividades', 'activity.detail.eyebrow': 'Actividad', + 'activity.detail.moreDetails': 'Más detalles', 'activity.detail.notFound': 'Actividad no encontrada', 'access.title': 'Elegir usuario', 'access.role.desk': 'Mesa', From 749b8b1098376e28b77a5e4219bcb807884df43e Mon Sep 17 00:00:00 2001 From: pablonete Date: Thu, 25 Jun 2026 11:00:47 +0200 Subject: [PATCH 5/5] Update drawing activity description Apply review feedback to simplify the Anima tu dibujo activity description. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/data/activities.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/activities.json b/src/data/activities.json index b061375..fa18e8e 100644 --- a/src/data/activities.json +++ b/src/data/activities.json @@ -26,7 +26,7 @@ { "id": "5", "title": "Anima tu dibujo", - "details": "Convierte un dibujo propio en una pequeña animación y descubre cómo dar vida digital a tus ideas.", + "details": "Haz que tu dibujo baile", "issueUrl": "https://github.com/opensouthcode/2026/issues/48" }, {