EventBuzz_Console/src/app/components/dashboard/GameManagePanel.tsx

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>;
}