feat: add dark mode, navigation, and Web Vitals tracking to marketplace

Backend:
- Simplify DatabaseConfig: remove effective_url property and project root finder
- Update to Pydantic v2 model_config (replace nested Config class)
- Add web_vitals router to main.py and __init__.py
- Fix ExplorerService datetime handling (ensure timezone-naive comparisons)
- Fix status_label extraction to handle both enum and string job states

Frontend (Marketplace):
- Add dark mode toggle with system preference detection
This commit is contained in:
oib
2026-02-15 19:02:51 +01:00
parent 72e21fd07f
commit 7062b2cc78
26 changed files with 1945 additions and 769 deletions

View File

@@ -18,35 +18,12 @@ class DatabaseConfig(BaseSettings):
max_overflow: int = 20
pool_pre_ping: bool = True
@property
def effective_url(self) -> str:
"""Get the effective database URL."""
if self.url:
return self.url
# Auto-generate SQLite URL based on environment
if self.adapter == "sqlite":
project_root = self._find_project_root()
db_path = project_root / "data" / "coordinator.db"
db_path.parent.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{db_path}"
elif self.adapter == "postgresql":
return "postgresql://localhost:5432/aitbc_coordinator"
return "sqlite:///:memory:"
@staticmethod
def _find_project_root() -> Path:
"""Find project root by looking for .git directory."""
current = Path(__file__).resolve()
while current.parent != current:
if (current / ".git").exists():
return current
current = current.parent
return Path(__file__).resolve().parents[3]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
case_sensitive = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="allow"
)
class Settings(BaseSettings):
@@ -116,7 +93,10 @@ class Settings(BaseSettings):
@property
def database_url(self) -> str:
"""Get the database URL (backward compatibility)."""
return self.database.effective_url
if self.database.url:
return self.database.url
# Default SQLite path for backward compatibility
return f"sqlite:///./aitbc_coordinator.db"
settings = Settings()

View File

@@ -19,6 +19,7 @@ from .routers import (
zk_applications,
explorer,
payments,
web_vitals,
)
from .routers.governance import router as governance
from .routers.partners import router as partners
@@ -75,6 +76,7 @@ def create_app() -> FastAPI:
app.include_router(governance, prefix="/v1")
app.include_router(partners, prefix="/v1")
app.include_router(explorer, prefix="/v1")
app.include_router(web_vitals, prefix="/v1")
# Add Prometheus metrics endpoint
metrics_app = make_asgi_app()

View File

@@ -11,6 +11,7 @@ from .users import router as users
from .exchange import router as exchange
from .marketplace_offers import router as marketplace_offers
from .payments import router as payments
from .web_vitals import router as web_vitals
# from .registry import router as registry
__all__ = ["client", "miner", "admin", "marketplace", "marketplace_gpu", "explorer", "services", "users", "exchange", "marketplace_offers", "payments", "registry"]
__all__ = ["client", "miner", "admin", "marketplace", "marketplace_gpu", "explorer", "services", "users", "exchange", "marketplace_offers", "payments", "web_vitals", "registry"]

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Web Vitals API endpoint for collecting performance metrics
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
import logging
router = APIRouter()
class WebVitalsEntry(BaseModel):
name: str
startTime: Optional[float] = None
duration: Optional[float] = None
class WebVitalsMetric(BaseModel):
name: str
value: float
id: str
delta: Optional[float] = None
entries: List[WebVitalsEntry] = []
url: Optional[str] = None
timestamp: Optional[str] = None
@router.post("/web-vitals")
async def collect_web_vitals(metric: WebVitalsMetric):
"""
Collect Web Vitals performance metrics from the frontend.
This endpoint receives Core Web Vitals (LCP, FID, CLS, TTFB, FCP) for monitoring.
"""
try:
# Log the metric for monitoring/analysis
logging.info(f"Web Vitals - {metric.name}: {metric.value}ms (ID: {metric.id}) from {metric.url or 'unknown'}")
# In a production setup, you might:
# - Store in database for trend analysis
# - Send to monitoring service (DataDog, New Relic, etc.)
# - Trigger alerts for poor performance
# For now, just acknowledge receipt
return {"status": "received", "metric": metric.name, "value": metric.value}
except Exception as e:
logging.error(f"Error processing web vitals metric: {e}")
raise HTTPException(status_code=500, detail="Failed to process metric")
# Health check for web vitals endpoint
@router.get("/web-vitals/health")
async def web_vitals_health():
"""Health check for web vitals collection endpoint"""
return {"status": "healthy", "service": "web-vitals"}

