diff --git a/apps/web/src/components/CardImagesModal.jsx b/apps/web/src/components/CardImagesModal.jsx
index 85ea150..75a112e 100644
--- a/apps/web/src/components/CardImagesModal.jsx
+++ b/apps/web/src/components/CardImagesModal.jsx
@@ -1,17 +1,26 @@
import { useEffect, useRef, useState } from 'react';
-import { X, Plus, Check, Trash2 } from 'lucide-react';
+import { X, Plus, Check, Trash2, Share2 } from 'lucide-react';
import {
CARD_COLORS,
+ CLUB_CARD_SET_ID,
DEFAULT_CARD_SETS,
getCardImageSettings,
+ activeCardSetId,
+ clubCardSetAvailable,
+ getClubCardImage,
selectCardSet,
addCustomCardSet,
deleteCustomCardSet,
getCustomCardImage,
fileToCardBlob,
initCardImages,
+ canPublishCards,
+ publishCardSet,
+ loadClubCards,
+ subscribeCardImages,
} from '@toastmaster-timer/shared';
import { getCardAssetUrl } from '../utils/cardArtwork';
+import { useClub } from '../hooks/useClub';
import { useToast } from '../context/ToastContext';
const COLOR_LABELS = {
@@ -34,12 +43,20 @@ const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files;
const STORAGE_FULL_MESSAGE =
'Could not save — browser storage is full. Try smaller images or delete another custom set.';
+const SHARE_ERRORS = {
+ forbidden: 'Only your club’s admins and editors can share cards with the club. Sign in to share.',
+ upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.',
+ storage_limit: 'Your club’s card storage is full.',
+ image_too_large: 'One of these images is too large to share.',
+ network: 'Could not reach the server. Check your connection and try again.',
+};
+
/**
* One selectable set: a labeled row of the four cards. The row itself is the
* radio; a custom set also carries a delete button, which must not double as
* a click on the row.
*/
-function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete }) {
+function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) {
return (
{CARD_COLORS.map((color) => (
@@ -97,6 +130,15 @@ function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete }) {
export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
const { showToast } = useToast();
const [settings, setSettings] = useState(() => getCardImageSettings());
+ // The club's set is not in the metadata, so which set shows is asked of the
+ // shared module rather than read off `settings`.
+ const [activeSetId, setActiveSetId] = useState(() => activeCardSetId());
+ const { club, clubName } = useClub();
+ // The kit carries the name when there is one; the club record always does.
+ const clubLabel = clubName || club?.club?.name || null;
+ const [sharingSetId, setSharingSetId] = useState(null);
+ // Re-render when the club's images arrive, so its thumbnails fill in.
+ const [, setClubGeneration] = useState(0);
// The set being assembled in the uploader row, color -> {blob, url}. Kept
// only in this modal until the organizer confirms it into a real set.
const [draft, setDraft] = useState({});
@@ -111,10 +153,18 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
useEffect(() => {
let cancelled = false;
initCardImages().then(() => {
- if (!cancelled) setSettings(getCardImageSettings());
+ if (!cancelled) {
+ setSettings(getCardImageSettings());
+ setActiveSetId(activeCardSetId());
+ }
+ });
+ const unsubscribe = subscribeCardImages(() => {
+ setActiveSetId(activeCardSetId());
+ setClubGeneration((generation) => generation + 1);
});
return () => {
cancelled = true;
+ unsubscribe();
Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url));
};
}, []);
@@ -123,11 +173,12 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
const changed = () => {
setSettings(getCardImageSettings());
+ setActiveSetId(activeCardSetId());
onImagesChanged?.();
};
const handleSelect = (setId) => {
- if (setId === settings.selectedSetId) return;
+ if (setId === activeSetId) return;
if (selectCardSet(setId)) changed();
else showToast(STORAGE_FULL_MESSAGE, 'error');
};
@@ -168,6 +219,20 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
}
};
+ // No label: "Custom 1" is this device's slot name, and every other club device
+ // has its own "Custom 1". Unlabelled, the set reads " cards" everywhere.
+ const handleShare = async (setId) => {
+ setSharingSetId(setId);
+ // The web session travels as a cookie, so there is no token to hand over.
+ const result = await publishCardSet(setId);
+ setSharingSetId(null);
+ if (result.ok) {
+ showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success');
+ } else {
+ showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error');
+ }
+ };
+
const pickDraftFile = (color) => {
draftColorRef.current = color;
fileInputRef.current?.click();
@@ -190,11 +255,21 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
+ {clubCardSetAvailable() && (
+ handleSelect(CLUB_CARD_SET_ID)}
+ thumbSrc={(color) => getClubCardImage(color) || getCardAssetUrl(CLASSIC_FILES[color])}
+ />
+ )}
+
{DEFAULT_CARD_SETS.map((set) => (
handleSelect(set.id)}
thumbSrc={(color) => getCardAssetUrl(set.files[color])}
/>
@@ -204,12 +279,14 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
handleSelect(set.id)}
// A color the set has no upload for shows — and times with —
// the Classic card.
thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardAssetUrl(CLASSIC_FILES[color])}
onDelete={() => handleDelete(set.id)}
+ onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined}
+ sharing={sharingSetId === set.id}
/>
))}
@@ -270,9 +347,10 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
/>
- Custom images are stored only in this browser. Please upload only images you have the
- right to use — for example your club's own materials. This app is independent and
- cannot grant permission for third-party logos or artwork.
+ Custom images are stored only in this browser.
+ {club?.entitled && canPublishCards() ? ' Share a set to put it on every club device.' : ''} Please
+ upload only images you have the right to use — for example your club's own materials.
+ This app is independent and cannot grant permission for third-party logos or artwork.
diff --git a/apps/web/src/components/LiveTab.jsx b/apps/web/src/components/LiveTab.jsx
index 52172d6..b9308f5 100644
--- a/apps/web/src/components/LiveTab.jsx
+++ b/apps/web/src/components/LiveTab.jsx
@@ -8,7 +8,7 @@ const EditRulesModal = lazy(() => import('./EditRulesModal'));
const CardImagesModal = lazy(() => import('./CardImagesModal'));
const SoundsModal = lazy(() => import('./SoundsModal'));
import TimeInput, { TimeInputModeToggle } from './TimeInput';
-import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, initCardImages } from '@toastmaster-timer/shared';
+import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, initCardImages, subscribeCardImages } from '@toastmaster-timer/shared';
import { setPageBackgroundFromStatus } from '../utils/pageBackground';
import { parseSpeakerFromSearch, stripSpeakerParams } from '../utils/speakerDeepLink';
import { getCardImageUrl, preloadCardImages } from '../utils/cardArtwork';
@@ -81,6 +81,13 @@ export default memo(function LiveTab({ onTimerStart }) {
return () => { cancelled = true; };
}, []);
+ // The club's card set arrives, changes or goes away while the page is open.
+ useEffect(() => subscribeCardImages(() => {
+ setCardImagesGeneration((generation) => generation + 1);
+ setPageBackgroundFromStatus(shownStatusRef.current);
+ preloadCardImages();
+ }), []);
+
// On mount, seed the speaker. A "Time this" deep link (?role=...&name=...)
// wins when nothing is loaded yet and the timer is idle; otherwise the
// default role applies. A speaker that is already there (restored state)
diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx
index 01738e2..16ccd23 100644
--- a/apps/web/src/main.jsx
+++ b/apps/web/src/main.jsx
@@ -7,7 +7,7 @@ import App from './App.jsx'
import './index.css'
import { initPostHog, trackEvent } from './utils/posthog.js'
import { startWebSession } from './utils/webIdentity.js'
-import { initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter } from '@toastmaster-timer/shared'
+import { initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter, startClubCardSync } from '@toastmaster-timer/shared'
// Before the first paint, and synchronous: a browser that joined a club is Pro,
// and reading that out of localStorage now is what stops the plan flashing
@@ -44,6 +44,9 @@ deferInit(() => {
refreshClub().catch((error) => {
console.warn('Failed to refresh the club:', error)
})
+ // The club's card set, fetched where this browser lacks it, now and whenever
+ // the club changes.
+ startClubCardSync()
// An upload that does not go out is invisible from here — the device keeps
// its own copy and the timer sees nothing wrong — so it has to report itself.
setArchiveReporter(trackEvent)
diff --git a/apps/web/src/pages/ClubAdmin.jsx b/apps/web/src/pages/ClubAdmin.jsx
index adec6d0..0c6ab14 100644
--- a/apps/web/src/pages/ClubAdmin.jsx
+++ b/apps/web/src/pages/ClubAdmin.jsx
@@ -371,6 +371,110 @@ function KitEditor({ kit, clubName, onSaved }) {
)
}
+const CARD_COLORS = ['blue', 'green', 'yellow', 'red']
+const CARD_LABELS = { blue: 'Idle (blue)', green: 'Green', yellow: 'Yellow', red: 'Red' }
+
+/**
+ * The club's card set, as the console shows it. Read-only apart from Remove:
+ * a set is published from a timer's card picker, where the artwork already
+ * is, not uploaded here.
+ *
+ * The images are private, so they are fetched with the console's credentials
+ * and shown from object URLs rather than linked.
+ */
+function ClubCards({ cards, clubName, onRemoved }) {
+ const [urls, setUrls] = useState({})
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!cards?.images) return undefined
+ let cancelled = false
+ const minted = []
+ Promise.all(
+ Object.entries(cards.images).map(async ([color, hash]) => {
+ try {
+ const response = await fetch(`/api/club/cards/${hash}`, {
+ credentials: 'same-origin',
+ headers: { ...clubHeaders() },
+ })
+ if (!response.ok) return null
+ const url = URL.createObjectURL(await response.blob())
+ minted.push(url)
+ return [color, url]
+ } catch {
+ return null
+ }
+ })
+ ).then((entries) => {
+ if (!cancelled) setUrls(Object.fromEntries(entries.filter(Boolean)))
+ })
+ return () => {
+ cancelled = true
+ minted.forEach((url) => URL.revokeObjectURL(url))
+ }
+ }, [cards])
+
+ if (!cards?.images) {
+ return (
+
+ No card set yet. An admin or editor shares one from Card Images in the timer, and every
+ club device shows it.
+
+ )
+}
+
const KIT_ERRORS = {
invalid_name: 'A club needs a name, and it has to fit in 80 characters.',
invalid_color: 'Pick a colour in #rrggbb form.',
@@ -580,6 +684,19 @@ export default function ClubAdmin() {
+
+
Card set
+ {
+ load()
+ // This browser may be a club device too; let it drop the set now.
+ refreshClub({ force: true }).catch(() => {})
+ }}
+ />
+
+
Billing
{roster.actor?.type === 'zoom' ? (
diff --git a/apps/zoom-app/src/components/CardImagesModal.jsx b/apps/zoom-app/src/components/CardImagesModal.jsx
index a8dc369..4e6c803 100644
--- a/apps/zoom-app/src/components/CardImagesModal.jsx
+++ b/apps/zoom-app/src/components/CardImagesModal.jsx
@@ -1,19 +1,29 @@
import { useEffect, useRef, useState, lazy, Suspense } from 'react';
import { useEntitlement } from '../hooks/useEntitlement';
import { useFlag } from '../hooks/useFlag';
-import { X, Plus, Check, Trash2 } from 'lucide-react';
+import { X, Plus, Check, Trash2, Share2 } from 'lucide-react';
import {
CARD_COLORS,
+ CLUB_CARD_SET_ID,
DEFAULT_CARD_SETS,
getCardImageSettings,
+ activeCardSetId,
+ clubCardSetAvailable,
+ getClubCardImage,
selectCardSet,
addCustomCardSet,
deleteCustomCardSet,
getCustomCardImage,
fileToCardBlob,
initCardImages,
+ canPublishCards,
+ publishCardSet,
+ loadClubCards,
+ subscribeCardImages,
} from '@toastmaster-timer/shared';
import { getCardFileUrl, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk';
+import { getSessionToken } from '../utils/zoomIdentity';
+import { useClub } from '../hooks/useClub';
import OwnBackgroundPicker from './OwnBackgroundPicker';
import { useToast } from '../context/ToastContext';
const UpgradeModal = lazy(() => import('./UpgradeModal'));
@@ -38,12 +48,20 @@ const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files;
const STORAGE_FULL_MESSAGE =
'Could not save — browser storage is full. Try smaller images or delete another custom set.';
+const SHARE_ERRORS = {
+ forbidden: 'Only your club’s admins and editors can share cards with the club.',
+ upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.',
+ storage_limit: 'Your club’s card storage is full.',
+ image_too_large: 'One of these images is too large to share.',
+ network: 'Could not reach the server. Check your connection and try again.',
+};
+
/**
* One selectable set: a labeled row of the four cards. The row itself is the
* radio; a custom set also carries a delete button, which must not double as
* a click on the row.
*/
-function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete }) {
+function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) {
return (
}
- {onDelete && (
-
- )}
+
+ {onShare && (
+
+ )}
+ {onDelete && (
+
+ )}
+
{CARD_COLORS.map((color) => (
@@ -107,6 +141,15 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
const { enabled: proEnabled, known: flagsKnown } = useFlag('pro');
const [showUpgrade, setShowUpgrade] = useState(false);
const [settings, setSettings] = useState(() => getCardImageSettings());
+ // The club's set is not in the metadata, so which set shows is asked of the
+ // shared module rather than read off `settings`.
+ const [activeSetId, setActiveSetId] = useState(() => activeCardSetId());
+ const { club, clubName } = useClub();
+ // The kit carries the name when there is one; the club record always does.
+ const clubLabel = clubName || club?.club?.name || null;
+ const [sharingSetId, setSharingSetId] = useState(null);
+ // Re-render when the club's images arrive, so its thumbnails fill in.
+ const [, setClubGeneration] = useState(0);
// The set being assembled in the uploader row, color -> {blob, url}. Kept
// only in this modal until the organizer confirms it into a real set.
const [draft, setDraft] = useState({});
@@ -121,10 +164,18 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
useEffect(() => {
let cancelled = false;
initCardImages().then(() => {
- if (!cancelled) setSettings(getCardImageSettings());
+ if (!cancelled) {
+ setSettings(getCardImageSettings());
+ setActiveSetId(activeCardSetId());
+ }
+ });
+ const unsubscribe = subscribeCardImages(() => {
+ setActiveSetId(activeCardSetId());
+ setClubGeneration((generation) => generation + 1);
});
return () => {
cancelled = true;
+ unsubscribe();
Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url));
};
}, []);
@@ -133,6 +184,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
const changed = () => {
setSettings(getCardImageSettings());
+ setActiveSetId(activeCardSetId());
notifyCardImagesChanged();
onImagesChanged?.();
// The notify above dropped every decoded overlay frame; re-decode the new
@@ -142,7 +194,7 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
};
const handleSelect = (setId) => {
- if (setId === settings.selectedSetId) return;
+ if (setId === activeSetId) return;
if (selectCardSet(setId)) changed();
else showToast(STORAGE_FULL_MESSAGE, 'error');
};
@@ -183,6 +235,19 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
}
};
+ // No label: "Custom 1" is this device's slot name, and every other club device
+ // has its own "Custom 1". Unlabelled, the set reads " cards" everywhere.
+ const handleShare = async (setId) => {
+ setSharingSetId(setId);
+ const result = await publishCardSet(setId, { getToken: getSessionToken });
+ setSharingSetId(null);
+ if (result.ok) {
+ showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success');
+ } else {
+ showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error');
+ }
+ };
+
const pickDraftFile = (color) => {
draftColorRef.current = color;
fileInputRef.current?.click();
@@ -205,11 +270,21 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
+ {clubCardSetAvailable() && (
+ handleSelect(CLUB_CARD_SET_ID)}
+ thumbSrc={(color) => getClubCardImage(color) || getCardFileUrl(CLASSIC_FILES[color])}
+ />
+ )}
+
{DEFAULT_CARD_SETS.map((set) => (
handleSelect(set.id)}
thumbSrc={(color) => getCardFileUrl(set.files[color])}
/>
@@ -219,12 +294,14 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
handleSelect(set.id)}
// A color the set has no upload for shows — and times with —
// the Classic card.
thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardFileUrl(CLASSIC_FILES[color])}
onDelete={() => handleDelete(set.id)}
+ onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined}
+ sharing={sharingSetId === set.id}
/>
))}
@@ -291,7 +368,12 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
/>
- {isPro ? (
+ {club?.entitled ? (
+ <>
+ Custom images are stored only in this browser.
+ {canPublishCards() ? ' Share a set to put it on every club device. ' : ' '}
+ >
+ ) : isPro ? (
<>Custom images are backed up and follow you to your other devices. >
) : known && flagsKnown && proEnabled ? (
<>
diff --git a/apps/zoom-app/src/components/CardImagesModal.test.jsx b/apps/zoom-app/src/components/CardImagesModal.test.jsx
index 70e6a7f..7e55e02 100644
--- a/apps/zoom-app/src/components/CardImagesModal.test.jsx
+++ b/apps/zoom-app/src/components/CardImagesModal.test.jsx
@@ -1,11 +1,14 @@
import '@testing-library/jest-dom';
-import { act, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
import {
+ resetCardImagesForTests,
+ resetClubForTests,
resetEntitlementForTests,
resetFlagsForTests,
setEntitlement,
setFlags,
} from '@toastmaster-timer/shared';
+import { publishCardSet } from '@toastmaster-timer/shared';
import CardImagesModal from './CardImagesModal';
import { ToastProvider } from '../context/ToastContext';
@@ -20,6 +23,12 @@ vi.mock('../utils/zoomSdk', () => ({
removeOwnBackground: vi.fn(),
}));
vi.mock('../utils/posthog', () => ({ trackEvent: vi.fn() }));
+// Only the publish is stubbed: the network and IndexedDB behind it are covered
+// in packages/shared/__tests__/clubCards.test.js.
+vi.mock('@toastmaster-timer/shared', async (importOriginal) => ({
+ ...(await importOriginal()),
+ publishCardSet: vi.fn(async () => ({ ok: true, ver: 3, cards: { label: null, images: { blue: 'a'.repeat(64) } } })),
+}));
vi.mock('../utils/zoomIdentity', () => ({
resolveZoomIdentity: vi.fn(async () => ({ identified: true, uid: 'u1' })),
getSessionToken: vi.fn(() => 'session-token'),
@@ -45,6 +54,80 @@ beforeEach(() => {
localStorage.clear();
resetEntitlementForTests();
resetFlagsForTests();
+ resetClubForTests();
+ resetCardImagesForTests();
+});
+
+/** Put this device in an entitled club, optionally with a published card set. */
+function joinClub({ role = null, cards = { label: null, images: { blue: 'a'.repeat(64) } } } = {}) {
+ localStorage.setItem(
+ 'toastmaster_club',
+ JSON.stringify({ clubToken: 'club-token', ver: 2, club: { id: 'c1', name: 'Downtown Speakers' }, kit: null, entitled: true, plan: 'pro', role })
+ );
+ if (cards) localStorage.setItem('toastmaster_club_cards', JSON.stringify(cards));
+ resetClubForTests();
+}
+
+/**
+ * The club's set: on offer in the picker whenever the club has one, and shown
+ * by default on a device that never chose cards of its own.
+ */
+describe('the club card set', () => {
+ it('is listed, and selected, on a device with no cards of its own', async () => {
+ joinClub();
+ await renderModal();
+ const row = screen.getByRole('radio', { name: /Downtown Speakers cards/ });
+ expect(row).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('radio', { name: /Classic/ })).toHaveAttribute('aria-checked', 'false');
+ });
+
+ it('is not listed when the club has not published one', async () => {
+ joinClub({ cards: null });
+ await renderModal();
+ expect(screen.queryByRole('radio', { name: /Downtown Speakers cards/ })).toBeNull();
+ });
+
+ // Publishing rewrites every club device's cards, so the button is for the
+ // people the server will accept it from.
+ it.each([
+ ['an editor', 'editor', true],
+ ['an admin', 'admin', true],
+ ['a member', 'member', false],
+ ['a guest device', null, false],
+ ])('offers Share with my club on a custom set to %s: %s', async (_, role, offered) => {
+ joinClub({ role });
+ localStorage.setItem(
+ 'toastmaster_custom_card_images',
+ JSON.stringify({ version: 3, selectedSetId: 'custom-1', customSets: [{ id: 'custom-1', colors: ['blue'] }] })
+ );
+ await renderModal();
+ const share = screen.queryByRole('button', { name: /Share the Custom 1 card set with my club/ });
+ expect(Boolean(share)).toBe(offered);
+ });
+
+ // "Custom 1" names a slot on the publisher's device; every other club device
+ // has its own "Custom 1", so sending it would show two rows with one name.
+ it('publishes without the slot name, so the set reads " cards"', async () => {
+ joinClub({ role: 'admin', cards: null });
+ localStorage.setItem(
+ 'toastmaster_custom_card_images',
+ JSON.stringify({ version: 3, selectedSetId: 'custom-1', customSets: [{ id: 'custom-1', colors: ['blue'] }] })
+ );
+ await renderModal();
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /Share the Custom 1 card set with my club/ }));
+ });
+ expect(publishCardSet).toHaveBeenCalledTimes(1);
+ const [setId, options] = publishCardSet.mock.calls[0];
+ expect(setId).toBe('custom-1');
+ expect(options?.label).toBeUndefined();
+ });
+
+ it('uses the label the publisher gave it', async () => {
+ joinClub({ cards: { label: 'Contest night', images: { red: 'b'.repeat(64) } } });
+ await renderModal();
+ expect(screen.getByRole('radio', { name: /Contest night/ })).toBeInTheDocument();
+ });
});
/**
diff --git a/apps/zoom-app/src/components/LiveTab.jsx b/apps/zoom-app/src/components/LiveTab.jsx
index 3fc6dcd..d9dc391 100644
--- a/apps/zoom-app/src/components/LiveTab.jsx
+++ b/apps/zoom-app/src/components/LiveTab.jsx
@@ -10,8 +10,8 @@ const EditRulesModal = lazy(() => import('./EditRulesModal'));
const CardImagesModal = lazy(() => import('./CardImagesModal'));
const SoundsModal = lazy(() => import('./SoundsModal'));
import TimeInput, { TimeInputModeToggle } from './TimeInput';
-import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages } from '@toastmaster-timer/shared';
-import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, getZoomParticipants, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE } from '../utils/zoomSdk';
+import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages, subscribeCardImages } from '@toastmaster-timer/shared';
+import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, getZoomParticipants, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk';
import { useClub } from '../hooks/useClub';
import { SOUNDS_LOCK_MESSAGES, useSoundsAccess } from '../hooks/useSounds';
import { formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } from '@toastmaster-timer/shared';
@@ -90,6 +90,14 @@ export default memo(function LiveTab() {
return () => { cancelled = true; };
}, []);
+ // The club's card set arrives, changes or goes away while the app is open.
+ // Drop the decoded frames and re-push, as the picker does after a change.
+ useEffect(() => subscribeCardImages(() => {
+ notifyCardImagesChanged();
+ preloadBackgroundImages();
+ setCardImagesGeneration((generation) => generation + 1);
+ }), []);
+
const [videoState, setVideoStateLocal] = useState(null); // null = unknown, true = on, false = off
// A clear is in flight. Disables both the eraser and RESET, because in Timer +
// Camera the wait is Zoom's confirmation dialog, and a second press behind the
diff --git a/apps/zoom-app/src/main.jsx b/apps/zoom-app/src/main.jsx
index b937048..ad87180 100644
--- a/apps/zoom-app/src/main.jsx
+++ b/apps/zoom-app/src/main.jsx
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import { initializeZoomSdk, preloadBackgroundImages, setOverlayTimingReporter } from './utils/zoomSdk'
-import { initCardImages, initProfileSync, syncCardAssets, setEntitlement, subscribeEntitlement, FREE_ENTITLEMENT, setFlags, initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter } from '@toastmaster-timer/shared'
+import { initCardImages, initProfileSync, syncCardAssets, setEntitlement, subscribeEntitlement, FREE_ENTITLEMENT, setFlags, initClubFromCache, refreshClub, warmClubLogo, drainOutbox, setArchiveReporter, startClubCardSync } from '@toastmaster-timer/shared'
import { initPostHog, identifyUser, setUserProperties, registerSessionProperties, trackEvent } from './utils/posthog'
import { resolveZoomIdentity, getSessionToken } from './utils/zoomIdentity'
import posthog from 'posthog-js'
@@ -54,6 +54,10 @@ refreshClub({ getToken: getSessionToken }).catch((error) => {
console.warn('Failed to refresh the club:', error);
});
+// The club's card set: fetched where this device lacks it, now and whenever
+// the club changes. Never awaited; a card that has not arrived shows Classic.
+startClubCardSync();
+
// An upload that does not go out is invisible from here — the device keeps its
// own copy and the timer sees nothing wrong — so it has to report itself.
setArchiveReporter(trackEvent);
diff --git a/docs/BILLING.md b/docs/BILLING.md
index 2b7976c..1af70a1 100644
--- a/docs/BILLING.md
+++ b/docs/BILLING.md
@@ -31,7 +31,7 @@ Zoom app polls GET /api/me until plan === 'pro'
payments waiting to become a club under `club-pending:`.
Bind a namespace as `ENTITLEMENTS` to move them; no code change.
- Club records live in the same namespace under their own prefixes — `club:`,
- `club-by-code:`, `club-by-customer:`, `club-by-email:`, `club-presets:`,
+ `club-by-code:`, `club-by-customer:`, `club-by-email:`, `club-presets:`, `club-cards:`,
`club-device:`, `club-member:`, `club-magic:`, `meeting:`, `speech:`,
`report-share:`. The full inventory is in
[CLUBS.md](./CLUBS.md#kv-inventory).
diff --git a/docs/CLUBS.md b/docs/CLUBS.md
index 6e17e08..d049c2c 100644
--- a/docs/CLUBS.md
+++ b/docs/CLUBS.md
@@ -62,8 +62,9 @@ rights to an anonymous device"** true by construction.
| | admin | editor | member / guest device |
|---|---|---|---|
-| Club presets, brand kit, archive | yes | yes | yes |
-| "Share with my club" (publish) | yes | yes | no |
+| Club presets, brand kit, card set, archive | yes | yes | yes |
+| "Share with my club" (presets, card set) | yes | yes | no |
+| Remove the card set | yes | no | no |
| Roster, grant/revoke, kit editor | yes | no | no |
| Billing | yes | no | no |
@@ -89,6 +90,7 @@ clubState = {
kit: { name, logoUrl, primaryColor, showOnCards, showOnReports } | null,
presets: { rules, order, hiddenBuiltins, publishedBy, publishedAt } | null,
badge: { x, y, scale }, ← the club's default placement
+ cards: { label, images: { color: sha256 }, publishedBy, publishedAt } | null,
timezone,
role, ← this caller's role, or null
plan, entitled, status, currentPeriodEnd, cancelAtPeriodEnd, source
@@ -110,6 +112,8 @@ mid-meeting.
toastmaster_club { clubToken, ver, club, kit, badge, plan, … }
toastmaster_club_presets the club's published list
toastmaster_preset_source 'club' | 'personal'
+toastmaster_club_cards the club's card set: label and image hashes
+toastmaster_card_source 'club' | 'own'
toastmaster_club_badge this device's badge move, if it made one
toastmaster_club_outbox finished speeches not yet uploaded
toastmaster_club_grace_dismissed the day the renewal reminder was dismissed
@@ -130,6 +134,25 @@ on the club's list; a device with its own presets keeps them. Editing the club's
list asks once, then forks it into the timer's own keys
(`packages/shared/clubPresets.js`).
+## Card set: a switch, like presets
+
+A club can publish one set of timing-card artwork. An admin or editor presses
+**Share with my club** on one of their own custom sets in **Card Images**; the
+images go to `club-card//` and the set to `club-cards:`, and
+`ver` moves. Every club device downloads what it is missing into IndexedDB
+(`packages/shared/clubCards.js`), so the card-switch path never waits on the
+network.
+
+Which set shows follows the preset rule: a device that never chose cards of its
+own opens on the club's set; one that did keeps its own, with the club's one tap
+away in the picker. Choosing in the picker moves `toastmaster_card_source`
+explicitly. A lapsed or left club drops the set from the picker and the device
+shows its own choice again; nothing is deleted. An admin can take the set down
+from the console.
+
+Publishing prunes images no set names any more, sparing uploads newer than 15
+minutes so a second publisher's uploads survive until their own publish lands.
+
## KV inventory
All in the `PROFILES` namespace, alongside the entitlement and Stripe prefixes.
@@ -146,6 +169,8 @@ club-pending: { uid, clubName, email, stripeCustomerId,
checkoutSessionId, paidAt }
club-presets: { rules, order, hiddenBuiltins, badge,
publishedBy, publishedAt }
+club-cards: { label, images: { color: sha256 },
+ publishedBy, publishedAt }
club-device:: { label, uid|null, activatedAt, lastSeenAt, revokedAt }
club-member::zoom: { role, displayName, addedAt, revokedAt }
club-magic: { clubId, email, exp } 15-minute TTL
@@ -158,6 +183,8 @@ report-share-by-meeting:: ← so a re-share reuses
R2 (CARD_ASSETS)
club// the club logo — served publicly, immutable
club//r-.png a shared report's OG preview image
+club-card// a card set image — private, never under club/
+ (which /api/club-assets serves to anyone)
```
`invTs` is an inverted timestamp so `list({prefix:'meeting::'})` comes
@@ -170,6 +197,10 @@ History list costs exactly one operation.
POST /api/club/activate public, rate-limited
GET /api/club X-Club
PUT /api/club/presets X-Club + uid, role admin|editor
+PUT /api/club/cards/ X-Club + uid, role admin|editor (one image)
+PUT /api/club/cards X-Club + uid, role admin|editor (the set)
+GET /api/club/cards/ X-Club, or an admin's console session
+DELETE /api/club/cards admin
POST /api/club/meetings//speeches X-Club, + device revocation read
GET /api/club/meetings X-Club
GET /api/club/meetings/ X-Club
diff --git a/docs/FEATURE_FLAGS.md b/docs/FEATURE_FLAGS.md
index 1b9926e..88c2d9d 100644
--- a/docs/FEATURE_FLAGS.md
+++ b/docs/FEATURE_FLAGS.md
@@ -81,7 +81,7 @@ them.
| Billing | `POST /api/billing/checkout`, `POST /api/billing/portal` (after the session check, so no session is still 401). Zoom: the Footer **Upgrade**/**Pro** button and the **Upgrade to Pro** link in Card images, which are the Upgrade modal's only doors. Both apps: **Manage billing** on the club banner. Web: the plan section on `/account` | `GET /api/billing/checkout-status`, the Stripe webhook, the success/cancel pages, entitlement itself |
| Web sign-in | `GET /api/auth/zoom/start`. A sign-in callback on `/oauth/redirect` falls through to the install page instead, so Marketplace installs keep working. Web: **Sign in** in the account menu, the signed-out card on `/account` | `POST /api/auth/logout` |
| Pro in the UI | `useEntitlement().isPro` in both apps is the plan **and** `pro`, so every Pro feature in the app reads a Pro plan as free while `pro` is off: Sounds shows the "Pro feature" lock (not "coming soon"), Card images says images stay in this browser, the account menu says Account. `known` waits for the flags as well as the plan | the server, which keeps keying on `entitled`: a paying user's sync, card backups and club archive carry on. Club surfaces that read the club record (`club.entitled`): the badge, presets, the archive |
-| Clubs | `/api/club/activate`, `/create`, `/magic-link` and `/manage`, whatever the method, checked before anything else so even an unauthenticated create is 404. Zoom: the code field and **Set up my club**, inside the Upgrade modal. Web: the setup card and code field on `/account`; `/club/admin`, `/club/manage` and `/pro/:code` are the not-found view (`FlagGate`) | `GET /api/club` (the daily refresh), presets, `meetings/*` (including queued speeches), the admin routes, `manage/signout`, `/api/club-assets/*` |
+| Clubs | `/api/club/activate`, `/create`, `/magic-link` and `/manage`, whatever the method, checked before anything else so even an unauthenticated create is 404. Zoom: the code field and **Set up my club**, inside the Upgrade modal. Web: the setup card and code field on `/account`; `/club/admin`, `/club/manage` and `/pro/:code` are the not-found view (`FlagGate`) | `GET /api/club` (the daily refresh), presets, the card set (`cards/*`), `meetings/*` (including queued speeches), the admin routes, `manage/signout`, `/api/club-assets/*` |
A refusal is a bare 404, the same as any unknown URL. The reason is logged
server-side only, as `flag off: pro `, so it shows in
diff --git a/e2e/zoom-client/INDEX.md b/e2e/zoom-client/INDEX.md
index ae275b2..5e75ad6 100644
--- a/e2e/zoom-client/INDEX.md
+++ b/e2e/zoom-client/INDEX.md
@@ -12,6 +12,7 @@ Read this first. Update it in the same PR as any spec change.
| [reauth-landing](reauth-landing.md) | stale | never | install landing: `tt_approved` mark, `?welcome=new` / `?welcome=back`, "You're reconnected" and Open in Zoom; a marked browser's bare `/` redirects to `/reconnect` (`/?home` and the `zoom.` host do not); where Zoom lands a guest after approving (#96) |
| [contact-capture](contact-capture.md) | draft | never | Zoom email/name capture: authorize, "Stay in touch" card, browser and web sign-in doors, purge on uninstall (#83) |
| [timer-sounds](timer-sounds.md) | draft | never | Pro sounds: card-change chime (1/2/3 notes), break music during Take a Break, locked for free plans, audible through Share sound (#105) |
+| [club-card-sets](club-card-sets.md) | stale | 23091dc, 2026-11-12 | an admin or editor shares a custom card set with the club; another club device downloads it and shows it on real video; own choice kept; members can't publish; console removal; lapse; republish prunes the old image (plan 10 Phase 1). CC-08 and CC-06's forced 403 not run yet; CC-02's label changed in the next commit and needs a rerun |
| pro-club | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` P1–P7, plus the internal Club Pro plan (`06-zoom-client-test-plan-club-pro.md`); reference run `test-runs/2026-09-28-club-pro/run.md` |
| webhooks-lifecycle | **not migrated** | — | source: `docs/ZOOM_TEST_PLAN.md` Steps 4, 5, 6 |
@@ -20,5 +21,5 @@ yet. Migrate one at a time, running each once so `verified_at` is real.
## Run order for a full pass
Dependencies between specs, so the skill can batch human steps: `auth-identity` →
-`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `webhooks-lifecycle` (deauth
+`connection-notice` → `reauth-landing` → `contact-capture` → `timer-core` → `timer-sounds` → `agenda-import` → `pro-club` → `club-card-sets` → `webhooks-lifecycle` (deauth
last, it removes the app).
diff --git a/e2e/zoom-client/club-card-sets.md b/e2e/zoom-client/club-card-sets.md
new file mode 100644
index 0000000..d3ff50b
--- /dev/null
+++ b/e2e/zoom-client/club-card-sets.md
@@ -0,0 +1,109 @@
+---
+id: club-card-sets
+title: Club card sets (share card artwork with the club)
+status: stale
+covers:
+ - worker/club-cards.js
+ - packages/shared/clubCards.js
+ - packages/shared/cardImages.js
+ - packages/shared/club.js
+ - apps/zoom-app/src/components/CardImagesModal.jsx
+ - apps/zoom-app/src/components/LiveTab.jsx
+ - apps/zoom-app/src/main.jsx
+automated:
+ - worker/club-cards.test.js
+ - packages/shared/__tests__/clubCards.test.js
+ - apps/zoom-app/src/components/CardImagesModal.test.jsx
+needs:
+ - dev deployed
+ - FLAGS_FORCE "1" on dev (the club doors are behind `pro`)
+ - a dev club with a code, made with `node scripts/club-cli.mjs create --env dev`
+ - Zoom account A, an admin or editor of that club (club-member record)
+ - a second computer or browser profile, B, with no cards of its own
+ - ENTITLEMENT_ENFORCE "1" on dev for CC-08 only (ask first; revert after)
+verified_at: 23091dc
+verified_on: 2026-11-12
+---
+
+# Club card sets (share card artwork with the club)
+
+Phase 1 of `docs/plans/10-pro-is-the-club-plan.md`. An admin or editor publishes one of
+their own custom card sets to the club; every device holding the club code downloads it
+and shows it on real video. Unit tests prove the server checks, the switch logic and the
+download. Only the real client proves that a club image pushed through the SDK bridge
+looks the same as a personal one, and that the image arrives on a second machine.
+
+## Purpose
+Catches a published set that never reaches another club device, a club card that shows on
+the stage but not on the virtual background, a device whose own chosen cards are silently
+replaced by the club's, and a lapsed or left club whose cards keep showing.
+
+## Steps
+
+### CC-01 · auto · A, the publisher, adds a custom set
+Do: on A in Zoom, open the timer, Card Images, upload one image each for blue and red, Add set.
+Expect: "Custom 1" appears selected. The row shows **Share with my club** (A is admin or editor).
+Evidence: localStorage `toastmaster_custom_card_images` lists `custom-1` with `["blue","red"]`.
+
+### CC-02 · auto · A shares the set
+Do: on A, press **Share with my club** on Custom 1.
+Expect: toast "Shared with . Every club device gets these cards." A's picker now also
+lists " cards" (e.g. "Claude Test Club cards") as the club's set at the top, not
+"Custom 1". A's own selection does not change.
+Evidence: `wrangler tail --env dev` shows two `PUT /api/club/cards/` → 200 and one
+`PUT /api/club/cards` → 200. KV `club-cards:` holds both hashes; `club:.ver`
+went up by 1. R2 has `club-card//` for each, and nothing under `club//`.
+
+### CC-03 · human · B joins the club and gets the set
+Do: on B (no cards of its own), open the app in Zoom and enter the club code (or open
+`/pro/` first). Start the timer in Timer + Camera mode with video on.
+Expect: within a few seconds B's picker lists the club's set, selected. The blue card on B's
+video is A's blue image; green and yellow are the Classic cards (the set skips them).
+Evidence: tail shows `GET /api/club/cards/` → 200 from B, once per image. B's
+localStorage has `toastmaster_club_cards`; `toastmaster_card_source` is absent (derived).
+
+### CC-04 · auto · The club card reaches red on real video
+Do: on B, run a speech past red.
+Expect: the red card on B's video is A's red image, the same framing as a personal custom
+card. Switching blue → green → yellow → red stays smooth (no visible decode stall).
+Evidence: screenshot of B's self-view at red.
+
+### CC-05 · auto · A device with its own cards keeps them
+Do: on a device that had picked Modern before joining, open Card Images.
+Expect: Modern stays selected; the club's set is listed above it but not selected. Choosing
+the club's set shows it; choosing Modern again goes back.
+Evidence: `toastmaster_card_source` is `club`, then `own`.
+
+### CC-06 · auto · Members cannot publish
+Do: on a device signed in as a club member (not editor or admin), open Card Images.
+Expect: no **Share with my club** button on any custom set.
+Evidence: a forced `PUT /api/club/cards` from that device returns 403.
+
+### CC-07 · human · An admin removes the set from the console
+Do: on the web, open `/club/admin` as the club admin. Under Card set, press **Remove card set**.
+Expect: the section says there is no card set. On B's next club refresh, the club's set leaves
+the picker and B shows its own choice (Classic on a device that never chose). A club refresh runs at app start only when the last one is
+over 24 h old (`CLUB_REFRESH_INTERVAL_MS`); to see it now, set `lastRefreshAt` to 0 in B's
+`toastmaster_club` and reload.
+Evidence: tail shows `DELETE /api/club/cards` → 200; KV `club-cards:` is gone and
+`ver` went up by 1.
+
+### CC-08 · human · A lapsed club stops showing its set
+Do: with ENTITLEMENT_ENFORCE "1", republish a set from A, then set the club record to
+`canceled` with `currentPeriodEnd` in the past. Force B's refresh (`lastRefreshAt` 0) and reload.
+Expect: B shows its own choice (Classic) and the picker no longer lists the club's set. A's
+**Share with my club** is gone. Revert the club record and ENTITLEMENT_ENFORCE afterwards.
+Evidence: `GET /api/club` returns `entitled: false`; B's `toastmaster_club_cards` remains.
+
+### CC-09 · auto · A republishes; the old image is pruned
+Do: on A, add a second custom set and press **Share with my club** on it, at least 15 minutes
+after the first share.
+Expect: the club row at the top of A's picker shows the new set. Another club device keeps the
+previous set until its next club refresh (see CC-07).
+Evidence: `PUT /api/club/cards` → 200; KV `club-cards:` names only the new hashes and
+`ver` went up by 1; `GET /api/club/cards/` with a club token → 404 (pruned).
+
+## Not covered
+- Sharing from the web timer: same shared module and endpoints, proven by unit tests.
+- Two publishers racing: the prune grace window and the client's retry are unit-tested.
+- Card sets are one per club for now (open question 2 in the plan).
diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md
index 4b5a033..0020b06 100644
--- a/e2e/zoom-client/timer-core.md
+++ b/e2e/zoom-client/timer-core.md
@@ -128,3 +128,6 @@ Human because it needs a second signed-in Zoom account.
`TimerContext.jsx`). See [agenda-import](agenda-import.md).
- A failed participant read at START (`timer_started` still sent, no `audience_size`). The
Zoom client can't be made to fail it on demand; `LiveTab.test.jsx` covers it (#107 AC-5).
+- A club card set arriving, changing or going away while the app is open. `LiveTab.jsx`
+ re-pushes the current card then, and only then; a device with no club card set pushes
+ exactly as above. See [club-card-sets](club-card-sets.md).
diff --git a/e2e/zoom-client/timer-sounds.md b/e2e/zoom-client/timer-sounds.md
index 1374bb6..56c10ec 100644
--- a/e2e/zoom-client/timer-sounds.md
+++ b/e2e/zoom-client/timer-sounds.md
@@ -115,3 +115,6 @@ log.
- Sound on Zoom mobile and web clients.
- START's `timer_started` analytics, including `audience_size` (#107). See
[timer-core](timer-core.md) TC-10 to TC-12.
+- A club card set re-pushing the card mid-speech (`LiveTab.jsx`). The chime follows the
+ status (`cueForStatusChange` in `TimerSounds.jsx`), not the artwork, so it does not
+ chime. See [club-card-sets](club-card-sets.md).
diff --git a/packages/shared/__tests__/clubCards.test.js b/packages/shared/__tests__/clubCards.test.js
new file mode 100644
index 0000000..7296512
--- /dev/null
+++ b/packages/shared/__tests__/clubCards.test.js
@@ -0,0 +1,284 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { IDBFactory } from 'fake-indexeddb';
+import { Blob as NodeBlob } from 'node:buffer';
+
+/**
+ * The club's card set on a device: which set shows, where its images come
+ * from, and how a publisher's own set becomes the club's.
+ */
+
+const CLUB_KEY = 'toastmaster_club';
+const CARDS_KEY = 'toastmaster_club_cards';
+const SOURCE_KEY = 'toastmaster_card_source';
+const SETTINGS_KEY = 'toastmaster_custom_card_images';
+
+const BLUE = 'a'.repeat(64);
+const RED = 'b'.repeat(64);
+
+// cardImages.js and club.js both keep per-session state, so every test loads
+// fresh copies. IndexedDB and localStorage survive the reload, as they would a
+// page reload.
+async function load() {
+ vi.resetModules();
+ const cardImages = await import('../cardImages.js');
+ const club = await import('../club.js');
+ const clubCards = await import('../clubCards.js');
+ return { ...cardImages, ...club, ...clubCards };
+}
+
+function joinClub({ entitled = true, role = null, ver = 3, cards = { images: { blue: BLUE, red: RED } } } = {}) {
+ localStorage.setItem(
+ CLUB_KEY,
+ JSON.stringify({ clubToken: 'club-token', ver, club: { id: 'c1', name: 'Downtown' }, entitled, plan: 'pro', role })
+ );
+ if (cards) localStorage.setItem(CARDS_KEY, JSON.stringify(cards));
+}
+
+const imageBlob = (text) => new NodeBlob([text], { type: 'image/jpeg' });
+
+beforeEach(() => {
+ localStorage.clear();
+ globalThis.indexedDB = new IDBFactory();
+ // fake-indexeddb cannot clone jsdom's Blob; Node's round-trips.
+ globalThis.Blob = NodeBlob;
+ let n = 0;
+ URL.createObjectURL = vi.fn(() => `blob:test/${++n}`);
+ URL.revokeObjectURL = vi.fn();
+});
+
+describe('which set shows', () => {
+ it('is the device\'s own choice when there is no club set', async () => {
+ const mod = await load();
+ expect(mod.activeCardSetId()).toBe('classic');
+ expect(mod.clubCardSetAvailable()).toBe(false);
+ });
+
+ // The same rule as the club's presets: a device that never chose opens on the club's.
+ it('is the club\'s set on a device that never chose cards of its own', async () => {
+ joinClub();
+ const mod = await load();
+ expect(mod.activeCardSetId()).toBe(mod.CLUB_CARD_SET_ID);
+ });
+
+ it('stays on a device\'s own set when it has one', async () => {
+ joinClub();
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify({ version: 3, selectedSetId: 'modern', customSets: [] }));
+ const mod = await load();
+ expect(mod.activeCardSetId()).toBe('modern');
+ });
+
+ it('follows the switch once someone moves it', async () => {
+ joinClub();
+ const mod = await load();
+ expect(mod.selectCardSet('classic')).toBe(true);
+ expect(localStorage.getItem(SOURCE_KEY)).toBe('own');
+ expect(mod.activeCardSetId()).toBe('classic');
+ expect(mod.selectCardSet(mod.CLUB_CARD_SET_ID)).toBe(true);
+ expect(mod.activeCardSetId()).toBe(mod.CLUB_CARD_SET_ID);
+ });
+
+ // A lapsed club keeps its record; its set just stops being on offer.
+ it('falls back to the device\'s own set when the club lapses', async () => {
+ joinClub({ entitled: false });
+ localStorage.setItem(SOURCE_KEY, 'club');
+ const mod = await load();
+ expect(mod.activeCardSetId()).toBe('classic');
+ expect(mod.selectCardSet(mod.CLUB_CARD_SET_ID)).toBe(false);
+ });
+
+ it('shows a set someone just added, even with a club set on offer', async () => {
+ joinClub();
+ const mod = await load();
+ const id = await mod.addCustomCardSet({ green: imageBlob('green') });
+ expect(mod.activeCardSetId()).toBe(id);
+ });
+});
+
+describe('resolving a card from the club set', () => {
+ it('serves a stored image, and Classic for a skipped colour or one not yet here', async () => {
+ joinClub();
+ const mod = await load();
+ await mod.initCardImages();
+ expect(mod.resolveCardImage('blue')).toEqual({ file: 'timer-blue-background.png' });
+
+ await mod.storeClubCardBlobs([[BLUE, imageBlob('blue')]]);
+ expect(mod.resolveCardImage('blue')).toEqual({ url: expect.stringMatching(/^blob:/) });
+ expect(mod.resolveCardImage('green')).toEqual({ file: 'timer-green-background.png' });
+ expect(mod.resolveCardImage('red')).toEqual({ file: 'timer-red-background.png' });
+ });
+
+ it('keeps the current set\'s images through a reload and sweeps a replaced set\'s', async () => {
+ joinClub();
+ let mod = await load();
+ await mod.initCardImages();
+ await mod.storeClubCardBlobs([[BLUE, imageBlob('blue')], [RED, imageBlob('red')]]);
+
+ // The club republishes with only a new red card.
+ const NEW_RED = 'c'.repeat(64);
+ localStorage.setItem(CARDS_KEY, JSON.stringify({ images: { red: NEW_RED } }));
+ mod = await load();
+ await mod.initCardImages();
+ expect(mod.missingClubCardHashes()).toEqual([NEW_RED]);
+ const stored = await mod.readStoredCardBlobs();
+ expect([...stored.keys()]).toEqual([]);
+ });
+
+ it('tells subscribers when images arrive', async () => {
+ joinClub();
+ const mod = await load();
+ const heard = vi.fn();
+ mod.subscribeCardImages(heard);
+ await mod.storeClubCardBlobs([[BLUE, imageBlob('blue')]]);
+ expect(heard).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('syncClubCardImages', () => {
+ it('fetches only what is missing, with the club credential', async () => {
+ joinClub();
+ const mod = await load();
+ const fetchImpl = vi.fn(async (url) => ({
+ ok: true,
+ headers: new Headers({ 'content-type': 'image/jpeg' }),
+ blob: async () => imageBlob(url),
+ }));
+
+ expect(await mod.syncClubCardImages({ fetchImpl })).toEqual([BLUE, RED]);
+ expect(fetchImpl).toHaveBeenCalledWith(`/api/club/cards/${BLUE}`, expect.objectContaining({
+ headers: { 'X-Club': 'club-token' },
+ }));
+
+ fetchImpl.mockClear();
+ expect(await mod.syncClubCardImages({ fetchImpl })).toEqual([]);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('does nothing without a club set, and survives a failed download', async () => {
+ let mod = await load();
+ const fetchImpl = vi.fn();
+ expect(await mod.syncClubCardImages({ fetchImpl })).toEqual([]);
+ expect(fetchImpl).not.toHaveBeenCalled();
+
+ joinClub();
+ mod = await load();
+ const failing = vi.fn(async () => ({ ok: false, status: 404 }));
+ expect(await mod.syncClubCardImages({ fetchImpl: failing })).toEqual([]);
+ });
+
+ // A dev server's SPA fallback, or a captive portal, answers 200 with a page.
+ it('keeps only responses that are images', async () => {
+ joinClub();
+ const mod = await load();
+ const page = vi.fn(async () => ({
+ ok: true,
+ headers: new Headers({ 'content-type': 'text/html' }),
+ blob: async () => imageBlob(''),
+ }));
+ expect(await mod.syncClubCardImages({ fetchImpl: page })).toEqual([]);
+ expect(mod.missingClubCardHashes()).toEqual([BLUE, RED]);
+ });
+});
+
+describe('the club cache', () => {
+ const okJson = (body, status = 200) => ({ ok: status < 400, status, json: async () => body });
+
+ it('takes a new set when ver moves, and one it never had even when ver has not', async () => {
+ joinClub({ cards: null, ver: 3 });
+ let mod = await load();
+ await mod.refreshClub({
+ force: true,
+ fetchImpl: async () => okJson({ ver: 3, club: { id: 'c1' }, entitled: true, cards: { images: { blue: BLUE } } }),
+ });
+ expect(mod.loadClubCards().images).toEqual({ blue: BLUE });
+
+ mod = await load();
+ await mod.refreshClub({
+ force: true,
+ fetchImpl: async () => okJson({ ver: 4, club: { id: 'c1' }, entitled: true, cards: null }),
+ });
+ expect(mod.loadClubCards()).toBeNull();
+ });
+
+ it('forgets the set and the switch on leaving', async () => {
+ joinClub();
+ localStorage.setItem(SOURCE_KEY, 'club');
+ const mod = await load();
+ mod.leaveClub({ fetchImpl: vi.fn(async () => ({})) });
+ expect(localStorage.getItem(CARDS_KEY)).toBeNull();
+ expect(localStorage.getItem(SOURCE_KEY)).toBeNull();
+ expect(mod.activeCardSetId()).toBe('classic');
+ });
+});
+
+describe('publishCardSet', () => {
+ async function withCustomSet(role = 'editor') {
+ joinClub({ role, cards: null });
+ const mod = await load();
+ const id = await mod.addCustomCardSet({ blue: imageBlob('blue pixels'), red: imageBlob('red pixels') });
+ return { mod, id };
+ }
+
+ it('is offered to admins and editors only', async () => {
+ joinClub({ role: 'member' });
+ expect((await load()).canPublishCards()).toBe(false);
+ joinClub({ role: 'editor' });
+ expect((await load()).canPublishCards()).toBe(true);
+ joinClub({ role: 'admin', entitled: false });
+ expect((await load()).canPublishCards()).toBe(false);
+ });
+
+ it('uploads each image, publishes the set and remembers it as the club\'s', async () => {
+ const { mod, id } = await withCustomSet();
+ const calls = [];
+ const fetchImpl = vi.fn(async (url, init) => {
+ calls.push([init.method, url]);
+ if (url === '/api/club/cards') {
+ const body = JSON.parse(init.body);
+ return { ok: true, status: 200, json: async () => ({ ver: 4, cards: { ...body, publishedBy: 'me', publishedAt: 1 } }) };
+ }
+ return { ok: true, status: 200, json: async () => ({ stored: true }) };
+ });
+
+ const result = await mod.publishCardSet(id, { label: 'Custom 1', getToken: () => 'tok', fetchImpl });
+ expect(result.ok).toBe(true);
+ expect(calls.map(([method]) => method)).toEqual(['PUT', 'PUT', 'PUT']);
+ expect(calls[2][1]).toBe('/api/club/cards');
+ const [, uploadInit] = fetchImpl.mock.calls[0];
+ expect(uploadInit.headers).toMatchObject({ 'X-Club': 'club-token', Authorization: 'Bearer tok' });
+
+ expect(mod.loadClub().ver).toBe(4);
+ expect(Object.keys(mod.loadClubCards().images)).toEqual(['blue', 'red']);
+ // The publisher already holds the pixels; nothing is left to download.
+ expect(mod.missingClubCardHashes()).toEqual([]);
+ });
+
+ // Another publisher's set may prune an upload between our upload and our publish.
+ it('re-uploads what the server says is missing, once', async () => {
+ const { mod, id } = await withCustomSet();
+ let publishes = 0;
+ let missingHash;
+ const fetchImpl = vi.fn(async (url, init) => {
+ if (url !== '/api/club/cards') return { ok: true, status: 200, json: async () => ({ stored: true }) };
+ publishes += 1;
+ const body = JSON.parse(init.body);
+ if (publishes === 1) {
+ missingHash = body.images.blue;
+ return { ok: false, status: 409, json: async () => ({ error: 'missing_images', missing: [missingHash] }) };
+ }
+ return { ok: true, status: 200, json: async () => ({ ver: 5, cards: body }) };
+ });
+
+ expect((await mod.publishCardSet(id, { fetchImpl })).ok).toBe(true);
+ expect(publishes).toBe(2);
+ expect(fetchImpl.mock.calls.filter(([url]) => url === `/api/club/cards/${missingHash}`)).toHaveLength(2);
+ });
+
+ it('says why it was refused', async () => {
+ const { mod, id } = await withCustomSet('member');
+ const fetchImpl = vi.fn(async () => ({ ok: false, status: 403, json: async () => ({ error: 'forbidden' }) }));
+ expect(await mod.publishCardSet(id, { fetchImpl })).toEqual({ ok: false, error: 'forbidden' });
+ expect(await mod.publishCardSet('custom-9', { fetchImpl })).toEqual({ ok: false, error: 'no_set' });
+ const offline = vi.fn(async () => { throw new Error('offline'); });
+ expect(await mod.publishCardSet(id, { fetchImpl: offline })).toEqual({ ok: false, error: 'network' });
+ });
+});
diff --git a/packages/shared/cardImages.js b/packages/shared/cardImages.js
index 463e265..8970531 100644
--- a/packages/shared/cardImages.js
+++ b/packages/shared/cardImages.js
@@ -23,6 +23,7 @@
// rest of the session, so migration is invisible.
import { notifyLocalWrite } from './storageEvents.js';
+import { loadClub, loadClubCards, readCardSource, writeCardSource } from './club.js';
const SETTINGS_KEY = 'toastmaster_custom_card_images';
const SETTINGS_VERSION = 3;
@@ -50,6 +51,13 @@ const OWN_BACKGROUND_FLAG_KEY = 'toastmaster_own_background';
export const CARD_COLORS = ['blue', 'green', 'yellow', 'red'];
+// The club's published set (club.js keeps which hashes it is). It is never one
+// of the device's own sets: it is not in the metadata, it cannot be deleted
+// here, and its Blobs are keyed by hash, so a republished set replaces the old
+// one without a delete step and an unchanged image is never fetched twice.
+export const CLUB_CARD_SET_ID = 'club';
+const clubImageKey = (hash) => `club:${hash}`;
+
// Cache-buster for the built-in files. They are served with
// `max-age=31536000, immutable`, so this must be bumped whenever their
// content changes or existing clients keep the old asset for a year.
@@ -104,6 +112,10 @@ const CARD_JPEG_QUALITY = 0.85;
// Metadata: { selectedSetId, customSets: [{ id, colors: [...] }] }.
let settingsCache = null;
+// Whether the metadata key exists at all. Absent means this device has never
+// made a choice of its own, which is what lets the club's set show by default.
+let settingsStored = false;
+const listeners = new Set();
// `${setId}:${color}` -> a displayable URL: an object URL minted from the
// stored Blob, or a legacy data URL still being served out of localStorage.
@@ -152,8 +164,10 @@ function normalizeMeta(raw) {
function readStoredState() {
const inlineImages = new Map();
let meta = normalizeMeta(null);
+ settingsStored = false;
try {
const raw = localStorage.getItem(SETTINGS_KEY);
+ settingsStored = Boolean(raw);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
@@ -212,9 +226,11 @@ function writeSettings(meta) {
try {
if (normalized.selectedSetId === DEFAULT_SET_ID && normalized.customSets.length === 0) {
localStorage.removeItem(SETTINGS_KEY);
+ settingsStored = false;
notifyLocalWrite(SETTINGS_KEY);
} else {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ version: SETTINGS_VERSION, ...normalized }));
+ settingsStored = true;
notifyLocalWrite(SETTINGS_KEY);
}
settingsCache = normalized;
@@ -408,6 +424,15 @@ async function loadImagesIntoCache() {
}
}
}
+ // Only the club's current set is kept. A set it has since replaced, or a
+ // club this device has left, is swept with the other orphans.
+ for (const hash of Object.values(loadClubCards()?.images ?? {})) {
+ const key = clubImageKey(hash);
+ referenced.add(key);
+ if (!imageUrlCache.has(key) && isBlobLike(stored.get(key))) {
+ cacheBlobUrl(key, stored.get(key));
+ }
+ }
// A crash between a Blob write and its metadata write leaves orphans;
// they are invisible, so sweep them here rather than letting them pile up.
@@ -430,7 +455,12 @@ async function loadImagesIntoCache() {
* @returns {{url: string} | {file: string}}
*/
export function resolveCardImage(color) {
- const { selectedSetId } = getCardImageSettings();
+ const selectedSetId = activeCardSetId();
+ if (selectedSetId === CLUB_CARD_SET_ID) {
+ const url = getClubCardImage(color);
+ if (url) return { url };
+ return { file: CLASSIC_FILES[color] || CLASSIC_FILES.blue };
+ }
const defaultSet = getDefaultCardSet(selectedSetId);
if (defaultSet) {
return { file: defaultSet.files[color] || defaultSet.files.blue };
@@ -453,14 +483,120 @@ export function getCustomCardImage(setId, color) {
// Writes
// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// The club's set
+// ---------------------------------------------------------------------------
+
+/**
+ * Whether this device has a club set it could show: a club that is still
+ * entitled, with a set published. A lapsed club keeps its cached record, so
+ * this is what makes lapse fall back to the device's own cards for free.
+ */
+export function clubCardSetAvailable() {
+ return Boolean(loadClub()?.entitled && loadClubCards());
+}
+
+/**
+ * The set the timer is showing: the club's, or one of the device's own.
+ *
+ * The same switch as the club's presets. A device that has never chosen cards
+ * of its own opens on the club's set; one that has keeps its own, with the
+ * club's one tap away in the picker.
+ *
+ * @returns {string} CLUB_CARD_SET_ID, a built-in id, or a custom set id
+ */
+export function activeCardSetId() {
+ const { selectedSetId } = getCardImageSettings();
+ if (!clubCardSetAvailable()) return selectedSetId;
+ const source = readCardSource();
+ if (source === 'club') return CLUB_CARD_SET_ID;
+ if (source === 'own') return selectedSetId;
+ return settingsStored ? selectedSetId : CLUB_CARD_SET_ID;
+}
+
+/**
+ * One color of the club's set, as a displayable URL. Null when the set skips
+ * that color (the Classic card shows, as for a custom set) or its Blob has not
+ * arrived yet.
+ */
+export function getClubCardImage(color) {
+ const hash = loadClubCards()?.images?.[color];
+ return hash ? imageUrlCache.get(clubImageKey(hash)) || null : null;
+}
+
+/** The club set's hashes this device does not hold yet. */
+export function missingClubCardHashes() {
+ const hashes = new Set(Object.values(loadClubCards()?.images ?? {}));
+ return [...hashes].filter((hash) => !imageUrlCache.has(clubImageKey(hash)));
+}
+
+/**
+ * Keep the club set's images, downloaded or just published from here.
+ *
+ * @param {Array<[string, Blob]>} entries - [hash, Blob]
+ * @returns {Promise} hashes actually stored
+ */
+export async function storeClubCardBlobs(entries) {
+ const usable = entries.filter(([hash, blob]) => hash && isBlobLike(blob));
+ if (!usable.length) return [];
+ const db = await openImageDb();
+ if (!db) return [];
+ try {
+ await idbPutMany(db, usable.map(([hash, blob]) => [clubImageKey(hash), blob]));
+ } catch (error) {
+ console.error('Failed to store the club card images:', error);
+ return [];
+ }
+ for (const [hash, blob] of usable) {
+ revokeCached(clubImageKey(hash));
+ cacheBlobUrl(clubImageKey(hash), blob);
+ }
+ notifyCardImages();
+ return usable.map(([hash]) => hash);
+}
+
+/**
+ * Hear about card artwork that changed without the picker being open: the
+ * club's set arriving, being replaced, or going away.
+ *
+ * @param {() => void} listener
+ * @returns {() => void} unsubscribe
+ */
+export function subscribeCardImages(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+/** Tell subscribers the cards the timer shows may have changed. */
+export function notifyCardImages() {
+ for (const listener of listeners) {
+ try {
+ listener();
+ } catch {
+ // One bad subscriber must not stop the others hearing about it.
+ }
+ }
+}
+
/**
* Select which set the timer shows. Metadata-only, so it is synchronous.
+ *
+ * Choosing the club's set, or any other set while the club's is on offer, moves
+ * the card switch explicitly, so the derived default never decides for someone
+ * who has already decided.
+ *
* @returns {boolean} false when the id names no existing set or storage
* refused the write
*/
export function selectCardSet(setId) {
+ if (setId === CLUB_CARD_SET_ID) {
+ if (!clubCardSetAvailable()) return false;
+ writeCardSource('club');
+ return true;
+ }
const meta = getCardImageSettings();
if (!getDefaultCardSet(setId) && !meta.customSets.some((set) => set.id === setId)) return false;
+ if (clubCardSetAvailable()) writeCardSource('own');
return writeSettings({ ...meta, selectedSetId: setId });
}
@@ -496,6 +632,8 @@ export async function addCustomCardSet(imagesByColor) {
customSets: [...meta.customSets, { id, colors }],
};
if (!writeSettings(nextMeta)) return null;
+ // A set just added is a set someone wants to see, club or no club.
+ if (clubCardSetAvailable()) writeCardSource('own');
for (const color of colors) cacheBlobUrl(imageKey(id, color), imagesByColor[color]);
@@ -753,3 +891,13 @@ export async function storeCardBlobs(entries) {
}
return usable.map(([key]) => key);
}
+
+/** Test seam: forget everything held in memory, as a page reload would. */
+export function resetCardImagesForTests() {
+ settingsCache = null;
+ settingsStored = false;
+ imageUrlCache.clear();
+ initPromise = null;
+ dbPromise = null;
+ listeners.clear();
+}
diff --git a/packages/shared/club.js b/packages/shared/club.js
index 71302e7..e81a2cb 100644
--- a/packages/shared/club.js
+++ b/packages/shared/club.js
@@ -26,6 +26,13 @@ export const CLUB_STORAGE_KEY = 'toastmaster_club';
export const CLUB_PRESETS_STORAGE_KEY = 'toastmaster_club_presets';
/** Which list this device is running: 'club' or 'personal'. */
export const PRESET_SOURCE_STORAGE_KEY = 'toastmaster_preset_source';
+/** The club's published card set, as the device last received it: hashes, not pixels. */
+export const CLUB_CARDS_STORAGE_KEY = 'toastmaster_club_cards';
+/**
+ * Which cards this device shows: 'club' or 'own'. Unset means derived, as the
+ * preset switch is: a device that never chose its own cards shows the club's.
+ */
+export const CARD_SOURCE_STORAGE_KEY = 'toastmaster_card_source';
/**
* Where this device puts the club's badge, when it has moved it.
*
@@ -176,6 +183,82 @@ export function saveClubPresets(presets) {
}
}
+// Parsed once and kept: the card resolver reads this on every status change,
+// which sits inside the 25 ms card-switch budget.
+let cardsCache;
+let cardsLoaded = false;
+
+function normalizeClubCards(raw) {
+ if (!raw || typeof raw !== 'object' || !raw.images || typeof raw.images !== 'object') return null;
+ const images = {};
+ for (const [color, hash] of Object.entries(raw.images)) {
+ if (typeof hash === 'string' && hash) images[color] = hash;
+ }
+ if (!Object.keys(images).length) return null;
+ return {
+ label: typeof raw.label === 'string' && raw.label ? raw.label : null,
+ images,
+ publishedBy: raw.publishedBy ?? null,
+ publishedAt: typeof raw.publishedAt === 'number' ? raw.publishedAt : null,
+ };
+}
+
+/**
+ * The club's published card set, as this device last received it, or null.
+ *
+ * @returns {{label: string|null, images: Object, publishedBy: string|null,
+ * publishedAt: number|null}|null}
+ */
+export function loadClubCards() {
+ if (!cardsLoaded) {
+ try {
+ const raw = localStorage.getItem(CLUB_CARDS_STORAGE_KEY);
+ cardsCache = raw ? normalizeClubCards(JSON.parse(raw)) : null;
+ } catch {
+ cardsCache = null;
+ }
+ cardsLoaded = true;
+ }
+ return cardsCache ?? null;
+}
+
+/** @param {Object|null} cards - null removes the key */
+export function saveClubCards(cards) {
+ const normalized = normalizeClubCards(cards);
+ cardsCache = normalized;
+ cardsLoaded = true;
+ try {
+ if (normalized) localStorage.setItem(CLUB_CARDS_STORAGE_KEY, JSON.stringify(normalized));
+ else localStorage.removeItem(CLUB_CARDS_STORAGE_KEY);
+ } catch {
+ // Private mode. The in-memory copy carries this page load.
+ }
+}
+
+/** The stored card switch, or null when it has never been set explicitly. */
+export function readCardSource() {
+ try {
+ const value = localStorage.getItem(CARD_SOURCE_STORAGE_KEY);
+ return value === 'club' || value === 'own' ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Move the card switch. Explicit only, like the preset switch.
+ *
+ * @param {'club'|'own'|null} source - null clears it back to derived
+ */
+export function writeCardSource(source) {
+ try {
+ if (source === 'club' || source === 'own') localStorage.setItem(CARD_SOURCE_STORAGE_KEY, source);
+ else localStorage.removeItem(CARD_SOURCE_STORAGE_KEY);
+ } catch {
+ // The derived default still answers for this page load.
+ }
+}
+
/** The stored switch position, or null when it has never been set explicitly. */
export function readPresetSource() {
try {
@@ -683,8 +766,10 @@ export async function activateClub(code, { getToken, fetchImpl } = {}) {
// Activation is the one moment a device has no club list at all, so the
// published one is taken unconditionally. Whether it is the list that shows
- // is a separate question, answered by the switch in clubPresets.js.
+ // is a separate question, answered by the switch in clubPresets.js. The card
+ // set lands the same way, behind its own switch in cardImages.js.
saveClubPresets(body.presets ?? null);
+ saveClubCards(body.cards ?? null);
const entry = store(toCacheEntry(body, { clubToken: body.clubToken, lastRefreshAt: Date.now() }));
applyPlan(clubEntitlementOf(entry));
warmLogoQuietly();
@@ -746,6 +831,7 @@ export async function createClub(details = {}, { getToken, fetchImpl } = {}) {
// Same landing as activation: the creator's device is on the club before this
// resolves, so nobody is ever shown a code and asked to type it back in.
saveClubPresets(body.presets ?? null);
+ saveClubCards(body.cards ?? null);
const entry = store(toCacheEntry(body, { clubToken: body.clubToken, lastRefreshAt: Date.now() }));
applyPlan(clubEntitlementOf(entry));
warmLogoQuietly();
@@ -821,7 +907,12 @@ export async function refreshClub({ getToken, fetchImpl, force = false, now = Da
// a device's list exactly as it was, and only an actual publish replaces it —
// and even then the switch is not touched, so a device sitting on its own
// presets stays there with a newer club list waiting behind the toggle.
- if ((body.ver ?? 1) !== (club.ver ?? 1)) saveClubPresets(body.presets ?? null);
+ const verMoved = (body.ver ?? 1) !== (club.ver ?? 1);
+ if (verMoved) saveClubPresets(body.presets ?? null);
+ // The card set rides the same version. It is also taken when this device has
+ // none at all, so a device that last refreshed on a bundle that did not know
+ // about card sets still receives the one already published.
+ if (verMoved || (!loadClubCards() && body.cards)) saveClubCards(body.cards ?? null);
const entry = store(toCacheEntry(body, { clubToken: body.clubToken ?? club.clubToken, lastRefreshAt: now }));
applyPlan(clubEntitlementOf(entry));
@@ -868,6 +959,10 @@ export function leaveClub({ fetchImpl } = {}) {
// the club was active, so there is nothing to put back.
saveClubPresets(null);
writePresetSource(null);
+ // The card set and its switch go too. The device's own sets were never
+ // touched, so its own choice is simply what shows again.
+ saveClubCards(null);
+ writeCardSource(null);
// The badge placement goes with the club: it is a position on the club's
// badge, and there is no badge to place once the club is gone.
clearClubBadgeOverride();
@@ -905,10 +1000,28 @@ export function rememberPublishedPresets(ver, presets) {
return store({ ...club, ver: typeof ver === 'number' ? ver : club.ver });
}
+/**
+ * Take the answer to a card-set publish or removal, for the same reason
+ * `rememberPublishedPresets` exists: the publisher's own device must not read
+ * its next refresh as news.
+ *
+ * @param {number} ver
+ * @param {Object|null} cards
+ * @returns {Object|null} the club as it now stands
+ */
+export function rememberPublishedCards(ver, cards) {
+ const club = loadClub();
+ if (!club) return null;
+ saveClubCards(cards ?? null);
+ return store({ ...club, ver: typeof ver === 'number' ? ver : club.ver });
+}
+
/** Test seam: drop the in-memory copy so the next read comes from storage. */
export function resetClubForTests() {
cached = undefined;
loaded = false;
+ cardsCache = undefined;
+ cardsLoaded = false;
listeners.clear();
logoImage = null;
logoImageUrl = null;
diff --git a/packages/shared/clubCards.js b/packages/shared/clubCards.js
new file mode 100644
index 0000000..924aa57
--- /dev/null
+++ b/packages/shared/clubCards.js
@@ -0,0 +1,208 @@
+import { loadClub, loadClubCards, subscribeClub, rememberPublishedCards } from './club.js';
+import {
+ CARD_COLORS,
+ cardImageKey,
+ getCardImageSettings,
+ initCardImages,
+ missingClubCardHashes,
+ notifyCardImages,
+ readStoredCardBlobs,
+ storeClubCardBlobs,
+} from './cardImages.js';
+
+/**
+ * Moving the club's card set between devices: downloading it where it is
+ * missing, and publishing one of this device's own sets as the club's.
+ *
+ * The pixels go to R2 under the club, content-addressed by SHA-256; the club
+ * state carries only the hashes. A device holds the images in IndexedDB, so
+ * the card-switch path never waits on the network.
+ */
+
+const CARDS_ENDPOINT = '/api/club/cards';
+
+/**
+ * SHA-256 of a Blob, as lowercase hex: the id the Worker checks the bytes
+ * against, so it is computed exactly the way the Worker computes it.
+ *
+ * @param {Blob} blob
+ * @returns {Promise} null where WebCrypto is unavailable
+ */
+export async function hashCardBlob(blob) {
+ if (typeof crypto === 'undefined' || !crypto.subtle) return null;
+ try {
+ const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+ } catch {
+ return null;
+ }
+}
+
+/** Whether this device's signed-in user may publish a card set to the club. */
+export function canPublishCards() {
+ const club = loadClub();
+ if (!club?.entitled) return false;
+ return club.role === 'admin' || club.role === 'editor';
+}
+
+/**
+ * Fetch the club set's images this device does not hold yet. Never rejects:
+ * a card that has not arrived shows the Classic card, which is what a device
+ * with no club shows anyway.
+ *
+ * @param {{fetchImpl?: typeof fetch}} [options]
+ * @returns {Promise} hashes stored
+ */
+export async function syncClubCardImages({ fetchImpl } = {}) {
+ const club = loadClub();
+ if (!club?.clubToken || !loadClubCards()) return [];
+ await initCardImages();
+
+ const entries = [];
+ for (const hash of missingClubCardHashes()) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ const response = await (fetchImpl ?? fetch)(`${CARDS_ENDPOINT}/${hash}`, {
+ headers: { 'X-Club': club.clubToken },
+ credentials: 'same-origin',
+ });
+ if (!response.ok) continue;
+ // Only an image is kept. An SPA fallback or a captive portal answers 200
+ // with a page, and a page stored as a card would show as a broken image
+ // until the club republished.
+ if (!(response.headers?.get?.('content-type') || '').startsWith('image/')) continue;
+ // eslint-disable-next-line no-await-in-loop
+ entries.push([hash, await response.blob()]);
+ } catch {
+ // Offline. The next app start, or the next club change, tries again.
+ }
+ }
+ return storeClubCardBlobs(entries);
+}
+
+/** What the timer would show from the club, so a change to it can be noticed. */
+function clubCardsSignature() {
+ const club = loadClub();
+ const cards = loadClubCards();
+ return `${Boolean(club?.entitled)}|${CARD_COLORS.map((color) => cards?.images?.[color] ?? '').join(',')}`;
+}
+
+/**
+ * Keep the club's set on this device for as long as the app runs.
+ *
+ * Fetches what is missing now, and again whenever the club changes: a new
+ * publish, a removal, a lapse or leaving. Each of those also tells the timer
+ * to re-show its card, since the set it should show may have changed.
+ *
+ * @param {{fetchImpl?: typeof fetch}} [options]
+ * @returns {() => void} stop
+ */
+export function startClubCardSync({ fetchImpl } = {}) {
+ let last = clubCardsSignature();
+ syncClubCardImages({ fetchImpl }).catch(() => {});
+ return subscribeClub(() => {
+ const next = clubCardsSignature();
+ if (next === last) return;
+ last = next;
+ notifyCardImages();
+ syncClubCardImages({ fetchImpl }).catch(() => {});
+ });
+}
+
+function errorFor(status, body) {
+ if (body?.error) return body.error;
+ if (status === 401 || status === 403) return 'forbidden';
+ if (status === 402) return 'upgrade_required';
+ return 'publish_failed';
+}
+
+async function readBody(response) {
+ try {
+ return await response.json();
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Publish one of this device's own sets as the club's.
+ *
+ * Refused server-side for anyone but an admin or editor, like a presets
+ * publish. Uploads go first, then the set; if another publisher's set pruned an
+ * image in between, the missing ones are uploaded once more and the set is
+ * sent again.
+ *
+ * @param {string} setId - a custom set id, e.g. 'custom-1'
+ * @param {{label?: string, getToken?: () => string|null, fetchImpl?: typeof fetch}} [options]
+ * @returns {Promise<{ok: true, ver: number, cards: Object}|{ok: false, error: string}>}
+ * never rejects
+ */
+export async function publishCardSet(setId, { label, getToken, fetchImpl } = {}) {
+ const club = loadClub();
+ if (!club?.clubToken) return { ok: false, error: 'no_club' };
+
+ const set = getCardImageSettings().customSets.find((s) => s.id === setId);
+ if (!set) return { ok: false, error: 'no_set' };
+
+ const stored = await readStoredCardBlobs();
+ const blobs = new Map();
+ const images = {};
+ for (const color of set.colors) {
+ const blob = stored.get(cardImageKey(setId, color));
+ if (!blob) continue;
+ // eslint-disable-next-line no-await-in-loop
+ const hash = await hashCardBlob(blob);
+ if (!hash) return { ok: false, error: 'unsupported_browser' };
+ images[color] = hash;
+ blobs.set(hash, blob);
+ }
+ if (!Object.keys(images).length) return { ok: false, error: 'no_set' };
+
+ const token = getToken?.();
+ const headers = {
+ 'X-Club': club.clubToken,
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+ const send = (url, init) => (fetchImpl ?? fetch)(url, { credentials: 'same-origin', cache: 'no-store', ...init });
+
+ const upload = async (hashes) => {
+ for (const hash of hashes) {
+ const blob = blobs.get(hash);
+ // eslint-disable-next-line no-await-in-loop
+ const response = await send(`${CARDS_ENDPOINT}/${hash}`, {
+ method: 'PUT',
+ headers: { ...headers, 'Content-Type': blob.type || 'image/jpeg' },
+ body: blob,
+ });
+ // eslint-disable-next-line no-await-in-loop
+ if (!response.ok) return errorFor(response.status, await readBody(response));
+ }
+ return null;
+ };
+
+ try {
+ const uploadError = await upload([...blobs.keys()]);
+ if (uploadError) return { ok: false, error: uploadError };
+
+ const body = JSON.stringify({ label: label ?? null, images });
+ const put = () =>
+ send(CARDS_ENDPOINT, { method: 'PUT', headers: { ...headers, 'Content-Type': 'application/json' }, body });
+
+ let response = await put();
+ let result = await readBody(response);
+ if (response.status === 409 && Array.isArray(result?.missing)) {
+ const retryError = await upload(result.missing.filter((hash) => blobs.has(hash)));
+ if (retryError) return { ok: false, error: retryError };
+ response = await put();
+ result = await readBody(response);
+ }
+ if (!response.ok || !result?.cards) return { ok: false, error: errorFor(response.status, result) };
+
+ // The publisher already holds every image, so it never downloads its own set.
+ await storeClubCardBlobs([...blobs.entries()]);
+ rememberPublishedCards(result.ver, result.cards);
+ return { ok: true, ver: result.ver, cards: result.cards };
+ } catch {
+ return { ok: false, error: 'network' };
+ }
+}
diff --git a/packages/shared/index.js b/packages/shared/index.js
index e23e94d..eacd07d 100644
--- a/packages/shared/index.js
+++ b/packages/shared/index.js
@@ -16,6 +16,7 @@ export * from './flags.js';
export * from './club.js';
export * from './clubBadge.js';
export * from './clubPresets.js';
+export * from './clubCards.js';
export * from './clubArchive.js';
export * from './reportImage.js';
export * from './timerSounds.js';
diff --git a/packages/shared/package.json b/packages/shared/package.json
index c1a124d..d3ef270 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -24,6 +24,7 @@
"./club": "./club.js",
"./clubBadge": "./clubBadge.js",
"./clubPresets": "./clubPresets.js",
+ "./clubCards": "./clubCards.js",
"./clubArchive": "./clubArchive.js",
"./reportImage": "./reportImage.js"
}
diff --git a/worker/club-admin-routes.js b/worker/club-admin-routes.js
index 5787d4c..1c9ab86 100644
--- a/worker/club-admin-routes.js
+++ b/worker/club-admin-routes.js
@@ -14,6 +14,7 @@ import {
writeAdminIndex,
} from './club-admin.js';
import { buildKit, DEFAULT_PRIMARY_COLOR } from './club.js';
+import { buildCards, readClubCards } from './club-cards.js';
import { readAdminSession } from './club-magic.js';
/**
@@ -262,6 +263,7 @@ export async function handleClubRoster(request, env) {
ver: club.ver ?? 1,
},
kit: buildKit(clubId, club),
+ cards: buildCards(await readClubCards(env, clubId)),
actor,
role: 'admin',
plan: access.plan,
diff --git a/worker/club-admin.js b/worker/club-admin.js
index 28b2639..a9c9b36 100644
--- a/worker/club-admin.js
+++ b/worker/club-admin.js
@@ -141,6 +141,7 @@ export const clubByEmailKey = (email) => `club-by-email:${normalizeEmail(email)}
export const clubDeviceKey = (clubId, deviceId) => `club-device:${clubId}:${deviceId}`;
export const clubMemberKey = (clubId, uid) => `club-member:${clubId}:zoom:${uid}`;
export const clubPresetsKey = (clubId) => `club-presets:${clubId}`;
+export const clubCardsKey = (clubId) => `club-cards:${clubId}`;
/**
* The club a signed-in person administers, found from their uid alone.
diff --git a/worker/club-cards.js b/worker/club-cards.js
new file mode 100644
index 0000000..2f82c73
--- /dev/null
+++ b/worker/club-cards.js
@@ -0,0 +1,367 @@
+import crypto from 'node:crypto';
+import { readSession, readClub } from './auth.js';
+import { json, methodNotAllowed, unauthorized } from './http.js';
+import { entitlementStore, clubKey, readClubRecord, resolveAccess } from './entitlements.js';
+import { canPublish, clubCardsKey, readMemberRole } from './club-admin.js';
+import { verifiedClubId } from './club.js';
+import { readAdminContext } from './club-admin-routes.js';
+
+/**
+ * The club's card set: one set of timing-card artwork every club device can show.
+ *
+ * Custom card images used to follow a *person* between their own computers. The
+ * timer role rotates every week, so the person who uploaded the club's artwork
+ * was rarely the person timing with it. A set published here reaches every
+ * device holding the club code instead, the same way the club's presets do.
+ *
+ * PUT /api/club/cards/ upload one image (admin or editor)
+ * GET /api/club/cards/ download one image (any club device, or the console)
+ * PUT /api/club/cards publish { label, images: { color: hash } } (admin or editor)
+ * DELETE /api/club/cards take the set down (admin)
+ *
+ * Images live at `club-card//`, deliberately outside `club//`:
+ * `/api/club-assets/*` serves everything under that prefix to anyone, and card
+ * artwork is private. Devices keep a copy in IndexedDB, so they never need the
+ * public CDN path the logo needs.
+ *
+ * Publishing and removing bump `club:.ver`, which is what makes devices
+ * replace their copy, exactly as a presets publish does.
+ */
+
+export const CARD_COLORS = Object.freeze(['blue', 'green', 'yellow', 'red']);
+
+/** The same limit personal card artwork had: a 1280×720 JPEG is a fraction of it. */
+export const MAX_CARD_BYTES = 2 * 1024 * 1024;
+/** Per club. Ten full sets of four, which is far more than one published set ever holds. */
+export const MAX_CLUB_CARD_BYTES = 20 * 1024 * 1024;
+const MAX_LABEL_LENGTH = 40;
+const HASH_PATTERN = /^[0-9a-f]{64}$/;
+
+/**
+ * Images uploaded this recently are never pruned. A second publisher's uploads
+ * land before their publish does; without this window, a publish in between
+ * would delete them and their publish would fail.
+ */
+export const PRUNE_GRACE_MS = 15 * 60 * 1000;
+
+export const clubCardObjectKey = (clubId, hash) => `club-card/${clubId}/${hash}`;
+const clubCardPrefix = (clubId) => `club-card/${clubId}/`;
+
+const forbidden = () => json({ error: 'forbidden' }, 403);
+
+/**
+ * The image type from the bytes themselves, never from the header the client
+ * sent. Raster only, for the reason the logo is: an SVG is a document that can
+ * carry script, and this route answers on the same origin as every session.
+ *
+ * @param {Uint8Array} bytes
+ * @returns {'image/jpeg'|'image/png'|'image/webp'|null}
+ */
+export function sniffImageType(bytes) {
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
+ if (
+ bytes.length >= 8 &&
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a
+ ) {
+ return 'image/png';
+ }
+ const ascii = (from, to) => String.fromCharCode(...bytes.slice(from, to));
+ if (bytes.length >= 12 && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'image/webp';
+ return null;
+}
+
+/**
+ * A published body as we store it, or null if it is unusable. A set needs at
+ * least one colour; the colours it skips show the Classic card on every device,
+ * the same rule a personal custom set follows.
+ *
+ * @param {unknown} body
+ * @returns {{label: string|null, images: Object}|null}
+ */
+export function normalizeCardSet(body) {
+ if (!body || typeof body !== 'object') return null;
+ const raw = body.images;
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+
+ const images = {};
+ for (const [color, hash] of Object.entries(raw)) {
+ if (!CARD_COLORS.includes(color)) return null;
+ if (typeof hash !== 'string' || !HASH_PATTERN.test(hash)) return null;
+ images[color] = hash;
+ }
+ if (!Object.keys(images).length) return null;
+
+ const label = typeof body.label === 'string' ? body.label.trim().slice(0, MAX_LABEL_LENGTH) : '';
+ return { label: label || null, images };
+}
+
+/** Whether a publish would change anything a device would see. */
+export function cardSetUnchanged(stored, next) {
+ if (!stored) return false;
+ return (
+ (stored.label ?? null) === next.label &&
+ CARD_COLORS.every((color) => (stored.images?.[color] ?? null) === (next.images[color] ?? null))
+ );
+}
+
+/**
+ * The club's published set, or null. Never throws: it runs on the app-start
+ * refresh, which must not fail because of a card set.
+ *
+ * @returns {Promise