mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
After re-audit/critique cycle the deterministic detector (npx impeccable)
flagged 34 design-spec violations. Knock out 33 of them.
• 25 × pure-black-white: sed-sweep bg-black/X → bg-dark-950/X across
18+ files (modal scrims, photo viewer backdrop, code blocks). The
base resolves to rgba(10,15,26,X) — visually identical to true
black, satisfies the 'no #000' impeccable rule.
• 3 × bounce-easing: SuccessNotificationModal celebration icon and
SyncTab loading arrows used animate-bounce; replaced with
animate-pulse. Bounce easing reads dated; pulse conveys 'in
progress' without the cartoon feel.
• 3 × border-accent-on-rounded: TelegramCallback + VerifyEmail
spinners used 'border-b-2 border-accent-500' on rounded-full —
detector reads it as a side-stripe even though it's a ring loader.
Switch to canonical 'border-2 border-accent-500 border-t-transparent'
(3/4 ring colored). Same visual, no spec violation.
• 1 × ai-color-palette: AdminLandingStats had text-purple-400 on a
gift-stats heading; purple is not in the brand palette. Swap to
text-accent-400.
• 1 × layout-transition: TrafficProgressBar.tsx fill bar still used
transition: width 1.2s (slipped past the earlier optimize pass).
Convert to transform: scaleX with origin-left. Same gradient, same
duration, runs on the compositor.
Remaining: 1 finding in third-party Aceternity background-beams-collision
component (indigo-500 gradient on decorative WebGL background) — left
as-is, it's lifted decorative third-party code.
Detector: 34 → 1.
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { contestsApi, ContestInfo, ContestGameData } from '../api/contests';
|
|
|
|
const GamepadIcon = () => (
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const TrophyIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 01-.982-3.172M9.497 14.25a7.454 7.454 0 00.981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 007.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 002.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 012.916.52 6.003 6.003 0 01-5.395 4.972m0 0a6.726 6.726 0 01-2.749 1.35m0 0a6.772 6.772 0 01-3.044 0"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
export default function Contests() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [selectedContest, setSelectedContest] = useState<ContestInfo | null>(null);
|
|
const [gameData, setGameData] = useState<ContestGameData | null>(null);
|
|
const [result, setResult] = useState<{ is_winner: boolean; message: string } | null>(null);
|
|
|
|
const {
|
|
data: contests,
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
queryKey: ['contests'],
|
|
queryFn: contestsApi.getContests,
|
|
});
|
|
|
|
const getGameMutation = useMutation({
|
|
mutationFn: contestsApi.getContestGame,
|
|
onSuccess: (data) => {
|
|
setGameData(data);
|
|
setResult(null);
|
|
},
|
|
});
|
|
|
|
const submitAnswerMutation = useMutation({
|
|
mutationFn: ({ roundId, answer }: { roundId: number; answer: string }) =>
|
|
contestsApi.submitAnswer(roundId, answer),
|
|
onSuccess: (data) => {
|
|
setResult(data);
|
|
queryClient.invalidateQueries({ queryKey: ['contests'] });
|
|
},
|
|
});
|
|
|
|
const handlePlayContest = async (contest: ContestInfo) => {
|
|
setSelectedContest(contest);
|
|
getGameMutation.mutate(contest.id);
|
|
};
|
|
|
|
const handleSubmitAnswer = (answer: string) => {
|
|
if (gameData) {
|
|
submitAnswerMutation.mutate({ roundId: gameData.round_id, answer });
|
|
}
|
|
};
|
|
|
|
const handleCloseGame = () => {
|
|
setSelectedContest(null);
|
|
setGameData(null);
|
|
setResult(null);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex min-h-64 items-center justify-center">
|
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="card border-error-500/20 bg-error-500/10">
|
|
<p className="text-error-400">{t('contests.error')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<GamepadIcon />
|
|
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('contests.title')}</h1>
|
|
</div>
|
|
|
|
{/* Game Modal */}
|
|
{selectedContest && (
|
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-dark-950/70 p-4 backdrop-blur-sm">
|
|
<div
|
|
className="bento-card max-h-[80vh] w-full max-w-lg overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-xl font-bold">{selectedContest.name}</h2>
|
|
<button onClick={handleCloseGame} className="text-dark-400 hover:text-dark-200">
|
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{getGameMutation.isPending && (
|
|
<div className="flex justify-center py-8">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
</div>
|
|
)}
|
|
|
|
{result && (
|
|
<div
|
|
className={`mb-4 rounded-lg p-4 ${result.is_winner ? 'bg-success-500/20 text-success-400' : 'bg-error-500/20 text-error-400'}`}
|
|
>
|
|
<p className="font-medium">{result.message}</p>
|
|
</div>
|
|
)}
|
|
|
|
{gameData && !result && (
|
|
<div className="space-y-4">
|
|
<p className="text-dark-300">{gameData.instructions}</p>
|
|
|
|
{/* Render game based on type */}
|
|
{(gameData.game_type === 'quest' || gameData.game_type === 'locks') && (
|
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
|
{Array.from({
|
|
length: gameData.game_data.total || gameData.game_data.grid_size || 9,
|
|
}).map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => handleSubmitAnswer(`${i}_${gameData.game_data.secret}`)}
|
|
disabled={submitAnswerMutation.isPending}
|
|
className="flex aspect-square items-center justify-center rounded-lg bg-dark-700 text-2xl transition-colors hover:bg-dark-600"
|
|
>
|
|
{gameData.game_type === 'locks' ? '🔒' : '🎛'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{gameData.game_type === 'server' && (
|
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
|
{gameData.game_data.flags?.map((flag: string, i: number) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => handleSubmitAnswer(flag)}
|
|
disabled={submitAnswerMutation.isPending}
|
|
className="rounded-lg bg-dark-700 p-3 text-2xl transition-colors hover:bg-dark-600"
|
|
>
|
|
{flag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{gameData.game_type === 'blitz' && (
|
|
<button
|
|
onClick={() => handleSubmitAnswer('blitz')}
|
|
disabled={submitAnswerMutation.isPending}
|
|
className="w-full rounded-lg bg-accent-500 py-4 text-lg font-bold transition-colors hover:bg-accent-600"
|
|
>
|
|
{gameData.game_data.button_text || t('contests.imHere')}
|
|
</button>
|
|
)}
|
|
|
|
{['cipher', 'emoji', 'anagram'].includes(gameData.game_type) && (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const input = e.currentTarget.elements.namedItem(
|
|
'answer',
|
|
) as HTMLInputElement;
|
|
handleSubmitAnswer(input.value);
|
|
}}
|
|
className="space-y-3"
|
|
>
|
|
<div className="rounded-lg bg-dark-700 p-4 text-center font-mono text-2xl">
|
|
{gameData.game_data.question || gameData.game_data.letters}
|
|
</div>
|
|
<input
|
|
name="answer"
|
|
type="text"
|
|
placeholder={t('contests.enterAnswer')}
|
|
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-4 py-3 focus:border-accent-500 focus:outline-none"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={submitAnswerMutation.isPending}
|
|
className="btn-primary w-full"
|
|
>
|
|
{t('contests.submit')}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{result && (
|
|
<button onClick={handleCloseGame} className="btn-secondary mt-4 w-full">
|
|
{t('common.close')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Contests List */}
|
|
{contests && contests.length > 0 ? (
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{contests.map((contest) => (
|
|
<div key={contest.id} className="card">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">{contest.name}</h3>
|
|
{contest.description && (
|
|
<p className="mt-1 text-sm text-dark-400">{contest.description}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 text-accent-400">
|
|
<TrophyIcon />
|
|
<span className="text-sm font-medium">
|
|
+{t('contests.days', { count: contest.prize_days })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
{contest.already_played ? (
|
|
<button disabled className="btn-secondary w-full cursor-not-allowed opacity-50">
|
|
{t('contests.alreadyPlayed')}
|
|
</button>
|
|
) : (
|
|
<button onClick={() => handlePlayContest(contest)} className="btn-primary w-full">
|
|
{t('contests.play')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="card py-12 text-center">
|
|
<GamepadIcon />
|
|
<p className="mt-4 text-dark-400">{t('contests.noContests')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|