641 lines
22 KiB
TypeScript
641 lines
22 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Copy, Image, Plus, Trash2, Upload } from 'lucide-react';
|
|
import { gamesApi, type GameQuizConfig, type MatchUpConfig, type MatchUpGroup } from '../../../services/api/games';
|
|
import { uploadMediaFile } from '../../../services/api/media';
|
|
import { gameQuizUrl, matchUpUrl } from '../../utils/gameUrls';
|
|
import { Field, Grid, HelperText, IS, Label, PanelShell, SS } from '../panels/PanelShell';
|
|
|
|
type Kind = 'matchUp' | 'quiz';
|
|
const buttonStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '8px 10px', border: '1px solid var(--border)', borderRadius: 'var(--radius-button)', background: 'var(--card)', color: 'var(--foreground)', cursor: 'pointer', fontSize: 'var(--text-sm)' };
|
|
|
|
const sectionTitleStyle: React.CSSProperties = {
|
|
color: '#163f7a',
|
|
fontSize: '20px',
|
|
fontWeight: 700,
|
|
lineHeight: 1.3,
|
|
margin: 0,
|
|
};
|
|
|
|
const sectionStyle: React.CSSProperties = {
|
|
display: 'grid',
|
|
gap: 16,
|
|
};
|
|
|
|
const groupStyle: React.CSSProperties = {
|
|
borderTop: '1px solid var(--border)',
|
|
paddingTop: 18,
|
|
display: 'grid',
|
|
gap: 14,
|
|
};
|
|
|
|
const groupHeaderStyle: React.CSSProperties = {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
minHeight: 24,
|
|
};
|
|
|
|
const groupTileCountStyle: React.CSSProperties = {
|
|
color: 'var(--muted)',
|
|
fontSize: 'var(--text-sm)',
|
|
lineHeight: 1.4,
|
|
whiteSpace: 'nowrap',
|
|
};
|
|
|
|
const fieldLabelStyle: React.CSSProperties = {
|
|
color: '#27364a',
|
|
fontSize: '15px',
|
|
fontWeight: 600,
|
|
};
|
|
|
|
const helperStyle: React.CSSProperties = {
|
|
marginTop: -4,
|
|
};
|
|
|
|
const descriptionStyle: React.CSSProperties = {
|
|
...IS,
|
|
minHeight: 72,
|
|
resize: 'vertical',
|
|
lineHeight: 1.45,
|
|
};
|
|
|
|
|
|
|
|
function emptyGroups(size: number): MatchUpGroup[] {
|
|
return Array.from({ length: size }, (_, i) => ({ title: `Group ${i + 1}`, description: '', items: Array.from({ length: size }, () => ({ text: '', imageUrl: '' })) }));
|
|
}
|
|
|
|
function emptyQuestions(count: number) {
|
|
return Array.from({ length: count }, () => ({ question: '', options: ['', '', '', ''], correctIndex: 0, points: 5 }));
|
|
}
|
|
|
|
export function GameManagePanel({ kind, eventId, eventName, canWrite, onClose, onChanged }: { kind: Kind; eventId: string; eventName?: string; canWrite: boolean; onClose: () => void; onChanged?: () => void }) {
|
|
const [matchUp, setMatchUp] = useState<MatchUpConfig | null>(null);
|
|
const [quiz, setQuiz] = useState<GameQuizConfig | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [uploadingBackground, setUploadingBackground] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [copied, setCopied] = useState(false);
|
|
const backgroundInputRef = useRef<HTMLInputElement>(null);
|
|
const isMatchUp = kind === 'matchUp';
|
|
const config = isMatchUp ? matchUp : quiz;
|
|
const link = config?.publicToken ? (isMatchUp ? matchUpUrl(config.publicToken) : gameQuizUrl(config.publicToken)) : '';
|
|
const title = isMatchUp ? 'Match Up' : 'Quiz';
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
setLoading(true);
|
|
const res = isMatchUp ? await gamesApi.getMatchUp(eventId) : await gamesApi.getQuiz(eventId);
|
|
if (res.error) setError(res.error);
|
|
else if (res.data) isMatchUp ? setMatchUp(res.data) : setQuiz(res.data);
|
|
setLoading(false);
|
|
})();
|
|
}, [eventId, isMatchUp]);
|
|
|
|
const save = async () => {
|
|
if (!config || !canWrite) return;
|
|
|
|
setSaving(true);
|
|
setError('');
|
|
|
|
const res = isMatchUp
|
|
? await gamesApi.updateMatchUp(eventId, {
|
|
name: matchUp!.name,
|
|
backgroundImage: matchUp!.backgroundImage,
|
|
enabled: matchUp!.enabled,
|
|
gridSize: matchUp!.gridSize,
|
|
|
|
groups: matchUp!.groups.map((group) => ({
|
|
title: group.title,
|
|
description: group.description || '',
|
|
items: group.items.map((item) => ({
|
|
text: item.text,
|
|
imageUrl: '',
|
|
})),
|
|
})),
|
|
})
|
|
: await gamesApi.updateQuiz(eventId, {
|
|
name: quiz!.name,
|
|
backgroundImage: quiz!.backgroundImage,
|
|
enabled: quiz!.enabled,
|
|
questions: quiz!.questions,
|
|
});
|
|
|
|
if (res.error) {
|
|
setError(res.error);
|
|
} else {
|
|
isMatchUp
|
|
? setMatchUp(res.data)
|
|
: setQuiz(res.data);
|
|
|
|
onChanged?.();
|
|
}
|
|
|
|
setSaving(false);
|
|
};
|
|
|
|
const uploadBackground = async (file?: File) => {
|
|
if (!file || !canWrite) return;
|
|
setUploadingBackground(true); setError('');
|
|
try {
|
|
const result = await uploadMediaFile(file, { eventId, module: `games/${kind}/background`, visibility: 'public' });
|
|
if (result.url) {
|
|
if (isMatchUp) setMatchUp((current) => current ? { ...current, backgroundImage: result.url } : current);
|
|
else setQuiz((current) => current ? { ...current, backgroundImage: result.url } : current);
|
|
}
|
|
} catch (err) { setError(err instanceof Error ? err.message : 'Background upload failed'); }
|
|
finally { setUploadingBackground(false); }
|
|
};
|
|
const removeBackground = () => {
|
|
if (!canWrite) return;
|
|
|
|
if (isMatchUp) {
|
|
setMatchUp((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
backgroundImage: '',
|
|
}
|
|
: current
|
|
);
|
|
} else {
|
|
setQuiz((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
backgroundImage: '',
|
|
}
|
|
: current
|
|
);
|
|
}
|
|
|
|
setError('');
|
|
};
|
|
|
|
// const uploadTileImage = async (
|
|
// file: File | undefined,
|
|
// groupIndex: number,
|
|
// tileIndex: number
|
|
// ) => {
|
|
// if (!file || !canWrite || !matchUp) return;
|
|
|
|
// setError('');
|
|
|
|
// try {
|
|
// const result = await uploadMediaFile(file, {
|
|
// eventId,
|
|
// module: 'games/matchUp/tile',
|
|
// visibility: 'public',
|
|
// });
|
|
|
|
// if (result.url) {
|
|
// setMatchUp((current) => {
|
|
// if (!current) return current;
|
|
|
|
// return {
|
|
// ...current,
|
|
// groups: current.groups.map((group, gi) =>
|
|
// gi === groupIndex
|
|
// ? {
|
|
// ...group,
|
|
// items: group.items.map((item, ii) =>
|
|
// ii === tileIndex
|
|
// ? {
|
|
// ...item,
|
|
// imageUrl: result.url,
|
|
// }
|
|
// : item
|
|
// ),
|
|
// }
|
|
// : group
|
|
// ),
|
|
// };
|
|
// });
|
|
// }
|
|
// } catch (err) {
|
|
// setError(
|
|
// err instanceof Error
|
|
// ? err.message
|
|
// : 'Tile image upload failed'
|
|
// );
|
|
// }
|
|
// };
|
|
|
|
const copyLink = async () => { if (!link) return; await navigator.clipboard.writeText(link); setCopied(true); window.setTimeout(() => setCopied(false), 1500); };
|
|
const setSize = (size: 3 | 4 | 5) => setMatchUp((current) => current ? { ...current, gridSize: size, groups: emptyGroups(size) } : current);
|
|
const addQuestion = () => setQuiz((current) => current ? { ...current, questions: [...current.questions, ...emptyQuestions(1)] } : current);
|
|
const questions = quiz?.questions || [];
|
|
const groups = matchUp?.groups || [];
|
|
const canSave = Boolean(config) && !loading && !saving && canWrite;
|
|
|
|
return <PanelShell onClose={onClose} title={title} breadcrumb={eventName ? `Event Home - ${eventName}` : 'Event Home'} description={isMatchUp ? 'Set up a tile matching activity for visitors' : 'Set up a scored quiz for visitors'} completedCount={0} totalSections={0} ctaLabel={saving ? 'Saving...' : 'Save settings'} showProgress={false} hidePrimaryCta={!canWrite} ctaDisabled={!canSave} onCtaClick={() => void save()} panelWidth="min(920px, 100vw)">
|
|
{loading ? (
|
|
<p style={{ color: 'var(--muted)' }}>Loading...</p>
|
|
) : config ? (
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gap: 28,
|
|
padding: '0 24px 24px',
|
|
}}
|
|
>
|
|
<section style={sectionStyle}>
|
|
<h2 style={sectionTitleStyle}>Game details</h2>
|
|
|
|
<Grid>
|
|
<Field>
|
|
<Label text="Display name" required />
|
|
|
|
<input
|
|
disabled={!canWrite}
|
|
value={config.name}
|
|
onChange={(e) =>
|
|
isMatchUp
|
|
? setMatchUp({ ...matchUp!, name: e.target.value })
|
|
: setQuiz({ ...quiz!, name: e.target.value })
|
|
}
|
|
style={IS}
|
|
placeholder={isMatchUp ? 'Match Up' : 'Quiz'}
|
|
/>
|
|
|
|
<div style={helperStyle}>
|
|
<HelperText text="Shown on the visitor start screen and public game." />
|
|
</div>
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label text="Background image" />
|
|
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
disabled={!canWrite || uploadingBackground}
|
|
onClick={() => backgroundInputRef.current?.click()}
|
|
style={buttonStyle}
|
|
>
|
|
<Upload size={15} />
|
|
{uploadingBackground
|
|
? 'Uploading...'
|
|
: config.backgroundImage
|
|
? 'Change image'
|
|
: 'Upload image'}
|
|
</button>
|
|
|
|
{config.backgroundImage ? (
|
|
<button
|
|
type="button"
|
|
disabled={!canWrite || uploadingBackground}
|
|
onClick={removeBackground}
|
|
style={{
|
|
...buttonStyle,
|
|
color: 'var(--destructive)',
|
|
}}
|
|
>
|
|
<Trash2 size={15} />
|
|
Remove image
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
<input
|
|
ref={backgroundInputRef}
|
|
hidden
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(e) => {
|
|
void uploadBackground(e.target.files?.[0]);
|
|
e.currentTarget.value = '';
|
|
}}
|
|
/>
|
|
|
|
{config.backgroundImage ? (
|
|
<Image size={17} color="var(--success)" />
|
|
) : null}
|
|
</div>
|
|
|
|
<div style={helperStyle}>
|
|
<HelperText text="Upload the image shown behind the public game." />
|
|
</div>
|
|
</Field>
|
|
</Grid>
|
|
|
|
{config.backgroundImage ? (
|
|
<img
|
|
src={config.backgroundImage}
|
|
alt="Background preview"
|
|
style={{
|
|
width: '100%',
|
|
maxHeight: 180,
|
|
objectFit: 'cover',
|
|
borderRadius: 8,
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
/>
|
|
) : null}
|
|
</section>
|
|
<label
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
color: '#163f7a',
|
|
fontSize: '17px',
|
|
fontWeight: 600,
|
|
cursor: canWrite ? 'pointer' : 'default',
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
disabled={!canWrite}
|
|
checked={config.enabled}
|
|
onChange={(e) =>
|
|
isMatchUp
|
|
? setMatchUp({ ...matchUp!, enabled: e.target.checked })
|
|
: setQuiz({ ...quiz!, enabled: e.target.checked })
|
|
}
|
|
style={{
|
|
width: 18,
|
|
height: 18,
|
|
accentColor: '#155eef',
|
|
}}
|
|
/>
|
|
|
|
Enable public game
|
|
</label>
|
|
{isMatchUp && matchUp ? (
|
|
<section style={sectionStyle}>
|
|
|
|
<h2 style={sectionTitleStyle}>
|
|
Puzzle structure
|
|
</h2>
|
|
|
|
<Field>
|
|
<Label text="Metric" required />
|
|
|
|
<select
|
|
disabled={!canWrite}
|
|
value={matchUp.gridSize}
|
|
onChange={(e) =>
|
|
setSize(Number(e.target.value) as 3 | 4 | 5)
|
|
}
|
|
style={SS}
|
|
>
|
|
<option value={3}>3 x 3</option>
|
|
<option value={4}>4 x 4</option>
|
|
<option value={5}>5 x 5</option>
|
|
</select>
|
|
|
|
<div style={helperStyle}>
|
|
<HelperText
|
|
text={`${matchUp.gridSize} groups, ${matchUp.gridSize} related tiles in each group.`}
|
|
/>
|
|
</div>
|
|
</Field>
|
|
|
|
{groups.map((group, gi) => (
|
|
<div
|
|
key={gi}
|
|
style={{
|
|
...groupStyle,
|
|
marginTop: gi === 0 ? 4 : 2,
|
|
}}
|
|
>
|
|
|
|
{/* GROUP HEADER */}
|
|
<div style={groupHeaderStyle}>
|
|
|
|
<Label
|
|
text={`Group ${gi + 1}`}
|
|
required
|
|
/>
|
|
|
|
<span style={groupTileCountStyle}>
|
|
{matchUp.gridSize} tiles
|
|
</span>
|
|
|
|
</div>
|
|
|
|
{/* CONNECTION TITLE */}
|
|
<Field>
|
|
<input
|
|
disabled={!canWrite}
|
|
value={group.title}
|
|
onChange={(e) =>
|
|
setMatchUp({
|
|
...matchUp,
|
|
groups: groups.map((g, i) =>
|
|
i === gi
|
|
? {
|
|
...g,
|
|
title: e.target.value,
|
|
}
|
|
: g
|
|
),
|
|
})
|
|
}
|
|
style={IS}
|
|
placeholder="Connection title"
|
|
/>
|
|
</Field>
|
|
|
|
{/* CONNECTION DESCRIPTION */}
|
|
<Field>
|
|
<textarea
|
|
disabled={!canWrite}
|
|
value={group.description || ''}
|
|
onChange={(e) =>
|
|
setMatchUp({
|
|
...matchUp,
|
|
groups: groups.map((g, i) =>
|
|
i === gi
|
|
? {
|
|
...g,
|
|
description: e.target.value,
|
|
}
|
|
: g
|
|
),
|
|
})
|
|
}
|
|
style={descriptionStyle}
|
|
placeholder="Connection description"
|
|
/>
|
|
</Field>
|
|
|
|
{/* TILES */}
|
|
<Grid>
|
|
{group.items.map((item, ii) => (
|
|
<Field key={ii}>
|
|
|
|
<Label
|
|
text={`Tile ${ii + 1}`}
|
|
required
|
|
/>
|
|
|
|
<input
|
|
disabled={!canWrite}
|
|
value={item.text}
|
|
onChange={(e) =>
|
|
setMatchUp({
|
|
...matchUp,
|
|
groups: groups.map((g, i) =>
|
|
i === gi
|
|
? {
|
|
...g,
|
|
items: g.items.map((it, j) =>
|
|
j === ii
|
|
? {
|
|
...it,
|
|
text: e.target.value,
|
|
}
|
|
: it
|
|
),
|
|
}
|
|
: g
|
|
),
|
|
})
|
|
}
|
|
style={IS}
|
|
placeholder="Tile text"
|
|
/>
|
|
|
|
</Field>
|
|
))}
|
|
</Grid>
|
|
|
|
{/* TILES
|
|
<Grid>
|
|
{group.items.map((item, ii) => (
|
|
<Field key={ii}>
|
|
|
|
<Label
|
|
text={`Tile ${ii + 1}`}
|
|
required
|
|
/>
|
|
|
|
<input
|
|
disabled={!canWrite}
|
|
value={item.text}
|
|
onChange={(e) =>
|
|
setMatchUp({
|
|
...matchUp,
|
|
groups: groups.map((g, i) =>
|
|
i === gi
|
|
? {
|
|
...g,
|
|
items: g.items.map((it, j) =>
|
|
j === ii
|
|
? {
|
|
...it,
|
|
text: e.target.value,
|
|
}
|
|
: it
|
|
),
|
|
}
|
|
: g
|
|
),
|
|
})
|
|
}
|
|
style={IS}
|
|
placeholder="Tile text"
|
|
/>
|
|
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<input
|
|
id={`tile-image-${gi}-${ii}`}
|
|
type="file"
|
|
accept="image/*"
|
|
disabled={!canWrite}
|
|
hidden
|
|
onChange={(e) => {
|
|
void uploadTileImage(
|
|
e.target.files?.[0],
|
|
gi,
|
|
ii
|
|
);
|
|
|
|
e.currentTarget.value = '';
|
|
}}
|
|
/>
|
|
|
|
<label
|
|
htmlFor={`tile-image-${gi}-${ii}`}
|
|
style={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 6,
|
|
width: 'fit-content',
|
|
padding: '8px 12px',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-button)',
|
|
background: 'var(--card)',
|
|
color: 'var(--foreground)',
|
|
cursor: canWrite ? 'pointer' : 'default',
|
|
fontSize: 'var(--text-sm)',
|
|
}}
|
|
>
|
|
<Upload size={15} />
|
|
{item.imageUrl ? 'Change image' : 'Upload image'}
|
|
</label>
|
|
|
|
{item.imageUrl ? (
|
|
<img
|
|
src={item.imageUrl}
|
|
alt={`Tile ${ii + 1}`}
|
|
style={{
|
|
width: '100%',
|
|
height: 100,
|
|
objectFit: 'cover',
|
|
borderRadius: 6,
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
|
|
</Field>
|
|
))}
|
|
</Grid> */}
|
|
|
|
</div>
|
|
))}
|
|
|
|
</section>
|
|
) : null}
|
|
{!isMatchUp && quiz ? <section style={{ display: 'grid', gap: 14 }}><strong style={{ color: 'var(--foreground)' }}>Question setup</strong><Field><Label text="Number of questions" required /><input type="number" min={1} max={50} disabled={!canWrite} value={questions.length} onChange={(e) => { const n = Math.max(1, Math.min(50, Number(e.target.value) || 1)); setQuiz({ ...quiz, questions: n > questions.length ? [...questions, ...emptyQuestions(n - questions.length)] : questions.slice(0, n) }); }} style={IS} /><HelperText text="Each correct answer is worth 5 points." /></Field>{questions.map((question, qi) => <div key={qi} style={{ borderTop: '1px solid var(--border)', paddingTop: 14, display: 'grid', gap: 10 }}><div style={{ display: 'flex', justifyContent: 'space-between' }}><Label text={`Question ${qi + 1}`} required />{questions.length > 1 ? <button type="button" disabled={!canWrite} onClick={() => setQuiz({ ...quiz, questions: questions.filter((_, i) => i !== qi) })} style={{ ...buttonStyle, color: 'var(--destructive)' }}><Trash2 size={14} />Remove</button> : null}</div><textarea disabled={!canWrite} value={question.question} onChange={(e) => setQuiz({ ...quiz, questions: questions.map((q, i) => i === qi ? { ...q, question: e.target.value } : q) })} style={{ ...IS, minHeight: 70, resize: 'vertical' }} placeholder="Write the question" /><Grid>{question.options.map((option, oi) => <Field key={oi}><Label text={`Option ${oi + 1}`} required /><input disabled={!canWrite} value={option} onChange={(e) => setQuiz({ ...quiz, questions: questions.map((q, i) => i === qi ? { ...q, options: q.options.map((o, j) => j === oi ? e.target.value : o) } : q) })} style={IS} placeholder="Answer option" /></Field>)}</Grid><Field><Label text="Correct option" required /><select disabled={!canWrite} value={question.correctIndex} onChange={(e) => setQuiz({ ...quiz, questions: questions.map((q, i) => i === qi ? { ...q, correctIndex: Number(e.target.value) } : q) })} style={SS}>{question.options.map((_, oi) => <option key={oi} value={oi}>Option {oi + 1}</option>)}</select></Field></div>)}<button type="button" disabled={!canWrite} onClick={addQuestion} style={{ ...buttonStyle, justifySelf: 'start' }}><Plus size={15} />Add question</button></section> : null}
|
|
<Field><Label text="Public game link" /><div style={{ display: 'flex', gap: 8 }}><input readOnly value={link || 'Save to generate a link'} style={{ ...IS, flex: 1 }} /><button type="button" disabled={!link} onClick={() => void copyLink()} style={buttonStyle}><Copy size={15} />{copied ? 'Copied' : 'Copy'}</button></div></Field>
|
|
|
|
{error ? (
|
|
<p style={{ color: 'var(--destructive)', margin: 0 }}>
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p style={{ color: 'var(--destructive)' }}>
|
|
{error || 'Could not load game.'}
|
|
</p>
|
|
)}
|
|
</PanelShell>;
|
|
} |