Autosave: 20260203-021911
This commit is contained in:
parent
3efcff95c6
commit
b4c212d6cc
@ -4,300 +4,211 @@ const axios = require('axios');
|
||||
const Helpers = require('../helpers');
|
||||
const qs = require('qs');
|
||||
|
||||
/**
|
||||
* Super Stealth Proxy v3
|
||||
* Engineered for high-fidelity dynamic site replication (YouTube, Google, etc.)
|
||||
*/
|
||||
|
||||
const PROXY_PATH = '/api/proxy';
|
||||
|
||||
const proxyHandler = Helpers.wrapAsync(async (req, res) => {
|
||||
let targetUrl = req.query.url;
|
||||
|
||||
if (targetUrl) {
|
||||
try {
|
||||
const urlObj = new URL(targetUrl);
|
||||
Object.keys(req.query).forEach(key => {
|
||||
if (key !== 'url') {
|
||||
urlObj.searchParams.set(key, req.query[key]);
|
||||
}
|
||||
});
|
||||
targetUrl = urlObj.href;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Improved Path-based and Referer detection
|
||||
// 1. URL Resolution
|
||||
if (!targetUrl || !targetUrl.startsWith('http')) {
|
||||
const originalUrl = req.originalUrl;
|
||||
|
||||
// Check if URL is in the path directly (e.g. /api/proxy/https://...)
|
||||
const pathMarker = '/api/proxy/';
|
||||
if (originalUrl.includes(pathMarker)) {
|
||||
const parts = originalUrl.split(pathMarker);
|
||||
const remainder = parts[1];
|
||||
if (remainder && remainder.startsWith('http')) {
|
||||
targetUrl = remainder;
|
||||
} else if (req.headers.referer && req.headers.referer.includes('url=')) {
|
||||
// Smart reconstruction from Referer
|
||||
try {
|
||||
const refUrl = new URL(req.headers.referer);
|
||||
const parentTarget = refUrl.searchParams.get('url');
|
||||
if (parentTarget) {
|
||||
// Reconstruct: parent origin + current query/path
|
||||
const currentPath = req.url.replace('/api/proxy', '');
|
||||
targetUrl = new URL(currentPath, parentTarget).href;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (originalUrl.includes(PROXY_PATH + '/')) {
|
||||
targetUrl = originalUrl.split(PROXY_PATH + '/')[1];
|
||||
} else if (req.headers.referer && req.headers.referer.includes(PROXY_PATH)) {
|
||||
try {
|
||||
const refUrl = new URL(req.headers.referer);
|
||||
const parentTarget = refUrl.searchParams.get('url');
|
||||
if (parentTarget) {
|
||||
const relativePath = req.url.replace(PROXY_PATH, '');
|
||||
targetUrl = new URL(relativePath, parentTarget).href;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUrl || !targetUrl.startsWith('http')) {
|
||||
return res.status(400).send(`
|
||||
<div style="background:#1a1a1a; color:#ccc; padding:20px; font-family:sans-serif; border-radius:8px; margin:20px; border:1px solid #333;">
|
||||
<h2 style="color:#ff4d4d; margin-top:0;">URL Required</h2>
|
||||
<p>The stealth proxy needs a target URL to work.</p>
|
||||
<button onclick="window.history.back()" style="background:#333; color:#fff; border:none; padding:8px 16px; border-radius:4px; cursor:pointer; margin-top:15px;">Go Back</button>
|
||||
</div>
|
||||
`);
|
||||
return res.status(400).send('Invalid target URL.');
|
||||
}
|
||||
|
||||
try {
|
||||
const method = req.method;
|
||||
const headers = {};
|
||||
|
||||
const forwardHeaders = [
|
||||
'accept', 'accept-language', 'accept-encoding', 'user-agent', 'content-type', 'range', 'authorization', 'x-requested-with'
|
||||
];
|
||||
|
||||
forwardHeaders.forEach(h => {
|
||||
if (req.headers[h]) {
|
||||
headers[h] = req.headers[h];
|
||||
}
|
||||
});
|
||||
const forwardHeaders = ['accept', 'accept-language', 'range', 'authorization', 'content-type'];
|
||||
forwardHeaders.forEach(h => { if (req.headers[h]) headers[h] = req.headers[h]; });
|
||||
|
||||
if (!headers['user-agent']) {
|
||||
headers['user-agent'] = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1';
|
||||
}
|
||||
|
||||
delete headers['referer'];
|
||||
delete headers['origin'];
|
||||
// Force Desktop User-Agent
|
||||
headers['user-agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
||||
|
||||
try {
|
||||
const targetObj = new URL(targetUrl);
|
||||
headers['referer'] = targetObj.origin + '/';
|
||||
headers['origin'] = targetObj.origin;
|
||||
headers['host'] = targetObj.host;
|
||||
} catch(e) {}
|
||||
|
||||
const axiosConfig = {
|
||||
url: targetUrl,
|
||||
method: method,
|
||||
method: req.method,
|
||||
headers: headers,
|
||||
responseType: 'arraybuffer',
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 10,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 5,
|
||||
timeout: 30000,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
decompress: true
|
||||
};
|
||||
|
||||
if (method !== 'GET' && method !== 'HEAD' && req.body) {
|
||||
if (headers['content-type'] && headers['content-type'].includes('application/x-www-form-urlencoded')) {
|
||||
axiosConfig.data = typeof req.body === 'string' ? req.body : qs.stringify(req.body);
|
||||
} else {
|
||||
axiosConfig.data = req.body;
|
||||
}
|
||||
if (req.method !== 'GET' && req.body) {
|
||||
axiosConfig.data = (headers['content-type'] || '').includes('form') ? qs.stringify(req.body) : req.body;
|
||||
}
|
||||
|
||||
const response = await axios(axiosConfig);
|
||||
const finalUrl = response.request.res.responseUrl || targetUrl;
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
const finalUrl = response.request.res ? response.request.res.responseUrl : targetUrl;
|
||||
|
||||
// Handle Redirects
|
||||
if (response.status >= 300 && response.status < 400 && response.headers.location) {
|
||||
const redirUrl = new URL(response.headers.location, finalUrl).href;
|
||||
return res.redirect(`/api/proxy?url=${encodeURIComponent(redirUrl)}`);
|
||||
return res.redirect(`${PROXY_PATH}?url=${encodeURIComponent(redirUrl)}`);
|
||||
}
|
||||
|
||||
// Strip Security Headers
|
||||
const forbiddenHeaders = ['x-frame-options', 'content-security-policy', 'content-security-policy-report-only', 'strict-transport-security', 'set-cookie'];
|
||||
Object.keys(response.headers).forEach(key => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (![
|
||||
'x-frame-options',
|
||||
'content-security-policy',
|
||||
'content-security-policy-report-only',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'connection',
|
||||
'strict-transport-security',
|
||||
'x-content-type-options',
|
||||
'set-cookie',
|
||||
'access-control-allow-origin'
|
||||
].includes(lowerKey)) {
|
||||
if (!forbiddenHeaders.includes(key.toLowerCase())) {
|
||||
res.setHeader(key, response.headers[key]);
|
||||
}
|
||||
});
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
|
||||
let data = response.data;
|
||||
res.setHeader('X-Proxy-Target', finalUrl);
|
||||
|
||||
if (contentType.includes('text/html')) {
|
||||
let html = data.toString('utf-8');
|
||||
let html = response.data.toString('utf-8');
|
||||
|
||||
let origin = '';
|
||||
try {
|
||||
const parsedUrl = new URL(finalUrl);
|
||||
origin = parsedUrl.origin;
|
||||
} catch (e) { origin = targetUrl; }
|
||||
|
||||
const headInjection = `
|
||||
<base href="${origin}/">
|
||||
const injection = `
|
||||
<script>
|
||||
(function() {
|
||||
const TARGET_URL = "${finalUrl}";
|
||||
const PROXY_BASE = window.location.origin + "${PROXY_PATH}?url=";
|
||||
|
||||
function wrapUrl(u) {
|
||||
if (!u || typeof u !== 'string' || u.startsWith('data:') || u.startsWith('javascript:') || u.startsWith('blob:')) return u;
|
||||
if (u.startsWith(window.location.origin)) return u;
|
||||
try {
|
||||
const abs = new URL(u, TARGET_URL).href;
|
||||
return PROXY_BASE + encodeURIComponent(abs);
|
||||
} catch(e) { return u; }
|
||||
}
|
||||
|
||||
// --- Network Virtualization ---
|
||||
const _fetch = window.fetch;
|
||||
window.fetch = (input, init) => {
|
||||
if (typeof input === 'string') input = wrapUrl(input);
|
||||
else if (input instanceof Request) {
|
||||
Object.defineProperty(input, 'url', { value: wrapUrl(input.url) });
|
||||
}
|
||||
return _fetch(input, init);
|
||||
};
|
||||
|
||||
const _open = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
|
||||
return _open.apply(this, [method, wrapUrl(url), async, user, password]);
|
||||
};
|
||||
|
||||
// --- Location & History Spoofing ---
|
||||
const targetOrigin = new URL(TARGET_URL).origin;
|
||||
const locationProxy = new Proxy(window.location, {
|
||||
get: (t, p) => {
|
||||
if (p === 'href') return TARGET_URL;
|
||||
if (p === 'origin') return targetOrigin;
|
||||
if (p === 'host' || p === 'hostname') return new URL(TARGET_URL).hostname;
|
||||
const val = t[p];
|
||||
return typeof val === 'function' ? val.bind(t) : val;
|
||||
},
|
||||
set: (t, p, v) => {
|
||||
if (p === 'href') window.location.href = wrapUrl(v);
|
||||
else t[p] = v;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
var PROXY_BASE = window.location.origin + "/api/proxy?url=";
|
||||
var TARGET_ORIGIN = "${origin}";
|
||||
var FINAL_URL = "${finalUrl}";
|
||||
|
||||
function toProxyUrl(url) {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
if (url.startsWith('javascript:') || url.startsWith('data:') || url.startsWith('mailto:') || url.startsWith('tel:')) return url;
|
||||
if (url.startsWith(window.location.origin + "/api/proxy")) return url;
|
||||
try {
|
||||
var absUrl = new URL(url, FINAL_URL).href;
|
||||
return PROXY_BASE + encodeURIComponent(absUrl);
|
||||
} catch(e) { return url; }
|
||||
}
|
||||
Object.defineProperty(window, 'location', { value: locationProxy, configurable: true });
|
||||
Object.defineProperty(document, 'location', { value: locationProxy, configurable: true });
|
||||
} catch(e) {}
|
||||
|
||||
function notifyParent() {
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'zen-browser-url-change',
|
||||
url: window.location.href,
|
||||
title: document.title
|
||||
}, '*');
|
||||
} catch(e) {}
|
||||
}
|
||||
const _pushState = history.pushState;
|
||||
history.pushState = (state, title, url) => {
|
||||
if (url) window.parent.postMessage({ type: 'proxy-url-change', url: new URL(url, TARGET_URL).href }, '*');
|
||||
return _pushState.apply(history, [state, title, url ? wrapUrl(url) : url]);
|
||||
};
|
||||
|
||||
var originalFetch = window.fetch;
|
||||
window.fetch = function(input, init) {
|
||||
if (typeof input === 'string') {
|
||||
input = toProxyUrl(input);
|
||||
} else if (input instanceof Request) {
|
||||
input = new Request(toProxyUrl(input.url), input);
|
||||
const _replaceState = history.replaceState;
|
||||
history.replaceState = (state, title, url) => {
|
||||
if (url) window.parent.postMessage({ type: 'proxy-url-change', url: new URL(url, TARGET_URL).href }, '*');
|
||||
return _replaceState.apply(history, [state, title, url ? wrapUrl(url) : url]);
|
||||
};
|
||||
|
||||
// --- Attribute Rewriting (Real-time) ---
|
||||
const observer = new MutationObserver(mutations => {
|
||||
document.querySelectorAll('a:not([data-proxied]), form:not([data-proxied])').forEach(el => {
|
||||
const attr = el.tagName === 'A' ? 'href' : 'action';
|
||||
const val = el.getAttribute(attr);
|
||||
if (val && !val.startsWith('#') && !val.startsWith('javascript:')) {
|
||||
el.setAttribute(attr, wrapUrl(val));
|
||||
el.setAttribute('data-proxied', 'true');
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
var originalOpenXHR = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
return originalOpenXHR.apply(this, [method, toProxyUrl(url), true]);
|
||||
};
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
var originalSendBeacon = navigator.sendBeacon;
|
||||
navigator.sendBeacon = function(url, data) {
|
||||
return originalSendBeacon.apply(this, [toProxyUrl(url), data]);
|
||||
};
|
||||
}
|
||||
|
||||
var originalPushState = history.pushState;
|
||||
history.pushState = function(state, title, url) {
|
||||
var res = originalPushState.apply(this, arguments);
|
||||
notifyParent();
|
||||
return res;
|
||||
};
|
||||
var originalReplaceState = history.replaceState;
|
||||
history.replaceState = function() {
|
||||
var res = originalReplaceState.apply(this, arguments);
|
||||
notifyParent();
|
||||
return res;
|
||||
};
|
||||
window.addEventListener('popstate', notifyParent);
|
||||
|
||||
var originalOpen = window.open;
|
||||
window.open = function(url, name, specs) {
|
||||
if (!url) return originalOpen(url, name, specs);
|
||||
window.location.href = toProxyUrl(url);
|
||||
return null;
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'top', { get: function() { return window.self; } });
|
||||
Object.defineProperty(window, 'parent', { get: function() { return window.self; } });
|
||||
|
||||
var originalReplace = window.location.replace;
|
||||
window.location.replace = function(url) {
|
||||
window.location.href = toProxyUrl(url);
|
||||
};
|
||||
|
||||
window.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
if (form.method.toLowerCase() === 'get') {
|
||||
// For GET forms, we want to stay in the proxy.
|
||||
// If we change the action to /api/proxy?url=target, the browser will append ?field=val
|
||||
// resulting in /api/proxy?field=val (losing the url parameter).
|
||||
// So we add a hidden input for the 'url'.
|
||||
var action = form.getAttribute('action') || '';
|
||||
var absoluteAction = new URL(action, FINAL_URL).href;
|
||||
|
||||
// Clean up existing url inputs
|
||||
form.querySelectorAll('input[name="url"]').forEach(el => el.remove());
|
||||
|
||||
var urlInput = document.createElement('input');
|
||||
urlInput.type = 'hidden';
|
||||
urlInput.name = 'url';
|
||||
urlInput.value = absoluteAction;
|
||||
form.appendChild(urlInput);
|
||||
|
||||
form.action = window.location.origin + "/api/proxy";
|
||||
} else if (form.action && !form.action.includes("/api/proxy")) {
|
||||
form.action = toProxyUrl(form.action);
|
||||
});
|
||||
document.querySelectorAll('img:not([data-proxied]), script:not([data-proxied]), link:not([data-proxied])').forEach(el => {
|
||||
const attr = el.tagName === 'LINK' ? 'href' : 'src';
|
||||
const val = el.getAttribute(attr);
|
||||
if (val && !val.startsWith('data:') && !val.startsWith('blob:')) {
|
||||
el.setAttribute(attr, wrapUrl(val));
|
||||
el.setAttribute('data-proxied', 'true');
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
|
||||
setInterval(function() {
|
||||
document.querySelectorAll('a[href]:not([data-proxied]), form[action]:not([data-proxied])').forEach(function(el) {
|
||||
var attr = el.tagName === 'A' ? 'href' : 'action';
|
||||
var val = el.getAttribute(attr);
|
||||
if (val && !val.includes('/api/proxy') && !val.startsWith('#') && !val.startsWith('javascript:')) {
|
||||
el.setAttribute(attr, toProxyUrl(val));
|
||||
el.setAttribute('data-proxied', 'true');
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
// --- Form Interception ---
|
||||
window.addEventListener('submit', e => {
|
||||
const form = e.target;
|
||||
if (form.method.toLowerCase() === 'get') {
|
||||
e.preventDefault();
|
||||
const action = new URL(form.getAttribute('action') || '', TARGET_URL).href;
|
||||
const params = new URLSearchParams(new FormData(form)).toString();
|
||||
window.location.href = wrapUrl(action + (action.includes('?') ? '&' : '?') + params);
|
||||
}
|
||||
}, true);
|
||||
|
||||
notifyParent();
|
||||
|
||||
} catch(e) { console.error("Proxy injection error:", e); }
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
html = html.replace(/<head>/i, `<head>${injection}`);
|
||||
|
||||
if (html.toLowerCase().includes('<head>')) {
|
||||
html = html.replace(/<head>/i, `<head>${headInjection}`);
|
||||
} else if (html.toLowerCase().includes('<html>')) {
|
||||
html = html.replace(/<html>/i, `<html><head>${headInjection}</head>`);
|
||||
} else {
|
||||
html = headInjection + html;
|
||||
}
|
||||
|
||||
html = html.replace(/(href|src|action)=["'](.*?)["']/gi, (match, attr, p1) => {
|
||||
if (!p1 || p1.startsWith('#') || p1.startsWith('javascript:') || p1.startsWith('data:') || p1.startsWith('mailto:')) return match;
|
||||
|
||||
try {
|
||||
const absoluteUrl = new URL(p1, finalUrl).href;
|
||||
const lowerAttr = attr.toLowerCase();
|
||||
|
||||
if (lowerAttr === 'href' || lowerAttr === 'action' || match.toLowerCase().includes('<script') || match.toLowerCase().includes('<iframe')) {
|
||||
if (p1.includes('/api/proxy')) return match;
|
||||
return `${attr}="/api/proxy?url=${encodeURIComponent(absoluteUrl)}"`;
|
||||
}
|
||||
return match;
|
||||
} catch(e) { return match; }
|
||||
// Initial static rewrite
|
||||
html = html.replace(/(href|src|action)=["']((?!data:|javascript:|#|blob:).*?)["']/gi, (m, a, v) => {
|
||||
try {
|
||||
return `${a}="${PROXY_BASE}${encodeURIComponent(new URL(v, finalUrl).href)}"`;
|
||||
} catch(e) { return m; }
|
||||
});
|
||||
|
||||
res.status(response.status).send(html);
|
||||
res.send(html);
|
||||
} else {
|
||||
res.status(response.status).send(data);
|
||||
res.send(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).send(`
|
||||
<div style="background:#1a1a1a; color:#ff4d4d; padding:20px; font-family:sans-serif; border-radius:8px; margin:20px; border:1px solid #333;">
|
||||
<h2>Proxy Error</h2>
|
||||
<code>${error.message}</code>
|
||||
<button onclick="window.location.reload()" style="margin-top:15px; background:#3ea6ff; color:#fff; border:none; padding:8px 16px; border-radius:4px; cursor:pointer;">Retry</button>
|
||||
</div>
|
||||
`);
|
||||
} catch (e) {
|
||||
res.status(500).send("Proxy Bridge Failure: " + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
router.all('/', proxyHandler);
|
||||
router.all('/:any*', proxyHandler);
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
604
frontend/src/components/Balatro/BalatroGame.tsx
Normal file
604
frontend/src/components/Balatro/BalatroGame.tsx
Normal file
@ -0,0 +1,604 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
|
||||
// --- Constants & Types ---
|
||||
type Suit = 'Spades' | 'Hearts' | 'Diamonds' | 'Clubs';
|
||||
type Rank = '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | 'J' | 'Q' | 'K' | 'A';
|
||||
|
||||
interface Card {
|
||||
id: string;
|
||||
suit: Suit;
|
||||
rank: Rank;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cost: number;
|
||||
type: 'Joker' | 'Celestial' | 'Voucher';
|
||||
}
|
||||
|
||||
interface Joker extends Item {
|
||||
type: 'Joker';
|
||||
rarity: 'Common' | 'Uncommon' | 'Rare' | 'Legendary';
|
||||
effect: (hand: Card[], evaluation: HandEvaluation, deckLength: number, otherJokers: Joker[]) => { chips: number; mult: number };
|
||||
}
|
||||
|
||||
interface Celestial extends Item {
|
||||
type: 'Celestial';
|
||||
handName: string;
|
||||
}
|
||||
|
||||
interface HandEvaluation {
|
||||
name: string;
|
||||
chips: number;
|
||||
mult: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
totalScore: number;
|
||||
handsLeft: number;
|
||||
discardsLeft: number;
|
||||
money: number;
|
||||
goal: number;
|
||||
round: number;
|
||||
ante: number;
|
||||
blindType: 'Small Blind' | 'Big Blind' | 'Boss Blind';
|
||||
}
|
||||
|
||||
const RANKS: Rank[] = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
|
||||
const SUITS: Suit[] = ['Spades', 'Hearts', 'Diamonds', 'Clubs'];
|
||||
|
||||
const RANK_VALUES: Record<Rank, number> = {
|
||||
'2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10,
|
||||
'J': 10, 'Q': 10, 'K': 10, 'A': 11
|
||||
};
|
||||
|
||||
const HAND_LEVELS_INITIAL: Record<string, { chips: number, mult: number, level: number }> = {
|
||||
'High Card': { chips: 5, mult: 1, level: 1 },
|
||||
'Pair': { chips: 10, mult: 2, level: 1 },
|
||||
'Two Pair': { chips: 20, mult: 2, level: 1 },
|
||||
'Three of a Kind': { chips: 30, mult: 3, level: 1 },
|
||||
'Straight': { chips: 30, mult: 4, level: 1 },
|
||||
'Flush': { chips: 35, mult: 4, level: 1 },
|
||||
'Full House': { chips: 40, mult: 4, level: 1 },
|
||||
'Four of a Kind': { chips: 60, mult: 7, level: 1 },
|
||||
'Straight Flush': { chips: 100, mult: 8, level: 1 },
|
||||
};
|
||||
|
||||
const AVAILABLE_JOKERS: Joker[] = [
|
||||
{
|
||||
id: 'joker-1', name: 'Joker', cost: 2, rarity: 'Common', description: '+4 Mult',
|
||||
type: 'Joker', effect: () => ({ chips: 0, mult: 4 })
|
||||
},
|
||||
{
|
||||
id: 'joker-2', name: 'Sly Joker', cost: 4, rarity: 'Common', description: '+50 Chips',
|
||||
type: 'Joker', effect: () => ({ chips: 50, mult: 0 })
|
||||
},
|
||||
{
|
||||
id: 'joker-3', name: 'Zany Joker', cost: 4, rarity: 'Common', description: '+12 Mult if hand is Three of a Kind',
|
||||
type: 'Joker', effect: (hand, evalResult) => evalResult.name === 'Three of a Kind' ? { chips: 0, mult: 12 } : { chips: 0, mult: 0 }
|
||||
},
|
||||
{
|
||||
id: 'joker-4', name: 'Blue Joker', cost: 5, rarity: 'Uncommon', description: '+2 Chips for each card in deck',
|
||||
type: 'Joker', effect: (hand, evalResult, deckLength) => ({ chips: deckLength * 2, mult: 0 })
|
||||
},
|
||||
{
|
||||
id: 'joker-5', name: 'Gros Michel', cost: 3, rarity: 'Common', description: '+15 Mult. 1 in 4 chance to be destroyed',
|
||||
type: 'Joker', effect: () => ({ chips: 0, mult: 15 })
|
||||
},
|
||||
{
|
||||
id: 'joker-6', name: 'Even Steven', cost: 4, rarity: 'Common', description: '+4 Mult for each played even card (10, 8, 6, 4, 2)',
|
||||
type: 'Joker', effect: (hand) => {
|
||||
const evenCount = hand.filter(c => ['10', '8', '6', '4', '2'].includes(c.rank)).length;
|
||||
return { chips: 0, mult: evenCount * 4 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'joker-7', name: 'Odd Todd', cost: 4, rarity: 'Common', description: '+30 Chips for each played odd card (A, 9, 7, 5, 3)',
|
||||
type: 'Joker', effect: (hand) => {
|
||||
const oddCount = hand.filter(c => ['A', '9', '7', '5', '3'].includes(c.rank)).length;
|
||||
return { chips: oddCount * 30, mult: 0 };
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'joker-8', name: 'Half Joker', cost: 4, rarity: 'Common', description: '+20 Mult if played hand contains 3 or fewer cards',
|
||||
type: 'Joker', effect: (hand) => hand.length <= 3 ? { chips: 0, mult: 20 } : { chips: 0, mult: 0 }
|
||||
}
|
||||
];
|
||||
|
||||
const CELESTIAL_CARDS: Celestial[] = Object.keys(HAND_LEVELS_INITIAL).map(name => ({
|
||||
id: `celestial-${name}`,
|
||||
name: `${name} Upgrade`,
|
||||
description: `Level up ${name}. Increases Chips and Mult.`,
|
||||
cost: 3,
|
||||
type: 'Celestial',
|
||||
handName: name
|
||||
}));
|
||||
|
||||
// --- Helper Functions ---
|
||||
const createDeck = (): Card[] => {
|
||||
const deck: Card[] = [];
|
||||
SUITS.forEach(suit => {
|
||||
RANKS.forEach(rank => {
|
||||
deck.push({
|
||||
id: `${rank}-${suit}-${Math.random()}`,
|
||||
suit,
|
||||
rank,
|
||||
value: RANK_VALUES[rank]
|
||||
});
|
||||
});
|
||||
});
|
||||
return deck.sort(() => Math.random() - 0.5);
|
||||
};
|
||||
|
||||
const evaluateHand = (cards: Card[], handLevels: any): HandEvaluation => {
|
||||
if (cards.length === 0) return { name: 'None', chips: 0, mult: 0, level: 0 };
|
||||
|
||||
const sorted = [...cards].sort((a, b) => RANKS.indexOf(a.rank) - RANKS.indexOf(b.rank));
|
||||
const counts: Record<string, number> = {};
|
||||
const suitCounts: Record<string, number> = {};
|
||||
cards.forEach(c => {
|
||||
counts[c.rank] = (counts[c.rank] || 0) + 1;
|
||||
suitCounts[c.suit] = (suitCounts[c.suit] || 0) + 1;
|
||||
});
|
||||
|
||||
const values = Object.values(counts).sort((a, b) => b - a);
|
||||
const isFlush = Object.values(suitCounts).some(v => v >= 5);
|
||||
|
||||
let isStraight = false;
|
||||
const uniqueRanks = Array.from(new Set(sorted.map(c => RANKS.indexOf(c.rank)))).sort((a, b) => a - b);
|
||||
if (uniqueRanks.length >= 5) {
|
||||
for (let i = 0; i <= uniqueRanks.length - 5; i++) {
|
||||
if (uniqueRanks[i+4] - uniqueRanks[i] === 4) isStraight = true;
|
||||
}
|
||||
if (uniqueRanks.includes(12) && uniqueRanks.includes(0) && uniqueRanks.includes(1) && uniqueRanks.includes(2) && uniqueRanks.includes(3)) {
|
||||
isStraight = true;
|
||||
}
|
||||
}
|
||||
|
||||
let handName = 'High Card';
|
||||
if (isStraight && isFlush) handName = 'Straight Flush';
|
||||
else if (values[0] === 4) handName = 'Four of a Kind';
|
||||
else if (values[0] === 3 && values[1] === 2) handName = 'Full House';
|
||||
else if (isFlush) handName = 'Flush';
|
||||
else if (isStraight) handName = 'Straight';
|
||||
else if (values[0] === 3) handName = 'Three of a Kind';
|
||||
else if (values[0] === 2 && values[1] === 2) handName = 'Two Pair';
|
||||
else if (values[0] === 2) handName = 'Pair';
|
||||
|
||||
return {
|
||||
name: handName,
|
||||
chips: handLevels[handName].chips,
|
||||
mult: handLevels[handName].mult,
|
||||
level: handLevels[handName].level
|
||||
};
|
||||
};
|
||||
|
||||
const BalatroGame: React.FC = () => {
|
||||
const [deck, setDeck] = useState<Card[]>(createDeck());
|
||||
const [hand, setHand] = useState<Card[]>([]);
|
||||
const [selectedCards, setSelectedCards] = useState<string[]>([]);
|
||||
const [gameState, setGameState] = useState<GameState>({
|
||||
totalScore: 0,
|
||||
handsLeft: 4,
|
||||
discardsLeft: 3,
|
||||
money: 4,
|
||||
goal: 300,
|
||||
round: 1,
|
||||
ante: 1,
|
||||
blindType: 'Small Blind'
|
||||
});
|
||||
const [handLevels, setHandLevels] = useState(HAND_LEVELS_INITIAL);
|
||||
const [jokers, setJokers] = useState<Joker[]>([]);
|
||||
const [isGameOver, setIsGameOver] = useState(false);
|
||||
const [isShopOpen, setIsShopOpen] = useState(false);
|
||||
const [shopItems, setShopItems] = useState<Item[]>([]);
|
||||
const [lastScore, setLastScore] = useState<{chips: number, mult: number, total: number, handName: string} | null>(null);
|
||||
|
||||
const drawCards = useCallback((count: number) => {
|
||||
setDeck(prevDeck => {
|
||||
const newToDraw = prevDeck.slice(0, count);
|
||||
setHand(prevHand => [...prevHand, ...newToDraw]);
|
||||
return prevDeck.slice(count);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
drawCards(8);
|
||||
}, [drawCards]);
|
||||
|
||||
// Clear last score after 2 seconds
|
||||
useEffect(() => {
|
||||
if (lastScore) {
|
||||
const timer = setTimeout(() => setLastScore(null), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [lastScore]);
|
||||
|
||||
const selectCard = (id: string) => {
|
||||
setSelectedCards(prev => {
|
||||
if (prev.includes(id)) return prev.filter(i => i !== id);
|
||||
if (prev.length >= 5) return prev;
|
||||
return [...prev, id];
|
||||
});
|
||||
};
|
||||
|
||||
const playHand = () => {
|
||||
if (selectedCards.length === 0) return;
|
||||
|
||||
const playedCards = hand.filter(c => selectedCards.includes(c.id));
|
||||
const evalResult = evaluateHand(playedCards, handLevels);
|
||||
|
||||
let baseChips = evalResult.chips;
|
||||
let baseMult = evalResult.mult;
|
||||
|
||||
playedCards.forEach(c => baseChips += c.value);
|
||||
|
||||
jokers.forEach(j => {
|
||||
const effect = j.effect(playedCards, evalResult, deck.length, jokers);
|
||||
baseChips += effect.chips;
|
||||
baseMult += effect.mult;
|
||||
});
|
||||
|
||||
const handScore = Math.floor(baseChips * baseMult);
|
||||
const newTotalScore = gameState.totalScore + handScore;
|
||||
|
||||
setLastScore({ chips: baseChips, mult: baseMult, total: handScore, handName: evalResult.name });
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
totalScore: newTotalScore,
|
||||
handsLeft: prev.handsLeft - 1
|
||||
}));
|
||||
|
||||
setHand(prev => prev.filter(c => !selectedCards.includes(c.id)));
|
||||
drawCards(selectedCards.length);
|
||||
setSelectedCards([]);
|
||||
|
||||
if (newTotalScore >= gameState.goal) {
|
||||
setTimeout(() => setIsShopOpen(true), 2500);
|
||||
} else if (gameState.handsLeft <= 1) {
|
||||
setIsGameOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const discardHand = () => {
|
||||
if (selectedCards.length === 0 || gameState.discardsLeft <= 0) return;
|
||||
setGameState(prev => ({ ...prev, discardsLeft: prev.discardsLeft - 1 }));
|
||||
setHand(prev => prev.filter(c => !selectedCards.includes(c.id)));
|
||||
drawCards(selectedCards.length);
|
||||
setSelectedCards([]);
|
||||
};
|
||||
|
||||
const openShop = () => {
|
||||
const pooledItems: Item[] = [...AVAILABLE_JOKERS, ...CELESTIAL_CARDS];
|
||||
const items = pooledItems.sort(() => 0.5 - Math.random()).slice(0, 3);
|
||||
setShopItems(items);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isShopOpen) openShop();
|
||||
}, [isShopOpen]);
|
||||
|
||||
const buyItem = (item: Item) => {
|
||||
if (gameState.money >= item.cost) {
|
||||
if (item.type === 'Joker' && jokers.length < 5) {
|
||||
setGameState(prev => ({ ...prev, money: prev.money - item.cost }));
|
||||
setJokers(prev => [...prev, item as Joker]);
|
||||
setShopItems(prev => prev.filter(i => i.id !== item.id));
|
||||
} else if (item.type === 'Celestial') {
|
||||
setGameState(prev => ({ ...prev, money: prev.money - item.cost }));
|
||||
const celestial = item as Celestial;
|
||||
setHandLevels(prev => ({
|
||||
...prev,
|
||||
[celestial.handName]: {
|
||||
...prev[celestial.handName],
|
||||
level: prev[celestial.handName].level + 1,
|
||||
chips: prev[celestial.handName].chips + 15,
|
||||
mult: prev[celestial.handName].mult + 1
|
||||
}
|
||||
}));
|
||||
setShopItems(prev => prev.filter(i => i.id !== item.id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const nextRound = () => {
|
||||
setIsShopOpen(false);
|
||||
|
||||
let nextBlind: 'Small Blind' | 'Big Blind' | 'Boss Blind' = 'Small Blind';
|
||||
let nextGoal = gameState.goal;
|
||||
let nextAnte = gameState.ante;
|
||||
let nextMoneyAdd = 5;
|
||||
|
||||
if (gameState.blindType === 'Small Blind') {
|
||||
nextBlind = 'Big Blind';
|
||||
nextGoal = Math.floor(gameState.goal * 1.5);
|
||||
} else if (gameState.blindType === 'Big Blind') {
|
||||
nextBlind = 'Boss Blind';
|
||||
nextGoal = Math.floor(gameState.goal * 2.0);
|
||||
} else {
|
||||
nextBlind = 'Small Blind';
|
||||
nextAnte = gameState.ante + 1;
|
||||
nextGoal = Math.floor(gameState.goal * 1.8);
|
||||
nextMoneyAdd = 8;
|
||||
}
|
||||
|
||||
setGameState(prev => ({
|
||||
...prev,
|
||||
totalScore: 0,
|
||||
handsLeft: 4,
|
||||
discardsLeft: 3,
|
||||
goal: nextGoal,
|
||||
round: prev.round + 1,
|
||||
ante: nextAnte,
|
||||
money: prev.money + nextMoneyAdd,
|
||||
blindType: nextBlind
|
||||
}));
|
||||
setDeck(createDeck());
|
||||
setHand([]);
|
||||
drawCards(8);
|
||||
setLastScore(null);
|
||||
};
|
||||
|
||||
const resetGame = () => {
|
||||
setGameState({ totalScore: 0, handsLeft: 4, discardsLeft: 3, money: 4, goal: 300, round: 1, ante: 1, blindType: 'Small Blind' });
|
||||
setDeck(createDeck());
|
||||
setHand([]);
|
||||
drawCards(8);
|
||||
setHandLevels(HAND_LEVELS_INITIAL);
|
||||
setJokers([]);
|
||||
setIsGameOver(false);
|
||||
setIsShopOpen(false);
|
||||
setLastScore(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d0e1b] text-white p-6 font-mono overflow-hidden select-none">
|
||||
{/* HUD */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="flex-grow flex gap-6 bg-[#16172d] p-4 rounded-2xl border border-[#2b2c4e] shadow-2xl relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Ante</span>
|
||||
<span className="text-2xl text-purple-400 font-bold">{gameState.ante}</span>
|
||||
</div>
|
||||
<div className="flex flex-col border-l border-slate-700 pl-6">
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Round</span>
|
||||
<span className="text-2xl text-blue-400 font-bold">{gameState.round}</span>
|
||||
</div>
|
||||
<div className="flex flex-col border-l border-slate-700 pl-6">
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Blind</span>
|
||||
<span className={`text-xl font-bold uppercase ${gameState.blindType === 'Boss Blind' ? 'text-red-500 animate-pulse' : 'text-orange-400'}`}>
|
||||
{gameState.blindType}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col border-l border-slate-700 pl-6">
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Goal</span>
|
||||
<span className="text-2xl text-orange-500 font-bold">{gameState.goal.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col border-l border-slate-700 pl-6 flex-grow">
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Score</span>
|
||||
<span className="text-3xl text-white font-black">{gameState.totalScore.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="bg-red-600/10 px-6 py-2 rounded-2xl border-2 border-red-600 flex flex-col items-center justify-center min-w-[100px] shadow-[0_0_15px_rgba(220,38,38,0.2)]">
|
||||
<span className="text-[10px] font-black text-red-500 uppercase">Hands</span>
|
||||
<span className="text-3xl font-black">{gameState.handsLeft}</span>
|
||||
</div>
|
||||
<div className="bg-blue-600/10 px-6 py-2 rounded-2xl border-2 border-blue-600 flex flex-col items-center justify-center min-w-[100px] shadow-[0_0_15px_rgba(37,99,235,0.2)]">
|
||||
<span className="text-[10px] font-black text-blue-500 uppercase">Discards</span>
|
||||
<span className="text-3xl font-black">{gameState.discardsLeft}</span>
|
||||
</div>
|
||||
<div className="bg-yellow-600/10 px-6 py-2 rounded-2xl border-2 border-yellow-600 flex flex-col items-center justify-center min-w-[100px] shadow-[0_0_15px_rgba(202,138,4,0.2)]">
|
||||
<span className="text-[10px] font-black text-yellow-500 uppercase">Cash</span>
|
||||
<span className="text-3xl font-black font-mono">${gameState.money}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jokers */}
|
||||
<div className="flex gap-4 mb-12 h-32 justify-center">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="w-24 h-32 rounded-xl border-2 border-dashed border-slate-800 flex items-center justify-center relative bg-slate-900/30">
|
||||
{jokers[i] ? (
|
||||
<div
|
||||
className="w-full h-full bg-gradient-to-br from-indigo-600 to-purple-700 rounded-xl p-3 text-[11px] flex flex-col justify-between border border-indigo-400 shadow-[0_0_20px_rgba(99,102,241,0.5)] transition-all duration-300 transform hover:scale-105 hover:rotate-1 group cursor-help"
|
||||
>
|
||||
<div className="font-black italic text-white uppercase leading-none truncate">{jokers[i].name}</div>
|
||||
<div className="text-[9px] opacity-90 leading-tight font-medium line-clamp-3">{jokers[i].description}</div>
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-indigo-500 rounded-full flex items-center justify-center text-[10px] border-2 border-[#16172d] shadow-lg">?</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-800 text-[10px] font-black uppercase tracking-tighter">Slot {i+1}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="relative flex flex-col items-center justify-center min-h-[480px]">
|
||||
{lastScore && (
|
||||
<div
|
||||
onClick={() => setLastScore(null)}
|
||||
className="absolute top-0 z-[150] bg-[#16172d]/95 border-4 border-blue-500 p-8 rounded-[2rem] shadow-[0_0_80px_rgba(59,130,246,0.5)] text-center min-w-[340px] backdrop-blur-md animate-in fade-in zoom-in duration-300 cursor-pointer"
|
||||
>
|
||||
<div className="text-blue-400 text-xl font-black uppercase mb-3 tracking-widest">{lastScore.handName}</div>
|
||||
<div className="flex items-center justify-center gap-6 text-5xl mb-3">
|
||||
<span className="text-blue-400 font-black drop-shadow-[0_0_10px_rgba(59,130,246,0.5)]">{lastScore.chips}</span>
|
||||
<span className="text-slate-600 font-light">×</span>
|
||||
<span className="text-red-500 font-black drop-shadow-[0_0_10px_rgba(239,68,68,0.5)]">{lastScore.mult}</span>
|
||||
</div>
|
||||
<div className="text-6xl font-black text-white tracking-tighter">{lastScore.total.toLocaleString()}</div>
|
||||
<div className="mt-4 text-[10px] text-slate-500 uppercase font-black">Click to Dismiss</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hand Area */}
|
||||
<div className="flex gap-2 justify-center mt-28">
|
||||
{hand.map((card, idx) => {
|
||||
const isSelected = selectedCards.includes(card.id);
|
||||
return (
|
||||
<div
|
||||
key={card.id}
|
||||
onClick={() => selectCard(card.id)}
|
||||
style={{
|
||||
transform: isSelected
|
||||
? 'translateY(-60px) scale(1.15) rotate(0deg)'
|
||||
: `rotate(${(idx - (hand.length - 1) / 2) * 5}deg)`,
|
||||
zIndex: isSelected ? 100 : idx
|
||||
}}
|
||||
className={`
|
||||
w-28 h-40 bg-white rounded-2xl shadow-2xl flex flex-col justify-between p-3 cursor-pointer
|
||||
border-4 ${isSelected ? 'border-blue-500 ring-8 ring-blue-500/20 shadow-blue-500/30' : 'border-slate-200'}
|
||||
transition-all duration-200 hover:-translate-y-12 active:scale-95
|
||||
`}
|
||||
>
|
||||
<div className={`text-2xl font-black leading-none ${['Hearts', 'Diamonds'].includes(card.suit) ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{card.rank}
|
||||
</div>
|
||||
<div className={`text-6xl self-center ${['Hearts', 'Diamonds'].includes(card.suit) ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{card.suit === 'Spades' ? '♠' : card.suit === 'Hearts' ? '♥' : card.suit === 'Diamonds' ? '♦' : '♣'}
|
||||
</div>
|
||||
<div className={`text-2xl font-black leading-none rotate-180 self-end ${['Hearts', 'Diamonds'].includes(card.suit) ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{card.rank}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mt-24 flex gap-8">
|
||||
<button
|
||||
onClick={playHand}
|
||||
disabled={selectedCards.length === 0 || !!lastScore}
|
||||
className="px-20 py-7 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-900 disabled:text-slate-700 rounded-[2rem] font-black text-3xl shadow-[0_12px_0_rgb(29,78,216)] active:translate-y-2 active:shadow-none transition-all flex flex-col items-center"
|
||||
>
|
||||
PLAY HAND
|
||||
<span className="text-xs opacity-50 font-medium tracking-widest mt-1">CHIPS × MULT</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={discardHand}
|
||||
disabled={selectedCards.length === 0 || gameState.discardsLeft <= 0 || !!lastScore}
|
||||
className="px-20 py-7 bg-red-600 hover:bg-red-500 disabled:bg-slate-900 disabled:text-slate-700 rounded-[2rem] font-black text-3xl shadow-[0_12px_0_rgb(185,28,28)] active:translate-y-2 active:shadow-none transition-all flex flex-col items-center"
|
||||
>
|
||||
DISCARD
|
||||
<span className="text-xs opacity-50 font-medium tracking-widest mt-1">{gameState.discardsLeft} LEFT</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shop Overlay */}
|
||||
{isShopOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-[#0d0e1b]/98 backdrop-blur-2xl z-[200] flex flex-col items-center justify-center p-12 animate-in fade-in duration-700"
|
||||
>
|
||||
<div className="absolute top-10 left-10 flex flex-col">
|
||||
<span className="text-slate-500 font-black uppercase tracking-widest text-sm">Shop Balance</span>
|
||||
<span className="text-6xl font-black text-yellow-500 font-mono">${gameState.money}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-9xl font-black text-blue-500 mb-16 italic tracking-tighter drop-shadow-[0_0_40px_rgba(59,130,246,0.6)]">SHOP</h2>
|
||||
|
||||
<div className="flex gap-12 mb-24">
|
||||
{shopItems.map(item => (
|
||||
<div key={item.id} className="w-72 bg-[#16172d] border-4 border-[#2b2c4e] rounded-[3rem] p-10 flex flex-col items-center text-center group hover:border-blue-500 transition-all hover:-translate-y-4 shadow-[0_30px_60px_rgba(0,0,0,0.5)]">
|
||||
<div className={`w-48 h-64 rounded-3xl mb-10 shadow-2xl flex flex-col p-6 justify-between border-2 transform group-hover:rotate-2 transition-transform ${
|
||||
item.type === 'Joker' ? 'bg-gradient-to-br from-indigo-600 to-purple-700 border-indigo-400' : 'bg-gradient-to-br from-pink-600 to-orange-600 border-pink-400'
|
||||
}`}>
|
||||
<div className="font-black text-xl italic leading-none uppercase tracking-tighter">{item.name}</div>
|
||||
<div className="text-xs opacity-90 font-bold leading-tight uppercase tracking-tight">{item.description}</div>
|
||||
<div className="text-[10px] self-end font-black opacity-50">{item.type}</div>
|
||||
</div>
|
||||
<div className="text-yellow-500 font-black text-4xl mb-8 font-mono">${item.cost}</div>
|
||||
<button
|
||||
onClick={() => buyItem(item)}
|
||||
disabled={gameState.money < item.cost || (item.type === 'Joker' && jokers.length >= 5)}
|
||||
className={`w-full py-5 rounded-2xl font-black uppercase tracking-widest text-sm transition-all shadow-xl ${
|
||||
gameState.money < item.cost || (item.type === 'Joker' && jokers.length >= 5)
|
||||
? 'bg-slate-800 text-slate-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-blue-900/40 active:scale-95'
|
||||
}`}
|
||||
>
|
||||
{gameState.money < item.cost ? 'NOT ENOUGH' : (item.type === 'Joker' && jokers.length >= 5) ? 'SLOTS FULL' : 'BUY ITEM'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={nextRound}
|
||||
className="px-32 py-10 bg-orange-600 hover:bg-orange-500 rounded-[2.5rem] font-black text-5xl shadow-[0_15px_0_rgb(194,65,12)] active:translate-y-2 active:shadow-none transition-all shadow-orange-900/40 group relative"
|
||||
>
|
||||
NEXT BLIND
|
||||
<span className="absolute -top-4 -right-4 bg-white text-orange-600 rounded-full w-12 h-12 flex items-center justify-center text-2xl animate-bounce">➔</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Game Over Screen */}
|
||||
{isGameOver && (
|
||||
<div className="fixed inset-0 bg-black/95 z-[300] flex flex-col items-center justify-center p-12 animate-in fade-in zoom-in duration-500">
|
||||
<h2 className="text-[12rem] font-black text-red-600 mb-8 italic tracking-tighter drop-shadow-[0_0_60px_rgba(220,38,38,0.5)]">GAME OVER</h2>
|
||||
<div className="flex gap-20 text-4xl mb-16 text-slate-400 font-black">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-sm opacity-50 uppercase mb-2">Ante</span>
|
||||
<span className="text-white text-6xl">{gameState.ante}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-sm opacity-50 uppercase mb-2">Round</span>
|
||||
<span className="text-white text-6xl">{gameState.round}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetGame}
|
||||
className="px-28 py-9 bg-white text-black rounded-[2.5rem] font-black text-5xl hover:bg-blue-500 hover:text-white transition-all shadow-[0_15px_0_rgb(203,213,225)] active:translate-y-2 active:shadow-none"
|
||||
>
|
||||
NEW RUN
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx global>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&display=swap');
|
||||
|
||||
body {
|
||||
background-color: #0d0e1b;
|
||||
color: white;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes zoom-in {
|
||||
from { transform: scale(0.9); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fade-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.zoom-in {
|
||||
animation: zoom-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BalatroGame;
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) {
|
||||
}
|
||||
|
||||
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
@ -126,4 +125,4 @@ export default function LayoutAuthenticated({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,16 @@ import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
icon: icon.mdiCardsPlayingOutline,
|
||||
label: 'Balatro',
|
||||
},
|
||||
{
|
||||
href: '/browser',
|
||||
icon: icon.mdiEarth,
|
||||
label: 'Stealth Browser',
|
||||
},
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
@ -88,4 +98,4 @@ const menuAside: MenuAsideItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export default menuAside
|
||||
export default menuAside
|
||||
@ -39,6 +39,36 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
const [stepName, setStepName] = React.useState('');
|
||||
const [steps, setSteps] = React.useState([]);
|
||||
|
||||
// Stealth Browser Trigger
|
||||
const [gCount, setGCount] = React.useState(0);
|
||||
const gTimerRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === 'g') {
|
||||
setGCount((prev) => {
|
||||
const newCount = prev + 1;
|
||||
if (newCount === 5) {
|
||||
router.push('/browser');
|
||||
return 0;
|
||||
}
|
||||
return newCount;
|
||||
});
|
||||
|
||||
if (gTimerRef.current) clearTimeout(gTimerRef.current);
|
||||
gTimerRef.current = setTimeout(() => {
|
||||
setGCount(0);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
if (gTimerRef.current) clearTimeout(gTimerRef.current);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token');
|
||||
@ -113,374 +143,12 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
return;
|
||||
}, [router.pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let keyPresses: number[] = [];
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === 'g') {
|
||||
const now = Date.now();
|
||||
keyPresses.push(now);
|
||||
keyPresses = keyPresses.filter((t) => now - t <= 5000);
|
||||
|
||||
if (keyPresses.length >= 5) {
|
||||
const win = window.open('about:blank', '_blank');
|
||||
if (win) {
|
||||
const apiBase = process.env.NEXT_PUBLIC_BACK_API || '/api';
|
||||
const proxyUrl = window.location.origin + apiBase + '/proxy?url=';
|
||||
const content = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SCHOOLWORK: ELA, SCIENCE, HISTORY, MATH, LITERATURE, SOCIAL STUDIES, AND WRITING</title>
|
||||
<link rel="icon" href="https://www.google.com/favicon.ico">
|
||||
<style>
|
||||
body { background: #fff; margin: 0; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; display: flex; flex-direction: column; height: 100vh; overflow: hidden; color: #333; }
|
||||
|
||||
/* Splash Screen */
|
||||
#splash { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #fff; z-index: 9999; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; cursor: pointer; transition: opacity 0.5s; }
|
||||
#splash h1 { font-size: 24px; color: #1a73e8; margin-bottom: 20px; font-weight: 500; }
|
||||
#splash p { font-size: 16px; color: #5f6368; }
|
||||
#splash .hint { margin-top: 50px; font-size: 14px; color: #999; border: 1px solid #eee; padding: 10px 20px; border-radius: 4px; }
|
||||
|
||||
/* Main UI */
|
||||
.header { background: #f8f9fa; border-bottom: 1px solid #dadce0; display: flex; flex-direction: column; z-index: 10; display: none; }
|
||||
.tab-bar { display: flex; padding: 10px 10px 0; gap: 4px; overflow-x: auto; background: #dee2e6; align-items: flex-end; }
|
||||
.tab { padding: 8px 16px; background: #e9ecef; border-radius: 8px 8px 0 0; cursor: pointer; display: flex; align-items: center; gap: 10px; font-size: 12px; min-width: 100px; max-width: 180px; white-space: nowrap; overflow: hidden; border: 1px solid #dee2e6; border-bottom: none; transition: all 0.2s; color: #495057; position: relative; }
|
||||
.tab:hover { background: #f1f3f4; }
|
||||
.tab.active { background: #fff; color: #202124; font-weight: 500; border-color: #dadce0; border-bottom-color: #fff; margin-bottom: -1px; z-index: 1; border-top: 3px solid #1a73e8; }
|
||||
.tab .close { font-size: 16px; color: #70757a; width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; border-radius: 50%; }
|
||||
.tab .close:hover { background: #dadce0; color: #202124; }
|
||||
|
||||
.search-bar { padding: 8px 12px; display: flex; gap: 8px; align-items: center; background: #fff; border-bottom: 1px solid #eee; }
|
||||
.nav-btns { display: flex; gap: 4px; }
|
||||
.nav-btn { cursor: pointer; color: #5f6368; padding: 6px; border-radius: 50%; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; transition: 0.2s; }
|
||||
.nav-btn:hover { background: #f1f3f4; color: #202124; }
|
||||
.nav-btn.disabled { color: #bdc1c6; cursor: default; pointer-events: none; }
|
||||
|
||||
form { flex-grow: 1; position: relative; max-width: 800px; margin: 0 auto; width: 100%; }
|
||||
input { padding: 10px 20px; font-size: 14px; border: 1px solid #dfe1e5; border-radius: 24px; width: 100%; outline: none; background: #f1f3f4; color: #202124; transition: all 0.2s; box-sizing: border-box; }
|
||||
input:focus { background: #fff; box-shadow: 0 1px 6px rgba(32,33,36,.28); border-color: rgba(223,225,229,0); }
|
||||
|
||||
.new-tab-btn { cursor: pointer; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 20px; color: #5f6368; margin-left: 8px; margin-bottom: 8px; transition: 0.2s; }
|
||||
.new-tab-btn:hover { background: #dadce0; color: #202124; }
|
||||
|
||||
.iframe-container { flex-grow: 1; position: relative; background: #fff; display: none; }
|
||||
iframe { border: none; width: 100%; height: 100%; position: absolute; top: 0; left: 0; display: none; background: #fff; }
|
||||
iframe.active { display: block; }
|
||||
|
||||
.loader { position: absolute; bottom: 0; left: 0; height: 2px; background: #1a73e8; transition: width 0.3s; width: 0; z-index: 100; }
|
||||
|
||||
.panic-hint { position: fixed; bottom: 10px; right: 10px; font-size: 10px; color: #ccc; z-index: 1000; pointer-events: none; opacity: 0.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="splash" onclick="unlock()">
|
||||
<h1>SCHOOLWORK</h1>
|
||||
<p>ELA, SCIENCE, HISTORY, MATH, LITERATURE, SOCIAL STUDIES, AND WRITING</p>
|
||||
<div class="hint">Click or press any key to hide this from your history.</div>
|
||||
</div>
|
||||
|
||||
<div class='header' id="header">
|
||||
<div class='tab-bar' id='tabBar'>
|
||||
<div class='new-tab-btn' onclick='addTab()' title='New Tab'>+</div>
|
||||
</div>
|
||||
<div class='search-bar'>
|
||||
<div class='nav-btns'>
|
||||
<div class='nav-btn' id='backBtn' onclick='goBack()' title='Back'>←</div>
|
||||
<div class='nav-btn' id='forwardBtn' onclick='goForward()' title='Forward'>→</div>
|
||||
<div class='nav-btn' onclick='reloadTab()' title='Reload'>↻</div>
|
||||
<div class='nav-btn' onclick='goHome()' title='Home'>🏠</div>
|
||||
</div>
|
||||
<form onsubmit='handleSearch(event); return false;'>
|
||||
<input id='searchInput' placeholder='Search Google or type a URL' autofocus autocomplete='off' />
|
||||
<div id='loader' class='loader'></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='iframe-container' id='iframeContainer'></div>
|
||||
<div class="panic-hint">Press [ESC] to Panic</div>
|
||||
|
||||
<script>
|
||||
var tabs = [];
|
||||
var activeTabId = null;
|
||||
const PROXY_BASE = "${proxyUrl}";
|
||||
const DEFAULT_TITLE = "SCHOOLWORK: ELA, SCIENCE, HISTORY, MATH, LITERATURE, SOCIAL STUDIES, AND WRITING";
|
||||
|
||||
function unlock() {
|
||||
document.getElementById('splash').style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
document.getElementById('splash').style.display = 'none';
|
||||
document.getElementById('header').style.display = 'flex';
|
||||
document.getElementById('iframeContainer').style.display = 'block';
|
||||
// History Cloaking
|
||||
history.replaceState(null, '', 'https://www.google.com/search?q=educational+resources+for+students');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', function(e) {
|
||||
if (document.getElementById('splash').style.display !== 'none') {
|
||||
unlock();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
window.location.href = 'https://classroom.google.com';
|
||||
}
|
||||
});
|
||||
|
||||
function getRawUrl(proxiedUrl) {
|
||||
try {
|
||||
var url = new URL(proxiedUrl);
|
||||
var raw = url.searchParams.get('url');
|
||||
if (raw) return raw;
|
||||
if (proxiedUrl.includes('/api/proxy?url=')) {
|
||||
return decodeURIComponent(proxiedUrl.split('/api/proxy?url=')[1]);
|
||||
}
|
||||
if (proxiedUrl.includes('/api/proxy/')) {
|
||||
return proxiedUrl.split('/api/proxy/')[1];
|
||||
}
|
||||
} catch(e) {}
|
||||
return proxiedUrl;
|
||||
}
|
||||
|
||||
function addTab(url, title) {
|
||||
url = url || 'https://www.google.com/search?igu=1';
|
||||
title = title || 'New Tab';
|
||||
var id = 'tab-' + Math.random().toString(36).substr(2, 9);
|
||||
|
||||
var tabObj = {
|
||||
id: id,
|
||||
url: url,
|
||||
title: title,
|
||||
history: [url],
|
||||
historyIndex: 0
|
||||
};
|
||||
tabs.push(tabObj);
|
||||
|
||||
var tabEl = document.createElement('div');
|
||||
tabEl.className = 'tab';
|
||||
tabEl.id = 'el-' + id;
|
||||
tabEl.onclick = function() { switchTab(id); };
|
||||
|
||||
var titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'title';
|
||||
titleSpan.style.flexGrow = '1';
|
||||
titleSpan.style.overflow = 'hidden';
|
||||
titleSpan.style.textOverflow = 'ellipsis';
|
||||
titleSpan.innerText = title;
|
||||
tabEl.appendChild(titleSpan);
|
||||
|
||||
var closeBtn = document.createElement('span');
|
||||
closeBtn.className = 'close';
|
||||
closeBtn.innerText = '×';
|
||||
closeBtn.onclick = function(e) {
|
||||
e.stopPropagation();
|
||||
removeTab(id);
|
||||
};
|
||||
tabEl.appendChild(closeBtn);
|
||||
|
||||
var tabBar = document.getElementById('tabBar');
|
||||
tabBar.insertBefore(tabEl, tabBar.querySelector('.new-tab-btn'));
|
||||
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.id = 'frame-' + id;
|
||||
iframe.src = PROXY_BASE + encodeURIComponent(url);
|
||||
iframe.setAttribute('allow', 'autoplay; encrypted-media; camera; microphone; fullscreen; clipboard-write; geolocation');
|
||||
|
||||
iframe.onload = function() {
|
||||
document.getElementById('loader').style.width = '0';
|
||||
try {
|
||||
var frameUrl = iframe.contentWindow.location.href;
|
||||
var rawUrl = getRawUrl(frameUrl);
|
||||
|
||||
if (rawUrl && rawUrl !== 'about:blank' && !rawUrl.includes('error-overlay')) {
|
||||
if (tabObj.history[tabObj.historyIndex] !== rawUrl) {
|
||||
tabObj.history = tabObj.history.slice(0, tabObj.historyIndex + 1);
|
||||
tabObj.history.push(rawUrl);
|
||||
tabObj.historyIndex++;
|
||||
}
|
||||
tabObj.url = rawUrl;
|
||||
if (activeTabId === id) {
|
||||
document.getElementById('searchInput').value = rawUrl;
|
||||
updateNavButtons();
|
||||
}
|
||||
if (iframe.contentDocument && iframe.contentDocument.title) {
|
||||
titleSpan.innerText = iframe.contentDocument.title;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
};
|
||||
|
||||
document.getElementById('iframeContainer').appendChild(iframe);
|
||||
switchTab(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
if (e.data && e.data.type === 'zen-browser-url-change') {
|
||||
var rawUrl = getRawUrl(e.data.url);
|
||||
var iframe = Array.from(document.querySelectorAll('iframe')).find(f => f.contentWindow === e.source);
|
||||
if (!iframe) return;
|
||||
var id = iframe.id.replace('frame-', '');
|
||||
var tab = tabs.find(t => t.id === id);
|
||||
|
||||
if (tab && rawUrl && rawUrl !== 'about:blank') {
|
||||
if (tab.history[tab.historyIndex] !== rawUrl) {
|
||||
tab.history = tab.history.slice(0, tab.historyIndex + 1);
|
||||
tab.history.push(rawUrl);
|
||||
tab.historyIndex++;
|
||||
}
|
||||
tab.url = rawUrl;
|
||||
if (activeTabId === tab.id) {
|
||||
document.getElementById('searchInput').value = rawUrl;
|
||||
updateNavButtons();
|
||||
}
|
||||
if (e.data.title) {
|
||||
var tabEl = document.getElementById('el-' + tab.id);
|
||||
if (tabEl) tabEl.querySelector('.title').innerText = e.data.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function switchTab(id) {
|
||||
activeTabId = id;
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
document.querySelectorAll('iframe').forEach(el => el.classList.remove('active'));
|
||||
|
||||
var el = document.getElementById('el-' + id);
|
||||
var frame = document.getElementById('frame-' + id);
|
||||
if (el) el.classList.add('active');
|
||||
if (frame) frame.classList.add('active');
|
||||
|
||||
var tab = tabs.find(t => t.id === id);
|
||||
if (tab) {
|
||||
document.getElementById('searchInput').value = tab.url;
|
||||
updateNavButtons();
|
||||
}
|
||||
document.title = DEFAULT_TITLE;
|
||||
}
|
||||
|
||||
function updateNavButtons() {
|
||||
var tab = tabs.find(t => t.id === activeTabId);
|
||||
if (!tab) return;
|
||||
document.getElementById('backBtn').classList.toggle('disabled', tab.historyIndex <= 0);
|
||||
document.getElementById('forwardBtn').classList.toggle('disabled', tab.historyIndex >= tab.history.length - 1);
|
||||
}
|
||||
|
||||
function removeTab(id) {
|
||||
var index = tabs.findIndex(t => t.id === id);
|
||||
if (index === -1) return;
|
||||
tabs = tabs.filter(t => t.id !== id);
|
||||
document.getElementById('el-' + id).remove();
|
||||
document.getElementById('frame-' + id).remove();
|
||||
if (activeTabId === id) {
|
||||
if (tabs.length > 0) {
|
||||
var nextTab = tabs[index] || tabs[index - 1];
|
||||
switchTab(nextTab.id);
|
||||
} else {
|
||||
addTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
var tab = tabs.find(t => t.id === activeTabId);
|
||||
if (tab && tab.historyIndex > 0) {
|
||||
tab.historyIndex--;
|
||||
loadUrlInTab(tab, tab.history[tab.historyIndex], true);
|
||||
}
|
||||
}
|
||||
|
||||
function goForward() {
|
||||
var tab = tabs.find(t => t.id === activeTabId);
|
||||
if (tab && tab.historyIndex < tab.history.length - 1) {
|
||||
tab.historyIndex++;
|
||||
loadUrlInTab(tab, tab.history[tab.historyIndex], true);
|
||||
}
|
||||
}
|
||||
|
||||
function loadUrlInTab(tab, url, skipHistory) {
|
||||
tab.url = url;
|
||||
var iframe = document.getElementById('frame-' + tab.id);
|
||||
iframe.src = PROXY_BASE + encodeURIComponent(url);
|
||||
if (activeTabId === tab.id) {
|
||||
document.getElementById('searchInput').value = url;
|
||||
}
|
||||
document.getElementById('loader').style.width = '40%';
|
||||
updateNavButtons();
|
||||
}
|
||||
|
||||
function reloadTab() {
|
||||
if (activeTabId) {
|
||||
var frame = document.getElementById('frame-' + activeTabId);
|
||||
frame.src = frame.src;
|
||||
document.getElementById('loader').style.width = '50%';
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
handleSearch({ preventDefault: () => {}, target: null }, 'https://www.google.com/search?igu=1');
|
||||
}
|
||||
|
||||
function handleSearch(e, forcedUrl) {
|
||||
if (e) e.preventDefault();
|
||||
var input = document.getElementById('searchInput');
|
||||
var query = forcedUrl || input.value.trim();
|
||||
if (!query) return false;
|
||||
|
||||
var url = query;
|
||||
if (!query.match(/^(https?:\\/\\/)/i)) {
|
||||
if (query.includes('.') && !query.includes(' ')) {
|
||||
url = 'https://' + query;
|
||||
} else {
|
||||
url = "https://www.google.com/search?igu=1&q=" + encodeURIComponent(query);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes('youtube.com') && !url.includes('m.youtube.com')) {
|
||||
url = url.replace('www.youtube.com', 'm.youtube.com');
|
||||
}
|
||||
|
||||
if (!activeTabId) {
|
||||
addTab(url);
|
||||
} else {
|
||||
var tab = tabs.find(t => t.id === activeTabId);
|
||||
loadUrlInTab(tab, url);
|
||||
}
|
||||
input.blur();
|
||||
return false;
|
||||
}
|
||||
|
||||
addTab();
|
||||
// Initial title set
|
||||
document.title = DEFAULT_TITLE;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
win.document.open();
|
||||
win.document.write(content);
|
||||
win.document.close();
|
||||
}
|
||||
keyPresses = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleExit = () => {
|
||||
setStepsEnabled(false);
|
||||
};
|
||||
|
||||
const title = 'App Draft'
|
||||
const description = "24/7 looped white noise audio player to improve focus during study sessions."
|
||||
const title = 'Balatro App'
|
||||
const description = "A high-fidelity poker-inspired roguelike deck builder."
|
||||
const url = "https://flatlogic.com/"
|
||||
const image = "https://flatlogic.com/logo.svg"
|
||||
const imageWidth = '1920'
|
||||
@ -524,4 +192,4 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
)
|
||||
}
|
||||
|
||||
export default appWithTranslation(MyApp);
|
||||
export default appWithTranslation(MyApp);
|
||||
304
frontend/src/pages/browser.tsx
Normal file
304
frontend/src/pages/browser.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { mdiArrowLeft, mdiArrowRight, mdiRefresh, mdiHome, mdiMagnify, mdiShieldCheck, mdiDotsVertical, mdiAlertOctagon, mdiPlus, mdiClose } from '@mdi/js';
|
||||
|
||||
const QUICK_LINKS = [
|
||||
{ name: 'Google', url: 'https://www.google.com', color: 'bg-blue-500' },
|
||||
{ name: 'YouTube', url: 'https://www.youtube.com', color: 'bg-red-500' },
|
||||
{ name: 'Wikipedia', url: 'https://www.wikipedia.org', color: 'bg-slate-500' },
|
||||
{ name: 'Reddit', url: 'https://www.reddit.com', color: 'bg-orange-500' },
|
||||
{ name: 'Twitch', url: 'https://www.twitch.tv', color: 'bg-purple-500' },
|
||||
];
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
url: string;
|
||||
displayUrl: string;
|
||||
iframeUrl: string;
|
||||
title: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function BrowserPage() {
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{ id: '1', url: '', displayUrl: '', iframeUrl: '', title: 'Educational Portal', isLoading: false }
|
||||
]);
|
||||
const [activeTabId, setActiveTabId] = useState('1');
|
||||
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0];
|
||||
|
||||
const getProxyUrl = (target: string) => {
|
||||
const apiBase = process.env.NEXT_PUBLIC_BACK_API || '/api';
|
||||
return `${apiBase}/proxy?url=${encodeURIComponent(target)}`;
|
||||
};
|
||||
|
||||
const updateTab = (id: string, updates: Partial<Tab>) => {
|
||||
setTabs(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
|
||||
};
|
||||
|
||||
const navigate = (target: string, tabId: string = activeTabId) => {
|
||||
let finalTarget = target.trim();
|
||||
if (!finalTarget) return;
|
||||
|
||||
if (!finalTarget.startsWith('http')) {
|
||||
if (finalTarget.includes('.') && !finalTarget.includes(' ')) {
|
||||
finalTarget = 'https://' + finalTarget;
|
||||
} else {
|
||||
finalTarget = `https://www.google.com/search?q=${encodeURIComponent(finalTarget)}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateTab(tabId, {
|
||||
iframeUrl: getProxyUrl(finalTarget),
|
||||
displayUrl: finalTarget,
|
||||
url: finalTarget,
|
||||
isLoading: true,
|
||||
title: 'Loading...'
|
||||
});
|
||||
};
|
||||
|
||||
const addTab = () => {
|
||||
const newId = Math.random().toString(36).substr(2, 9);
|
||||
setTabs(prev => [...prev, {
|
||||
id: newId,
|
||||
url: '',
|
||||
displayUrl: '',
|
||||
iframeUrl: '',
|
||||
title: 'New Tab',
|
||||
isLoading: false
|
||||
}]);
|
||||
setActiveTabId(newId);
|
||||
};
|
||||
|
||||
const closeTab = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
if (tabs.length === 1) {
|
||||
updateTab(id, { url: '', displayUrl: '', iframeUrl: '', title: 'Educational Portal', isLoading: false });
|
||||
return;
|
||||
}
|
||||
const newTabs = tabs.filter(t => t.id !== id);
|
||||
setTabs(newTabs);
|
||||
if (activeTabId === id) {
|
||||
setActiveTabId(newTabs[newTabs.length - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
updateTab(activeTabId, { iframeUrl: '', url: '', displayUrl: '', title: 'Educational Portal' });
|
||||
};
|
||||
|
||||
const panic = () => {
|
||||
window.location.href = 'https://classroom.google.com';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data && e.data.type === 'proxy-url-change') {
|
||||
const newUrl = e.data.url;
|
||||
try {
|
||||
const hostname = new URL(newUrl).hostname;
|
||||
updateTab(activeTabId, { displayUrl: newUrl, url: newUrl, title: hostname });
|
||||
} catch(e) {
|
||||
updateTab(activeTabId, { displayUrl: newUrl, url: newUrl });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') panic();
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [activeTabId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-[#f8f9fa] text-slate-900 overflow-hidden font-sans">
|
||||
<Head>
|
||||
<title>SCHOOLWORK - Educational Resources</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
</Head>
|
||||
|
||||
{/* Chrome-style Tab Bar */}
|
||||
<div className="bg-[#dee1e6] px-2 pt-2 flex items-end gap-1 select-none overflow-x-auto no-scrollbar">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
className={`
|
||||
rounded-t-lg px-4 py-2 text-xs flex items-center gap-2 min-w-[120px] max-w-[200px] cursor-pointer relative group transition-all
|
||||
${activeTabId === tab.id ? 'bg-white shadow-sm border-t border-x border-[#bdc1c6] z-10' : 'bg-[#dee1e6] hover:bg-[#cfd2d7] text-slate-600'}
|
||||
`}
|
||||
>
|
||||
<BaseIcon path={mdiShieldCheck} size={14} className={activeTabId === tab.id ? 'text-green-600' : 'text-slate-400'} />
|
||||
<span className="truncate flex-grow font-medium">
|
||||
{tab.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => closeTab(e, tab.id)}
|
||||
className="hover:bg-gray-200 rounded-full p-0.5 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<BaseIcon path={mdiClose} size={14} />
|
||||
</button>
|
||||
{activeTabId !== tab.id && <div className="absolute right-0 top-1/4 bottom-1/4 w-px bg-slate-400"></div>}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={addTab}
|
||||
className="mb-2 ml-1 p-1 hover:bg-[#cfd2d7] rounded-lg cursor-pointer text-slate-600"
|
||||
>
|
||||
<BaseIcon path={mdiPlus} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white p-2 flex gap-3 items-center border-b border-[#dee1e6] shadow-sm z-20">
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => window.history.back()} className="p-1.5 hover:bg-gray-100 rounded-full transition-colors text-slate-600">
|
||||
<BaseIcon path={mdiArrowLeft} size={20} />
|
||||
</button>
|
||||
<button onClick={() => window.history.forward()} className="p-1.5 hover:bg-gray-100 rounded-full transition-colors text-slate-600">
|
||||
<BaseIcon path={mdiArrowRight} size={20} />
|
||||
</button>
|
||||
<button onClick={() => updateTab(activeTabId, { iframeUrl: activeTab.iframeUrl })} className="p-1.5 hover:bg-gray-100 rounded-full transition-colors text-slate-600">
|
||||
<BaseIcon path={mdiRefresh} size={20} className={activeTab.isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button onClick={goHome} className="p-1.5 hover:bg-gray-100 rounded-full transition-colors text-slate-600">
|
||||
<BaseIcon path={mdiHome} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); navigate(activeTab.url); }}
|
||||
className="flex-grow flex items-center bg-[#f1f3f4] hover:bg-[#e8eaed] border border-transparent focus-within:bg-white focus-within:border-blue-500 rounded-full px-4 py-1.5 transition-all group"
|
||||
>
|
||||
<BaseIcon path={mdiMagnify} size={18} className="text-slate-500 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
value={activeTab.url}
|
||||
onChange={(e) => updateTab(activeTabId, { url: e.target.value })}
|
||||
placeholder="Search Google or type a URL"
|
||||
className="flex-grow bg-transparent outline-none text-sm text-slate-700"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-1 items-center">
|
||||
<button
|
||||
onClick={panic}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-50 text-red-600 hover:bg-red-100 rounded-full text-xs font-bold transition-colors border border-red-100"
|
||||
>
|
||||
<BaseIcon path={mdiAlertOctagon} size={16} />
|
||||
PANIC (ESC)
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full text-slate-600">
|
||||
<BaseIcon path={mdiDotsVertical} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Browser Content */}
|
||||
<div className="flex-grow relative bg-white overflow-hidden">
|
||||
{tabs.map(tab => (
|
||||
<div key={tab.id} className={`absolute inset-0 ${tab.id === activeTabId ? 'block' : 'hidden'}`}>
|
||||
{!tab.iframeUrl ? (
|
||||
<div className="flex flex-col items-center justify-center h-full bg-white p-8">
|
||||
<div className="max-w-2xl w-full text-center">
|
||||
<h1 className="text-5xl font-bold mb-8 tracking-tight text-[#1a73e8]">
|
||||
<span className="text-blue-500">S</span>
|
||||
<span className="text-red-500">t</span>
|
||||
<span className="text-yellow-500">u</span>
|
||||
<span className="text-blue-500">d</span>
|
||||
<span className="text-green-500">y</span>
|
||||
<span className="text-red-500 ml-1">P</span>
|
||||
<span className="text-blue-500">o</span>
|
||||
<span className="text-yellow-500">r</span>
|
||||
<span className="text-green-500">t</span>
|
||||
<span className="text-red-500">a</span>
|
||||
<span className="text-blue-500">l</span>
|
||||
</h1>
|
||||
|
||||
<div className="relative mb-12 group">
|
||||
<div className="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
||||
<BaseIcon path={mdiMagnify} size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search educational resources..."
|
||||
className="w-full bg-white border border-gray-200 hover:shadow-md focus:shadow-md rounded-full py-4 pl-12 pr-6 outline-none transition-all text-lg"
|
||||
onKeyDown={(e) => e.key === 'Enter' && navigate((e.target as HTMLInputElement).value, tab.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{QUICK_LINKS.map(link => (
|
||||
<button
|
||||
key={link.name}
|
||||
onClick={() => navigate(link.url, tab.id)}
|
||||
className="flex flex-col items-center gap-2 group"
|
||||
>
|
||||
<div className={`${link.color} w-14 h-14 rounded-full flex items-center justify-center shadow-sm group-hover:shadow-md transition-all text-white font-bold text-xl`}>
|
||||
{link.name[0]}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 group-hover:underline">{link.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{tab.isLoading && (
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-blue-100 overflow-hidden z-50">
|
||||
<div className="h-full bg-blue-500 animate-progress origin-left w-full"></div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={tab.iframeUrl}
|
||||
className="w-full h-full border-none bg-white"
|
||||
onLoad={() => {
|
||||
updateTab(tab.id, { isLoading: false });
|
||||
try {
|
||||
const hostname = new URL(tab.displayUrl).hostname;
|
||||
updateTab(tab.id, { title: hostname });
|
||||
} catch(e) {}
|
||||
}}
|
||||
allow="autoplay; encrypted-media; fullscreen; clipboard-write; geolocation"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes progress {
|
||||
0% { transform: scaleX(0); }
|
||||
50% { transform: scaleX(0.7); }
|
||||
100% { transform: scaleX(1); }
|
||||
}
|
||||
.animate-progress {
|
||||
animation: progress 2s infinite ease-in-out;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BrowserPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
@ -1,335 +1,21 @@
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
mdiPlay,
|
||||
mdiPause,
|
||||
mdiVolumeHigh,
|
||||
mdiTuneVariant,
|
||||
mdiClockOutline,
|
||||
mdiCoffeeOutline,
|
||||
mdiBookOpenVariant,
|
||||
mdiRefresh,
|
||||
mdiWeatherRainy,
|
||||
mdiWaves,
|
||||
mdiTree,
|
||||
mdiVolumeMedium
|
||||
} from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import BalatroGame from '../components/Balatro/BalatroGame';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
export default function WhiteNoiseHome() {
|
||||
// Audio State
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.5);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const sounds = [
|
||||
{ id: 'white', name: 'White Noise', url: 'https://actions.google.com/sounds/v1/ambiences/white_noise.ogg', icon: mdiTuneVariant, color: 'indigo' },
|
||||
{ id: 'rain', name: 'Heavy Rain', url: 'https://actions.google.com/sounds/v1/weather/rain_heavy_loud.ogg', icon: mdiWeatherRainy, color: 'blue' },
|
||||
{ id: 'ocean', name: 'Ocean Waves', url: 'https://actions.google.com/sounds/v1/water/waves_crashing_on_shore.ogg', icon: mdiWaves, color: 'cyan' },
|
||||
{ id: 'forest', name: 'Night Forest', url: 'https://actions.google.com/sounds/v1/ambiences/night_forest_with_insects.ogg', icon: mdiTree, color: 'green' },
|
||||
];
|
||||
const [currentSound, setCurrentSound] = useState(sounds[0]);
|
||||
|
||||
// Timer State
|
||||
const [timerMinutes, setTimerMinutes] = useState(25);
|
||||
const [timerSeconds, setTimerSeconds] = useState(0);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const [timerMode, setTimerMode] = useState<'focus' | 'break'>('focus');
|
||||
|
||||
// Timer Logic
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (isTimerRunning) {
|
||||
interval = setInterval(() => {
|
||||
if (timerSeconds > 0) {
|
||||
setTimerSeconds(timerSeconds - 1);
|
||||
} else if (timerMinutes > 0) {
|
||||
setTimerMinutes(timerMinutes - 1);
|
||||
setTimerSeconds(59);
|
||||
} else {
|
||||
// Timer finished
|
||||
const nextMode = timerMode === 'focus' ? 'break' : 'focus';
|
||||
setTimerMode(nextMode);
|
||||
setTimerMinutes(nextMode === 'focus' ? 25 : 5);
|
||||
setTimerSeconds(0);
|
||||
setIsTimerRunning(false);
|
||||
// Play a notification sound could be added here
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isTimerRunning, timerMinutes, timerSeconds, timerMode]);
|
||||
|
||||
const toggleTimer = () => setIsTimerRunning(!isTimerRunning);
|
||||
const resetTimer = () => {
|
||||
setIsTimerRunning(false);
|
||||
setTimerMinutes(timerMode === 'focus' ? 25 : 5);
|
||||
setTimerSeconds(0);
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
} else {
|
||||
audioRef.current.play().catch(err => console.error("Audio play failed:", err));
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const changeSound = (sound: typeof sounds[0]) => {
|
||||
setCurrentSound(sound);
|
||||
setIsPlaying(false);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.load();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = newVolume;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col font-sans selection:bg-indigo-500/30">
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('ZenNoise - Deep Focus Study Tools')}</title>
|
||||
<meta name="description" content="Seamless white noise loop and study timer. Achieve deep flow state with ZenNoise." />
|
||||
<title>{getPageTitle('Balatro - Poker Roguelike')}</title>
|
||||
<meta name="description" content="A high-fidelity poker-inspired roguelike deck builder." />
|
||||
</Head>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={currentSound.url}
|
||||
loop
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<nav className="flex items-center justify-between px-6 md:px-12 py-6 bg-slate-950/50 backdrop-blur-xl sticky top-0 z-50 border-b border-white/5">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/20 rotate-3">
|
||||
<BaseIcon path={mdiTuneVariant} size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-black tracking-tighter text-white">
|
||||
ZenNoise
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 md:space-x-8">
|
||||
<Link href="/login" className="hidden md:block text-sm font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-widest">
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-6 py-2.5 bg-white hover:bg-slate-200 text-slate-950 text-sm font-bold rounded-full transition-all shadow-xl hover:scale-105 active:scale-95"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="flex-grow container mx-auto px-4 py-12 space-y-24">
|
||||
|
||||
{/* Hero & Primary Player */}
|
||||
<section className="flex flex-col items-center justify-center text-center space-y-12 relative py-20">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-indigo-600/10 rounded-full blur-[120px] -z-10" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-5xl md:text-7xl font-black tracking-tight text-white leading-tight">
|
||||
Design Your <br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-400">Atmosphere.</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-lg md:text-xl max-w-2xl mx-auto font-medium">
|
||||
Mixing high-fidelity noise loops with a precision study timer to help you reach a state of absolute focus.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-8">
|
||||
<div className="relative group cursor-pointer" onClick={togglePlay}>
|
||||
<div className={`absolute -inset-10 bg-indigo-500/20 rounded-full blur-3xl transition-all duration-1000 ${isPlaying ? 'animate-pulse scale-125' : 'scale-100'}`} />
|
||||
<div className={`relative w-48 h-48 rounded-full flex items-center justify-center transition-all duration-700 ${
|
||||
isPlaying
|
||||
? 'bg-white text-slate-950 scale-105 shadow-[0_0_80px_rgba(255,255,255,0.2)]'
|
||||
: 'bg-indigo-600 text-white shadow-2xl shadow-indigo-600/40'
|
||||
}`}>
|
||||
<BaseIcon path={isPlaying ? mdiPause : mdiPlay} size={80} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 text-slate-400 font-bold uppercase tracking-widest text-xs">
|
||||
<span className="w-8 h-[1px] bg-slate-800" />
|
||||
<span>Now Playing: {currentSound.name}</span>
|
||||
<span className="w-8 h-[1px] bg-slate-800" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
|
||||
{/* Study Timer Section */}
|
||||
<section className="bg-white/5 border border-white/10 rounded-[40px] p-8 md:p-12 space-y-8 backdrop-blur-sm relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<BaseIcon path={mdiClockOutline} size={120} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-black text-white">Study Timer</h2>
|
||||
<p className="text-slate-400 font-medium">Precision Pomodoro to keep your brain sharp.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-8">
|
||||
<div className="text-8xl md:text-9xl font-black tabular-nums tracking-tighter text-white">
|
||||
{String(timerMinutes).padStart(2, '0')}:{String(timerSeconds).padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={toggleTimer}
|
||||
className={`px-10 py-4 rounded-2xl font-bold text-lg transition-all active:scale-95 ${
|
||||
isTimerRunning
|
||||
? 'bg-slate-800 text-white hover:bg-slate-700'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-500 shadow-xl shadow-indigo-600/20'
|
||||
}`}
|
||||
>
|
||||
{isTimerRunning ? 'Pause' : 'Start Focus'}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetTimer}
|
||||
className="p-4 bg-white/5 hover:bg-white/10 rounded-2xl text-slate-400 hover:text-white transition-all"
|
||||
>
|
||||
<BaseIcon path={mdiRefresh} size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-slate-900 p-1.5 rounded-2xl border border-white/5">
|
||||
<button
|
||||
onClick={() => { setTimerMode('focus'); setTimerMinutes(25); setTimerSeconds(0); setIsTimerRunning(false); }}
|
||||
className={`px-6 py-2 rounded-xl text-sm font-bold transition-all ${timerMode === 'focus' ? 'bg-white text-slate-950 shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
Focus (25m)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTimerMode('break'); setTimerMinutes(5); setTimerSeconds(0); setIsTimerRunning(false); }}
|
||||
className={`px-6 py-2 rounded-xl text-sm font-bold transition-all ${timerMode === 'break' ? 'bg-white text-slate-950 shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
Break (5m)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Library Section */}
|
||||
<section className="bg-white/5 border border-white/10 rounded-[40px] p-8 md:p-12 space-y-8 backdrop-blur-sm relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<BaseIcon path={mdiBookOpenVariant} size={120} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-black text-white">Sound Library</h2>
|
||||
<p className="text-slate-400 font-medium">Switch between different high-fidelity environments.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{sounds.map((sound) => (
|
||||
<button
|
||||
key={sound.id}
|
||||
onClick={() => changeSound(sound)}
|
||||
className={`p-6 rounded-3xl border transition-all text-left flex flex-col space-y-4 group/card ${
|
||||
currentSound.id === sound.id
|
||||
? 'bg-white border-white text-slate-950'
|
||||
: 'bg-white/5 border-white/10 text-white hover:bg-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-colors ${
|
||||
currentSound.id === sound.id ? 'bg-slate-100 text-indigo-600' : 'bg-slate-900 text-slate-400 group-hover/card:text-indigo-400'
|
||||
}`}>
|
||||
<BaseIcon path={sound.icon} size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-lg">{sound.name}</div>
|
||||
<div className={`text-xs uppercase tracking-widest font-bold opacity-50 ${currentSound.id === sound.id ? 'text-indigo-600' : 'text-slate-400'}`}>
|
||||
{sound.id === 'white' ? 'Continuous' : 'Atmospheric'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Volume Control Overlay */}
|
||||
<div className="pt-4">
|
||||
<div className="bg-slate-900/80 backdrop-blur-xl p-6 rounded-[32px] border border-white/5 flex items-center space-x-6">
|
||||
<BaseIcon path={volume > 0.5 ? mdiVolumeHigh : volume > 0 ? mdiVolumeMedium : mdiVolumeMedium} size={24} className="text-indigo-400" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="flex-grow h-1.5 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500"
|
||||
/>
|
||||
<span className="text-slate-400 font-mono text-xs w-8">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center py-12">
|
||||
<div className="space-y-4">
|
||||
<div className="w-16 h-16 bg-indigo-500/10 rounded-2xl flex items-center justify-center mx-auto text-indigo-400">
|
||||
<BaseIcon path={mdiTuneVariant} size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">Mask Distractions</h3>
|
||||
<p className="text-slate-500 leading-relaxed">Cancel out annoying background noise like construction or chatter with ease.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="w-16 h-16 bg-purple-500/10 rounded-2xl flex items-center justify-center mx-auto text-purple-400">
|
||||
<BaseIcon path={mdiClockOutline} size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">Improve Focus</h3>
|
||||
<p className="text-slate-500 leading-relaxed">Standardize your study environment to trigger "flow state" faster and more reliably.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="w-16 h-16 bg-cyan-500/10 rounded-2xl flex items-center justify-center mx-auto text-cyan-400">
|
||||
<BaseIcon path={mdiCoffeeOutline} size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">Reduce Stress</h3>
|
||||
<p className="text-slate-500 leading-relaxed">Gentle noise frequencies are proven to lower cortisol levels during high-stress tasks.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-12 border-t border-white/5 flex flex-col md:flex-row items-center justify-between text-slate-500 text-sm bg-slate-950">
|
||||
<div className="flex items-center space-x-2 opacity-50 mb-6 md:mb-0">
|
||||
<BaseIcon path={mdiTuneVariant} size={18} />
|
||||
<span className="font-bold tracking-tighter">ZenNoise</span>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-center md:space-x-12 space-y-4 md:space-y-0">
|
||||
<p>© 2026. Designed for Focus.</p>
|
||||
<div className="flex space-x-6">
|
||||
<Link href="/privacy-policy" className="hover:text-indigo-400 transition-colors">Privacy</Link>
|
||||
<Link href="/terms" className="hover:text-indigo-400 transition-colors">Terms</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<BalatroGame />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
WhiteNoiseHome.getLayout = function getLayout(page: ReactElement) {
|
||||
Home.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user