Initial commit
This commit is contained in:
commit
5cb295d0aa
697
admin.html
Normal file
697
admin.html
Normal file
@ -0,0 +1,697 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Moxiegen Admin Dashboard">
|
||||
<title>Admin Dashboard • Moxiegen</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--dark-background: #0F172A;
|
||||
--dark-surface: #1E293B;
|
||||
--dark-primary-text: #F8FAFC;
|
||||
--dark-secondary-text: #94A3B8;
|
||||
--dark-accent-primary: #38BDF8;
|
||||
--dark-accent-neural: #A5B4FC;
|
||||
--dark-border: #334155;
|
||||
}
|
||||
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--dark-background);
|
||||
color: var(--dark-primary-text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--dark-accent-primary), var(--dark-accent-neural));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--dark-surface);
|
||||
border: 1px solid var(--dark-border);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.status-new { background: #3b82f6; }
|
||||
.status-read { background: #6366f1; }
|
||||
.status-replied { background: #22c55e; }
|
||||
.status-archived { background: #64748b; }
|
||||
.status-spam { background: #ef4444; }
|
||||
|
||||
.tab-active {
|
||||
background: linear-gradient(135deg, #38BDF8, #A5B4FC);
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #38BDF8, #A5B4FC);
|
||||
color: #0F172A;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px -5px rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--dark-surface);
|
||||
border: 1px solid var(--dark-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--dark-accent-primary);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-slate-800 bg-slate-950/80 backdrop-blur-lg sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="index.html" class="flex items-center gap-2">
|
||||
<img src="logo.svg" alt="Moxiegen" class="h-8 w-auto" onerror="this.onerror=null; this.src='logo.png';">
|
||||
<span class="font-semibold text-xl" style="font-family: 'Space Grotesk', sans-serif;">Moxiegen</span>
|
||||
</a>
|
||||
<span class="text-slate-500">/</span>
|
||||
<span class="gradient-text font-semibold">Admin</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="index.html" class="text-slate-400 hover:text-white transition-colors">← Back to Site</a>
|
||||
<button onclick="logout()" class="btn-secondary px-4 py-2 rounded-lg text-sm font-medium">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Access Denied Message -->
|
||||
<div id="accessDenied" class="hidden min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4">🚫</div>
|
||||
<h1 class="text-3xl font-bold mb-2">Access Denied</h1>
|
||||
<p class="text-slate-400 mb-6">You need admin privileges to view this page.</p>
|
||||
<a href="index.html" class="btn-primary px-6 py-3 rounded-lg font-semibold inline-block">Go Home</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main id="mainContent" class="hidden max-w-7xl mx-auto px-6 py-8">
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="card p-6">
|
||||
<div class="text-slate-400 text-sm mb-1">Total Users</div>
|
||||
<div id="statUsers" class="text-3xl font-bold gradient-text">-</div>
|
||||
</div>
|
||||
<div class="card p-6">
|
||||
<div class="text-slate-400 text-sm mb-1">New Messages</div>
|
||||
<div id="statNewMessages" class="text-3xl font-bold text-blue-400">-</div>
|
||||
</div>
|
||||
<div class="card p-6">
|
||||
<div class="text-slate-400 text-sm mb-1">Total Credits</div>
|
||||
<div id="statCredits" class="text-3xl font-bold text-green-400">-</div>
|
||||
</div>
|
||||
<div class="card p-6">
|
||||
<div class="text-slate-400 text-sm mb-1">Active API Keys</div>
|
||||
<div id="statApiKeys" class="text-3xl font-bold text-purple-400">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button onclick="switchTab('messages')" id="tabMessages" class="tab-active px-6 py-2 rounded-lg font-medium transition-all">
|
||||
Messages
|
||||
</button>
|
||||
<button onclick="switchTab('users')" id="tabUsers" class="btn-secondary px-6 py-2 rounded-lg font-medium transition-all">
|
||||
Users
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages Tab -->
|
||||
<div id="messagesTab" class="card overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Contact Submissions</h2>
|
||||
<div class="flex gap-2">
|
||||
<select id="statusFilter" onchange="loadMessages()" class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="new">New</option>
|
||||
<option value="read">Read</option>
|
||||
<option value="replied">Replied</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="spam">Spam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messagesLoading" class="p-8 text-center">
|
||||
<div class="loading inline-block text-4xl">⟳</div>
|
||||
<p class="text-slate-400 mt-2">Loading messages...</p>
|
||||
</div>
|
||||
|
||||
<div id="messagesEmpty" class="hidden p-8 text-center text-slate-400">
|
||||
<div class="text-4xl mb-2">📭</div>
|
||||
<p>No messages yet</p>
|
||||
</div>
|
||||
|
||||
<div id="messagesContent" class="hidden divide-y divide-slate-800">
|
||||
<!-- Messages will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div id="usersTab" class="card overflow-hidden hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Users</h2>
|
||||
</div>
|
||||
|
||||
<div id="usersLoading" class="p-8 text-center">
|
||||
<div class="loading inline-block text-4xl">⟳</div>
|
||||
<p class="text-slate-400 mt-2">Loading users...</p>
|
||||
</div>
|
||||
|
||||
<div id="usersEmpty" class="hidden p-8 text-center text-slate-400">
|
||||
<div class="text-4xl mb-2">👥</div>
|
||||
<p>No users yet</p>
|
||||
</div>
|
||||
|
||||
<div id="usersContent" class="hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">User</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Email</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Role</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Credits</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Created</th>
|
||||
<th class="text-left px-4 py-3 text-sm font-medium text-slate-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody" class="divide-y divide-slate-800">
|
||||
<!-- Users will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Message Detail Modal -->
|
||||
<div id="messageModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-overlay absolute inset-0" onclick="closeModal()"></div>
|
||||
<div class="card relative z-10 w-full max-w-2xl max-h-[90vh] overflow-auto">
|
||||
<div class="p-6 border-b border-slate-700 flex items-start justify-between">
|
||||
<div>
|
||||
<h3 id="modalSubject" class="text-xl font-semibold">Message from <span id="modalName">-</span></h3>
|
||||
<p id="modalMeta" class="text-sm text-slate-400 mt-1">-</p>
|
||||
</div>
|
||||
<button onclick="closeModal()" class="text-slate-400 hover:text-white text-2xl">×</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<span class="text-slate-400 text-sm">From:</span>
|
||||
<span id="modalEmail" class="ml-2">-</span>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<span class="text-slate-400 text-sm">Company:</span>
|
||||
<span id="modalCompany" class="ml-2">-</span>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<span class="text-slate-400 text-sm">Status:</span>
|
||||
<span id="modalStatus" class="ml-2 px-2 py-0.5 rounded text-xs font-medium">-</span>
|
||||
</div>
|
||||
<div class="border-t border-slate-700 pt-4">
|
||||
<span class="text-slate-400 text-sm block mb-2">Message:</span>
|
||||
<p id="modalMessage" class="whitespace-pre-wrap">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-700 flex flex-wrap gap-2">
|
||||
<button onclick="updateStatus('read')" class="btn-secondary px-4 py-2 rounded-lg text-sm">Mark Read</button>
|
||||
<button onclick="updateStatus('replied')" class="btn-secondary px-4 py-2 rounded-lg text-sm">Mark Replied</button>
|
||||
<button onclick="updateStatus('archived')" class="btn-secondary px-4 py-2 rounded-lg text-sm">Archive</button>
|
||||
<button onclick="updateStatus('spam')" class="btn-secondary px-4 py-2 rounded-lg text-sm text-red-400">Spam</button>
|
||||
<a id="replyLink" href="#" class="btn-primary px-4 py-2 rounded-lg text-sm font-medium ml-auto">Reply via Email →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits Modal -->
|
||||
<div id="creditsModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="modal-overlay absolute inset-0" onclick="closeCreditsModal()"></div>
|
||||
<div class="card relative z-10 w-full max-w-md">
|
||||
<div class="p-6 border-b border-slate-700">
|
||||
<h3 class="text-xl font-semibold">Adjust Credits</h3>
|
||||
<p id="creditsModalUser" class="text-sm text-slate-400">User: -</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-slate-400 mb-2">Current Balance</label>
|
||||
<p id="creditsCurrentBalance" class="text-2xl font-bold gradient-text">0</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-slate-400 mb-2">Amount</label>
|
||||
<input type="number" id="creditsAmount" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2" placeholder="Enter amount (positive or negative)">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-slate-400 mb-2">Description</label>
|
||||
<input type="text" id="creditsDescription" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2" placeholder="Reason for adjustment">
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-700 flex gap-2">
|
||||
<button onclick="closeCreditsModal()" class="btn-secondary px-4 py-2 rounded-lg">Cancel</button>
|
||||
<button onclick="submitCredits()" class="btn-primary px-4 py-2 rounded-lg font-medium">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>
|
||||
let currentTab = 'messages';
|
||||
let currentMessageId = null;
|
||||
let currentUserId = null;
|
||||
let messages = [];
|
||||
let users = [];
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await moxieAuth.init();
|
||||
|
||||
if (!moxieAuth.isAuthenticated) {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if admin
|
||||
const isAdmin = await checkAdmin();
|
||||
if (!isAdmin) {
|
||||
document.getElementById('accessDenied').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('mainContent').classList.remove('hidden');
|
||||
await Promise.all([loadStats(), loadMessages(), loadUsers()]);
|
||||
} catch (error) {
|
||||
console.error('Init error:', error);
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
});
|
||||
|
||||
async function checkAdmin() {
|
||||
const token = await moxieAuth.getToken();
|
||||
try {
|
||||
const response = await fetch('/api/users/me', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.success && data.data?.user?.role === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const token = await moxieAuth.getToken();
|
||||
|
||||
// Load users for stats
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const users = data.data.users || data.data;
|
||||
const totalUsers = users.length;
|
||||
const totalCredits = users.reduce((sum, u) => sum + (u.credits || 0), 0);
|
||||
document.getElementById('statUsers').textContent = totalUsers;
|
||||
document.getElementById('statCredits').textContent = totalCredits.toLocaleString();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stats error:', error);
|
||||
}
|
||||
|
||||
// Load messages for stats
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const messages = data.data || [];
|
||||
const newMessages = messages.filter(m => m.status === 'new').length;
|
||||
document.getElementById('statNewMessages').textContent = newMessages;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Message stats error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
const token = await moxieAuth.getToken();
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
|
||||
document.getElementById('messagesLoading').classList.remove('hidden');
|
||||
document.getElementById('messagesContent').classList.add('hidden');
|
||||
document.getElementById('messagesEmpty').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
messages = data.data;
|
||||
|
||||
if (statusFilter) {
|
||||
messages = messages.filter(m => m.status === statusFilter);
|
||||
}
|
||||
|
||||
document.getElementById('messagesLoading').classList.add('hidden');
|
||||
|
||||
if (messages.length === 0) {
|
||||
document.getElementById('messagesEmpty').classList.remove('hidden');
|
||||
} else {
|
||||
renderMessages();
|
||||
document.getElementById('messagesContent').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load messages error:', error);
|
||||
document.getElementById('messagesLoading').innerHTML = '<p class="text-red-400">Failed to load messages</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
const container = document.getElementById('messagesContent');
|
||||
container.innerHTML = messages.map(m => `
|
||||
<div class="table-row p-4 cursor-pointer" onclick="openMessage('${m.id}')">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-cyan-400 to-indigo-400 flex items-center justify-center font-semibold text-slate-900">
|
||||
${(m.name || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium">${escapeHtml(m.name)}</span>
|
||||
<span class="status-${m.status} px-2 py-0.5 rounded text-xs">${m.status}</span>
|
||||
<span class="text-slate-500 text-sm ml-auto">${formatDate(m.created_at)}</span>
|
||||
</div>
|
||||
<div class="text-slate-400 text-sm">${escapeHtml(m.email)}</div>
|
||||
<div class="text-slate-500 text-sm mt-1 truncate">${escapeHtml(m.message || '').substring(0, 100)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openMessage(id) {
|
||||
const message = messages.find(m => m.id === id);
|
||||
if (!message) return;
|
||||
|
||||
currentMessageId = id;
|
||||
|
||||
document.getElementById('modalName').textContent = message.name || '-';
|
||||
document.getElementById('modalEmail').textContent = message.email || '-';
|
||||
document.getElementById('modalCompany').textContent = message.company || 'Not provided';
|
||||
document.getElementById('modalMessage').textContent = message.message || '-';
|
||||
document.getElementById('modalMeta').textContent = `Submitted ${formatDate(message.created_at)}`;
|
||||
|
||||
const statusEl = document.getElementById('modalStatus');
|
||||
statusEl.textContent = message.status;
|
||||
statusEl.className = `ml-2 px-2 py-0.5 rounded text-xs font-medium status-${message.status}`;
|
||||
|
||||
document.getElementById('replyLink').href = `mailto:${message.email}?subject=Re: Your message to Moxiegen`;
|
||||
|
||||
document.getElementById('messageModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('messageModal').classList.add('hidden');
|
||||
currentMessageId = null;
|
||||
}
|
||||
|
||||
async function updateStatus(status) {
|
||||
if (!currentMessageId) return;
|
||||
|
||||
const token = await moxieAuth.getToken();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contact/${currentMessageId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
closeModal();
|
||||
await loadMessages();
|
||||
await loadStats();
|
||||
} else {
|
||||
alert('Failed to update status: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update status error:', error);
|
||||
alert('Failed to update status');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const token = await moxieAuth.getToken();
|
||||
|
||||
document.getElementById('usersLoading').classList.remove('hidden');
|
||||
document.getElementById('usersContent').classList.add('hidden');
|
||||
document.getElementById('usersEmpty').classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
users = data.data.users || data.data;
|
||||
|
||||
document.getElementById('usersLoading').classList.add('hidden');
|
||||
|
||||
if (users.length === 0) {
|
||||
document.getElementById('usersEmpty').classList.remove('hidden');
|
||||
} else {
|
||||
renderUsers();
|
||||
document.getElementById('usersContent').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load users error:', error);
|
||||
document.getElementById('usersLoading').innerHTML = '<p class="text-red-400">Failed to load users</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const tbody = document.getElementById('usersTableBody');
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr class="table-row">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-cyan-400 to-indigo-400 flex items-center justify-center font-medium text-slate-900 text-sm flex-shrink-0">
|
||||
${(u.name || u.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<span class="truncate">${escapeHtml(u.name || 'No name')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-400 truncate max-w-[200px]">${escapeHtml(u.email || '-')}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-0.5 rounded text-xs whitespace-nowrap ${u.role === 'admin' ? 'bg-purple-500/20 text-purple-400' : 'bg-slate-700 text-slate-300'}">
|
||||
${u.role || 'user'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium whitespace-nowrap">${(u.credits || 0).toLocaleString()}</td>
|
||||
<td class="px-4 py-3 text-slate-400 text-sm whitespace-nowrap">${formatDate(u.created_at)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2 flex-nowrap">
|
||||
<button onclick="openCreditsModal('${u.id}', '${escapeHtml(u.name || u.email)}', ${u.credits || 0})" class="btn-secondary px-3 py-1 rounded text-sm whitespace-nowrap">
|
||||
Credits
|
||||
</button>
|
||||
${u.role === 'admin' ? `
|
||||
<button onclick="toggleAdmin('${u.id}', 'user')" class="px-3 py-1 rounded text-sm whitespace-nowrap bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 transition-colors">
|
||||
Remove Admin
|
||||
</button>
|
||||
` : `
|
||||
<button onclick="toggleAdmin('${u.id}', 'admin')" class="px-3 py-1 rounded text-sm whitespace-nowrap bg-purple-500/20 text-purple-400 hover:bg-purple-500/30 transition-colors">
|
||||
Make Admin
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function openCreditsModal(userId, userName, currentCredits) {
|
||||
currentUserId = userId;
|
||||
document.getElementById('creditsModalUser').textContent = `User: ${userName}`;
|
||||
document.getElementById('creditsCurrentBalance').textContent = currentCredits.toLocaleString();
|
||||
document.getElementById('creditsAmount').value = '';
|
||||
document.getElementById('creditsDescription').value = '';
|
||||
document.getElementById('creditsModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeCreditsModal() {
|
||||
document.getElementById('creditsModal').classList.add('hidden');
|
||||
currentUserId = null;
|
||||
}
|
||||
|
||||
async function submitCredits() {
|
||||
if (!currentUserId) return;
|
||||
|
||||
const amount = parseInt(document.getElementById('creditsAmount').value);
|
||||
const description = document.getElementById('creditsDescription').value;
|
||||
|
||||
if (isNaN(amount) || amount === 0) {
|
||||
alert('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await moxieAuth.getToken();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${currentUserId}/credits`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ amount, description: description || 'Admin adjustment' })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
closeCreditsModal();
|
||||
await Promise.all([loadUsers(), loadStats()]);
|
||||
} else {
|
||||
alert('Failed to update credits: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Credits error:', error);
|
||||
alert('Failed to update credits');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAdmin(userId, newRole) {
|
||||
const user = users.find(u => u.id === userId);
|
||||
const userName = user?.name || user?.email || 'this user';
|
||||
|
||||
const action = newRole === 'admin' ? 'promote' : 'demote';
|
||||
if (!confirm(`Are you sure you want to ${action} ${userName} to ${newRole}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await moxieAuth.getToken();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/role`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ role: newRole })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
await loadUsers();
|
||||
} else {
|
||||
alert('Failed to update role: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Role update error:', error);
|
||||
alert('Failed to update role');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
document.getElementById('tabMessages').classList.remove('tab-active');
|
||||
document.getElementById('tabMessages').classList.add('btn-secondary');
|
||||
document.getElementById('tabUsers').classList.remove('tab-active');
|
||||
document.getElementById('tabUsers').classList.add('btn-secondary');
|
||||
|
||||
document.getElementById('messagesTab').classList.add('hidden');
|
||||
document.getElementById('usersTab').classList.add('hidden');
|
||||
|
||||
if (tab === 'messages') {
|
||||
document.getElementById('tabMessages').classList.add('tab-active');
|
||||
document.getElementById('tabMessages').classList.remove('btn-secondary');
|
||||
document.getElementById('messagesTab').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('tabUsers').classList.add('tab-active');
|
||||
document.getElementById('tabUsers').classList.remove('btn-secondary');
|
||||
document.getElementById('usersTab').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await moxieAuth.logout();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
496
dashboard.html
Normal file
496
dashboard.html
Normal file
@ -0,0 +1,496 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard • Moxiegen</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--dark-background: #0F172A;
|
||||
--dark-surface: #1E293B;
|
||||
--dark-primary-text: #F8FAFC;
|
||||
--dark-secondary-text: #94A3B8;
|
||||
--dark-accent-primary: #38BDF8;
|
||||
--dark-accent-neural: #A5B4FC;
|
||||
--dark-border: #334155;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--dark-background);
|
||||
color: var(--dark-primary-text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--dark-accent-primary), var(--dark-accent-neural));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--dark-accent-primary), var(--dark-accent-neural));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px -10px rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--dark-surface);
|
||||
border: 1px solid var(--dark-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--dark-accent-primary);
|
||||
}
|
||||
|
||||
.loading-skeleton {
|
||||
background: linear-gradient(90deg, var(--dark-surface) 0%, var(--dark-border) 50%, var(--dark-surface) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.api-key-mask {
|
||||
font-family: monospace;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--dark-accent-primary);
|
||||
color: var(--dark-background);
|
||||
}
|
||||
|
||||
.toast {
|
||||
animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.7s;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Navigation -->
|
||||
<nav class="border-b border-slate-800 bg-slate-950/80 backdrop-blur-lg sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="index.html">
|
||||
<img src="logo.svg" alt="Moxiegen" class="h-10 w-auto" onerror="this.src='logo.png'">
|
||||
</a>
|
||||
<span class="font-semibold text-2xl" style="font-family: 'Space Grotesk', sans-serif;">Moxiegen</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div id="userInfo" class="flex items-center gap-3">
|
||||
<div class="text-right">
|
||||
<div id="userName" class="font-medium"></div>
|
||||
<div id="userEmail" class="text-sm text-slate-400"></div>
|
||||
</div>
|
||||
<div id="userAvatar" class="w-10 h-10 rounded-full bg-gradient-to-br from-[#38BDF8] to-[#A5B4FC] flex items-center justify-center font-semibold text-slate-950"></div>
|
||||
</div>
|
||||
<a id="adminBtn" href="admin.html" class="hidden px-4 py-2 text-sm font-medium bg-gradient-to-r from-[#38BDF8] to-[#A5B4FC] text-slate-950 rounded-lg hover:opacity-90 transition-all">
|
||||
Admin
|
||||
</a>
|
||||
<button onclick="logout()" class="px-4 py-2 text-sm font-medium text-slate-400 hover:text-white border border-slate-700 hover:border-slate-500 rounded-lg transition-all">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
<!-- Welcome Section -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-semibold mb-2">Welcome back, <span id="welcomeName" class="text-gradient">User</span></h1>
|
||||
<p class="text-slate-400">Manage your API keys, credits, and account settings.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Credits -->
|
||||
<div class="card rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-slate-400 text-sm font-medium uppercase tracking-wider">Credits</span>
|
||||
<span class="text-2xl">💰</span>
|
||||
</div>
|
||||
<div id="creditsBalance" class="text-4xl font-bold text-gradient">--</div>
|
||||
<p class="text-slate-400 text-sm mt-2">Available balance</p>
|
||||
</div>
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="card rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-slate-400 text-sm font-medium uppercase tracking-wider">API Keys</span>
|
||||
<span class="text-2xl">🔑</span>
|
||||
</div>
|
||||
<div id="apiKeyCount" class="text-4xl font-bold text-gradient">--</div>
|
||||
<p class="text-slate-400 text-sm mt-2">Active keys</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription -->
|
||||
<div class="card rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="text-slate-400 text-sm font-medium uppercase tracking-wider">Plan</span>
|
||||
<span class="text-2xl">⚡</span>
|
||||
</div>
|
||||
<div id="subscriptionPlan" class="text-4xl font-bold text-gradient capitalize">--</div>
|
||||
<p id="subscriptionStatus" class="text-slate-400 text-sm mt-2">--</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Panels -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- API Keys Panel -->
|
||||
<div class="card rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold">API Keys</h2>
|
||||
<button onclick="createApiKey()" class="btn-primary px-4 py-2 text-sm font-medium text-slate-950 rounded-lg flex items-center gap-2">
|
||||
<span>+</span> Create Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="apiKeysList" class="space-y-3">
|
||||
<div class="loading-skeleton h-16 rounded-lg"></div>
|
||||
<div class="loading-skeleton h-16 rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
<div id="noApiKeys" class="hidden text-center py-8 text-slate-400">
|
||||
<p class="mb-4">No API keys yet</p>
|
||||
<button onclick="createApiKey()" class="text-[#38BDF8] hover:underline">Create your first API key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Panel -->
|
||||
<div class="card rounded-2xl p-6">
|
||||
<h2 class="text-xl font-semibold mb-6">Recent Activity</h2>
|
||||
|
||||
<div id="activityList" class="space-y-3">
|
||||
<div class="loading-skeleton h-14 rounded-lg"></div>
|
||||
<div class="loading-skeleton h-14 rounded-lg"></div>
|
||||
<div class="loading-skeleton h-14 rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
<div id="noActivity" class="hidden text-center py-8 text-slate-400">
|
||||
<p>No recent activity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Settings -->
|
||||
<div class="card rounded-2xl p-6 mt-6">
|
||||
<h2 class="text-xl font-semibold mb-6">Account Settings</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Display Name</label>
|
||||
<input type="text" id="settingsName" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 outline-none focus:border-[#38BDF8] transition-colors" placeholder="Your name">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Email</label>
|
||||
<input type="email" id="settingsEmail" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-3 outline-none focus:border-[#38BDF8] transition-colors" disabled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 mt-6">
|
||||
<button onclick="saveSettings()" class="btn-primary px-6 py-2 text-sm font-medium text-slate-950 rounded-lg">
|
||||
Save Changes
|
||||
</button>
|
||||
<button onclick="deactivateAccount()" class="px-6 py-2 text-sm font-medium text-red-400 hover:text-red-300 border border-red-400/30 hover:border-red-400 rounded-lg transition-colors">
|
||||
Deactivate Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 space-y-2"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script>
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
// Initialize auth
|
||||
await moxieAuth.init();
|
||||
|
||||
// Check if authenticated
|
||||
if (!moxieAuth.isAuthenticated) {
|
||||
// Redirect to login
|
||||
await moxieAuth.login(window.location.href);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load user data
|
||||
await loadDashboard();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Initialization error:', error);
|
||||
showToast('Failed to initialize. Please refresh.', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
// Get user profile from API
|
||||
const profileResponse = await moxieAPI.getProfile();
|
||||
const user = profileResponse.data.user;
|
||||
|
||||
// Update UI with user info
|
||||
updateUserUI(user);
|
||||
|
||||
// Load credits
|
||||
const creditsResponse = await moxieAPI.getCredits(1, 5);
|
||||
document.getElementById('creditsBalance').textContent = creditsResponse.data.credits.toLocaleString();
|
||||
|
||||
// Update activity list
|
||||
updateActivityList(creditsResponse.data.transactions);
|
||||
|
||||
// Load API keys
|
||||
const keysResponse = await moxieAPI.getApiKeys();
|
||||
document.getElementById('apiKeyCount').textContent = keysResponse.data.keys.length;
|
||||
updateApiKeysList(keysResponse.data.keys);
|
||||
|
||||
// Update subscription
|
||||
document.getElementById('subscriptionPlan').textContent = user.subscription_tier || 'Free';
|
||||
document.getElementById('subscriptionStatus').textContent = user.subscription_status || 'No active subscription';
|
||||
|
||||
// Update settings form
|
||||
document.getElementById('settingsName').value = user.name || '';
|
||||
document.getElementById('settingsEmail').value = user.email || '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
showToast('Failed to load dashboard data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateUserUI(user) {
|
||||
document.getElementById('userName').textContent = user.name || 'User';
|
||||
document.getElementById('userEmail').textContent = user.email || '';
|
||||
document.getElementById('welcomeName').textContent = user.name || 'User';
|
||||
|
||||
// Show admin button if user is admin
|
||||
if (user.role === 'admin') {
|
||||
document.getElementById('adminBtn').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Avatar
|
||||
const avatar = document.getElementById('userAvatar');
|
||||
if (user.picture) {
|
||||
avatar.innerHTML = `<img src="${user.picture}" class="w-full h-full rounded-full object-cover">`;
|
||||
} else {
|
||||
avatar.textContent = (user.name || 'U').charAt(0).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
function updateApiKeysList(keys) {
|
||||
const container = document.getElementById('apiKeysList');
|
||||
const noKeys = document.getElementById('noApiKeys');
|
||||
|
||||
if (keys.length === 0) {
|
||||
container.innerHTML = '';
|
||||
noKeys.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
noKeys.classList.add('hidden');
|
||||
|
||||
container.innerHTML = keys.map(key => `
|
||||
<div class="flex items-center justify-between bg-slate-800/50 rounded-lg p-4">
|
||||
<div>
|
||||
<div class="font-medium">${escapeHtml(key.name)}</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
Created ${new Date(key.created_at).toLocaleDateString()}
|
||||
${key.last_used ? ` • Last used ${new Date(key.last_used).toLocaleDateString()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="revokeKey('${key.id}')" class="px-3 py-1 text-sm text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded transition-colors">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateActivityList(transactions) {
|
||||
const container = document.getElementById('activityList');
|
||||
const noActivity = document.getElementById('noActivity');
|
||||
|
||||
if (!transactions || transactions.length === 0) {
|
||||
container.innerHTML = '';
|
||||
noActivity.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
noActivity.classList.add('hidden');
|
||||
|
||||
container.innerHTML = transactions.slice(0, 5).map(tx => `
|
||||
<div class="flex items-center justify-between bg-slate-800/50 rounded-lg p-4">
|
||||
<div>
|
||||
<div class="font-medium">${escapeHtml(tx.description || 'Credit transaction')}</div>
|
||||
<div class="text-sm text-slate-400">${new Date(tx.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="font-mono ${tx.amount > 0 ? 'text-green-400' : 'text-red-400'}">
|
||||
${tx.amount > 0 ? '+' : ''}${tx.amount}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
const name = prompt('Enter a name for this API key:', 'My API Key');
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
const response = await moxieAPI.createApiKey(name);
|
||||
const key = response.data.key;
|
||||
|
||||
// Show the key (only time it will be visible)
|
||||
showToast('API key created! Copy it now - it won\'t be shown again.', 'success');
|
||||
|
||||
// Show modal with key
|
||||
showApiKeyModal(key);
|
||||
|
||||
// Refresh list
|
||||
const keysResponse = await moxieAPI.getApiKeys();
|
||||
document.getElementById('apiKeyCount').textContent = keysResponse.data.keys.length;
|
||||
updateApiKeysList(keysResponse.data.keys);
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to create API key: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showApiKeyModal(key) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-slate-800 rounded-2xl p-6 max-w-lg w-full">
|
||||
<h3 class="text-xl font-semibold mb-4">API Key Created</h3>
|
||||
<p class="text-slate-400 mb-4">Copy this key now. It won't be shown again.</p>
|
||||
<div class="bg-slate-900 rounded-lg p-4 mb-4">
|
||||
<code class="text-[#38BDF8] break-all text-sm">${escapeHtml(key.key)}</code>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="navigator.clipboard.writeText('${escapeHtml(key.key)}'); this.textContent='Copied!';"
|
||||
class="btn-primary px-4 py-2 text-sm font-medium text-slate-950 rounded-lg">
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
<button onclick="this.closest('.fixed').remove()"
|
||||
class="px-4 py-2 text-sm font-medium text-slate-400 hover:text-white border border-slate-700 rounded-lg">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.remove();
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeKey(keyId) {
|
||||
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
await moxieAPI.revokeApiKey(keyId);
|
||||
showToast('API key revoked', 'success');
|
||||
|
||||
// Refresh list
|
||||
const keysResponse = await moxieAPI.getApiKeys();
|
||||
document.getElementById('apiKeyCount').textContent = keysResponse.data.keys.length;
|
||||
updateApiKeysList(keysResponse.data.keys);
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to revoke API key: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const name = document.getElementById('settingsName').value.trim();
|
||||
|
||||
try {
|
||||
await moxieAPI.updateProfile({ name });
|
||||
showToast('Settings saved', 'success');
|
||||
|
||||
// Update UI
|
||||
document.getElementById('userName').textContent = name || 'User';
|
||||
document.getElementById('welcomeName').textContent = name || 'User';
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save settings: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateAccount() {
|
||||
if (!confirm('Are you sure you want to deactivate your account? This action cannot be undone.')) return;
|
||||
if (!confirm('This will permanently delete your account and all associated data. Continue?')) return;
|
||||
|
||||
try {
|
||||
await moxieAPI.deactivateAccount();
|
||||
showToast('Account deactivated', 'success');
|
||||
await logout();
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to deactivate account: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await moxieAuth.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
|
||||
const bgColor = type === 'error' ? 'bg-red-500/20 border-red-500/50' :
|
||||
type === 'success' ? 'bg-green-500/20 border-green-500/50' :
|
||||
'bg-slate-800 border-slate-700';
|
||||
|
||||
toast.className = `toast ${bgColor} border rounded-lg px-4 py-3 text-sm max-w-sm`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
591
index-o.html
Normal file
591
index-o.html
Normal file
@ -0,0 +1,591 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Moxiegen - Algorithmic enhancement for enterprise LLM and AI models. 10-100x faster inference and training.">
|
||||
<title>Moxiegen • Our Algos, Your AI Advantage</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--tw-color-primary: #00f0c8;
|
||||
}
|
||||
|
||||
* {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
background: radial-gradient(circle at 30% 20%, rgba(0, 240, 200, 0.15) 0%, transparent 70%),
|
||||
radial-gradient(circle at 70% 80%, rgba(0, 240, 200, 0.1) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* SCROLL-DRIVEN ANIMATIONS */
|
||||
.scroll-animate {
|
||||
opacity: 0;
|
||||
transform: translateY(60px);
|
||||
transition: all 900ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.scroll-animate.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Staggered delays for benefit cards */
|
||||
.scroll-animate.card-1 { transition-delay: 100ms; }
|
||||
.scroll-animate.card-2 { transition-delay: 250ms; }
|
||||
.scroll-animate.card-3 { transition-delay: 400ms; }
|
||||
|
||||
/* Navbar scroll effect */
|
||||
.nav-scrolled {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* BALLOON + M STYLES (hero) */
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 620px; /* widened so the full M is visible */
|
||||
height: 680px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.glowing-m {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
filter: drop-shadow(0 0 20px rgba(224, 242, 254, 0.6));
|
||||
top: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
#canvas {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-white font-sans">
|
||||
<!-- NAVBAR -->
|
||||
<nav id="navbar" class="border-b border-slate-800 bg-slate-950/80 backdrop-blur-lg sticky top-0 z-50">
|
||||
<div class="max-w-screen-2xl mx-auto px-8 py-5 flex items-center justify-between">
|
||||
<!-- Logo + Brand -->
|
||||
<a href="index.html" class="flex items-center gap-x-3">
|
||||
<img src="logo.svg"
|
||||
alt="Moxiegen"
|
||||
class="h-11 w-auto"
|
||||
onerror="this.onerror=null; this.src='logo.png';">
|
||||
<span class="font-semibold text-3xl tracking-[-1px] text-white" style="font-family: 'Space Grotesk', sans-serif;">Moxiegen</span>
|
||||
</a>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex items-center gap-x-8 text-sm font-medium">
|
||||
<a href="#benefits" class="hover:text-emerald-400">How It Works</a>
|
||||
<a href="#moxy" class="hover:text-emerald-400">See Moxy Go</a>
|
||||
<a href="#licensing" class="hover:text-emerald-400">Licensing</a>
|
||||
|
||||
<!-- Auth buttons -->
|
||||
<div id="authButtons" class="flex items-center gap-x-4">
|
||||
<!-- Login button (shown when not authenticated) -->
|
||||
<button id="loginBtn" onclick="login()" class="hidden px-6 py-2.5 bg-white text-slate-950 font-semibold rounded-3xl hover:bg-emerald-400 hover:text-white">
|
||||
Login
|
||||
</button>
|
||||
|
||||
<!-- Dashboard button (shown when authenticated) -->
|
||||
<a id="dashboardBtn" href="dashboard.html" class="hidden px-6 py-2.5 bg-white text-slate-950 font-semibold rounded-3xl hover:bg-emerald-400 hover:text-white">
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('contact').scrollIntoView({ behavior: 'smooth' })"
|
||||
class="px-6 py-2.5 bg-white text-slate-950 font-semibold rounded-3xl hover:bg-emerald-400 hover:text-white">
|
||||
Contact Us
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO SECTION -->
|
||||
<header class="hero-bg pt-16 pb-20">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
|
||||
<!-- Left Text -->
|
||||
<div class="lg:col-span-7 scroll-animate" id="hero-text">
|
||||
<div class="inline-flex items-center gap-x-2 bg-slate-900 border border-emerald-400/30 text-emerald-400 text-sm font-medium px-5 py-2 rounded-3xl mb-6">
|
||||
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
|
||||
NOW IN ENTERPRISE BETA
|
||||
</div>
|
||||
|
||||
<h1 class="text-7xl lg:text-8xl font-semibold leading-none tracking-[-3px] mb-6" style="font-family: 'Space Grotesk', sans-serif;">
|
||||
Our Algos,<br>your AI Advantage.
|
||||
</h1>
|
||||
|
||||
<p class="text-2xl text-slate-300 max-w-2xl mb-8">
|
||||
Moxiegen offers algorithmic enhancement of enterprise LLM and AI models,
|
||||
increasing efficiency of both inference and training by <span class="text-emerald-400 font-semibold">10-100x</span>.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-x-4">
|
||||
<button onclick="document.getElementById('contact').scrollIntoView({ behavior: 'smooth' })"
|
||||
class="px-10 py-5 bg-emerald-400 hover:bg-emerald-300 text-slate-950 text-xl font-semibold rounded-3xl flex items-center gap-x-3">
|
||||
Get Moxie
|
||||
<span class="text-3xl leading-none">→</span>
|
||||
</button>
|
||||
|
||||
<a href="#moxy"
|
||||
class="px-8 py-5 border border-white/30 hover:border-white/60 text-xl font-medium rounded-3xl flex items-center">
|
||||
See Moxy Go
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: CUSTOM BALLOON + M (hero) – widened container only -->
|
||||
<div class="lg:col-span-5 flex justify-center lg:justify-end scroll-animate" id="hero-logo">
|
||||
<div class="stage">
|
||||
<!-- THE M – widened SVG so edges are no longer clipped -->
|
||||
<svg width="590" height="540" viewBox="0 0 500 500" class="glowing-m">
|
||||
<text
|
||||
x="50%" y="60%"
|
||||
font-family="'Linux Libertine', serif"
|
||||
font-size="600" font-weight="400"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
fill="#020617" stroke="#e0f2fe" stroke-width="3"
|
||||
paint-order="stroke">
|
||||
M
|
||||
</text>
|
||||
</svg>
|
||||
<!-- THE BALLOON – unchanged -->
|
||||
<canvas id="canvas" width="400" height="620"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MOXY GO SECTION -->
|
||||
<section id="moxy" class="py-20 border-b border-slate-800 scroll-animate">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div class="max-w-md">
|
||||
<h2 class="text-5xl font-semibold tracking-[-1px] mb-3" style="font-family: 'Space Grotesk', sans-serif;">See Moxy Go.</h2>
|
||||
<p class="text-slate-400 text-xl">Real-time performance leaderboard</p>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard Placeholder -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-2xl w-full md:w-auto scroll-animate">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="text-emerald-400 font-medium flex items-center gap-x-2">
|
||||
<span class="text-2xl">🏆</span>
|
||||
LIVE LEADERBOARD
|
||||
</div>
|
||||
<div class="text-xs uppercase tracking-widest bg-slate-800 text-slate-400 px-4 py-1 rounded-3xl">Updated 11 seconds ago</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-16 border border-dashed border-slate-600 rounded-3xl text-slate-400">
|
||||
<p class="text-2xl mb-2">[Leaderboard Placement]</p>
|
||||
<p class="text-sm">Insert your Moxy Go leaderboard table, chart, or screenshot here.<br>Current top models show 87x inference speedup.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BENEFITS SECTION -->
|
||||
<section id="benefits" class="py-20 bg-slate-900">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="max-w-2xl mx-auto text-center mb-16 scroll-animate">
|
||||
<h2 class="text-6xl font-semibold tracking-[-2px] mb-6 leading-none" style="font-family: 'Space Grotesk', sans-serif;">
|
||||
Looks like your AI<br>could use some Moxie.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Benefit 1 -->
|
||||
<div class="scroll-animate card-1 bg-slate-950 border border-slate-800 rounded-3xl p-8 hover:border-emerald-400/30 group">
|
||||
<div class="h-12 w-12 bg-emerald-400/10 text-emerald-400 rounded-2xl flex items-center justify-center text-3xl mb-6 group-hover:scale-110">🔄</div>
|
||||
<h3 class="text-2xl font-semibold mb-3">Simultaneous Training + Inference</h3>
|
||||
<p class="text-slate-400">Allows training data center GPU resources to simultaneously perform inference.</p>
|
||||
</div>
|
||||
|
||||
<!-- Benefit 2 -->
|
||||
<div class="scroll-animate card-2 bg-slate-950 border border-slate-800 rounded-3xl p-8 hover:border-emerald-400/30 group">
|
||||
<div class="h-12 w-12 bg-emerald-400/10 text-emerald-400 rounded-2xl flex items-center justify-center text-3xl mb-6 group-hover:scale-110">🏭</div>
|
||||
<h3 class="text-2xl font-semibold mb-3">Massive Offload</h3>
|
||||
<p class="text-slate-400">Massively offloads inference data centers, freeing expensive GPU clusters for training workloads.</p>
|
||||
</div>
|
||||
|
||||
<!-- Benefit 3 -->
|
||||
<div class="scroll-animate card-3 bg-slate-950 border border-slate-800 rounded-3xl p-8 hover:border-emerald-400/30 group">
|
||||
<div class="h-12 w-12 bg-emerald-400/10 text-emerald-400 rounded-2xl flex items-center justify-center text-3xl mb-6 group-hover:scale-110">📱</div>
|
||||
<h3 class="text-2xl font-semibold mb-3">Native Consumer Inference</h3>
|
||||
<p class="text-slate-400">Enables native LLM inference on consumer devices without any API calls or cloud dependency.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LICENSING CTA -->
|
||||
<section id="licensing" class="py-16 scroll-animate">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="bg-gradient-to-r from-slate-900 to-slate-800 border border-emerald-400/20 rounded-3xl p-16 flex flex-col md:flex-row items-center justify-between gap-10">
|
||||
<div class="max-w-lg">
|
||||
<p class="text-4xl font-semibold leading-tight tracking-[-1px]">
|
||||
Licensing now available through customized enterprise contracts.
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="document.getElementById('contact').scrollIntoView({ behavior: 'smooth' })"
|
||||
class="flex-shrink-0 px-14 py-6 text-2xl font-semibold bg-white text-slate-950 hover:bg-emerald-400 rounded-3xl flex items-center gap-x-4">
|
||||
Request Enterprise License
|
||||
<span class="text-4xl">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONTACT SECTION -->
|
||||
<section id="contact" class="py-24 bg-slate-900 scroll-animate">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
<div class="lg:col-span-5">
|
||||
<h2 class="text-5xl font-semibold mb-6" style="font-family: 'Space Grotesk', sans-serif;">Ready for 10-100× more Moxie?</h2>
|
||||
<p class="text-slate-400 text-2xl">Let's talk about how our algorithms can transform your LLM infrastructure.</p>
|
||||
|
||||
<div class="mt-12 border-l-4 border-emerald-400 pl-8">
|
||||
<p class="font-medium">Email us at:</p>
|
||||
<a href="mailto:info@moxiegen.com" class="block text-3xl font-semibold text-emerald-400 hover:text-white">info@moxiegen.com</a>
|
||||
</div>
|
||||
<div class="mt-12 border-l-4 border-emerald-400 pl-8">
|
||||
<p class="font-medium">Call us at:</p>
|
||||
<a href="tel:+18552466943" class="block text-3xl font-semibold text-emerald-400 hover:text-white">+1 (855) 246-6943</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-7 bg-slate-950 rounded-3xl p-10">
|
||||
<form id="contact-form" class="space-y-8">
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Your name</label>
|
||||
<input type="text" name="name" placeholder="Jane Doe"
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-2xl px-6 py-5 outline-none text-lg" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Company</label>
|
||||
<input type="text" name="company" placeholder="Acme AI Labs"
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-2xl px-6 py-5 outline-none text-lg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Work email</label>
|
||||
<input type="email" name="email" placeholder="you@company.com"
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-2xl px-6 py-5 outline-none text-lg" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Tell us about your use case</label>
|
||||
<textarea name="message" rows="4" placeholder="We run 400 H100s and want to run inference + training simultaneously..."
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-3xl px-6 py-5 outline-none text-lg resize-none" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full py-6 bg-emerald-400 hover:bg-emerald-300 text-slate-950 font-semibold text-2xl rounded-3xl">
|
||||
Send message to Moxiegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Success message area -->
|
||||
<div id="success-message" class="hidden mt-6 text-center py-4 bg-emerald-400/10 border border-emerald-400 rounded-3xl text-emerald-400 font-medium">
|
||||
Message sent successfully! We'll get back to you soon.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="bg-slate-950 border-t border-slate-800 py-8">
|
||||
<div class="max-w-screen-2xl mx-auto px-8 flex flex-col md:flex-row justify-between items-center gap-4 text-slate-400 text-sm">
|
||||
<div>Moxiegen Business Group</div>
|
||||
<div class="flex gap-x-6">
|
||||
<a href="#" class="hover:text-white">Privacy</a>
|
||||
<a href="#" class="hover:text-white">Terms</a>
|
||||
<a href="#" class="hover:text-white">Legal</a>
|
||||
</div>
|
||||
<div class="text-emerald-400 text-xs font-medium">Built with Moxie</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Tailwind initialization
|
||||
function initializeTailwind() {
|
||||
return { config(userConfig = {}) { return { content: [], theme: { extend: {} }, plugins: [], ...userConfig }; }, theme: { extend: {} } };
|
||||
}
|
||||
document.documentElement.setAttribute('data-tailwind-config', JSON.stringify(initializeTailwind()));
|
||||
|
||||
// SCROLL-DRIVEN ANIMATIONS (Intersection Observer)
|
||||
function initScrollAnimations() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.15,
|
||||
rootMargin: "0px 0px -50px 0px"
|
||||
});
|
||||
|
||||
document.querySelectorAll('.scroll-animate').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// Navbar scroll effect
|
||||
const navbar = document.getElementById('navbar');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (Math.abs(window.scrollY - lastScrollY) > 5) {
|
||||
if (window.scrollY > 100) {
|
||||
navbar.classList.add('nav-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('nav-scrolled');
|
||||
}
|
||||
lastScrollY = window.scrollY;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// BALLOON + M ANIMATION (hero – still, no bobbing)
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const particles = [];
|
||||
const NUM_PARTICLES = 275;
|
||||
const MAX_RADIUS = 120;
|
||||
const INTAKE_RADIUS = MAX_RADIUS / 8;
|
||||
const DOME_HEIGHT = 0.33;
|
||||
const BOTTOM_TOTAL_HEIGHT = 1.0 - DOME_HEIGHT;
|
||||
const COMPACTED_BOTTOM_HEIGHT = BOTTOM_TOTAL_HEIGHT * 0.6;
|
||||
const TOTAL_COMPACTED_HEIGHT = DOME_HEIGHT + COMPACTED_BOTTOM_HEIGHT;
|
||||
|
||||
const Y_OFFSET = 50 - 100;
|
||||
const TOP_SHIFT = 70;
|
||||
|
||||
function getRadius(h) {
|
||||
if (h < DOME_HEIGHT) return MAX_RADIUS * Math.sqrt(Math.max(0, 1 - Math.pow((h - DOME_HEIGHT) / DOME_HEIGHT, 2)));
|
||||
if (h >= DOME_HEIGHT && h < TOTAL_COMPACTED_HEIGHT) {
|
||||
const t = (h - DOME_HEIGHT) / COMPACTED_BOTTOM_HEIGHT;
|
||||
return MAX_RADIUS - (MAX_RADIUS - INTAKE_RADIUS) * Math.pow(t, 1.5);
|
||||
}
|
||||
return INTAKE_RADIUS;
|
||||
}
|
||||
|
||||
class Particle {
|
||||
constructor(isAnchor = false, h = null, theta = null) {
|
||||
this.isAnchor = isAnchor;
|
||||
this.h = isAnchor ? h : Math.random() * TOTAL_COMPACTED_HEIGHT;
|
||||
this.theta = isAnchor ? theta : Math.random() * Math.PI * 2;
|
||||
this.speed = isAnchor ? 0 : 0.003 + Math.random() * 0.004;
|
||||
}
|
||||
}
|
||||
|
||||
function getPos(h, theta) {
|
||||
const r = getRadius(h);
|
||||
return { x: 200 + r * Math.cos(theta), y: Y_OFFSET + h * 380, z: Math.sin(theta) };
|
||||
}
|
||||
|
||||
function drawBasket() {
|
||||
const baseCenter = { x: 200, y: Y_OFFSET + TOTAL_COMPACTED_HEIGHT * 380 };
|
||||
const ropeAttachY = baseCenter.y - 10.8;
|
||||
const basketTopY = baseCenter.y + 20;
|
||||
const w1 = 36;
|
||||
const angleRad = 10 * Math.PI / 180;
|
||||
const topXOffset = 22 * Math.cos(angleRad);
|
||||
const bottomXOffset = (w1 / 2) - (w1 * 0.05);
|
||||
|
||||
ctx.shadowColor = '#e0f2fe';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.strokeStyle = 'rgba(224, 242, 254, 0.2)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseCenter.x - topXOffset, ropeAttachY);
|
||||
ctx.lineTo(baseCenter.x - bottomXOffset, basketTopY);
|
||||
ctx.moveTo(baseCenter.x + topXOffset, ropeAttachY);
|
||||
ctx.lineTo(baseCenter.x + bottomXOffset, basketTopY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fillStyle = 'rgba(2, 6, 23, 0.9)';
|
||||
ctx.strokeStyle = '#e0f2fe';
|
||||
ctx.lineWidth = 1.5;
|
||||
const bH = 8.5;
|
||||
const widths = [w1, w1 * 0.9, (w1 * 0.9) * 0.9];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(baseCenter.x - widths[i]/2, basketTopY + (i * (bH + 2)), widths[i], bH, 3);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function initBalloon() {
|
||||
particles.length = 0;
|
||||
particles.push(new Particle(true, 0, 0));
|
||||
for(let i = 0; i < 5; i++) particles.push(new Particle(true, TOTAL_COMPACTED_HEIGHT, (i / 5) * Math.PI * 2));
|
||||
for (let i = 0; i < NUM_PARTICLES; i++) particles.push(new Particle());
|
||||
animateBalloon();
|
||||
}
|
||||
|
||||
function animateBalloon() {
|
||||
ctx.clearRect(0, 0, 400, 620);
|
||||
ctx.save();
|
||||
ctx.translate(0, TOP_SHIFT);
|
||||
|
||||
particles.forEach(p => { if(!p.isAnchor) p.theta += p.speed; });
|
||||
drawBasket();
|
||||
|
||||
ctx.lineWidth = 0.4;
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const p1 = getPos(particles[i].h, particles[i].theta);
|
||||
const p2 = getPos(particles[j].h, particles[j].theta);
|
||||
if (Math.hypot(p1.x - p2.x, p1.y - p2.y) < 30) {
|
||||
const alpha = 0.05 + (((p1.z + p2.z) / 2) + 1) * 0.05;
|
||||
ctx.strokeStyle = `rgba(52, 211, 153, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
ctx.lineTo(p2.x, p2.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
particles.forEach(p => {
|
||||
const pos = getPos(p.h, p.theta);
|
||||
ctx.fillStyle = `rgba(52, 211, 153, ${0.3 + (pos.z + 1) * 0.35})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, 1.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
requestAnimationFrame(animateBalloon);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTHENTICATION
|
||||
// ============================================
|
||||
async function initAuth() {
|
||||
try {
|
||||
await moxieAuth.init();
|
||||
updateAuthUI();
|
||||
} catch (error) {
|
||||
console.error('Auth init error:', error);
|
||||
document.getElementById('loginBtn').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateAuthUI() {
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const dashboardBtn = document.getElementById('dashboardBtn');
|
||||
|
||||
if (moxieAuth.isAuthenticated) {
|
||||
loginBtn.classList.add('hidden');
|
||||
dashboardBtn.classList.remove('hidden');
|
||||
} else {
|
||||
loginBtn.classList.remove('hidden');
|
||||
dashboardBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
await moxieAuth.login(window.location.origin + '/dashboard.html');
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
alert('Failed to login. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FORM SUBMIT HANDLER (Backend API)
|
||||
// ============================================
|
||||
function initContactForm() {
|
||||
const form = document.getElementById('contact-form');
|
||||
const successDiv = document.getElementById('success-message');
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
name: form.name.value.trim(),
|
||||
email: form.email.value.trim(),
|
||||
company: form.company.value.trim() || null,
|
||||
message: form.message.value.trim()
|
||||
};
|
||||
|
||||
if (!data.name || !data.email || !data.message) {
|
||||
alert('Please fill in all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable the submit button while sending
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
successDiv.classList.remove('hidden');
|
||||
form.reset();
|
||||
|
||||
setTimeout(() => {
|
||||
successDiv.classList.add('hidden');
|
||||
}, 6000);
|
||||
} else {
|
||||
alert(result.message || 'Something went wrong. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize everything when page loads
|
||||
window.addEventListener('load', () => {
|
||||
initScrollAnimations();
|
||||
initBalloon();
|
||||
initAuth();
|
||||
initContactForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Auth Scripts -->
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
591
index.html
Normal file
591
index.html
Normal file
@ -0,0 +1,591 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Moxiegen - Algorithmic enhancement for enterprise LLM and AI models. 10-100x faster inference and training.">
|
||||
<title>Moxiegen • Our Algos, Your AI Advantage</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--tw-color-primary: #00f0c8;
|
||||
}
|
||||
|
||||
* {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
background: radial-gradient(circle at 30% 20%, rgba(0, 240, 200, 0.15) 0%, transparent 70%),
|
||||
radial-gradient(circle at 70% 80%, rgba(0, 240, 200, 0.1) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* SCROLL-DRIVEN ANIMATIONS */
|
||||
.scroll-animate {
|
||||
opacity: 0;
|
||||
transform: translateY(60px);
|
||||
transition: all 900ms cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
}
|
||||
|
||||
.scroll-animate.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Staggered delays for benefit cards */
|
||||
.scroll-animate.card-1 { transition-delay: 100ms; }
|
||||
.scroll-animate.card-2 { transition-delay: 250ms; }
|
||||
.scroll-animate.card-3 { transition-delay: 400ms; }
|
||||
|
||||
/* Navbar scroll effect */
|
||||
.nav-scrolled {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
|
||||
/* BALLOON + M STYLES (hero) */
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 620px; /* widened so the full M is visible */
|
||||
height: 680px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.glowing-m {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
filter: drop-shadow(0 0 20px rgba(224, 242, 254, 0.6));
|
||||
top: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
#canvas {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-white font-sans">
|
||||
<!-- NAVBAR -->
|
||||
<nav id="navbar" class="border-b border-slate-800 bg-slate-950/80 backdrop-blur-lg sticky top-0 z-50">
|
||||
<div class="max-w-screen-2xl mx-auto px-8 py-5 flex items-center justify-between">
|
||||
<!-- Logo + Brand -->
|
||||
<a href="index.html" class="flex items-center gap-x-3">
|
||||
<img src="logo.svg"
|
||||
alt="Moxiegen"
|
||||
class="h-11 w-auto"
|
||||
onerror="this.onerror=null; this.src='logo.png';">
|
||||
<span class="font-semibold text-3xl tracking-[-1px] text-white" style="font-family: 'Space Grotesk', sans-serif;">Moxiegen</span>
|
||||
</a>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex items-center gap-x-8 text-sm font-medium">
|
||||
<a href="#benefits" class="hover:text-emerald-400">How It Works</a>
|
||||
<a href="https://ai.moxiegen.com/" class="hover:text-emerald-400">Moxie Demo</a>
|
||||
<a href="#licensing" class="hover:text-emerald-400">Licensing</a>
|
||||
|
||||
<!-- Auth buttons -->
|
||||
<div id="authButtons" class="flex items-center gap-x-4">
|
||||
<!-- Login button (shown when not authenticated) -->
|
||||
<button id="loginBtn" onclick="login()" class="hidden px-6 py-2.5 bg-white text-slate-950 font-semibold rounded-3xl hover:bg-emerald-400 hover:text-white">
|
||||
Login
|
||||
</button>
|
||||
|
||||
<!-- Dashboard button (shown when authenticated) -->
|
||||
<a id="dashboardBtn" href="dashboard.html" class="hidden px-6 py-2.5 bg-white text-slate-950 font-semibold rounded-3xl hover:bg-emerald-400 hover:text-white">
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('contact').scrollIntoView({ behavior: 'smooth' })"
|
||||
class="px-6 py-2.5 bg-white text-slate-950 font-semibold rounded-3xl hover:bg-emerald-400 hover:text-white">
|
||||
Contact Us
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO SECTION -->
|
||||
<header class="hero-bg pt-16 pb-20">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
|
||||
<!-- Left Text -->
|
||||
<div class="lg:col-span-7 scroll-animate" id="hero-text">
|
||||
<div class="inline-flex items-center gap-x-2 bg-slate-900 border border-emerald-400/30 text-emerald-400 text-sm font-medium px-5 py-2 rounded-3xl mb-6">
|
||||
<div class="w-2 h-2 bg-emerald-400 rounded-full animate-pulse"></div>
|
||||
NOW IN ENTERPRISE BETA
|
||||
</div>
|
||||
|
||||
<h1 class="text-7xl lg:text-8xl font-semibold leading-none tracking-[-3px] mb-6" style="font-family: 'Space Grotesk', sans-serif;">
|
||||
Our Algos,<br>your AI Advantage.
|
||||
</h1>
|
||||
|
||||
<p class="text-2xl text-slate-300 max-w-2xl mb-8">
|
||||
Moxiegen offers algorithmic enhancement of enterprise LLM and AI models,
|
||||
increasing efficiency of both inference and training by <span class="text-emerald-400 font-semibold">10-100x</span>.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-x-4">
|
||||
<button onclick="document.getElementById('contact').scrollIntoView({ behavior: 'smooth' })"
|
||||
class="px-10 py-5 bg-emerald-400 hover:bg-emerald-300 text-slate-950 text-xl font-semibold rounded-3xl flex items-center gap-x-3">
|
||||
Get Moxie
|
||||
<span class="text-3xl leading-none">→</span>
|
||||
</button>
|
||||
|
||||
<a href="https://ai.moxiegen.com/"
|
||||
class="px-8 py-5 border border-white/30 hover:border-white/60 text-xl font-medium rounded-3xl flex items-center">
|
||||
Moxie Demo
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: CUSTOM BALLOON + M (hero) – widened container only -->
|
||||
<div class="lg:col-span-5 flex justify-center lg:justify-end scroll-animate" id="hero-logo">
|
||||
<div class="stage">
|
||||
<!-- THE M – widened SVG so edges are no longer clipped -->
|
||||
<svg width="590" height="540" viewBox="0 0 500 500" class="glowing-m">
|
||||
<text
|
||||
x="50%" y="60%"
|
||||
font-family="'Linux Libertine', serif"
|
||||
font-size="600" font-weight="400"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
fill="#020617" stroke="#e0f2fe" stroke-width="3"
|
||||
paint-order="stroke">
|
||||
M
|
||||
</text>
|
||||
</svg>
|
||||
<!-- THE BALLOON – unchanged -->
|
||||
<canvas id="canvas" width="400" height="620"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MOXY GO SECTION -->
|
||||
<section id="moxy" class="py-20 border-b border-slate-800 scroll-animate">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div class="max-w-md">
|
||||
<h2 class="text-5xl font-semibold tracking-[-1px] mb-3" style="font-family: 'Space Grotesk', sans-serif;">Moxie Demo.</h2>
|
||||
<p class="text-slate-400 text-xl">Real-time performance leaderboard</p>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard Placeholder -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-3xl p-8 max-w-2xl w-full md:w-auto scroll-animate">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="text-emerald-400 font-medium flex items-center gap-x-2">
|
||||
<span class="text-2xl">🏆</span>
|
||||
LIVE LEADERBOARD
|
||||
</div>
|
||||
<div class="text-xs uppercase tracking-widest bg-slate-800 text-slate-400 px-4 py-1 rounded-3xl">Updated 11 seconds ago</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-16 border border-dashed border-slate-600 rounded-3xl text-slate-400">
|
||||
<p class="text-2xl mb-2">[Leaderboard Placement]</p>
|
||||
<p class="text-sm">Insert your Moxie Go leaderboard table, chart, or screenshot here.<br>Current top models show 87x inference speedup.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BENEFITS SECTION -->
|
||||
<section id="benefits" class="py-20 bg-slate-900">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="max-w-2xl mx-auto text-center mb-16 scroll-animate">
|
||||
<h2 class="text-6xl font-semibold tracking-[-2px] mb-6 leading-none" style="font-family: 'Space Grotesk', sans-serif;">
|
||||
Looks like your AI<br>could use some Moxie.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Benefit 1 -->
|
||||
<div class="scroll-animate card-1 bg-slate-950 border border-slate-800 rounded-3xl p-8 hover:border-emerald-400/30 group">
|
||||
<div class="h-12 w-12 bg-emerald-400/10 text-emerald-400 rounded-2xl flex items-center justify-center text-3xl mb-6 group-hover:scale-110">🔄</div>
|
||||
<h3 class="text-2xl font-semibold mb-3">Simultaneous Training + Inference</h3>
|
||||
<p class="text-slate-400">Allows training data center GPU resources to simultaneously perform inference.</p>
|
||||
</div>
|
||||
|
||||
<!-- Benefit 2 -->
|
||||
<div class="scroll-animate card-2 bg-slate-950 border border-slate-800 rounded-3xl p-8 hover:border-emerald-400/30 group">
|
||||
<div class="h-12 w-12 bg-emerald-400/10 text-emerald-400 rounded-2xl flex items-center justify-center text-3xl mb-6 group-hover:scale-110">🏭</div>
|
||||
<h3 class="text-2xl font-semibold mb-3">Massive Offload</h3>
|
||||
<p class="text-slate-400">Massively offloads inference data centers, freeing expensive GPU clusters for training workloads.</p>
|
||||
</div>
|
||||
|
||||
<!-- Benefit 3 -->
|
||||
<div class="scroll-animate card-3 bg-slate-950 border border-slate-800 rounded-3xl p-8 hover:border-emerald-400/30 group">
|
||||
<div class="h-12 w-12 bg-emerald-400/10 text-emerald-400 rounded-2xl flex items-center justify-center text-3xl mb-6 group-hover:scale-110">📱</div>
|
||||
<h3 class="text-2xl font-semibold mb-3">Native Consumer Inference</h3>
|
||||
<p class="text-slate-400">Enables native LLM inference on consumer devices without any API calls or cloud dependency.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LICENSING CTA -->
|
||||
<section id="licensing" class="py-16 scroll-animate">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="bg-gradient-to-r from-slate-900 to-slate-800 border border-emerald-400/20 rounded-3xl p-16 flex flex-col md:flex-row items-center justify-between gap-10">
|
||||
<div class="max-w-lg">
|
||||
<p class="text-4xl font-semibold leading-tight tracking-[-1px]">
|
||||
Licensing now available through customized enterprise contracts.
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="document.getElementById('contact').scrollIntoView({ behavior: 'smooth' })"
|
||||
class="flex-shrink-0 px-14 py-6 text-2xl font-semibold bg-white text-slate-950 hover:bg-emerald-400 rounded-3xl flex items-center gap-x-4">
|
||||
Request Enterprise License
|
||||
<span class="text-4xl">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CONTACT SECTION -->
|
||||
<section id="contact" class="py-24 bg-slate-900 scroll-animate">
|
||||
<div class="max-w-screen-2xl mx-auto px-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 items-center">
|
||||
<div class="lg:col-span-5">
|
||||
<h2 class="text-5xl font-semibold mb-6" style="font-family: 'Space Grotesk', sans-serif;">Ready for 10-100× more Moxie?</h2>
|
||||
<p class="text-slate-400 text-2xl">Let's talk about how our algorithms can transform your LLM infrastructure.</p>
|
||||
|
||||
<div class="mt-12 border-l-4 border-emerald-400 pl-8">
|
||||
<p class="font-medium">Email us at:</p>
|
||||
<a href="mailto:info@moxiegen.com" class="block text-3xl font-semibold text-emerald-400 hover:text-white">info@moxiegen.com</a>
|
||||
</div>
|
||||
<div class="mt-12 border-l-4 border-emerald-400 pl-8">
|
||||
<p class="font-medium">Call us at:</p>
|
||||
<a href="tel:+18552466943" class="block text-3xl font-semibold text-emerald-400 hover:text-white">+1 (855) 246-6943</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-7 bg-slate-950 rounded-3xl p-10">
|
||||
<form id="contact-form" class="space-y-8">
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Your name</label>
|
||||
<input type="text" name="name" placeholder="Jane Doe"
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-2xl px-6 py-5 outline-none text-lg" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Company</label>
|
||||
<input type="text" name="company" placeholder="Acme AI Labs"
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-2xl px-6 py-5 outline-none text-lg">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Work email</label>
|
||||
<input type="email" name="email" placeholder="you@company.com"
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-2xl px-6 py-5 outline-none text-lg" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-slate-400 mb-2">Tell us about your use case</label>
|
||||
<textarea name="message" rows="4" placeholder="We run 400 H100s and want to run inference + training simultaneously..."
|
||||
class="w-full bg-slate-900 border border-slate-700 focus:border-emerald-400 rounded-3xl px-6 py-5 outline-none text-lg resize-none" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full py-6 bg-emerald-400 hover:bg-emerald-300 text-slate-950 font-semibold text-2xl rounded-3xl">
|
||||
Send message to Moxiegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Success message area -->
|
||||
<div id="success-message" class="hidden mt-6 text-center py-4 bg-emerald-400/10 border border-emerald-400 rounded-3xl text-emerald-400 font-medium">
|
||||
Message sent successfully! We'll get back to you soon.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="bg-slate-950 border-t border-slate-800 py-8">
|
||||
<div class="max-w-screen-2xl mx-auto px-8 flex flex-col md:flex-row justify-between items-center gap-4 text-slate-400 text-sm">
|
||||
<div>© 2026, Moxiegen Business Group</div>
|
||||
<div class="flex gap-x-6">
|
||||
<a href="#" class="hover:text-white">Privacy</a>
|
||||
<a href="#" class="hover:text-white">Terms</a>
|
||||
<a href="#" class="hover:text-white">Legal</a>
|
||||
</div>
|
||||
<div class="text-emerald-400 text-xs font-medium">Built with Moxie</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Tailwind initialization
|
||||
function initializeTailwind() {
|
||||
return { config(userConfig = {}) { return { content: [], theme: { extend: {} }, plugins: [], ...userConfig }; }, theme: { extend: {} } };
|
||||
}
|
||||
document.documentElement.setAttribute('data-tailwind-config', JSON.stringify(initializeTailwind()));
|
||||
|
||||
// SCROLL-DRIVEN ANIMATIONS (Intersection Observer)
|
||||
function initScrollAnimations() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.15,
|
||||
rootMargin: "0px 0px -50px 0px"
|
||||
});
|
||||
|
||||
document.querySelectorAll('.scroll-animate').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// Navbar scroll effect
|
||||
const navbar = document.getElementById('navbar');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (Math.abs(window.scrollY - lastScrollY) > 5) {
|
||||
if (window.scrollY > 100) {
|
||||
navbar.classList.add('nav-scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('nav-scrolled');
|
||||
}
|
||||
lastScrollY = window.scrollY;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// BALLOON + M ANIMATION (hero – still, no bobbing)
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const particles = [];
|
||||
const NUM_PARTICLES = 275;
|
||||
const MAX_RADIUS = 120;
|
||||
const INTAKE_RADIUS = MAX_RADIUS / 8;
|
||||
const DOME_HEIGHT = 0.33;
|
||||
const BOTTOM_TOTAL_HEIGHT = 1.0 - DOME_HEIGHT;
|
||||
const COMPACTED_BOTTOM_HEIGHT = BOTTOM_TOTAL_HEIGHT * 0.6;
|
||||
const TOTAL_COMPACTED_HEIGHT = DOME_HEIGHT + COMPACTED_BOTTOM_HEIGHT;
|
||||
|
||||
const Y_OFFSET = 50 - 100;
|
||||
const TOP_SHIFT = 70;
|
||||
|
||||
function getRadius(h) {
|
||||
if (h < DOME_HEIGHT) return MAX_RADIUS * Math.sqrt(Math.max(0, 1 - Math.pow((h - DOME_HEIGHT) / DOME_HEIGHT, 2)));
|
||||
if (h >= DOME_HEIGHT && h < TOTAL_COMPACTED_HEIGHT) {
|
||||
const t = (h - DOME_HEIGHT) / COMPACTED_BOTTOM_HEIGHT;
|
||||
return MAX_RADIUS - (MAX_RADIUS - INTAKE_RADIUS) * Math.pow(t, 1.5);
|
||||
}
|
||||
return INTAKE_RADIUS;
|
||||
}
|
||||
|
||||
class Particle {
|
||||
constructor(isAnchor = false, h = null, theta = null) {
|
||||
this.isAnchor = isAnchor;
|
||||
this.h = isAnchor ? h : Math.random() * TOTAL_COMPACTED_HEIGHT;
|
||||
this.theta = isAnchor ? theta : Math.random() * Math.PI * 2;
|
||||
this.speed = isAnchor ? 0 : 0.003 + Math.random() * 0.004;
|
||||
}
|
||||
}
|
||||
|
||||
function getPos(h, theta) {
|
||||
const r = getRadius(h);
|
||||
return { x: 200 + r * Math.cos(theta), y: Y_OFFSET + h * 380, z: Math.sin(theta) };
|
||||
}
|
||||
|
||||
function drawBasket() {
|
||||
const baseCenter = { x: 200, y: Y_OFFSET + TOTAL_COMPACTED_HEIGHT * 380 };
|
||||
const ropeAttachY = baseCenter.y - 10.8;
|
||||
const basketTopY = baseCenter.y + 20;
|
||||
const w1 = 36;
|
||||
const angleRad = 10 * Math.PI / 180;
|
||||
const topXOffset = 22 * Math.cos(angleRad);
|
||||
const bottomXOffset = (w1 / 2) - (w1 * 0.05);
|
||||
|
||||
ctx.shadowColor = '#e0f2fe';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.strokeStyle = 'rgba(224, 242, 254, 0.2)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseCenter.x - topXOffset, ropeAttachY);
|
||||
ctx.lineTo(baseCenter.x - bottomXOffset, basketTopY);
|
||||
ctx.moveTo(baseCenter.x + topXOffset, ropeAttachY);
|
||||
ctx.lineTo(baseCenter.x + bottomXOffset, basketTopY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fillStyle = 'rgba(2, 6, 23, 0.9)';
|
||||
ctx.strokeStyle = '#e0f2fe';
|
||||
ctx.lineWidth = 1.5;
|
||||
const bH = 8.5;
|
||||
const widths = [w1, w1 * 0.9, (w1 * 0.9) * 0.9];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(baseCenter.x - widths[i]/2, basketTopY + (i * (bH + 2)), widths[i], bH, 3);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
function initBalloon() {
|
||||
particles.length = 0;
|
||||
particles.push(new Particle(true, 0, 0));
|
||||
for(let i = 0; i < 5; i++) particles.push(new Particle(true, TOTAL_COMPACTED_HEIGHT, (i / 5) * Math.PI * 2));
|
||||
for (let i = 0; i < NUM_PARTICLES; i++) particles.push(new Particle());
|
||||
animateBalloon();
|
||||
}
|
||||
|
||||
function animateBalloon() {
|
||||
ctx.clearRect(0, 0, 400, 620);
|
||||
ctx.save();
|
||||
ctx.translate(0, TOP_SHIFT);
|
||||
|
||||
particles.forEach(p => { if(!p.isAnchor) p.theta += p.speed; });
|
||||
drawBasket();
|
||||
|
||||
ctx.lineWidth = 0.4;
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const p1 = getPos(particles[i].h, particles[i].theta);
|
||||
const p2 = getPos(particles[j].h, particles[j].theta);
|
||||
if (Math.hypot(p1.x - p2.x, p1.y - p2.y) < 30) {
|
||||
const alpha = 0.05 + (((p1.z + p2.z) / 2) + 1) * 0.05;
|
||||
ctx.strokeStyle = `rgba(52, 211, 153, ${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
ctx.lineTo(p2.x, p2.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
particles.forEach(p => {
|
||||
const pos = getPos(p.h, p.theta);
|
||||
ctx.fillStyle = `rgba(52, 211, 153, ${0.3 + (pos.z + 1) * 0.35})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, 1.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
requestAnimationFrame(animateBalloon);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTHENTICATION
|
||||
// ============================================
|
||||
async function initAuth() {
|
||||
try {
|
||||
await moxieAuth.init();
|
||||
updateAuthUI();
|
||||
} catch (error) {
|
||||
console.error('Auth init error:', error);
|
||||
document.getElementById('loginBtn').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updateAuthUI() {
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const dashboardBtn = document.getElementById('dashboardBtn');
|
||||
|
||||
if (moxieAuth.isAuthenticated) {
|
||||
loginBtn.classList.add('hidden');
|
||||
dashboardBtn.classList.remove('hidden');
|
||||
} else {
|
||||
loginBtn.classList.remove('hidden');
|
||||
dashboardBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
try {
|
||||
await moxieAuth.login(window.location.origin + '/dashboard.html');
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
alert('Failed to login. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FORM SUBMIT HANDLER (Backend API)
|
||||
// ============================================
|
||||
function initContactForm() {
|
||||
const form = document.getElementById('contact-form');
|
||||
const successDiv = document.getElementById('success-message');
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const data = {
|
||||
name: form.name.value.trim(),
|
||||
email: form.email.value.trim(),
|
||||
company: form.company.value.trim() || null,
|
||||
message: form.message.value.trim()
|
||||
};
|
||||
|
||||
if (!data.name || !data.email || !data.message) {
|
||||
alert('Please fill in all required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable the submit button while sending
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Sending...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
successDiv.classList.remove('hidden');
|
||||
form.reset();
|
||||
|
||||
setTimeout(() => {
|
||||
successDiv.classList.add('hidden');
|
||||
}, 6000);
|
||||
} else {
|
||||
alert(result.message || 'Something went wrong. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize everything when page loads
|
||||
window.addEventListener('load', () => {
|
||||
initScrollAnimations();
|
||||
initBalloon();
|
||||
initAuth();
|
||||
initContactForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Auth Scripts -->
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
177
js/api.js
Normal file
177
js/api.js
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Moxiegen API Client
|
||||
* Handles all API calls to the backend with authentication
|
||||
*/
|
||||
|
||||
class MoxieAPI {
|
||||
constructor() {
|
||||
this.baseUrl = CONFIG.api.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated API request
|
||||
*/
|
||||
async request(endpoint, options = {}) {
|
||||
const token = await moxieAuth.getToken();
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const url = endpoint.startsWith('http') ? endpoint : this.baseUrl + endpoint;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(data.message || 'Request failed', response.status, data);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
throw error;
|
||||
}
|
||||
throw new APIError(error.message, 0, { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
async get(endpoint) {
|
||||
return this.request(endpoint, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
async post(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
async put(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
async delete(endpoint, data) {
|
||||
return this.request(endpoint, {
|
||||
method: 'DELETE',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Endpoints
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
*/
|
||||
async getProfile() {
|
||||
return this.get(CONFIG.api.endpoints.me);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
async updateProfile(data) {
|
||||
return this.put(CONFIG.api.endpoints.me, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate account
|
||||
*/
|
||||
async deactivateAccount() {
|
||||
return this.delete(CONFIG.api.endpoints.me);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Credits Endpoints
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get credits balance and history
|
||||
*/
|
||||
async getCredits(page = 1, limit = 10) {
|
||||
return this.get(`${CONFIG.api.endpoints.credits}?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Keys Endpoints
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get all API keys
|
||||
*/
|
||||
async getApiKeys() {
|
||||
return this.get(CONFIG.api.endpoints.apiKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new API key
|
||||
*/
|
||||
async createApiKey(name = 'API Key') {
|
||||
return this.post(CONFIG.api.endpoints.apiKeys, { name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke API key
|
||||
*/
|
||||
async revokeApiKey(keyId) {
|
||||
return this.delete(`${CONFIG.api.endpoints.apiKeys}/${keyId}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Health Check
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check API health
|
||||
*/
|
||||
async checkHealth() {
|
||||
return this.get(CONFIG.api.endpoints.health);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom API Error class
|
||||
*/
|
||||
class APIError extends Error {
|
||||
constructor(message, status, data) {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const moxieAPI = new MoxieAPI();
|
||||
|
||||
// Export for use in other modules
|
||||
window.moxieAPI = moxieAPI;
|
||||
window.APIError = APIError;
|
||||
261
js/auth.js
Normal file
261
js/auth.js
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Moxiegen Authentication Module
|
||||
* Handles Auth0 authentication with backend token exchange
|
||||
*/
|
||||
|
||||
class MoxieAuth {
|
||||
constructor() {
|
||||
this.isAuthenticated = false;
|
||||
this.user = null;
|
||||
this.token = null;
|
||||
this.expiresAt = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize - check for existing session or handle callback
|
||||
*/
|
||||
async init() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Check for tokens in URL (coming back from token exchange)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const accessToken = urlParams.get('access_token');
|
||||
const idToken = urlParams.get('id_token');
|
||||
const expiresIn = urlParams.get('expires_in');
|
||||
const code = urlParams.get('code');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
console.error('Auth error:', error, urlParams.get('error_description'));
|
||||
this.clearTokens();
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
throw new Error(urlParams.get('error_description') || error);
|
||||
}
|
||||
|
||||
// If we have a code but no token, exchange it via backend
|
||||
if (code && !accessToken) {
|
||||
console.log('Exchanging authorization code for tokens...');
|
||||
await this.exchangeCode(code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessToken) {
|
||||
// Store tokens from URL
|
||||
this.token = accessToken;
|
||||
this.expiresAt = Date.now() + (parseInt(expiresIn) || 3600) * 1000;
|
||||
|
||||
if (idToken) {
|
||||
this.user = this.decodeJwt(idToken);
|
||||
localStorage.setItem('id_token', idToken);
|
||||
}
|
||||
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('expires_at', this.expiresAt.toString());
|
||||
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
this.isAuthenticated = true;
|
||||
this.isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for stored tokens
|
||||
const storedToken = localStorage.getItem('access_token');
|
||||
const storedExpiresAt = localStorage.getItem('expires_at');
|
||||
const storedIdToken = localStorage.getItem('id_token');
|
||||
|
||||
if (storedToken && storedExpiresAt) {
|
||||
if (Date.now() < parseInt(storedExpiresAt)) {
|
||||
this.token = storedToken;
|
||||
this.expiresAt = parseInt(storedExpiresAt);
|
||||
if (storedIdToken) {
|
||||
this.user = this.decodeJwt(storedIdToken);
|
||||
}
|
||||
this.isAuthenticated = true;
|
||||
} else {
|
||||
// Token expired - clear it
|
||||
this.clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('Auth initialized. Authenticated:', this.isAuthenticated);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
this.isInitialized = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens via backend
|
||||
*/
|
||||
async exchangeCode(code) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
redirect_uri: window.location.origin + '/dashboard.html'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Token exchange failed');
|
||||
}
|
||||
|
||||
const { access_token, id_token, expires_in } = data.data;
|
||||
|
||||
// Store tokens
|
||||
this.token = access_token;
|
||||
this.expiresAt = Date.now() + (expires_in || 3600) * 1000;
|
||||
|
||||
if (id_token) {
|
||||
this.user = this.decodeJwt(id_token);
|
||||
localStorage.setItem('id_token', id_token);
|
||||
}
|
||||
|
||||
localStorage.setItem('access_token', access_token);
|
||||
localStorage.setItem('expires_at', this.expiresAt.toString());
|
||||
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
this.isAuthenticated = true;
|
||||
this.isInitialized = true;
|
||||
|
||||
console.log('Token exchange successful');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token exchange error:', error);
|
||||
// Redirect to login on failure
|
||||
this.clearTokens();
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT (just the payload, no verification)
|
||||
*/
|
||||
decodeJwt(token) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(c => {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (e) {
|
||||
console.error('Failed to decode JWT:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login - redirect to Auth0
|
||||
*/
|
||||
async login(returnTo = null) {
|
||||
const redirectUri = returnTo || window.location.origin + '/dashboard.html';
|
||||
|
||||
// Build Auth0 authorize URL
|
||||
let authUrl = `https://${CONFIG.auth0.domain}/authorize?` +
|
||||
`response_type=code&` +
|
||||
`client_id=${CONFIG.auth0.clientId}&` +
|
||||
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||
`scope=openid%20profile%20email`;
|
||||
|
||||
// Only add audience if it's defined and not empty
|
||||
if (CONFIG.auth0.audience && CONFIG.auth0.audience.trim() !== '') {
|
||||
authUrl += `&audience=${encodeURIComponent(CONFIG.auth0.audience)}`;
|
||||
}
|
||||
|
||||
window.location.href = authUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
async logout() {
|
||||
this.clearTokens();
|
||||
|
||||
const returnTo = encodeURIComponent(window.location.origin);
|
||||
const logoutUrl = `https://${CONFIG.auth0.domain}/v2/logout?client_id=${CONFIG.auth0.clientId}&returnTo=${returnTo}`;
|
||||
|
||||
window.location.href = logoutUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored tokens
|
||||
*/
|
||||
clearTokens() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
localStorage.removeItem('expires_at');
|
||||
this.token = null;
|
||||
this.user = null;
|
||||
this.isAuthenticated = false;
|
||||
this.expiresAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token
|
||||
*/
|
||||
async getToken() {
|
||||
if (!this.isInitialized) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (this.expiresAt && Date.now() >= this.expiresAt) {
|
||||
this.clearTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*/
|
||||
async getUser() {
|
||||
if (!this.isInitialized) {
|
||||
await this.init();
|
||||
}
|
||||
return this.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
async checkAuth() {
|
||||
if (!this.isInitialized) {
|
||||
await this.init();
|
||||
}
|
||||
return this.isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific role
|
||||
*/
|
||||
hasRole(role) {
|
||||
if (!this.user) return false;
|
||||
const roles = this.user['https://moxiegen.client.guacamolebox.net/roles'] ||
|
||||
this.user.roles ||
|
||||
[];
|
||||
return Array.isArray(roles) && roles.includes(role);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const moxieAuth = new MoxieAuth();
|
||||
|
||||
// Export for use in other modules
|
||||
window.moxieAuth = moxieAuth;
|
||||
40
js/config.js
Normal file
40
js/config.js
Normal file
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Moxiegen Frontend Configuration
|
||||
* Update these values to match your Auth0 and API settings
|
||||
*/
|
||||
|
||||
const CONFIG = {
|
||||
// Auth0 Configuration
|
||||
auth0: {
|
||||
domain: 'dev-t13zhs74oltgqtfx.us.auth0.com',
|
||||
clientId: 'AWRYU8EBnKaHvRQOMXXADxgGEoBN45oN',
|
||||
// Leave audience empty for standard OpenID Connect authentication
|
||||
// Or set to your custom API identifier if you created one in Auth0
|
||||
audience: '',
|
||||
redirectUri: window.location.origin + '/dashboard.html',
|
||||
logoutUri: window.location.origin
|
||||
},
|
||||
|
||||
// API Configuration
|
||||
api: {
|
||||
baseUrl: '/api', // Proxied through Caddy
|
||||
endpoints: {
|
||||
me: '/users/me',
|
||||
credits: '/users/credits',
|
||||
apiKeys: '/users/api-keys',
|
||||
health: '/health'
|
||||
}
|
||||
},
|
||||
|
||||
// App Configuration
|
||||
app: {
|
||||
name: 'Moxiegen',
|
||||
version: '2.0.0'
|
||||
}
|
||||
};
|
||||
|
||||
// Freeze config to prevent modifications
|
||||
Object.freeze(CONFIG);
|
||||
Object.freeze(CONFIG.auth0);
|
||||
Object.freeze(CONFIG.api);
|
||||
Object.freeze(CONFIG.app);
|
||||
5
logo.svg
Normal file
5
logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 169 KiB |
Loading…
Reference in New Issue
Block a user