diff --git a/CopyToKeycloak/theme/kusche-desktop/login/login.ftl b/CopyToKeycloak/theme/kusche-desktop/login/login.ftl index 3acdedea..eab13feb 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/login.ftl +++ b/CopyToKeycloak/theme/kusche-desktop/login/login.ftl @@ -8,6 +8,12 @@ <#elseif section = "form">
+
diff --git a/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css b/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css index ed80058f..18aa61ab 100644 --- a/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css +++ b/CopyToKeycloak/theme/kusche-desktop/login/resources/css/desktop-kusche.css @@ -4,6 +4,7 @@ body { } body { + overflow: hidden; background: radial-gradient(circle at top, rgba(255, 221, 177, 0.30), transparent 26%), radial-gradient(circle at 80% 18%, rgba(132, 216, 166, 0.18), transparent 20%), @@ -42,12 +43,62 @@ body { .kb-login-shell { position: relative; + isolation: isolate; display: grid; place-items: center; min-height: calc(100vh - 80px); padding: 24px; } +.kb-login-curtains { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} + +.kb-login-curtain { + position: absolute; + left: 50%; + width: 180vmax; + height: 180vmax; + opacity: 0.72; + border-radius: 999px; + transform-origin: 0 50%; + animation-duration: 1.1s; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + animation-fill-mode: both; +} + +.kb-login-curtain-top, +.kb-login-curtain-bottom { + top: 50%; + margin-top: -90vmax; +} + +.kb-login-curtain-top-a { + background: linear-gradient(135deg, rgba(236, 101, 105, 0.84), rgba(238, 175, 129, 0.76)); + animation-name: kbLoginCurtainTopA; +} + +.kb-login-curtain-top-b { + background: linear-gradient(135deg, rgba(255, 196, 104, 0.66), rgba(255, 138, 76, 0.6)); + animation-name: kbLoginCurtainTopB; + animation-delay: 0.08s; +} + +.kb-login-curtain-bottom-a { + background: linear-gradient(135deg, rgba(96, 184, 212, 0.7), rgba(55, 69, 181, 0.82)); + animation-name: kbLoginCurtainBottomA; + animation-delay: 0.12s; +} + +.kb-login-curtain-bottom-b { + background: linear-gradient(135deg, rgba(72, 125, 255, 0.5), rgba(36, 74, 179, 0.76)); + animation-name: kbLoginCurtainBottomB; + animation-delay: 0.18s; +} + .kb-login-ambient { position: absolute; inset: 0; @@ -70,6 +121,17 @@ body { border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 24px 80px rgba(2, 6, 23, 0.42); backdrop-filter: blur(24px); + opacity: 0; + transform: translateY(28px) scale(0.98); + animation: kbLoginPanelReveal 0.72s cubic-bezier(0.22, 1, 0.36, 1) 0.35s forwards; + transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; +} + +.kb-login-panel:hover, +.kb-login-panel:focus-within { + transform: translateY(-2px); + box-shadow: 0 30px 90px rgba(2, 6, 23, 0.5); + border-color: rgba(255, 255, 255, 0.18); } .kb-login-copy { @@ -129,10 +191,12 @@ body { color: #082032 !important; font-weight: 700; box-shadow: none !important; + transition: transform 160ms ease, filter 160ms ease, background-color 160ms ease, border-color 160ms ease; } #kc-form-login .pf-v5-c-button:hover, #kc-login:hover { + transform: translateY(-1px); filter: brightness(1.03); } @@ -162,6 +226,70 @@ body { border-radius: 14px !important; } +@keyframes kbLoginPanelReveal { + from { + opacity: 0; + transform: translateY(28px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes kbLoginCurtainTopA { + from { + transform: rotate(46deg) translateX(0); + } + + to { + transform: rotate(46deg) translateX(22vmax); + } +} + +@keyframes kbLoginCurtainTopB { + from { + transform: rotate(136deg) translateX(0); + } + + to { + transform: rotate(136deg) translateX(18vmax); + } +} + +@keyframes kbLoginCurtainBottomA { + from { + transform: rotate(-44deg) translateX(0); + } + + to { + transform: rotate(-44deg) translateX(22vmax); + } +} + +@keyframes kbLoginCurtainBottomB { + from { + transform: rotate(-134deg) translateX(0); + } + + to { + transform: rotate(-134deg) translateX(18vmax); + } +} + +@media (prefers-reduced-motion: reduce) { + .kb-login-curtain, + .kb-login-panel { + animation: none; + } + + .kb-login-panel { + opacity: 1; + transform: none; + } +} + @media (max-width: 640px) { #kc-content-wrapper { padding-top: 20px; diff --git a/config/apps.php b/config/apps.php index b14dc8af..4d0f1be6 100644 --- a/config/apps.php +++ b/config/apps.php @@ -68,4 +68,18 @@ return [ 'module_name' => 'auth', 'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.', ], + [ + 'app_id' => 'mining-checker', + 'title' => 'Mining-Checker', + 'icon' => 'MC', + 'entry_route' => '/apps/mining-checker', + 'window_mode' => 'window', + 'content_mode' => 'iframe', + 'default_width' => 1180, + 'default_height' => 760, + 'supports_widget' => false, + 'supports_tray' => true, + 'module_name' => 'finance', + 'summary' => 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.', + ], ]; diff --git a/public/api/mining-checker/index.php b/public/api/mining-checker/index.php new file mode 100644 index 00000000..3f1a8f7d --- /dev/null +++ b/public/api/mining-checker/index.php @@ -0,0 +1,110 @@ +shouldShowDesktop()) { + http_response_code(401); + echo json_encode(['error' => 'Nicht autorisiert.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$store = new MiningCheckerStore($projectRoot, MiningCheckerUserScope::current()); +$calculator = new MiningCheckerCalculator(); +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + +try { + if ($method === 'GET') { + $state = $store->load(); + $state['metrics'] = $calculator->calculate((array) ($state['settings'] ?? [])); + echo json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + $payloadRaw = file_get_contents('php://input'); + $payload = json_decode($payloadRaw !== false ? $payloadRaw : '', true); + $payload = is_array($payload) ? $payload : []; + + if ($method === 'PUT') { + $state = $store->load(); + $state['settings'] = normalizeSettings(array_merge((array) ($state['settings'] ?? []), $payload)); + $store->save($state); + $state['metrics'] = $calculator->calculate((array) $state['settings']); + echo json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + if ($method === 'POST') { + $state = $store->load(); + $settings = normalizeSettings(array_merge((array) ($state['settings'] ?? []), is_array($payload['settings'] ?? null) ? $payload['settings'] : [])); + $metrics = $calculator->calculate($settings); + $note = trim((string) ($payload['note'] ?? '')); + $history = is_array($state['history'] ?? null) ? $state['history'] : []; + + array_unshift($history, [ + 'id' => bin2hex(random_bytes(8)), + 'created_at' => gmdate('c'), + 'project_name' => (string) ($settings['project_name'] ?? ''), + 'coin_symbol' => (string) ($settings['coin_symbol'] ?? ''), + 'currency' => (string) ($settings['currency'] ?? 'EUR'), + 'note' => $note, + 'settings' => $settings, + 'metrics' => $metrics, + ]); + + $state['settings'] = $settings; + $state['history'] = array_slice($history, 0, 25); + $store->save($state); + + $state['metrics'] = $metrics; + echo json_encode($state, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + http_response_code(405); + echo json_encode(['error' => 'Methode nicht erlaubt.'], JSON_UNESCAPED_UNICODE); +} catch (Throwable $throwable) { + http_response_code(500); + echo json_encode(['error' => $throwable->getMessage()], JSON_UNESCAPED_UNICODE); +} + +/** + * @param array $settings + * @return array + */ +function normalizeSettings(array $settings): array +{ + return [ + 'project_name' => trim((string) ($settings['project_name'] ?? 'DOGE Mining')), + 'coin_symbol' => strtoupper(trim((string) ($settings['coin_symbol'] ?? 'DOGE'))), + 'algorithm' => trim((string) ($settings['algorithm'] ?? 'Scrypt')), + 'currency' => strtoupper(trim((string) ($settings['currency'] ?? 'EUR'))), + 'hashrate' => (float) ($settings['hashrate'] ?? 0), + 'hashrate_unit' => trim((string) ($settings['hashrate_unit'] ?? 'MH/s')), + 'network_hashrate' => (float) ($settings['network_hashrate'] ?? 0), + 'network_hashrate_unit' => trim((string) ($settings['network_hashrate_unit'] ?? 'TH/s')), + 'block_reward' => (float) ($settings['block_reward'] ?? 0), + 'block_time_seconds' => (float) ($settings['block_time_seconds'] ?? 60), + 'coin_price' => (float) ($settings['coin_price'] ?? 0), + 'power_watts' => (float) ($settings['power_watts'] ?? 0), + 'electricity_price' => (float) ($settings['electricity_price'] ?? 0), + 'pool_fee_percent' => (float) ($settings['pool_fee_percent'] ?? 0), + 'hardware_cost' => (float) ($settings['hardware_cost'] ?? 0), + 'notes' => trim((string) ($settings['notes'] ?? '')), + ]; +} diff --git a/public/apps/mining-checker/index.php b/public/apps/mining-checker/index.php new file mode 100644 index 00000000..883feb26 --- /dev/null +++ b/public/apps/mining-checker/index.php @@ -0,0 +1,164 @@ +shouldShowDesktop()) { + header('Location: /auth/login', true, 302); + exit; +} +?> + + + + + Mining-Checker + + + +
+
+
+

App Modul

+

Mining-Checker

+

Profitabilitaet, Stromkosten, ROI und Messhistorie fuer dein Mining-Setup.

+
+
+ + + +
+
+ +
+
+
+

Setup

+

Alle Werte werden pro Benutzer gespeichert.

+
+ +
+ + + + + + + + + + + + + + + + +
+ +
+ + +
+
+ +
+
+
+

Kennzahlen

+

Direkt aus den aktuellen Werten berechnet.

+
+
+
+ +
+
+

Snapshot Verlauf

+

Gespeicherte Stichtage pro Benutzer.

+
+
+
+
+
+
+ + + + + + diff --git a/public/assets/apps/mining-checker/app.css b/public/assets/apps/mining-checker/app.css new file mode 100644 index 00000000..fa77f635 --- /dev/null +++ b/public/assets/apps/mining-checker/app.css @@ -0,0 +1,258 @@ +:root { + color-scheme: light; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(253, 224, 71, 0.16), transparent 26%), + linear-gradient(180deg, #f7fafc 0%, #edf2f7 100%); + color: #0f172a; +} + +.mining-app { + display: grid; + gap: 20px; + min-height: 100vh; + padding: 20px; +} + +.mining-hero, +.mining-panel { + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 24px; + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08); + backdrop-filter: blur(14px); +} + +.mining-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 24px; +} + +.mining-kicker { + margin: 0 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #92400e; +} + +.mining-hero h1, +.panel-header h2 { + margin: 0; +} + +.mining-copy, +.panel-header p { + margin: 8px 0 0; + color: #475569; + line-height: 1.5; +} + +.mining-hero-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.preset-button, +.primary-button, +.secondary-button { + border: 0; + border-radius: 999px; + padding: 11px 16px; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.preset-button, +.secondary-button { + background: #e2e8f0; + color: #0f172a; +} + +.primary-button { + background: linear-gradient(180deg, #facc15, #f59e0b); + color: #111827; +} + +.mining-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr); + gap: 20px; +} + +.mining-form, +.mining-sidebar { + display: grid; + gap: 20px; +} + +.mining-panel { + padding: 22px; +} + +.panel-header { + margin-bottom: 18px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.form-grid label, +.stat-card, +.history-item { + display: grid; + gap: 8px; +} + +.form-grid span { + font-size: 13px; + font-weight: 700; + color: #334155; +} + +.form-grid input, +.form-grid select, +.form-grid textarea { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 14px; + padding: 12px 14px; + font: inherit; + background: #fff; + color: #0f172a; +} + +.form-grid textarea { + resize: vertical; +} + +.form-span-2 { + grid-column: 1 / -1; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 18px; + flex-wrap: wrap; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.stat-card { + padding: 16px; + border-radius: 18px; + background: #f8fafc; + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.stat-label, +.stat-meta { + color: #64748b; + font-size: 13px; +} + +.stat-value { + font-size: 22px; + line-height: 1.2; +} + +.history-list { + display: grid; + gap: 12px; +} + +.history-empty { + padding: 18px; + border-radius: 18px; + background: #f8fafc; + color: #64748b; +} + +.history-item { + padding: 16px; + border-radius: 18px; + background: #f8fafc; + border: 1px solid rgba(148, 163, 184, 0.16); +} + +.history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.history-title { + font-weight: 700; +} + +.history-note { + margin: 0; + color: #475569; + line-height: 1.45; +} + +.history-meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + color: #64748b; + font-size: 13px; +} + +.toast { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 30; + padding: 12px 16px; + border-radius: 14px; + background: rgba(15, 23, 42, 0.92); + color: #fff; + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18); +} + +@media (max-width: 1100px) { + .mining-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .mining-app { + padding: 14px; + } + + .mining-hero { + flex-direction: column; + } + + .form-grid, + .stats-grid { + grid-template-columns: 1fr; + } +} diff --git a/public/assets/apps/mining-checker/app.js b/public/assets/apps/mining-checker/app.js new file mode 100644 index 00000000..d7b9f2ac --- /dev/null +++ b/public/assets/apps/mining-checker/app.js @@ -0,0 +1,298 @@ +(function () { + const form = document.getElementById('mining-settings-form'); + const statsGrid = document.getElementById('stats-grid'); + const historyList = document.getElementById('history-list'); + const snapshotButton = document.getElementById('snapshot-button'); + const statTemplate = document.getElementById('stat-card-template'); + const presetButtons = Array.from(document.querySelectorAll('[data-preset]')); + + if (!form || !statsGrid || !historyList || !snapshotButton || !statTemplate) { + return; + } + + const presets = { + doge: { + project_name: 'DOGE Mining', + coin_symbol: 'DOGE', + algorithm: 'Scrypt', + currency: 'EUR', + hashrate: 950, + hashrate_unit: 'MH/s', + network_hashrate: 920, + network_hashrate_unit: 'TH/s', + block_reward: 10000, + block_time_seconds: 60, + coin_price: 0.14, + power_watts: 3420, + electricity_price: 0.32, + pool_fee_percent: 1.5, + hardware_cost: 8400, + }, + ltc: { + project_name: 'LTC Mining', + coin_symbol: 'LTC', + algorithm: 'Scrypt', + currency: 'EUR', + hashrate: 950, + hashrate_unit: 'MH/s', + network_hashrate: 1.35, + network_hashrate_unit: 'PH/s', + block_reward: 6.25, + block_time_seconds: 150, + coin_price: 82, + power_watts: 3420, + electricity_price: 0.32, + pool_fee_percent: 1.5, + hardware_cost: 8400, + }, + }; + + const fmtNumber = (value, digits = 4) => { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + + return Number(value).toLocaleString('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: digits, + }); + }; + + const fmtMoney = (value, currency) => { + if (value === null || value === undefined || currency === '') { + return 'n/a'; + } + + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency, + maximumFractionDigits: 4, + }).format(Number(value)); + }; + + const fmtDateTime = (value) => { + if (!value) { + return 'n/a'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('de-DE', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); + }; + + const toHashrateHps = (value, unit) => { + const numericValue = Number(value || 0); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return 0; + } + + const multipliers = { + 'H/S': 1, + 'KH/S': 1e3, + 'MH/S': 1e6, + 'GH/S': 1e9, + 'TH/S': 1e12, + 'PH/S': 1e15, + 'EH/S': 1e18, + }; + + return numericValue * (multipliers[String(unit || 'MH/s').toUpperCase()] || 1e6); + }; + + const calculateMetrics = (settings) => { + const hashrate = toHashrateHps(settings.hashrate, settings.hashrate_unit); + const networkHashrate = toHashrateHps(settings.network_hashrate, settings.network_hashrate_unit); + const powerWatts = Math.max(0, Number(settings.power_watts || 0)); + const electricityPrice = Math.max(0, Number(settings.electricity_price || 0)); + const poolFeePercent = Math.max(0, Math.min(100, Number(settings.pool_fee_percent || 0))); + const coinPrice = Math.max(0, Number(settings.coin_price || 0)); + const blockReward = Math.max(0, Number(settings.block_reward || 0)); + const blockTimeSeconds = Math.max(1, Number(settings.block_time_seconds || 60)); + const hardwareCost = Math.max(0, Number(settings.hardware_cost || 0)); + + const share = networkHashrate > 0 ? hashrate / networkHashrate : null; + const blocksPerDay = 86400 / blockTimeSeconds; + const dailyCoinsGross = share !== null ? share * blocksPerDay * blockReward : null; + const dailyCoinsNet = dailyCoinsGross !== null ? dailyCoinsGross * (1 - poolFeePercent / 100) : null; + const dailyRevenue = dailyCoinsNet !== null ? dailyCoinsNet * coinPrice : null; + const dailyPowerCost = (powerWatts / 1000) * 24 * electricityPrice; + const dailyProfit = dailyRevenue !== null ? dailyRevenue - dailyPowerCost : null; + const monthlyProfit = dailyProfit !== null ? dailyProfit * 30 : null; + const annualProfit = dailyProfit !== null ? dailyProfit * 365 : null; + const breakEvenDays = dailyProfit !== null && dailyProfit > 0 && hardwareCost > 0 ? hardwareCost / dailyProfit : null; + + return { + daily_coins_net: dailyCoinsNet, + daily_revenue: dailyRevenue, + daily_power_cost: dailyPowerCost, + daily_profit: dailyProfit, + monthly_profit: monthlyProfit, + annual_profit: annualProfit, + break_even_days: breakEvenDays, + share_ratio: share, + }; + }; + + const showToast = (message) => { + const node = document.createElement('div'); + node.className = 'toast'; + node.textContent = message; + document.body.appendChild(node); + + window.setTimeout(() => { + node.remove(); + }, 2400); + }; + + const readForm = () => Object.fromEntries(new FormData(form).entries()); + + const writeForm = (settings) => { + Object.entries(settings).forEach(([key, value]) => { + const field = form.elements.namedItem(key); + if (!(field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement)) { + return; + } + + field.value = String(value ?? ''); + }); + }; + + const renderStats = (settings, metrics) => { + const currency = String(settings.currency || 'EUR').toUpperCase(); + const cards = [ + ['Taegliche Coins netto', fmtNumber(metrics.daily_coins_net, 6), `${settings.coin_symbol || 'COIN'} pro Tag`], + ['Taeglicher Umsatz', fmtMoney(metrics.daily_revenue, currency), 'vor Stromkosten'], + ['Taeglicher Strom', fmtMoney(metrics.daily_power_cost, currency), `${fmtNumber(settings.power_watts, 0)} W @ ${fmtMoney(settings.electricity_price, currency)}/kWh`], + ['Taeglicher Profit', fmtMoney(metrics.daily_profit, currency), 'nach Pool Fee und Strom'], + ['Monatlicher Profit', fmtMoney(metrics.monthly_profit, currency), '30 Tage Hochrechnung'], + ['Jaehrlicher Profit', fmtMoney(metrics.annual_profit, currency), '365 Tage Hochrechnung'], + ['ROI / Break-even', metrics.break_even_days ? `${fmtNumber(metrics.break_even_days, 1)} Tage` : 'n/a', 'bezogen auf Hardwarekosten'], + ['Rig Anteil am Netzwerk', metrics.share_ratio ? `${fmtNumber(metrics.share_ratio * 100, 8)} %` : 'n/a', 'Hashrate Share'], + ]; + + statsGrid.innerHTML = ''; + + cards.forEach(([label, value, meta]) => { + const fragment = statTemplate.content.cloneNode(true); + fragment.querySelector('.stat-label').textContent = label; + fragment.querySelector('.stat-value').textContent = value; + fragment.querySelector('.stat-meta').textContent = meta; + statsGrid.appendChild(fragment); + }); + }; + + const renderHistory = (entries) => { + historyList.innerHTML = ''; + + if (!Array.isArray(entries) || entries.length === 0) { + const empty = document.createElement('div'); + empty.className = 'history-empty'; + empty.textContent = 'Noch keine Snapshots gespeichert.'; + historyList.appendChild(empty); + return; + } + + entries.forEach((entry) => { + const item = document.createElement('article'); + item.className = 'history-item'; + item.innerHTML = ` +
+ ${entry.project_name || 'Snapshot'} + ${fmtDateTime(entry.created_at)} +
+
+ ${entry.coin_symbol || 'COIN'} + Profit/Tag: ${fmtMoney(entry.metrics?.daily_profit, entry.currency || 'EUR')} + ROI: ${entry.metrics?.break_even_days ? `${fmtNumber(entry.metrics.break_even_days, 1)} Tage` : 'n/a'} +
+ ${entry.note ? `

${entry.note}

` : ''} + `; + historyList.appendChild(item); + }); + }; + + const load = async () => { + const response = await fetch('/api/mining-checker/'); + const data = await response.json(); + writeForm(data.settings || {}); + renderStats(data.settings || {}, data.metrics || {}); + renderHistory(data.history || []); + }; + + const saveSettings = async () => { + const response = await fetch('/api/mining-checker/', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(readForm()), + }); + const data = await response.json(); + writeForm(data.settings || {}); + renderStats(data.settings || {}, data.metrics || {}); + renderHistory(data.history || []); + showToast('Mining-Checker gespeichert'); + }; + + const saveSnapshot = async () => { + const note = window.prompt('Optionaler Snapshot-Hinweis', ''); + if (note === null) { + return; + } + + const response = await fetch('/api/mining-checker/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + settings: readForm(), + note, + }), + }); + const data = await response.json(); + writeForm(data.settings || {}); + renderStats(data.settings || {}, data.metrics || {}); + renderHistory(data.history || []); + showToast('Snapshot gespeichert'); + }; + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + await saveSettings(); + }); + + snapshotButton.addEventListener('click', async () => { + await saveSnapshot(); + }); + + presetButtons.forEach((button) => { + button.addEventListener('click', () => { + const preset = button.dataset.preset; + if (!preset || preset === 'custom') { + showToast('Aktuelle Werte bleiben unveraendert'); + return; + } + + writeForm(presets[preset]); + renderStats(readForm(), calculateMetrics(readForm())); + showToast(`Preset ${preset.toUpperCase()} geladen`); + }); + }); + + form.addEventListener('input', () => { + const settings = readForm(); + renderStats(settings, calculateMetrics(settings)); + }); + + load().catch((error) => { + console.error(error); + showToast('Mining-Checker konnte nicht geladen werden'); + }); +}()); diff --git a/public/assets/desktop/desktop.css b/public/assets/desktop/desktop.css index 8df19fda..63d45a5c 100644 --- a/public/assets/desktop/desktop.css +++ b/public/assets/desktop/desktop.css @@ -388,6 +388,20 @@ h1 { line-height: 1.5; } +.window-content--embedded { + flex: 1; + padding: 0; + overflow: hidden; +} + +.window-app-frame { + display: block; + width: 100%; + height: 100%; + border: 0; + background: #ffffff; +} + .taskbar { position: fixed; left: var(--taskbar-left); diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index f0ac33d2..25c7b8a5 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -107,6 +107,26 @@ if (payloadNode) { `; }; + const buildWindowBody = (definition) => { + if (definition.content_mode === 'iframe' && typeof definition.entry_route === 'string' && definition.entry_route !== '') { + const title = escapeHtml(definition.title); + const src = escapeHtml(definition.entry_route); + + return ` +
+ +
+ `; + } + + return ` +
+

${escapeHtml(definition.content)}

+

Route: ${escapeHtml(definition.app_id)}

+
+ `; + }; + const loadWindowState = () => { try { const raw = window.localStorage.getItem(storageKey); @@ -463,10 +483,7 @@ if (payloadNode) { node.dataset.windowControls = windowControlsMode; node.innerHTML = ` ${buildWindowHeader(definition)} -
-

${escapeHtml(definition.content)}

-

Route: ${escapeHtml(definition.app_id)}

-
+ ${buildWindowBody(definition)} @@ -564,6 +581,8 @@ if (payloadNode) { width: app.default_width || 640, height: app.default_height || 420, content: app.summary || '', + entry_route: app.entry_route || '', + content_mode: app.content_mode || 'summary', }); }; diff --git a/src/App/bootstrap.php b/src/App/bootstrap.php index 6b495f71..ccb7359c 100644 --- a/src/App/bootstrap.php +++ b/src/App/bootstrap.php @@ -6,6 +6,7 @@ spl_autoload_register(static function (string $class): void { $prefixes = [ 'App\\' => __DIR__ . '/', 'Desktop\\' => dirname(__DIR__) . '/Desktop/', + 'MiningChecker\\' => dirname(__DIR__) . '/MiningChecker/', ]; foreach ($prefixes as $prefix => $baseDir) { diff --git a/src/MiningChecker/MiningCheckerCalculator.php b/src/MiningChecker/MiningCheckerCalculator.php new file mode 100644 index 00000000..00847120 --- /dev/null +++ b/src/MiningChecker/MiningCheckerCalculator.php @@ -0,0 +1,84 @@ + $settings + * @return array + */ + public function calculate(array $settings): array + { + $hashrate = $this->toHashrateHps( + (float) ($settings['hashrate'] ?? 0), + (string) ($settings['hashrate_unit'] ?? 'MH/s') + ); + $networkHashrate = $this->toHashrateHps( + (float) ($settings['network_hashrate'] ?? 0), + (string) ($settings['network_hashrate_unit'] ?? 'TH/s') + ); + + $powerWatts = max(0.0, (float) ($settings['power_watts'] ?? 0)); + $electricityPrice = max(0.0, (float) ($settings['electricity_price'] ?? 0)); + $poolFeePercent = max(0.0, min(100.0, (float) ($settings['pool_fee_percent'] ?? 0))); + $coinPrice = max(0.0, (float) ($settings['coin_price'] ?? 0)); + $blockReward = max(0.0, (float) ($settings['block_reward'] ?? 0)); + $blockTimeSeconds = max(1.0, (float) ($settings['block_time_seconds'] ?? 60)); + $hardwareCost = max(0.0, (float) ($settings['hardware_cost'] ?? 0)); + + $share = $networkHashrate > 0 ? $hashrate / $networkHashrate : null; + $blocksPerDay = 86400 / $blockTimeSeconds; + $dailyCoinsGross = $share !== null ? $share * $blocksPerDay * $blockReward : null; + $dailyCoinsNet = $dailyCoinsGross !== null ? $dailyCoinsGross * (1 - ($poolFeePercent / 100)) : null; + $dailyRevenue = $dailyCoinsNet !== null ? $dailyCoinsNet * $coinPrice : null; + $dailyPowerCost = ($powerWatts / 1000) * 24 * $electricityPrice; + $dailyProfit = $dailyRevenue !== null ? $dailyRevenue - $dailyPowerCost : null; + $monthlyProfit = $dailyProfit !== null ? $dailyProfit * 30 : null; + $annualProfit = $dailyProfit !== null ? $dailyProfit * 365 : null; + $breakEvenDays = ($dailyProfit !== null && $dailyProfit > 0.0 && $hardwareCost > 0.0) + ? $hardwareCost / $dailyProfit + : null; + $efficiency = $hashrate > 0 ? $powerWatts / ($hashrate / 1000000) : null; + + return [ + 'hashrate_hps' => $hashrate > 0 ? $hashrate : null, + 'network_hashrate_hps' => $networkHashrate > 0 ? $networkHashrate : null, + 'share_ratio' => $share, + 'blocks_per_day' => $blocksPerDay, + 'daily_coins_gross' => $dailyCoinsGross, + 'daily_coins_net' => $dailyCoinsNet, + 'daily_revenue' => $dailyRevenue, + 'daily_power_cost' => $dailyPowerCost, + 'daily_profit' => $dailyProfit, + 'monthly_profit' => $monthlyProfit, + 'annual_profit' => $annualProfit, + 'break_even_days' => $breakEvenDays, + 'efficiency_w_per_mh' => $efficiency, + ]; + } + + private function toHashrateHps(float $value, string $unit): float + { + if ($value <= 0) { + return 0.0; + } + + $normalized = strtoupper(trim($unit)); + $multipliers = [ + 'H/S' => 1, + 'KH/S' => 1000, + 'MH/S' => 1000000, + 'GH/S' => 1000000000, + 'TH/S' => 1000000000000, + 'PH/S' => 1000000000000000, + 'EH/S' => 1000000000000000000, + ]; + + $multiplier = $multipliers[$normalized] ?? 1000000; + + return $value * $multiplier; + } +} diff --git a/src/MiningChecker/MiningCheckerStore.php b/src/MiningChecker/MiningCheckerStore.php new file mode 100644 index 00000000..f406b7fd --- /dev/null +++ b/src/MiningChecker/MiningCheckerStore.php @@ -0,0 +1,92 @@ + + */ + public function load(): array + { + $path = $this->path(); + + if (!is_file($path)) { + return $this->defaultState(); + } + + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + return $this->defaultState(); + } + + $data = json_decode($raw, true); + + return is_array($data) ? array_replace_recursive($this->defaultState(), $data) : $this->defaultState(); + } + + /** + * @param array $state + */ + public function save(array $state): void + { + $directory = dirname($this->path()); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { + throw new RuntimeException('Mining-Checker-Verzeichnis konnte nicht erstellt werden.'); + } + + $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new RuntimeException('Mining-Checker-Daten konnten nicht serialisiert werden.'); + } + + if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) { + throw new RuntimeException('Mining-Checker-Daten konnten nicht gespeichert werden.'); + } + } + + private function path(): string + { + $safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest'; + + return $this->projectRoot . '/data/mining-checker/' . $safeScope . '.json'; + } + + /** + * @return array + */ + private function defaultState(): array + { + return [ + 'settings' => [ + 'project_name' => 'DOGE Mining', + 'coin_symbol' => 'DOGE', + 'algorithm' => 'Scrypt', + 'currency' => 'EUR', + 'hashrate' => 950, + 'hashrate_unit' => 'MH/s', + 'network_hashrate' => 920, + 'network_hashrate_unit' => 'TH/s', + 'block_reward' => 10000, + 'block_time_seconds' => 60, + 'coin_price' => 0.14, + 'power_watts' => 3420, + 'electricity_price' => 0.32, + 'pool_fee_percent' => 1.5, + 'hardware_cost' => 8400, + 'notes' => '', + ], + 'history' => [], + ]; + } +} diff --git a/src/MiningChecker/MiningCheckerUserScope.php b/src/MiningChecker/MiningCheckerUserScope.php new file mode 100644 index 00000000..b9713063 --- /dev/null +++ b/src/MiningChecker/MiningCheckerUserScope.php @@ -0,0 +1,21 @@ +