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:
4
website/assets/css/font-awesome.min.css
vendored
Normal file
4
website/assets/css/font-awesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
516
website/assets/css/main.css
Normal file
516
website/assets/css/main.css
Normal file
@@ -0,0 +1,516 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--secondary-color: #1e40af;
|
||||
--accent-color: #3b82f6;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--text-dark: #1f2937;
|
||||
--text-light: #6b7280;
|
||||
--bg-light: #f9fafb;
|
||||
--bg-white: #ffffff;
|
||||
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--primary-color: #3b82f6;
|
||||
--secondary-color: #2563eb;
|
||||
--accent-color: #60a5fa;
|
||||
--text-dark: #f9fafb;
|
||||
--text-light: #d1d5db;
|
||||
--bg-light: #111827;
|
||||
--bg-white: #1f2937;
|
||||
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-dark);
|
||||
background-color: var(--bg-light);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background: var(--bg-white);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-dark);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--text-light);
|
||||
color: var(--text-dark);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: color 0.3s, border-color 0.3s;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dark-mode-toggle:hover {
|
||||
color: var(--primary-color);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
padding: 100px 0 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3.5rem;
|
||||
margin-bottom: 1rem;
|
||||
animation: fadeInUp 0.8s ease;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
animation: fadeInUp 0.8s ease 0.2s both;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background: var(--bg-white);
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: 600;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
animation: fadeInUp 0.8s ease 0.4s both;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 80px 0;
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 3rem;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-white);
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.08);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text-light);
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.feature-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.feature-link:hover {
|
||||
color: var(--secondary-color);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
/* Architecture Section */
|
||||
.architecture {
|
||||
padding: 80px 0;
|
||||
background: var(--bg-white);
|
||||
}
|
||||
|
||||
/* Header styles matching Exchange */
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: transparent !important;
|
||||
color: white !important;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.nav-button:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.architecture-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.arch-component {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
border: 2px solid var(--text-light);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-white);
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.arch-component:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.arch-component i {
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Stats Section */
|
||||
.stats {
|
||||
padding: 60px 0;
|
||||
background: var(--gradient);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item h3 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-item p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Documentation Section */
|
||||
.documentation {
|
||||
padding: 80px 0;
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
background: var(--bg-white);
|
||||
padding: 2.5rem;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.doc-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.doc-card:nth-child(1)::before {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.doc-card:nth-child(2)::before {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.doc-card:nth-child(3)::before {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
.doc-card:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.8rem;
|
||||
color: white;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.doc-card:nth-child(1) .doc-icon {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.doc-card:nth-child(2) .doc-icon {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.doc-card:nth-child(3) .doc-icon {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
.doc-card h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.doc-card p {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.doc-link {
|
||||
gap: 0.5rem;
|
||||
color: var(--text-dark);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.doc-card:nth-child(1) .doc-link:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.doc-card:nth-child(2) .doc-link:hover {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.doc-card:nth-child(3) .doc-link:hover {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.docs-cta {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Roadmap Section */
|
||||
.roadmap {
|
||||
padding: 80px 0;
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
.roadmap-timeline {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.roadmap-item {
|
||||
display: flex;
|
||||
margin-bottom: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.roadmap-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 30px;
|
||||
bottom: -30px;
|
||||
width: 2px;
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.roadmap-item:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.roadmap-marker {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
margin-right: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roadmap-content {
|
||||
background: var(--bg-white);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.roadmap-content h4 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
background: var(--text-dark);
|
||||
color: white;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
2
website/assets/css/tailwind.css
Normal file
2
website/assets/css/tailwind.css
Normal file
@@ -0,0 +1,2 @@
|
||||
/* Tailwind CSS placeholder - replace with actual Tailwind build if needed */
|
||||
/* For now, using custom CSS in main.css */
|
||||
0
website/assets/favicon.ico
Normal file
0
website/assets/favicon.ico
Normal file
53
website/assets/js/analytics.js
Normal file
53
website/assets/js/analytics.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Lightweight privacy-focused analytics for AITBC
|
||||
// No cookies, no tracking, just basic page view metrics
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const script = document.currentScript;
|
||||
const host = script.getAttribute('data-host') || window.location.origin;
|
||||
const website = script.getAttribute('data-website') || 'default';
|
||||
|
||||
// Send page view on load
|
||||
function sendPageView() {
|
||||
const data = {
|
||||
url: window.location.href,
|
||||
pathname: window.location.pathname,
|
||||
hostname: window.location.hostname,
|
||||
referrer: document.referrer,
|
||||
screenWidth: window.screen.width,
|
||||
screenHeight: window.screen.height,
|
||||
language: navigator.language,
|
||||
website: website,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Use sendBeacon for reliable delivery
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(`${host}/api/analytics`, JSON.stringify(data));
|
||||
} else {
|
||||
// Fallback to fetch
|
||||
fetch(`${host}/api/analytics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
keepalive: true
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial page view
|
||||
if (document.readyState === 'complete') {
|
||||
sendPageView();
|
||||
} else {
|
||||
window.addEventListener('load', sendPageView);
|
||||
}
|
||||
|
||||
// Track route changes for SPA
|
||||
let lastPath = window.location.pathname;
|
||||
setInterval(() => {
|
||||
if (window.location.pathname !== lastPath) {
|
||||
lastPath = window.location.pathname;
|
||||
sendPageView();
|
||||
}
|
||||
}, 1000);
|
||||
})();
|
||||
307
website/assets/js/main.js
Normal file
307
website/assets/js/main.js
Normal file
@@ -0,0 +1,307 @@
|
||||
// Smooth scrolling for navigation links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('href');
|
||||
if (targetId && targetId !== '#') {
|
||||
const targetElement = document.querySelector(targetId);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add animation on scroll
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver(function(entries) {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.style.opacity = '1';
|
||||
entry.target.style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
|
||||
// Observe all feature cards
|
||||
document.querySelectorAll('.feature-card, .arch-component').forEach(el => {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(20px)';
|
||||
el.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// Dark mode functionality with enhanced persistence and system preference detection
|
||||
function toggleDarkMode() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
// Apply theme immediately
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// Update button display if it exists
|
||||
updateThemeButton(theme);
|
||||
|
||||
// Send analytics event
|
||||
if (window.analytics) {
|
||||
window.analytics.track('theme_changed', { theme });
|
||||
}
|
||||
}
|
||||
|
||||
function updateThemeButton(theme) {
|
||||
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() {
|
||||
// 1. Check localStorage first (user preference)
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
// 2. Check system preference
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
// 3. 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('theme')) {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme immediately (before DOM loads)
|
||||
initializeTheme();
|
||||
|
||||
// Touch gesture support for mobile navigation
|
||||
class TouchNavigation {
|
||||
constructor() {
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.touchEndX = 0;
|
||||
this.touchEndY = 0;
|
||||
this.minSwipeDistance = 50;
|
||||
this.maxVerticalDistance = 100;
|
||||
|
||||
// Get all major sections for navigation
|
||||
this.sections = ['hero', 'features', 'architecture', 'achievements', 'documentation'];
|
||||
this.currentSectionIndex = 0;
|
||||
|
||||
this.bindEvents();
|
||||
this.setupMobileOptimizations();
|
||||
}
|
||||
|
||||
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 sectionId = this.sections[index];
|
||||
const element = document.getElementById(sectionId);
|
||||
|
||||
if (element) {
|
||||
this.currentSectionIndex = index;
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
|
||||
// Update URL hash without triggering scroll
|
||||
history.replaceState(null, null, `#${sectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
setupMobileOptimizations() {
|
||||
// Add touch-friendly interactions
|
||||
this.setupTouchButtons();
|
||||
this.setupScrollOptimizations();
|
||||
this.setupMobileMenu();
|
||||
}
|
||||
|
||||
setupTouchButtons() {
|
||||
// Make buttons more touch-friendly
|
||||
const buttons = document.querySelectorAll('button, .cta-button, .nav-button');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('touchstart', () => {
|
||||
button.style.transform = 'scale(0.98)';
|
||||
}, { passive: true });
|
||||
|
||||
button.addEventListener('touchend', () => {
|
||||
button.style.transform = '';
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
setupScrollOptimizations() {
|
||||
// Improve momentum scrolling on iOS
|
||||
if ('webkitOverflowScrolling' in document.body.style) {
|
||||
document.body.style.webkitOverflowScrolling = 'touch';
|
||||
}
|
||||
|
||||
// Add smooth scrolling for anchor links with touch feedback
|
||||
document.querySelectorAll('a[href^="#"]').forEach(link => {
|
||||
link.addEventListener('touchstart', () => {
|
||||
link.style.opacity = '0.7';
|
||||
}, { passive: true });
|
||||
|
||||
link.addEventListener('touchend', () => {
|
||||
link.style.opacity = '';
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
setupMobileMenu() {
|
||||
// Create mobile menu toggle if nav is hidden on mobile
|
||||
const nav = document.querySelector('nav');
|
||||
if (nav && window.innerWidth < 768) {
|
||||
this.createMobileMenu();
|
||||
}
|
||||
}
|
||||
|
||||
createMobileMenu() {
|
||||
// Create hamburger menu for mobile
|
||||
const header = document.querySelector('header');
|
||||
if (!header) return;
|
||||
|
||||
const mobileMenuBtn = document.createElement('button');
|
||||
mobileMenuBtn.className = 'mobile-menu-btn';
|
||||
mobileMenuBtn.innerHTML = '☰';
|
||||
mobileMenuBtn.setAttribute('aria-label', 'Toggle mobile menu');
|
||||
|
||||
const nav = document.querySelector('nav');
|
||||
if (nav) {
|
||||
nav.style.display = 'none';
|
||||
|
||||
mobileMenuBtn.addEventListener('click', () => {
|
||||
const isOpen = nav.style.display !== 'none';
|
||||
nav.style.display = isOpen ? 'none' : 'flex';
|
||||
mobileMenuBtn.innerHTML = isOpen ? '☰' : '✕';
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!header.contains(e.target)) {
|
||||
nav.style.display = 'none';
|
||||
mobileMenuBtn.innerHTML = '☰';
|
||||
}
|
||||
});
|
||||
|
||||
header.appendChild(mobileMenuBtn);
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentSection() {
|
||||
// Update current section based on scroll position
|
||||
const scrollY = window.scrollY + window.innerHeight / 2;
|
||||
|
||||
this.sections.forEach((sectionId, index) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const elementTop = rect.top + window.scrollY;
|
||||
const elementBottom = elementTop + rect.height;
|
||||
|
||||
if (scrollY >= elementTop && scrollY < elementBottom) {
|
||||
this.currentSectionIndex = index;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize touch navigation when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new TouchNavigation();
|
||||
|
||||
// Update current section on scroll
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.touchNav) {
|
||||
window.touchNav.updateCurrentSection();
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
69
website/assets/js/sw.js
Normal file
69
website/assets/js/sw.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const CACHE_NAME = 'aitbc-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/assets/css/main.css',
|
||||
'/assets/css/font-awesome.min.css',
|
||||
'/assets/js/main.js',
|
||||
'/favicon.ico',
|
||||
'/explorer/',
|
||||
'/marketplace/',
|
||||
'/docs/'
|
||||
];
|
||||
|
||||
// Install event - cache assets
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Caching assets...');
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
.catch(err => console.log('Cache failed:', err))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache or network
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return cached version or fetch from network
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request)
|
||||
.then(networkResponse => {
|
||||
// Cache successful network responses
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const clonedResponse = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
cache.put(event.request, clonedResponse);
|
||||
});
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(() => {
|
||||
// Return offline fallback for HTML requests
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(name => name !== CACHE_NAME)
|
||||
.map(name => caches.delete(name))
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
158
website/assets/js/web-vitals.js
Normal file
158
website/assets/js/web-vitals.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// Web Vitals monitoring for AITBC
|
||||
// Tracks Core Web Vitals: LCP, FID, CLS, TTFB, FCP
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function sendToAnalytics(metric) {
|
||||
const data = {
|
||||
name: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
id: metric.id,
|
||||
delta: Math.round(metric.delta),
|
||||
entries: metric.entries.map(e => ({
|
||||
name: e.name,
|
||||
startTime: e.startTime,
|
||||
duration: e.duration
|
||||
})),
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Send to analytics endpoint
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon('/api/web-vitals', JSON.stringify(data));
|
||||
}
|
||||
|
||||
// Also log to console in development
|
||||
console.log(`[Web Vitals] ${metric.name}: ${metric.value}`, metric);
|
||||
}
|
||||
|
||||
// Largest Contentful Paint (LCP)
|
||||
function observeLCP() {
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
sendToAnalytics({
|
||||
name: 'LCP',
|
||||
value: lastEntry.renderTime || lastEntry.loadTime,
|
||||
id: lastEntry.id,
|
||||
delta: 0,
|
||||
entries: entries
|
||||
});
|
||||
});
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint'] });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// First Input Delay (FID)
|
||||
function observeFID() {
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
entries.forEach(entry => {
|
||||
sendToAnalytics({
|
||||
name: 'FID',
|
||||
value: entry.processingStart - entry.startTime,
|
||||
id: entry.id,
|
||||
delta: 0,
|
||||
entries: [entry]
|
||||
});
|
||||
});
|
||||
});
|
||||
observer.observe({ entryTypes: ['first-input'] });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Cumulative Layout Shift (CLS)
|
||||
function observeCLS() {
|
||||
try {
|
||||
let clsValue = 0;
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
try {
|
||||
const entries = list.getEntries();
|
||||
entries.forEach(entry => {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value;
|
||||
}
|
||||
});
|
||||
sendToAnalytics({
|
||||
name: 'CLS',
|
||||
value: clsValue,
|
||||
id: 'cls',
|
||||
delta: 0,
|
||||
entries: entries
|
||||
});
|
||||
} catch (e) {
|
||||
// CLS measurement failed, but don't crash the whole script
|
||||
console.log('CLS measurement error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Try to observe layout-shift, but handle if it's not supported
|
||||
try {
|
||||
observer.observe({ entryTypes: ['layout-shift'] });
|
||||
} catch (e) {
|
||||
console.log('Layout-shift observation not supported:', e.message);
|
||||
// CLS will not be measured, but other metrics will still work
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('CLS observer setup failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Time to First Byte (TTFB)
|
||||
function measureTTFB() {
|
||||
try {
|
||||
const nav = performance.getEntriesByType('navigation')[0];
|
||||
if (nav) {
|
||||
sendToAnalytics({
|
||||
name: 'TTFB',
|
||||
value: nav.responseStart,
|
||||
id: 'ttfb',
|
||||
delta: 0,
|
||||
entries: [nav]
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// First Contentful Paint (FCP)
|
||||
function observeFCP() {
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
entries.forEach(entry => {
|
||||
if (entry.name === 'first-contentful-paint') {
|
||||
sendToAnalytics({
|
||||
name: 'FCP',
|
||||
value: entry.startTime,
|
||||
id: entry.id,
|
||||
delta: 0,
|
||||
entries: [entry]
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe({ entryTypes: ['paint'] });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
if (document.readyState === 'complete') {
|
||||
observeLCP();
|
||||
observeFID();
|
||||
observeCLS();
|
||||
observeFCP();
|
||||
measureTTFB();
|
||||
} else {
|
||||
window.addEventListener('load', () => {
|
||||
observeLCP();
|
||||
observeFID();
|
||||
observeCLS();
|
||||
observeFCP();
|
||||
measureTTFB();
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user