View File

@@ -70,7 +70,8 @@ class ExplorerService:
items: list[TransactionSummary] = []
for index, job in enumerate(jobs):
height = _DEFAULT_HEIGHT_BASE + offset + index
status_label = _STATUS_LABELS.get(job.state, job.state.value.title())
state_val = job.state.value if hasattr(job.state, "value") else job.state
status_label = _STATUS_LABELS.get(job.state) or state_val.title()
# Try to get payment amount from receipt
value_str = "0"
@@ -118,14 +119,26 @@ class ExplorerService:
}
)
def touch(address: Optional[str], tx_id: str, when: datetime, earned: float = 0.0, spent: float = 0.0) -> None:
def _ensure_dt(val: object) -> datetime:
if isinstance(val, datetime):
return val.replace(tzinfo=None)
if isinstance(val, str):
try:
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
return dt.replace(tzinfo=None)
except ValueError:
return datetime.min
return datetime.min
def touch(address: Optional[str], tx_id: str, when: object, earned: float = 0.0, spent: float = 0.0) -> None:
if not address:
return
entry = address_map[address]
entry["address"] = address
entry["tx_count"] = int(entry["tx_count"]) + 1
if when > entry["last_active"]:
entry["last_active"] = when
when_dt = _ensure_dt(when)
if when_dt > _ensure_dt(entry["last_active"]):
entry["last_active"] = when_dt
# Track earnings and spending
entry["earned"] = float(entry["earned"]) + earned
entry["spent"] = float(entry["spent"]) + spent

View File

