Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 99 additions & 21 deletions apps/web/src/components/CardImagesModal.jsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 (
<div
role="radio"
Expand All @@ -61,18 +78,34 @@ function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete }) {
{label}
{selected && <Check className="h-4 w-4 text-blue-600" />}
</span>
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label={`Delete the ${label} card set`}
>
<Trash2 className="h-4 w-4" />
</button>
)}
<span className="flex items-center gap-1">
{onShare && (
<button
onClick={(e) => {
e.stopPropagation();
onShare();
}}
disabled={sharing}
className="flex items-center gap-1 px-1.5 py-1 rounded text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-50 transition-colors"
aria-label={`Share the ${label} card set with my club`}
>
<Share2 className="h-3.5 w-3.5" />
{sharing ? 'Sharing…' : 'Share with my club'}
</button>
)}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label={`Delete the ${label} card set`}
>
<Trash2 className="h-4 w-4" />
</button>
)}
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{CARD_COLORS.map((color) => (
Expand All @@ -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({});
Expand All @@ -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));
};
}, []);
Expand All @@ -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');
};
Expand Down Expand Up @@ -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 "<club> 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();
Expand All @@ -190,11 +255,21 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
</p>

<div className="space-y-3" role="radiogroup" aria-label="Card set">
{clubCardSetAvailable() && (
<CardSetRow
key={CLUB_CARD_SET_ID}
label={loadClubCards()?.label || `${clubLabel || 'Club'} cards`}
selected={activeSetId === CLUB_CARD_SET_ID}
onSelect={() => handleSelect(CLUB_CARD_SET_ID)}
thumbSrc={(color) => getClubCardImage(color) || getCardAssetUrl(CLASSIC_FILES[color])}
/>
)}

{DEFAULT_CARD_SETS.map((set) => (
<CardSetRow
key={set.id}
label={set.label}
selected={settings.selectedSetId === set.id}
selected={activeSetId === set.id}
onSelect={() => handleSelect(set.id)}
thumbSrc={(color) => getCardAssetUrl(set.files[color])}
/>
Expand All @@ -204,12 +279,14 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
<CardSetRow
key={set.id}
label={`Custom ${index + 1}`}
selected={settings.selectedSetId === set.id}
selected={activeSetId === set.id}
onSelect={() => 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}
/>
))}

Expand Down Expand Up @@ -270,9 +347,10 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) {
/>

<p className="text-xs text-gray-500 mt-4">
Custom images are stored only in this browser. Please upload only images you have the
right to use — for example your club&apos;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&apos;s own materials.
This app is independent and cannot grant permission for third-party logos or artwork.
</p>
</div>
</div>
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/LiveTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
117 changes: 117 additions & 0 deletions apps/web/src/pages/ClubAdmin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<p className="mt-3 text-sm text-gray-300">
No card set yet. An admin or editor shares one from <strong>Card Images</strong> in the timer, and every
club device shows it.
</p>
)
}

const remove = async () => {
setBusy(true)
setError(null)
const result = await api('/api/club/cards', { method: 'DELETE' })
setBusy(false)
if (!result.ok) {
setError('Could not remove the card set. Please try again.')
return
}
trackEvent('club_cards_removed', { surface: 'web' })
onRemoved?.()
}

return (
<div className="mt-3">
<p className="text-sm text-gray-200">
{cards.label || `${clubName || 'Club'} cards`}
{cards.publishedAt ? <span className="text-gray-400"> · shared {shortDate(cards.publishedAt)}</span> : null}
</p>
<div className="mt-2 grid grid-cols-4 gap-2">
{CARD_COLORS.map((color) =>
urls[color] ? (
<img
key={color}
src={urls[color]}
alt={`${CARD_LABELS[color]} card`}
className="aspect-video w-full rounded border border-white/10 object-cover"
/>
) : (
<div
key={color}
className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-white/10 text-center text-xs text-gray-500"
>
{cards.images[color] ? '…' : 'Classic'}
</div>
)
)}
</div>
<button
onClick={remove}
disabled={busy}
className="mt-3 inline-flex items-center gap-2 rounded-lg bg-white/10 px-3 py-1.5 text-sm text-white hover:bg-white/20 disabled:opacity-60"
>
<Trash2 className="h-4 w-4" />
Remove card set
</button>
{error && <p className="mt-2 text-sm text-red-300">{error}</p>}
</div>
)
}

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.',
Expand Down Expand Up @@ -580,6 +684,19 @@ export default function ClubAdmin() {
</div>
</section>

<section className="mt-6 rounded-2xl border border-white/10 bg-black/30 px-6 py-6">
<h3 className="text-sm uppercase tracking-wide text-gray-400">Card set</h3>
<ClubCards
cards={roster.cards}
clubName={roster.club?.name}
onRemoved={() => {
load()
// This browser may be a club device too; let it drop the set now.
refreshClub({ force: true }).catch(() => {})
}}
/>
</section>

<section className="mt-6 rounded-2xl border border-white/10 bg-black/30 px-6 py-6">
<h3 className="text-sm uppercase tracking-wide text-gray-400">Billing</h3>
{roster.actor?.type === 'zoom' ? (
Expand Down
Loading
Loading