0
GAMES#b8e0cb86
TOURNAMENT DIRECTOR - fixed save
@Owner·deposited 2w ago·updated 2w ago·47 views
GAMES#b8e0cb86
TOURNAMENT DIRECTOR - fixed save
OW
@Owner
47Views
0Comments
1Forks
0Saves
SHARE · REMIX
TOURNAMENT DIRECTOR - fixed save — a JSX Games widget by @Owner.
CONTROLS
animationgametimerclock
No comments yet. Be the first!
✦ Remix with AI
SDK in this widget
vibes.onReadyvibes.savevibes.loadvibes.shared.setvibes.shared.getvibes.shared.onChange
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "TOURNAMENT DIRECTOR - fixed save" by @Owner]
Source: https://itjustvibes.com/Owner/tournament-director-fixed-save
Type: React/JSX
--- SOURCE CODE ---
```jsx
/* Forked from: https://itjustvibes.com/GigaChad/tournament-director-fork */
import { useEffect, useRef, useState } from "react";
const CHIP_COLORS = {
white: { bg: "#F0EDE8", border: "#C8C3BA", text: "#3A3632", label: "White" },
red: { bg: "#C0392B", border: "#922B21", text: "#FFF", label: "Red" },
blue: { bg: "#2471A3", border: "#1A5276", text: "#FFF", label: "Blue" },
green: { bg: "#1E8449", border: "#145A32", text: "#FFF", label: "Green" },
black: { bg: "#2C2C2C", border: "#111", text: "#F0D060", label: "Black" },
};
const DEFAULT_CHIPS = [
{ color: "white", value: 1, count: 100 },
{ color: "red", value: 5, count: 50 },
{ color: "blue", value: 10, count: 50 },
{ color: "green", value: 25, count: 25 },
{ color: "black", value: 100, count: 10 },
];
const STORAGE_KEY = "poker-tourney-v3";
const SHARED_ROOM = "poker-tournament-director";
const MAX_LEVEL_INDEX = 14;
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function toInt(value, fallback = 0) {
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : fallback;
}
function parseSaved(value) {
if (!value) return null;
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return null;
}
}
return typeof value === "object" ? value : null;
}
function normalizeChips(chips) {
const source = Array.isArray(chips) && chips.length ? chips : DEFAULT_CHIPS;
return DEFAULT_CHIPS.map((base, index) => {
const incoming = source[index] || base;
return {
color: CHIP_COLORS[incoming.color] ? incoming.color : base.color,
value: clamp(toInt(incoming.value, base.value), 0, 1000000),
count: clamp(toInt(incoming.count, base.count), 0, 1000000),
};
});
}
function genBlinds(stack) {
const safeStack = Math.max(1, stack || 1);
const levels = [];
const baseSmallBlind = Math.max(1, Math.round(safeStack * 0.005));
const multipliers = [1, 1.5, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 100, 150];
for (let i = 0; i < 15; i += 1) {
const sb = Math.max(1, Math.round(baseSmallBlind * multipliers[i]));
levels.push({
level: i + 1,
sb,
bb: sb * 2,
ante: i >= 4 ? Math.round(sb * 0.5) : 0,
duration: i < 6 ? 15 : i < 10 ? 12 : 10,
});
}
return levels;
}
function distChips(chips, players, stack) {
const sorted = [...chips]
.filter((c) => c.count > 0 && c.value > 0)
.sort((a, b) => a.value - b.value);
if (!sorted.length || !players) return { dist: [], total: 0, left: [] };
let remaining = stack;
const dist = [];
const used = {};
for (let i = sorted.length - 1; i >= 0; i -= 1) {
const chip = sorted[i];
const perPlayer = Math.min(
Math.floor(remaining / chip.value),
Math.floor(chip.count / players)
);
if (perPlayer > 0) {
dist.push({ ...chip, per: perPlayer });
used[chip.color] = perPlayer * players;
remaining -= perPlayer * chip.value;
}
}
return {
dist: dist.reverse(),
total: stack - remaining,
left: sorted
.map((chip) => ({ ...chip, count: chip.count - (used[chip.color] || 0) }))
.filter((chip) => chip.count > 0),
};
}
function ChipIcon({ color, size = 32 }) {
const c = CHIP_COLORS[color] || CHIP_COLORS.white;
return (
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: `radial-gradient(circle at 35% 35%, ${c.bg}, ${c.border})`,
border: `2px solid ${c.border}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow:
"0 2px 4px rgba(0,0,0,0.35), inset 0 1px 2px rgba(255,255,255,0.24)",
position: "relative",
overflow: "hidden",
flexShrink: 0,
}}
aria-label={`${c.label} chip`}
>
<div
style={{
position: "absolute",
inset: 3,
borderRadius: "50%",
border: `1.5px dashed ${c.text}40`,
}}
/>
<div
style={{
position: "absolute",
inset: "31%",
borderRadius: "50%",
border: `1px solid ${c.text}35`,
background: "rgba(0,0,0,0.05)",
}}
/>
</div>
);
}
function Timer({ duration, isRunning, baseSec, startedAt, onToggle, onReset, onComplete }) {
const completedRef = useRef(false);
const getCurrentSeconds = () => {
const base = baseSec == null ? duration * 60 : baseSec;
if (isRunning && startedAt) {
return Math.max(0, base - Math.floor((Date.now() - startedAt) / 1000));
}
return Math.max(0, base);
};
const [sec, setSec] = useState(getCurrentSeconds);
useEffect(() => {
completedRef.current = false;
setSec(getCurrentSeconds());
}, [duration, baseSec, startedAt, isRunning]);
useEffect(() => {
if (!isRunning) return undefined;
const id = setInterval(() => {
const next = getCurrentSeconds();
setSec(next);
if (next <= 0 && !completedRef.current) {
completedRef.current = true;
onComplete?.();
}
}, 250);
return () => clearInterval(id);
}, [duration, baseSec, startedAt, isRunning]);
const total = Math.max(1, duration * 60);
const m = Math.floor(sec / 60);
const s = sec % 60;
const pct = clamp((sec / total) * 100, 0, 100);
const urgent = sec < 60;
return (
<div style={{ textAlign: "center" }}>
<div
style={{
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: "clamp(42px, 12vw, 64px)",
fontWeight: 800,
letterSpacing: 3,
color: urgent ? "#E74C3C" : "#F0D060",
textShadow: urgent ? "0 0 20px #E74C3C80" : "0 0 15px #F0D06040",
animation: urgent && isRunning ? "pulse 1s infinite" : "none",
fontVariantNumeric: "tabular-nums",
}}
>
{String(m).padStart(2, "0")}:{String(s).padStart(2, "0")}
</div>
<div
style={{
height: 7,
background: "#101318",
borderRadius: 99,
margin: "8px 0 16px",
overflow: "hidden",
border: "1px solid #333",
}}
>
<div
style={{
height: "100%",
borderRadius: 99,
transition: "width 0.3s linear",
width: `${pct}%`,
background: urgent
? "linear-gradient(90deg,#E74C3C,#C0392B)"
: "linear-gradient(90deg,#F0D060,#D4A017)",
}}
/>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "center", flexWrap: "wrap" }}>
<button onClick={onToggle} style={timerButtonStyle}>
{isRunning ? "⏸ Pause" : "▶ Start"}
</button>
<button onClick={onReset} style={timerButtonStyle}>
↺ Reset
</button>
</div>
</div>
);
}
const timerButtonStyle = {
padding: "9px 20px",
borderRadius: 8,
background: "linear-gradient(135deg,#2C2C2C,#171A1F)",
border: "1px solid #F0D06050",
color: "#F0D060",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 14,
cursor: "pointer",
fontWeight: 700,
letterSpacing: 0.8,
};
export default function App() {
const [loaded, setLoaded] = useState(false);
const [players, setPlayers] = useState(8);
const [chips, setChips] = useState(DEFAULT_CHIPS);
const [tab, setTab] = useState("setup");
const [lvl, setLvl] = useState(0);
const [running, setRunning] = useState(false);
const [pRem, setPRem] = useState(8);
const [cDur, setCDur] = useState({});
const [tSec, setTSec] = useState(null);
const [timerStartedAt, setTimerStartedAt] = useState(null);
const [eLog, setELog] = useState([]);
const [status, setStatus] = useState("Starting...");
const [sharedReady, setSharedReady] = useState(false);
const [userCount, setUserCount] = useState(null);
const vibesRef = useRef(null);
const saveRef = useRef(null);
const suppressSaveUntilRef = useRef(0);
const statusRef = useRef(null);
const clientIdRef = useRef(
`td-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
);
const pool = chips.reduce((sum, chip) => sum + chip.value * chip.count, 0);
const perPlayerTarget = Math.max(1, Math.floor(pool / Math.max(players, 1)));
const blinds = genBlinds(perPlayerTarget);
const { dist, total, left } = distChips(chips, players, perPlayerTarget);
const currentBlind = blinds[lvl] || blinds[MAX_LEVEL_INDEX];
const duration = cDur[lvl] ?? currentBlind.duration;
const avgStack = Math.round(pool / Math.max(pRem, 1));
const bigBlindsAverage = currentBlind ? Math.round(avgStack / currentBlind.bb) : 0;
function showStatus(text, ms = 1800) {
clearTimeout(statusRef.current);
setStatus(text);
statusRef.current = setTimeout(() => setStatus(""), ms);
}
function currentTimerSeconds() {
const base = tSec == null ? duration * 60 : tSec;
if (running && timerStartedAt) {
return Math.max(0, base - Math.floor((Date.now() - timerStartedAt) / 1000));
}
return Math.max(0, base);
}
function makeSnapshot(overrides = {}) {
return {
schema: 3,
players,
chips,
tab,
lvl,
running,
pRem,
cDur,
tSec,
timerStartedAt,
eLog,
at: Date.now(),
clientId: clientIdRef.current,
...overrides,
};
}
function applySnapshot(raw) {
const saved = parseSaved(raw);
if (!saved) return;
suppressSaveUntilRef.current = Date.now() + 1000;
setPlayers(clamp(toInt(saved.players, 8), 2, 20));
setChips(normalizeChips(saved.chips));
setTab(["setup", "blinds", "timer", "rules"].includes(saved.tab) ? saved.tab : "setup");
setLvl(clamp(toInt(saved.lvl, 0), 0, MAX_LEVEL_INDEX));
setRunning(Boolean(saved.running));
setPRem(clamp(toInt(saved.pRem, saved.players || 8), 1, clamp(toInt(saved.players, 8), 2, 20)));
setCDur(saved.cDur && typeof saved.cDur === "object" ? saved.cDur : {});
setTSec(saved.tSec == null ? null : Math.max(0, toInt(saved.tSec, 0)));
setTimerStartedAt(saved.timerStartedAt || null);
setELog(Array.isArray(saved.eLog) ? saved.eLog.slice(-50) : []);
}
async function saveSnapshot(customSnapshot, okMessage = "Saved ✓") {
const vibes = vibesRef.current;
if (!vibes) {
showStatus("Preview only -- Vibes SDK not found", 2400);
return;
}
const snapshot = customSnapshot || makeSnapshot();
try {
await vibes.save(STORAGE_KEY, snapshot);
if (sharedReady && vibes.shared?.set) {
await vibes.shared.set(STORAGE_KEY, snapshot);
}
showStatus(okMessage);
} catch (err) {
console.error("Tournament Director save failed", err);
showStatus("Save failed", 3000);
}
}
useEffect(() => {
let alive = true;
async function boot() {
const vibes = window.vibes;
vibesRef.current = vibes || null;
if (!vibes) {
if (alive) {
setLoaded(true);
showStatus("Preview only -- Vibes SDK not found", 2600);
}
return;
}
try {
let restored = null;
if (vibes.shared?.join) {
try {
await vibes.shared.join(SHARED_ROOM, { persistent: true });
if (alive) setSharedReady(true);
if (vibes.shared.get) {
restored = parseSaved(vibes.shared.get(STORAGE_KEY));
}
if (vibes.shared.onChange) {
vibes.shared.onChange(STORAGE_KEY, (value) => {
const incoming = parseSaved(value);
if (!incoming || incoming.clientId === clientIdRef.current) return;
applySnapshot(incoming);
showStatus("Synced from table ✓", 1800);
});
}
if (vibes.shared.getUserCount) {
const count = await vibes.shared.getUserCount();
if (alive) setUserCount(count);
setInterval(async () => {
try {
const nextCount = await vibes.shared.getUserCount();
if (alive) setUserCount(nextCount);
} catch {
// Non-critical. Shared sync still works without a count badge.
}
}, 15000);
}
} catch (err) {
console.warn("Shared room unavailable; falling back to per-user save.", err);
}
}
if (!restored && vibes.load) {
restored = parseSaved(await vibes.load(STORAGE_KEY));
}
if (restored) {
applySnapshot(restored);
showStatus("Loaded saved tournament ✓", 1800);
} else {
showStatus("Ready ✓", 1200);
}
} catch (err) {
console.error("Tournament Director startup failed", err);
showStatus("Storage unavailable -- running unsaved", 3200);
} finally {
if (alive) setLoaded(true);
}
}
if (window.vibes?.onReady) {
window.vibes.onReady(boot);
} else {
boot();
}
return () => {
alive = false;
clearTimeout(saveRef.current);
clearTimeout(statusRef.current);
try {
vibesRef.current?.shared?.leave?.();
} catch {
// Ignore cleanup failures.
}
};
}, []);
useEffect(() => {
if (!loaded) return undefined;
if (Date.now() < suppressSaveUntilRef.current) return undefined;
clearTimeout(saveRef.current);
saveRef.current = setTimeout(() => {
saveSnapshot();
}, 900);
return () => clearTimeout(saveRef.current);
}, [players, chips, tab, lvl, running, pRem, cDur, tSec, timerStartedAt, eLog, loaded, sharedReady]);
useEffect(() => {
if (!loaded) return;
setPRem((previous) => clamp(previous, 1, players));
}, [players, loaded]);
function updateChip(index, field, value) {
setChips((old) => {
const next = [...old];
next[index] = {
...next[index],
[field]: clamp(toInt(value, 0), 0, 1000000),
};
return next;
});
}
function toggleTimer() {
const nowSec = currentTimerSeconds();
if (running) {
setTSec(nowSec);
setTimerStartedAt(null);
setRunning(false);
} else {
setTSec(nowSec > 0 ? nowSec : duration * 60);
setTimerStartedAt(Date.now());
setRunning(true);
setTab("timer");
}
}
function resetTimer() {
setRunning(false);
setTimerStartedAt(null);
setTSec(null);
}
function jumpToLevel(nextLevel) {
setLvl(clamp(nextLevel, 0, MAX_LEVEL_INDEX));
resetTimer();
}
function completeLevel() {
setRunning(false);
setTimerStartedAt(null);
setTSec(null);
setLvl((old) => Math.min(MAX_LEVEL_INDEX, old + 1));
showStatus(lvl < MAX_LEVEL_INDEX ? "Level advanced ✓" : "Final level complete", 2200);
}
function changeDuration(value) {
const next = clamp(toInt(value, 1), 1, 240);
setCDur((old) => ({ ...old, [lvl]: next }));
resetTimer();
}
function eliminatePlayer() {
if (pRem <= 1) return;
const nextRemaining = pRem - 1;
setPRem(nextRemaining);
setELog((old) => [
...old,
{ place: pRem, lvl: lvl + 1, time: new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) },
].slice(-50));
}
async function resetAll() {
const fresh = {
schema: 3,
players: 8,
chips: DEFAULT_CHIPS,
tab: "setup",
lvl: 0,
running: false,
pRem: 8,
cDur: {},
tSec: null,
timerStartedAt: null,
eLog: [],
at: Date.now(),
clientId: clientIdRef.current,
};
suppressSaveUntilRef.current = Date.now() + 1000;
applySnapshot(fresh);
await saveSnapshot(fresh, "Reset ✓");
}
if (!loaded) {
return (
<Shell>
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#F0D060",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
fontSize: 16,
letterSpacing: 3,
}}
>
LOADING TABLE...
</div>
</Shell>
);
}
return (
<Shell>
<div style={{ minHeight: "100vh", color: "#E8E2D6", fontFamily: "Georgia, Cambria, serif" }}>
<style>{globalCss}</style>
<header style={headerStyle}>
<div style={{ fontSize: 12, letterSpacing: 6, color: "#F0D060", marginBottom: 6 }}>♠ ♥ ♦ ♣</div>
<h1 style={titleStyle}>TOURNAMENT DIRECTOR</h1>
<div style={subTitleStyle}>TEXAS HOLD'EM · VIBES SYNC READY</div>
<div style={statusWrapStyle}>
{sharedReady && <span style={pillStyle}>Shared{userCount ? ` · ${userCount}` : ""}</span>}
{!sharedReady && <span style={{ ...pillStyle, color: "#AAB", borderColor: "#ffffff18" }}>Local</span>}
{status && <span style={status.includes("fail") || status.includes("unavailable") ? badStatusStyle : goodStatusStyle}>{status}</span>}
</div>
</header>
<nav style={tabsStyle} aria-label="Tournament sections">
{[
["setup", "Setup"],
["blinds", "Blinds"],
["timer", "Play"],
["rules", "Rules"],
].map(([id, label]) => (
<button
key={id}
onClick={() => setTab(id)}
style={{
...tabButtonStyle,
background: tab === id ? "#1a1f28" : "transparent",
color: tab === id ? "#F0D060" : "#76808D",
borderBottom: tab === id ? "2px solid #F0D060" : "2px solid transparent",
}}
>
{label}
</button>
))}
</nav>
<main style={{ padding: 20, maxWidth: 680, margin: "0 auto" }}>
{tab === "setup" && (
<section style={{ animation: "fadeIn 0.3s ease" }}>
<Sec title="Players">
<div style={{ display: "flex", alignItems: "center", gap: 16, justifyContent: "center" }}>
<button onClick={() => setPlayers((old) => Math.max(2, old - 1))} style={roundButtonStyle} aria-label="Remove player">
-
</button>
<span style={bigNumberStyle}>{players}</span>
<button onClick={() => setPlayers((old) => Math.min(20, old + 1))} style={roundButtonStyle} aria-label="Add player">
+
</button>
</div>
</Sec>
<Sec title="Chip Inventory">
{chips.map((chip, index) => (
<div key={chip.color} style={chipRowStyle}>
<ChipIcon color={chip.color} size={30} />
<span style={{ fontSize: 13, color: "#AAB", width: 52, fontFamily: monoFont }}>{CHIP_COLORS[chip.color]?.label}</span>
<div style={{ flex: 1, display: "flex", gap: 8, minWidth: 0 }}>
<InF label="Value" value={chip.value} onChange={(value) => updateChip(index, "value", value)} />
<InF label="Count" value={chip.count} onChange={(value) => updateChip(index, "count", value)} />
</div>
</div>
))}
</Sec>
<Sec title="Tournament Summary">
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 10 }}>
<StatBox label="Total Pool" value={pool.toLocaleString()} />
<StatBox label="Starting Stack" value={total.toLocaleString()} />
<StatBox label="Starting BB" value={(blinds[0]?.bb || 0).toLocaleString()} />
<StatBox label="BBs Deep" value={Math.round(total / (blinds[0]?.bb || 1)).toLocaleString()} />
</div>
{total < perPlayerTarget && (
<div style={warningStyle}>
⚠ Can't evenly distribute {perPlayerTarget.toLocaleString()} chips per player. Each player gets {total.toLocaleString()} chips ({(perPlayerTarget - total).toLocaleString()} short).
</div>
)}
</Sec>
<Sec title="Chip Distribution Per Player">
{dist.length === 0 && <EmptyNote text="Add chip counts and values to generate a distribution." />}
{dist.map((d, index) => (
<div key={d.color} style={{ ...distributionRowStyle, animation: `slideIn 0.3s ease ${index * 0.05}s both` }}>
<ChipIcon color={d.color} size={24} />
<span style={{ fontFamily: monoFont, fontSize: 13, color: "#D7DBE0", flex: 1 }}>
{CHIP_COLORS[d.color]?.label} (${d.value.toLocaleString()})
</span>
<span style={{ fontFamily: monoFont, fontSize: 16, color: "#F0D060", fontWeight: 800 }}>*{d.per}</span>
<span style={{ fontFamily: monoFont, fontSize: 12, color: "#7D8791" }}>= ${(d.per * d.value).toLocaleString()}</span>
</div>
))}
{left.length > 0 && (
<div style={bankStyle}>
Bank: {left.map((l) => `${l.count} ${CHIP_COLORS[l.color]?.label}`).join(", ")} -- use for color-ups.
</div>
)}
</Sec>
<button onClick={resetAll} style={resetButtonStyle}>RESET EVERYTHING</button>
</section>
)}
{tab === "blinds" && (
<section style={{ animation: "fadeIn 0.3s ease" }}>
<Sec title="Blind Structure">
<div style={helpTextStyle}>Antes begin at Level 5. Durations shorten as levels rise. Tap a level to jump there and reset the timer.</div>
{blinds.map((blind, index) => (
<button
key={blind.level}
onClick={() => jumpToLevel(index)}
style={{
...blindRowStyle,
background: index === lvl ? "#F0D06015" : "#1a1f2850",
border: index === lvl ? "1px solid #F0D06035" : "1px solid transparent",
animation: `slideIn 0.3s ease ${index * 0.03}s both`,
}}
>
<span
style={{
...levelBadgeStyle,
background: index === lvl ? "#F0D060" : "#303844",
color: index === lvl ? "#111" : "#AAB",
}}
>
{blind.level}
</span>
<div style={{ flex: 1, textAlign: "left" }}>
<div style={{ fontFamily: monoFont, fontSize: 14, color: "#E8E2D6" }}>
{blind.sb.toLocaleString()} / {blind.bb.toLocaleString()}
</div>
{blind.ante > 0 && <div style={{ fontSize: 11, color: "#F0D06090", fontFamily: monoFont }}>Ante: {blind.ante.toLocaleString()}</div>}
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 11, color: "#8A939F", fontFamily: monoFont }}>{cDur[index] ?? blind.duration} min</div>
<div style={{ fontSize: 10, color: "#66717E", fontFamily: monoFont }}>{Math.round(total / blind.bb).toLocaleString()} BBs</div>
</div>
</button>
))}
</Sec>
</section>
)}
{tab === "timer" && (
<section style={{ animation: "fadeIn 0.3s ease" }}>
<div style={heroCardStyle}>
<div style={{ fontSize: 10, letterSpacing: 4, color: "#AAB", fontFamily: monoFont, marginBottom: 4 }}>LEVEL {currentBlind.level}</div>
<div style={{ fontSize: "clamp(34px, 11vw, 48px)", fontWeight: 800, color: "#F0D060", fontFamily: monoFont, letterSpacing: 1 }}>
{currentBlind.sb.toLocaleString()} / {currentBlind.bb.toLocaleString()}
</div>
{currentBlind.ante > 0 && <div style={{ fontSize: 16, color: "#F0D06090", fontFamily: monoFont, marginTop: 2 }}>Ante: {currentBlind.ante.toLocaleString()}</div>}
<div style={smallStatsStyle}>
<span>Avg Stack: {avgStack.toLocaleString()}</span>
<span>{bigBlindsAverage.toLocaleString()} BBs</span>
</div>
</div>
<div style={timerCardStyle}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8, gap: 10 }}>
<span style={{ fontSize: 10, letterSpacing: 3, color: "#AAB", fontFamily: monoFont }}>ROUND TIMER</span>
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 10, color: "#8A939F", fontFamily: monoFont }}>MIN:</span>
<input type="number" value={duration} onChange={(e) => changeDuration(e.target.value)} style={{ ...numberInputStyle, width: 48, textAlign: "center" }} />
</label>
</div>
<Timer
duration={duration}
isRunning={running}
baseSec={tSec}
startedAt={timerStartedAt}
onToggle={toggleTimer}
onReset={resetTimer}
onComplete={completeLevel}
/>
</div>
<div style={{ display: "flex", gap: 10, marginBottom: 16 }}>
<button disabled={lvl === 0} onClick={() => jumpToLevel(lvl - 1)} style={{ ...navButtonStyle, opacity: lvl === 0 ? 0.35 : 1 }}>← Prev</button>
<button disabled={lvl >= MAX_LEVEL_INDEX} onClick={() => jumpToLevel(lvl + 1)} style={{ ...navButtonStyle, opacity: lvl >= MAX_LEVEL_INDEX ? 0.35 : 1 }}>Next →</button>
</div>
{lvl < MAX_LEVEL_INDEX && (
<div style={nextLevelStyle}>
<span style={{ fontSize: 11, color: "#8A939F", fontFamily: monoFont }}>NEXT:</span>
<span style={{ fontFamily: monoFont, fontSize: 14, color: "#D7DBE0" }}>
{blinds[lvl + 1].sb.toLocaleString()} / {blinds[lvl + 1].bb.toLocaleString()}
{blinds[lvl + 1].ante > 0 && ` (ante ${blinds[lvl + 1].ante.toLocaleString()})`}
</span>
</div>
)}
<Sec title="Players Remaining">
<div style={{ display: "flex", alignItems: "center", gap: 16, justifyContent: "center" }}>
<button onClick={() => setPRem((old) => Math.min(players, old + 1))} style={roundButtonStyle}>+</button>
<div style={{ textAlign: "center" }}>
<span style={{ fontSize: 34, fontWeight: 800, color: "#F0D060", fontFamily: monoFont }}>{pRem}</span>
<span style={{ fontSize: 14, color: "#7D8791", fontFamily: monoFont }}> / {players}</span>
</div>
<button onClick={eliminatePlayer} style={{ ...roundButtonStyle, borderColor: pRem <= 1 ? "#333" : "#C0392B60", color: pRem <= 1 ? "#555" : "#E15A4F" }}>✕</button>
</div>
{pRem <= Math.ceil(players / 2) && pRem > 2 && <Callout tone="gold">Consider a chip color-up -- remove the lowest denomination.</Callout>}
{pRem === 2 && <Callout tone="blue">★ HEADS-UP! Dealer posts the small blind and acts first pre-flop.</Callout>}
{pRem === 1 && <div style={winnerStyle}>🏆 WINNER 🏆</div>}
</Sec>
{eLog.length > 0 && (
<Sec title="Elimination Log">
{[...eLog].reverse().map((entry, index) => (
<div key={`${entry.place}-${entry.time}-${index}`} style={logRowStyle}>
<span>{ord(entry.place)} place</span>
<span>Lvl {entry.lvl} · {entry.time}</span>
</div>
))}
</Sec>
)}
<Sec title="Payout Structure">
<div style={{ fontSize: 12, color: "#AAB", fontFamily: monoFont, lineHeight: 1.8 }}>
{players >= 7 ? (
<>
<PayoutRow label="1st" pct="50%" color="#F0D060" />
<PayoutRow label="2nd" pct="30%" color="#D7DBE0" />
<PayoutRow label="3rd" pct="20%" color="#A67C52" />
</>
) : players >= 4 ? (
<>
<PayoutRow label="1st" pct="60%" color="#F0D060" />
<PayoutRow label="2nd" pct="40%" color="#D7DBE0" />
</>
) : (
<PayoutRow label="1st" pct="Winner Takes All" color="#F0D060" />
)}
</div>
</Sec>
</section>
)}
{tab === "rules" && (
<section style={{ animation: "fadeIn 0.3s ease" }}>
<Sec title="Tournament Rules">
<Rule title="Buy-In & Stacks" text={`All players start with ${total.toLocaleString()} chips. No rebuys unless agreed before the start.`} />
<Rule title="Blind Increases" text="Blinds go up on a timer. New blinds take effect at the start of the next hand -- never mid-hand." />
<Rule title="Antes" text="Antes kick in at Level 5. Every player posts an ante before each hand to grow pots and force action." />
<Rule title="Dealing" text="Dealer button rotates clockwise. Left of dealer posts the small blind; next posts the big blind. Burn before flop, turn, and river." />
<Rule title="Betting" text="Minimum raise equals the previous raise. All-in for less than a full raise does not reopen action. No string bets." />
<Rule title="Side Pots" text="When a player is all-in and others keep betting, create a side pot. The all-in player competes only for the main pot." />
<Rule title="Color-Ups" text="When small chips fall below the small blind, race them off: convert to larger denominations and round odd chips up." />
<Rule title="Showdown" text="Last aggressor shows first. No final-street bet? First clockwise from dealer shows. Players may muck." />
<Rule title="Heads-Up" text="With 2 players, dealer posts the small blind and acts first pre-flop, but second on all later streets." />
<Rule title="Etiquette" text="No slow-rolling. No discussing active hands. Act in turn. Large denominations stay in front and visible." />
</Sec>
<Sec title="How Saving Works">
<div style={saveInfoStyle}>
This fork uses the It Just Vibes SDK instead of blocked browser storage. It saves your table with <strong>vibes.save</strong> and, when available, mirrors the tournament through a persistent shared room so multiple viewers can follow the same live board.
</div>
</Sec>
</section>
)}
</main>
</div>
</Shell>
);
}
function Shell({ children }) {
return <div style={{ minHeight: "100vh", background: "linear-gradient(160deg,#090B0E 0%,#14191F 42%,#0D1117 100%)" }}>{children}</div>;
}
function ord(n) {
const suffixes = ["th", "st", "nd", "rd"];
const value = n % 100;
return n + (suffixes[(value - 20) % 10] || suffixes[value] || suffixes[0]);
}
function Sec({ title, children }) {
return (
<div style={{ marginBottom: 20 }}>
<div style={sectionTitleStyle}>{title.toUpperCase()}</div>
{children}
</div>
);
}
function StatBox({ label, value }) {
return (
<div style={statBoxStyle}>
<div style={{ fontSize: 22, fontWeight: 800, color: "#F0D060", fontFamily: monoFont }}>{value}</div>
<div style={{ fontSize: 10, color: "#8A939F", fontFamily: monoFont, letterSpacing: 1, marginTop: 2 }}>{label.toUpperCase()}</div>
</div>
);
}
function InF({ label, value, onChange }) {
return (
<label style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, color: "#69727D", fontFamily: monoFont, marginBottom: 3, letterSpacing: 1 }}>{label.toUpperCase()}</div>
<input type="number" value={value} onChange={(e) => onChange(e.target.value)} style={numberInputStyle} />
</label>
);
}
function EmptyNote({ text }) {
return <div style={{ color: "#8A939F", fontFamily: monoFont, fontSize: 12, padding: "10px 0" }}>{text}</div>;
}
function Callout({ children, tone }) {
const isBlue = tone === "blue";
return (
<div
style={{
marginTop: 10,
padding: "9px 12px",
borderRadius: 8,
background: isBlue ? "#2471A320" : "#F0D06010",
border: isBlue ? "1px solid #2471A350" : "1px solid #F0D06025",
fontSize: 12,
color: isBlue ? "#6DB7E8" : "#F0D060",
fontFamily: monoFont,
textAlign: "center",
}}
>
{children}
</div>
);
}
function Rule({ title, text }) {
return (
<div style={ruleStyle}>
<div style={{ fontFamily: monoFont, fontSize: 13, color: "#F0D060", marginBottom: 4, fontWeight: 800 }}>{title}</div>
<div style={{ fontSize: 13, color: "#C4CAD2", lineHeight: 1.6 }}>{text}</div>
</div>
);
}
function PayoutRow({ label, pct, color }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", padding: "6px 0", borderBottom: "1px solid #ffffff08" }}>
<span>{label} Place</span>
<span style={{ color }}>{pct}</span>
</div>
);
}
const monoFont = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
const globalCss = `
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
@keyframes slideIn{from{opacity:0;transform:translateX(-12px)}to{opacity:1;transform:translateX(0)}}
input[type=number]{-moz-appearance:textfield}
input::-webkit-outer-spin-button,input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
button{touch-action:manipulation}
*{box-sizing:border-box}
*::-webkit-scrollbar{width:6px}
*::-webkit-scrollbar-track{background:#111}
*::-webkit-scrollbar-thumb{background:#333;border-radius:3px}
`;
const headerStyle = {
padding: "28px 16px 20px",
textAlign: "center",
borderBottom: "1px solid #F0D06020",
background: "linear-gradient(180deg,#14191F,transparent)",
position: "relative",
};
const titleStyle = {
fontSize: "clamp(23px, 7vw, 32px)",
fontWeight: 800,
margin: 0,
background: "linear-gradient(135deg,#F0D060,#D4A017,#F0D060)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: 2,
};
const subTitleStyle = {
fontSize: 11,
letterSpacing: 3,
color: "#8A939F",
marginTop: 5,
fontFamily: monoFont,
};
const statusWrapStyle = {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
flexWrap: "wrap",
marginTop: 12,
};
const pillStyle = {
fontSize: 10,
color: "#58D68D",
fontFamily: monoFont,
letterSpacing: 1,
border: "1px solid #58D68D40",
borderRadius: 999,
padding: "4px 8px",
background: "#58D68D10",
};
const goodStatusStyle = {
...pillStyle,
color: "#F0D060",
borderColor: "#F0D06035",
background: "#F0D06010",
animation: "fadeIn 0.2s ease",
};
const badStatusStyle = {
...pillStyle,
color: "#E74C3C",
borderColor: "#E74C3C50",
background: "#E74C3C10",
animation: "fadeIn 0.2s ease",
};
const tabsStyle = {
display: "flex",
borderBottom: "1px solid #F0D06020",
background: "#0D1117",
position: "sticky",
top: 0,
zIndex: 5,
};
const tabButtonStyle = {
flex: 1,
padding: "13px 0",
border: "none",
cursor: "pointer",
fontFamily: monoFont,
fontSize: 12,
letterSpacing: 1.7,
};
const sectionTitleStyle = {
fontSize: 10,
letterSpacing: 4,
color: "#F0D060A0",
fontFamily: monoFont,
marginBottom: 12,
paddingBottom: 7,
borderBottom: "1px solid #F0D06012",
};
const roundButtonStyle = {
width: 44,
height: 44,
borderRadius: "50%",
background: "linear-gradient(135deg,#2C2C2C,#171A1F)",
border: "1px solid #F0D06035",
color: "#F0D060",
fontSize: 24,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: monoFont,
};
const bigNumberStyle = {
fontSize: 38,
fontWeight: 800,
color: "#F0D060",
fontFamily: monoFont,
minWidth: 58,
textAlign: "center",
fontVariantNumeric: "tabular-nums",
};
const chipRowStyle = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
marginBottom: 7,
background: "#1a1f2880",
border: "1px solid #ffffff08",
};
const numberInputStyle = {
width: "100%",
padding: "7px 8px",
borderRadius: 7,
background: "#0D1117",
border: "1px solid #3A414B",
color: "#E8E2D6",
fontFamily: monoFont,
fontSize: 14,
outline: "none",
};
const statBoxStyle = {
padding: "12px 10px",
borderRadius: 10,
background: "#1a1f2880",
border: "1px solid #ffffff08",
textAlign: "center",
};
const warningStyle = {
marginTop: 10,
padding: "9px 12px",
borderRadius: 8,
background: "#C0392B20",
border: "1px solid #C0392B40",
fontSize: 12,
color: "#EE9A93",
fontFamily: monoFont,
lineHeight: 1.5,
};
const distributionRowStyle = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 0",
borderBottom: "1px solid #ffffff08",
};
const bankStyle = {
marginTop: 12,
fontSize: 11,
color: "#7D8791",
fontFamily: monoFont,
lineHeight: 1.5,
};
const resetButtonStyle = {
width: "100%",
padding: "13px",
borderRadius: 10,
marginTop: 8,
background: "#C0392B15",
border: "1px solid #C0392B40",
color: "#E15A4F",
fontFamily: monoFont,
fontSize: 12,
cursor: "pointer",
letterSpacing: 2,
fontWeight: 800,
};
const helpTextStyle = {
fontSize: 12,
color: "#AAB",
marginBottom: 14,
fontFamily: monoFont,
lineHeight: 1.6,
};
const blindRowStyle = {
width: "100%",
display: "flex",
alignItems: "center",
gap: 9,
padding: "10px 12px",
marginBottom: 7,
borderRadius: 10,
cursor: "pointer",
};
const levelBadgeStyle = {
width: 29,
height: 29,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
fontFamily: monoFont,
fontWeight: 800,
flexShrink: 0,
};
const heroCardStyle = {
textAlign: "center",
padding: "24px 16px",
borderRadius: 14,
background: "linear-gradient(135deg,#1a1f28,#141820)",
border: "1px solid #F0D06018",
marginBottom: 16,
boxShadow: "0 18px 42px rgba(0,0,0,0.22)",
};
const smallStatsStyle = {
marginTop: 16,
display: "flex",
justifyContent: "center",
gap: 24,
fontSize: 11,
color: "#8A939F",
fontFamily: monoFont,
flexWrap: "wrap",
};
const timerCardStyle = {
padding: "20px 16px",
borderRadius: 14,
background: "#1a1f2868",
border: "1px solid #ffffff08",
marginBottom: 16,
};
const navButtonStyle = {
flex: 1,
padding: "12px",
borderRadius: 10,
background: "linear-gradient(135deg,#1a1f28,#141820)",
border: "1px solid #F0D06025",
color: "#F0D060",
fontFamily: monoFont,
fontSize: 12,
cursor: "pointer",
letterSpacing: 1,
fontWeight: 800,
};
const nextLevelStyle = {
padding: "12px 14px",
borderRadius: 10,
background: "#1a1f2840",
border: "1px solid #ffffff08",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
gap: 12,
};
const winnerStyle = {
marginTop: 10,
padding: 14,
borderRadius: 10,
background: "linear-gradient(135deg,#F0D06022,#D4A01718)",
border: "1px solid #F0D06045",
fontSize: 16,
color: "#F0D060",
fontFamily: monoFont,
textAlign: "center",
fontWeight: 900,
letterSpacing: 2,
};
const logRowStyle = {
display: "flex",
justifyContent: "space-between",
padding: "7px 0",
borderBottom: "1px solid #ffffff08",
fontFamily: monoFont,
fontSize: 12,
color: "#AAB",
};
const ruleStyle = {
padding: "12px 14px",
marginBottom: 8,
borderRadius: 10,
background: "#1a1f2850",
borderLeft: "3px solid #F0D06035",
};
const saveInfoStyle = {
padding: "12px 14px",
borderRadius: 10,
background: "#1E844915",
border: "1px solid #1E844935",
fontSize: 13,
color: "#C4CAD2",
fontFamily: monoFont,
lineHeight: 1.7,
};
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **Do NOT import external JS libraries via script tags** — they will be blocked. Include library code inline or use a CDN link in a `<link>` tag for CSS only.
6. **Keep total code under 80KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained JSX file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/tournament-director-fixed-save */