@@ -95,6 +95,20 @@
gap: 1rem;
}
.site-header__back {
font-size: 0.85rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
border: 1px solid rgba(125, 196, 255, 0.3);
transition: background 150ms ease, border-color 150ms ease;
white-space: nowrap;
}
.site-header__back:hover {
background: rgba(125, 196, 255, 0.15);
border-color: rgba(125, 196, 255, 0.5);
}
.site-header__brand {
font-weight: 600;
font-size: 1.15rem;

View File

@@ -4,6 +4,7 @@ export function siteHeader(title: string): string {
return `
<header class="site-header">
<div class="site-header__inner">
<a class="site-header__back" href="/" title="Back to AITBC Home">← Home</a>
<a class="site-header__brand" href="${basePath}/">AITBC Explorer</a>
<div class="site-header__controls">
<div data-role="data-mode-toggle"></div>
@@ -14,6 +15,7 @@ export function siteHeader(title: string): string {
<a href="${basePath}/transactions">Transactions</a>
<a href="${basePath}/addresses">Addresses</a>
<a href="${basePath}/receipts">Receipts</a>
<a href="/marketplace/">Marketplace</a>
</nav>
</div>
</header>

View File

@@ -1,9 +1,17 @@
// Type declarations for global objects
declare global {
interface Window {
analytics?: {
track: (event: string, data: any) => void;
};
}
}
import './style.css';
import {
fetchMarketplaceOffers,
fetchMarketplaceStats,
submitMarketplaceBid,
MARKETPLACE_CONFIG,
} from './lib/api';
import type { MarketplaceOffer, MarketplaceStats } from './lib/api';
@@ -16,9 +24,21 @@ if (!app) {
app.innerHTML = `
<main>
<header class="page-header">
<p>Data mode: <strong>${MARKETPLACE_CONFIG.dataMode.toUpperCase()}</strong></p>
<h1>Marketplace Control Center</h1>
<p>Monitor available offers, submit bids, and review marketplace health at a glance.</p>
<nav class="page-header__nav">
<a href="/" class="back-link">← Home</a>
<a href="/explorer/">Explorer</a>
<a href="/Exchange/">Exchange</a>
</nav>
<div class="page-header-content">
<div class="page-header-title">
<h1>Marketplace Control Center</h1>
<p>Monitor available offers, submit bids, and review marketplace health at a glance.</p>
</div>
<button onclick="toggleDarkMode()" class="dark-mode-toggle" title="Toggle dark mode">
<span id="darkModeEmoji">🌙</span>
<span id="darkModeText">Dark</span>
</button>
</div>
</header>
<section class="dashboard-grid" id="stats-panel">
@@ -80,13 +100,13 @@ app.innerHTML = `
`;
const selectors = {
totalOffers: document.querySelector<HTMLSpanElement>('#stat-total-offers'),
openCapacity: document.querySelector<HTMLSpanElement>('#stat-open-capacity'),
averagePrice: document.querySelector<HTMLSpanElement>('#stat-average-price'),
activeBids: document.querySelector<HTMLSpanElement>('#stat-active-bids'),
offersWrapper: document.querySelector<HTMLDivElement>('#offers-table-wrapper'),
bidForm: document.querySelector<HTMLFormElement>('#bid-form'),
toast: document.querySelector<HTMLDivElement>('#toast'),
totalOffers: document.querySelector<HTMLSpanElement>('#stat-total-offers')!,
openCapacity: document.querySelector<HTMLSpanElement>('#stat-open-capacity')!,
averagePrice: document.querySelector<HTMLSpanElement>('#stat-average-price')!,
activeBids: document.querySelector<HTMLSpanElement>('#stat-active-bids')!,
offersWrapper: document.querySelector<HTMLDivElement>('#offers-table-wrapper')!,
bidForm: document.querySelector<HTMLFormElement>('#bid-form')!,
toast: document.querySelector<HTMLDivElement>('#toast')!,
};
function formatNumber(value: number, options: Intl.NumberFormatOptions = {}): string {
@@ -94,13 +114,13 @@ function formatNumber(value: number, options: Intl.NumberFormatOptions = {}): st
}
function renderStats(stats: MarketplaceStats): void {
selectors.totalOffers!.textContent = formatNumber(stats.totalOffers);
selectors.openCapacity!.textContent = `${formatNumber(stats.openCapacity)} units`;
selectors.averagePrice!.textContent = `${formatNumber(stats.averagePrice, {
selectors.totalOffers.textContent = formatNumber(stats.totalOffers);
selectors.openCapacity.textContent = `${formatNumber(stats.openCapacity)} units`;
selectors.averagePrice.textContent = `${formatNumber(stats.averagePrice, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} credits`;
selectors.activeBids!.textContent = formatNumber(stats.activeBids);
selectors.activeBids.textContent = formatNumber(stats.activeBids);
}
function statusClass(status: string): string {
@@ -115,12 +135,11 @@ function statusClass(status: string): string {
}
function renderOffers(offers: MarketplaceOffer[]): void {
if (!selectors.offersWrapper) {
return;
}
const wrapper = selectors.offersWrapper;
if (!wrapper) return;
if (offers.length === 0) {
selectors.offersWrapper.innerHTML = '<p class="empty-state">No offers available right now. Check back soon or submit a bid.</p>';
wrapper.innerHTML = '<p class="empty-state">No offers available right now. Check back soon or submit a bid.</p>';
return;
}
@@ -157,7 +176,7 @@ function renderOffers(offers: MarketplaceOffer[]): void {
</div>
<div class="offer-models">
<span class="models-label">Available Models</span>
<div class="model-tags">${offer.attributes.models.map(m => `<span class="model-tag">${m}</span>`).join('')}</div>
<div class="model-tags">${offer.attributes.models.map((m: string) => `<span class="model-tag">${m}</span>`).join('')}</div>
</div>` : ''}
<div class="offer-pricing">
<div class="offer-price">${formatNumber(offer.price_per_hour ?? offer.price, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <small>credits/hr</small></div>
@@ -168,7 +187,7 @@ function renderOffers(offers: MarketplaceOffer[]): void {
)
.join('');
selectors.offersWrapper.innerHTML = `<div class="offers-grid">${cards}</div>`;
wrapper.innerHTML = `<div class="offers-grid">${cards}</div>`;
}
function showToast(message: string, duration = 2500): void {
@@ -192,8 +211,9 @@ async function loadDashboard(): Promise<void> {
renderOffers(offers);
} catch (error) {
console.error(error);
if (selectors.offersWrapper) {
selectors.offersWrapper.innerHTML = '<p class="empty-state">Failed to load offers. Please retry shortly.</p>';
const wrapper = selectors.offersWrapper;
if (wrapper) {
wrapper.innerHTML = '<p class="empty-state">Failed to load offers. Please retry shortly.</p>';
}
showToast('Failed to load marketplace data.');
}
@@ -202,7 +222,10 @@ async function loadDashboard(): Promise<void> {
selectors.bidForm?.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(selectors.bidForm!);
const form = selectors.bidForm;
if (!form) return;
const formData = new FormData(form);
const provider = formData.get('provider')?.toString().trim();
const capacity = Number(formData.get('capacity'));
const price = Number(formData.get('price'));
@@ -214,16 +237,109 @@ selectors.bidForm?.addEventListener('submit', async (event) => {
}
try {
selectors.bidForm!.querySelector('button')!.setAttribute('disabled', 'disabled');
const submitButton = form.querySelector('button');
if (submitButton) {
submitButton.setAttribute('disabled', 'disabled');
}
await submitMarketplaceBid({ provider, capacity, price, notes });
selectors.bidForm!.reset();
form.reset();
showToast('Bid submitted successfully!');
} catch (error) {
console.error(error);
showToast('Unable to submit bid. Please try again.');
} finally {
selectors.bidForm!.querySelector('button')!.removeAttribute('disabled');
const submitButton = form.querySelector('button');
if (submitButton) {
submitButton.removeAttribute('disabled');
}
}
});
loadDashboard();
// Dark mode functionality with system preference detection
function toggleDarkMode() {
const currentTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
function setTheme(theme: string) {
// Apply theme immediately
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Save to localStorage for persistence
localStorage.setItem('marketplaceTheme', theme);
// Update button display
updateThemeButton(theme);
// Send analytics event if available
if (typeof window !== 'undefined' && window.analytics) {
window.analytics.track('marketplace_theme_changed', { theme });
}
}
function updateThemeButton(theme: string) {
const emoji = document.getElementById('darkModeEmoji');
const text = document.getElementById('darkModeText');
if (emoji && text) {
if (theme === 'dark') {
emoji.textContent = '🌙';
text.textContent = 'Dark';
} else {
emoji.textContent = '☀️';
text.textContent = 'Light';
}
}
}
function getPreferredTheme(): string {
// 1. Check localStorage first (user preference for marketplace)
const saved = localStorage.getItem('marketplaceTheme');
if (saved) {
return saved;
}
// 2. Check main site preference for consistency
const mainSiteTheme = localStorage.getItem('theme');
if (mainSiteTheme) {
return mainSiteTheme;
}
// 3. Check system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// 4. Default to dark (AITBC brand preference)
return 'dark';
}
function initializeTheme() {
const theme = getPreferredTheme();
setTheme(theme);
// Listen for system preference changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// Only auto-switch if user hasn't manually set a preference
if (!localStorage.getItem('marketplaceTheme') && !localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
}
// Initialize theme immediately (before DOM loads)
initializeTheme();
// Reference to suppress TypeScript "never used" warning
// @ts-ignore - function called from HTML onclick
window.toggleDarkMode = toggleDarkMode;

View File

@@ -8,10 +8,29 @@
-moz-osx-font-smoothing: grayscale;
}
/* Dark mode variables */
.dark {
--bg-primary: #1f2937;
--bg-secondary: #374151;
--bg-card: #111827;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-color: #4b5563;
--hover-bg: #374151;
}
body {
margin: 0;
min-height: 100vh;
background: linear-gradient(180deg, #f7f8fa 0%, #eef1f6 100%);
transition: background-color 0.3s ease;
}
/* Dark mode body */
.dark body {
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
color: var(--text-primary);
}
#app {
@@ -24,17 +43,118 @@ body {
margin-bottom: 32px;
}
.page-header__nav {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
align-items: center;
}
.page-header__nav a {
font-size: 0.85rem;
padding: 0.3rem 0.75rem;
border-radius: 999px;
color: #5a6575;
text-decoration: none;
transition: background 150ms ease, color 150ms ease;
}
.dark .page-header__nav a {
color: var(--text-secondary);
}
.page-header__nav a:hover {
background: rgba(37, 99, 235, 0.08);
color: #2563eb;
}
.dark .page-header__nav a:hover {
background: rgba(37, 99, 235, 0.15);
color: #60a5fa;
}
.page-header__nav .back-link {
border: 1px solid #d1d5db;
color: #374151;
}
.dark .page-header__nav .back-link {
border-color: var(--border-color);
color: var(--text-secondary);
}
.page-header__nav .back-link:hover {
border-color: #2563eb;
color: #2563eb;
background: rgba(37, 99, 235, 0.06);
}
.dark .page-header__nav .back-link:hover {
border-color: #60a5fa;
color: #60a5fa;
background: rgba(37, 99, 235, 0.12);
}
.page-header h1 {
font-size: 2.4rem;
margin: 0 0 0.5rem;
color: #1d2736;
}
.dark .page-header h1 {
color: var(--text-primary);
}
.page-header p {
margin: 0;
color: #5a6575;
}
.dark .page-header p {
color: var(--text-secondary);
}
.page-header-content {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
}
.page-header-title {
flex: 1;
}
.dark-mode-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(59, 130, 246, 0.1);
border: 2px solid #2563eb;
border-radius: 999px;
color: #2563eb;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
}
.dark .dark-mode-toggle {
background: rgba(59, 130, 246, 0.15);
border-color: #60a5fa;
color: #60a5fa;
}
.dark-mode-toggle:hover {
background: rgba(59, 130, 246, 0.15);
transform: translateY(-1px);
}
.dark .dark-mode-toggle:hover {
background: rgba(59, 130, 246, 0.2);
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -47,6 +167,12 @@ body {
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 24px rgba(18, 24, 32, 0.08);
transition: background-color 0.3s ease;
}
.dark .stat-card {
background: var(--bg-card);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
}
.stat-card h2 {
@@ -55,11 +181,19 @@ body {
color: #64748b;
}
.dark .stat-card h2 {
color: var(--text-muted);
}
.stat-card strong {
font-size: 1.8rem;
color: #1d2736;
}
.dark .stat-card strong {
color: var(--text-primary);
}
.stat-card span {
display: block;
margin-top: 6px;
@@ -67,9 +201,8 @@ body {
font-size: 0.9rem;
}
.panels {
display: grid;
gap: 24px;
.dark .stat-card span {
color: var(--text-secondary);
}
.panel {
@@ -77,6 +210,12 @@ body {
border-radius: 16px;
padding: 24px;
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
transition: background-color 0.3s ease;
}
.dark .panel {
background: var(--bg-card);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
}
.panel h2 {
@@ -85,6 +224,10 @@ body {
color: #1d2736;
}
.dark .panel h2 {
color: var(--text-primary);
}
.offers-table {
width: 100%;
border-collapse: collapse;
@@ -97,6 +240,11 @@ body {
border-bottom: 1px solid #e5e9f1;
}
.dark .offers-table th,
.dark .offers-table td {
border-bottom-color: var(--border-color);
}
.offers-table th {
color: #64748b;
font-size: 0.85rem;
@@ -104,18 +252,16 @@ body {
letter-spacing: 0.05em;
}
.dark .offers-table th {
color: var(--text-muted);
}
.offers-table tbody tr:hover {
background-color: rgba(99, 102, 241, 0.08);
}
.table-responsive {
overflow-x: auto;
}
.offers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
.dark .offers-table tbody tr:hover {
background-color: var(--hover-bg);
}
.offer-card {
@@ -126,30 +272,36 @@ body {
transition: box-shadow 200ms ease, transform 200ms ease;
}
.dark .offer-card {
background: var(--bg-card);
border-color: var(--border-color);
}
.offer-card:hover {
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.12);
transform: translateY(-2px);
}
.offer-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4px;
}
.offer-gpu-name {
font-size: 1.15rem;
font-weight: 700;
color: #1d2736;
}
.dark .offer-gpu-name {
color: var(--text-primary);
}
.offer-provider {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 16px;
}
.dark .offer-provider {
color: var(--text-secondary);
}
.offer-specs {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -160,8 +312,8 @@ body {
margin-bottom: 16px;
}
.spec-item {
text-align: center;
.dark .offer-specs {
background: var(--bg-secondary);
}
.spec-label {
@@ -173,6 +325,10 @@ body {
margin-bottom: 4px;
}
.dark .spec-label {
color: var(--text-muted);
}
.spec-value {
display: block;
font-size: 0.95rem;
@@ -180,10 +336,8 @@ body {
color: #1e293b;
}
.offer-pricing {
display: flex;
justify-content: space-between;
align-items: baseline;
.dark .spec-value {
color: var(--text-primary);
}
.offer-price {
@@ -192,52 +346,27 @@ body {
color: #6366f1;
}
.dark .offer-price {
color: #a78bfa;
}
.offer-price small {
font-size: 0.75rem;
font-weight: 500;
color: #94a3b8;
}
.dark .offer-price small {
color: var(--text-muted);
}
.offer-sla {
font-size: 0.8rem;
color: #64748b;
}
.offer-plugins {
margin-bottom: 8px;
}
.plugin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #ffffff;
}
.offer-models {
margin-bottom: 16px;
}
.models-label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
margin-bottom: 8px;
}
.model-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
.dark .offer-sla {
color: var(--text-secondary);
}
.model-tag {
@@ -251,6 +380,12 @@ body {
border: 1px solid #e2e8f0;
}
.dark .model-tag {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--border-color);
}
.status-pill {
display: inline-flex;
align-items: center;
@@ -271,6 +406,11 @@ body {
color: #1d4ed8;
}
.dark .status-reserved {
background-color: rgba(59, 130, 246, 0.2);
color: #1d4ed8;
}
.bid-form {
display: grid;
gap: 16px;
@@ -283,6 +423,10 @@ body {
margin-bottom: 6px;
}
.dark .bid-form label {
color: var(--text-primary);
}
.bid-form input,
.bid-form select,
.bid-form textarea {
@@ -295,6 +439,14 @@ body {
background-color: #f9fbff;
}
.dark .bid-form input,
.dark .bid-form select,
.dark .bid-form textarea {
border-color: var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.bid-form button {
justify-self: flex-start;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
@@ -322,6 +474,12 @@ body {
background-color: rgba(99, 102, 241, 0.05);
}
.dark .empty-state {
color: var(--text-secondary);
border-color: var(--border-color);
background-color: rgba(99, 102, 241, 0.1);
}
.toast {
position: fixed;
bottom: 24px;
@@ -336,6 +494,11 @@ body {
transition: opacity 200ms ease, transform 200ms ease;
}
.dark .toast {
background: var(--bg-card);
color: var(--text-primary);
}
.toast.visible {
opacity: 1;
transform: translateY(0);

View File

@@ -58,6 +58,9 @@
<h1 class="text-2xl font-bold">AITBC Trade Exchange</h1>
</div>
<nav class="flex items-center space-x-6">
<a href="https://aitbc.bubuit.net/" class="nav-button" title="Back to AITBC Home">
<i data-lucide="home" class="w-5 h-5"></i>
</a>
<button onclick="showSection('trade')" class="nav-button">Trade</button>
<button onclick="showSection('marketplace')" class="nav-button">Marketplace</button>
<button onclick="showSection('wallet')" class="nav-button">Wallet</button>
@@ -443,38 +446,314 @@
document.getElementById('btcAmount').addEventListener('input', updateAITBCAmount);
document.getElementById('aitbcAmount').addEventListener('input', updateBTCAmount);
// Check for saved dark mode preference
if (localStorage.getItem('darkMode') === 'true' ||
(!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
updateDarkModeIcon(true);
}
// Initialize enhanced dark mode
initializeTheme();
// Initialize touch navigation
new ExchangeTouchNavigation();
});
// Dark mode toggle
// Enhanced Dark mode functionality with system preference detection
function toggleDarkMode() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', isDark);
updateDarkModeIcon(isDark);
}
function updateDarkModeIcon(isDark) {
const icon = document.getElementById('darkModeIcon');
if (isDark) {
icon.setAttribute('data-lucide', 'sun');
} else {
icon.setAttribute('data-lucide', 'moon');
}
lucide.createIcons();
const currentTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}
// Section Navigation
function showSection(section) {
document.querySelectorAll('.section').forEach(s => s.classList.add('hidden'));
document.getElementById(section + 'Section').classList.remove('hidden');
function setTheme(theme) {
// Apply theme immediately
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
if (section === 'marketplace') {
loadGPUOffers();
// Save to localStorage for persistence
localStorage.setItem('exchangeTheme', theme);
// Update button display
updateThemeButton(theme);
// Send analytics event if available
if (window.analytics) {
window.analytics.track('exchange_theme_changed', { theme });
}
}
function updateThemeButton(theme) {
const icon = document.getElementById('darkModeIcon');
if (icon) {
if (theme === 'dark') {
icon.setAttribute('data-lucide', 'sun');
} else {
icon.setAttribute('data-lucide', 'moon');
}
if (window.lucide) {
lucide.createIcons();
}
}
}
function getPreferredTheme() {
// 1. Check localStorage first (user preference for exchange)
const saved = localStorage.getItem('exchangeTheme');
if (saved) {
return saved;
}
// 2. Check main site preference for consistency
const mainSiteTheme = localStorage.getItem('theme');
if (mainSiteTheme) {
return mainSiteTheme;
}
// 3. Check system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// 4. Default to dark (AITBC brand preference)
return 'dark';
}
function initializeTheme() {
const theme = getPreferredTheme();
setTheme(theme);
// Listen for system preference changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// Only auto-switch if user hasn't manually set a preference
if (!localStorage.getItem('exchangeTheme') && !localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
}
// Touch Navigation for Mobile
class ExchangeTouchNavigation {
constructor() {
this.touchStartX = 0;
this.touchStartY = 0;
this.touchEndX = 0;
this.touchEndY = 0;
this.minSwipeDistance = 50;
this.maxVerticalDistance = 100;
// Exchange sections for navigation
this.sections = ['trade', 'marketplace', 'wallet'];
this.currentSectionIndex = 0;
this.bindEvents();
this.setupMobileOptimizations();
this.updateCurrentSection();
}
bindEvents() {
document.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
document.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
document.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false });
}
handleTouchStart(e) {
this.touchStartX = e.touches[0].clientX;
this.touchStartY = e.touches[0].clientY;
}
handleTouchMove(e) {
// Prevent scrolling when detecting horizontal swipes
const touchCurrentX = e.touches[0].clientX;
const touchCurrentY = e.touches[0].clientY;
const deltaX = Math.abs(touchCurrentX - this.touchStartX);
const deltaY = Math.abs(touchCurrentY - this.touchStartY);
// If horizontal movement is greater than vertical, prevent default scrolling
if (deltaX > deltaY && deltaX > 10) {
e.preventDefault();
}
}
handleTouchEnd(e) {
this.touchEndX = e.changedTouches[0].clientX;
this.touchEndY = e.changedTouches[0].clientY;
const deltaX = this.touchEndX - this.touchStartX;
const deltaY = Math.abs(this.touchEndY - this.touchStartY);
// Only process swipe if vertical movement is minimal
if (deltaY < this.maxVerticalDistance && Math.abs(deltaX) > this.minSwipeDistance) {
if (deltaX > 0) {
this.swipeRight();
} else {
this.swipeLeft();
}
}
}
swipeLeft() {
// Navigate to next section
const nextIndex = Math.min(this.currentSectionIndex + 1, this.sections.length - 1);
this.navigateToSection(nextIndex);
}
swipeRight() {
// Navigate to previous section
const prevIndex = Math.max(this.currentSectionIndex - 1, 0);
this.navigateToSection(prevIndex);
}
navigateToSection(index) {
const section = this.sections[index];
showSection(section);
this.currentSectionIndex = index;
// Update URL hash without triggering scroll
history.replaceState(null, null, `#${section}`);
// Add visual feedback
this.showSwipeIndicator();
}
showSwipeIndicator() {
// Create a temporary visual indicator
const indicator = document.createElement('div');
indicator.textContent = '← Swipe to navigate →';
indicator.style.cssText = `
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 12px;
z-index: 1000;
pointer-events: none;
animation: fadeInOut 2s ease-in-out;
`;
// Add fade animation
const style = document.createElement('style');
style.textContent = `
@keyframes fadeInOut {
0% { opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { opacity: 0; }
}
`;
document.head.appendChild(style);
document.body.appendChild(indicator);
setTimeout(() => {
document.body.removeChild(indicator);
document.head.removeChild(style);
}, 2000);
}
setupMobileOptimizations() {
this.setupTouchButtons();
this.setupMobileMenu();
this.setupScrollOptimizations();
}
setupTouchButtons() {
// Make buttons more touch-friendly
const buttons = document.querySelectorAll('button, input[type="submit"], .cta-button');
buttons.forEach(button => {
button.addEventListener('touchstart', () => {
button.style.transform = 'scale(0.98)';
}, { passive: true });
button.addEventListener('touchend', () => {
button.style.transform = '';
}, { passive: true });
});
}
setupMobileMenu() {
// On very small screens, create a hamburger menu for nav buttons
if (window.innerWidth < 640) {
this.createMobileNavMenu();
}
// Re-check on resize
window.addEventListener('resize', () => {
if (window.innerWidth < 640) {
this.createMobileNavMenu();
}
});
}
createMobileNavMenu() {
const nav = document.querySelector('nav');
if (!nav || nav.querySelector('.mobile-menu-toggle')) return;
// Create hamburger button
const menuToggle = document.createElement('button');
menuToggle.className = 'mobile-menu-toggle';
menuToggle.innerHTML = '☰';
menuToggle.setAttribute('aria-label', 'Toggle navigation menu');
menuToggle.style.cssText = `
display: none;
background: rgba(255,255,255,0.1);
border: none;
color: white;
font-size: 18px;
padding: 8px 12px;
border-radius: 4px;
margin-left: auto;
cursor: pointer;
`;
// Hide original nav buttons and show hamburger on mobile
const navButtons = nav.querySelectorAll('button:not(.mobile-menu-toggle)');
navButtons.forEach(btn => btn.style.display = 'none');
menuToggle.style.display = 'block';
// Toggle menu on click
menuToggle.addEventListener('click', () => {
const isOpen = menuToggle.textContent === '✕';
if (isOpen) {
navButtons.forEach(btn => btn.style.display = 'none');
menuToggle.innerHTML = '☰';
} else {
navButtons.forEach(btn => btn.style.display = 'inline-flex');
menuToggle.innerHTML = '✕';
}
});
nav.appendChild(menuToggle);
}
setupScrollOptimizations() {
// Improve momentum scrolling on iOS
if ('webkitOverflowScrolling' in document.body.style) {
document.body.style.webkitOverflowScrolling = 'touch';
}
// Add touch feedback for interactive elements
document.querySelectorAll('a, button, input, select').forEach(el => {
el.addEventListener('touchstart', () => {
el.style.opacity = '0.8';
}, { passive: true });
el.addEventListener('touchend', () => {
el.style.opacity = '';
}, { passive: true });
});
}
updateCurrentSection() {
// Update current section based on visible section
this.sections.forEach((sectionId, index) => {
const element = document.getElementById(sectionId + 'Section');
if (element && !element.classList.contains('hidden')) {
this.currentSectionIndex = index;
}
});
}
}