test/moxie/web/templates/profile.html
Z User 1f9535d683 Add complete MOXIE web UI with authentication and user management
- New web UI with OpenWebUI-like interface using Tailwind CSS
- SQLite-based authentication with session management
- User registration, login, and profile pages
- Chat interface with conversation history
- Streaming responses with visible thinking phase
- File attachments support
- User document uploads in profile
- Rate limiting (5 requests/day for free users)
- Admin panel user management with promote/demote/delete
- Custom color theme matching balloon logo design
- Compatible with Nuitka build system
2026-03-24 05:15:50 +00:00

258 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profile - MOXIE</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
background: '#0B0C1C',
surface: '#1A1A1A',
'balloon-red': '#EA744C',
'balloon-yellow': '#ECDC67',
'balloon-blue': '#4F9AC3',
'balloon-purple': '#6A4477',
'text-main': '#F0F0F0',
}
}
}
}
</script>
</head>
<body class="bg-background text-text-main min-h-screen">
<!-- Header -->
<header class="border-b border-balloon-purple/20 bg-surface/50">
<div class="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/chat" class="flex items-center gap-3 hover:opacity-80 transition-opacity">
<img src="/static/moxie_logo.jpg" alt="MOXIE" class="w-8 h-8 rounded-full">
<span class="font-bold">MOXIE</span>
</a>
<a href="/chat" class="text-sm text-text-main/50 hover:text-text-main transition-colors">← Back to Chat</a>
</div>
</header>
<!-- Main content -->
<main class="max-w-4xl mx-auto px-6 py-8">
<!-- Profile header -->
<div class="flex items-center gap-6 mb-8">
<div class="w-20 h-20 bg-balloon-purple/30 rounded-full flex items-center justify-center text-3xl font-bold">
{{ user.username[0].upper() }}
</div>
<div>
<h1 class="text-2xl font-bold">{{ user.username }}</h1>
<p class="text-text-main/50">{{ user.email }}</p>
{% if user.is_admin %}
<span class="inline-block mt-1 px-2 py-0.5 bg-balloon-yellow/20 text-balloon-yellow text-xs rounded-full">Admin</span>
{% endif %}
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div class="bg-surface/50 rounded-xl p-5 border border-balloon-purple/20">
<div class="text-3xl font-bold text-balloon-blue">{{ rate_info.remaining }}</div>
<div class="text-sm text-text-main/50">Requests remaining today</div>
</div>
<div class="bg-surface/50 rounded-xl p-5 border border-balloon-purple/20">
<div class="text-3xl font-bold text-balloon-yellow">{{ user.request_limit }}</div>
<div class="text-sm text-text-main/50">Daily request limit</div>
</div>
<div class="bg-surface/50 rounded-xl p-5 border border-balloon-purple/20">
<div class="text-3xl font-bold text-balloon-purple">{{ documents|length }}</div>
<div class="text-sm text-text-main/50">Documents uploaded</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Change password -->
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-balloon-red" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Change Password
</h2>
<form id="passwordForm" class="space-y-4">
<div>
<label class="block text-sm text-text-main/70 mb-1">Current Password</label>
<input type="password" name="current_password" required class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
</div>
<div>
<label class="block text-sm text-text-main/70 mb-1">New Password</label>
<input type="password" name="new_password" required minlength="6" class="w-full px-4 py-2 bg-background/50 border border-balloon-purple/30 rounded-lg focus:outline-none focus:border-balloon-blue transition-colors">
</div>
<button type="submit" class="w-full py-2 bg-balloon-red hover:bg-balloon-red/80 text-white font-medium rounded-lg transition-colors">
Update Password
</button>
</form>
</div>
<!-- Upload documents -->
<div class="bg-surface/50 rounded-xl p-6 border border-balloon-purple/20">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-balloon-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
My Documents
</h2>
<p class="text-sm text-text-main/50 mb-4">Upload documents to use in your conversations with MOXIE.</p>
<!-- Upload area -->
<div id="uploadArea" class="border-2 border-dashed border-balloon-purple/30 rounded-lg p-6 text-center cursor-pointer hover:border-balloon-blue transition-colors mb-4">
<input type="file" id="docInput" class="hidden" accept=".pdf,.doc,.docx,.txt,.md">
<svg class="w-10 h-10 mx-auto text-text-main/30 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<p class="text-sm text-text-main/50">Click to upload or drag and drop</p>
<p class="text-xs text-text-main/30 mt-1">PDF, DOC, DOCX, TXT, MD</p>
</div>
<!-- Documents list -->
<div id="documentsList" class="space-y-2">
{% for doc in documents %}
<div class="flex items-center justify-between p-3 bg-background/30 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-xl">📄</span>
<div>
<div class="text-sm font-medium">{{ doc.filename }}</div>
<div class="text-xs text-text-main/40">{{ doc.size|filesizeformat }}</div>
</div>
</div>
<button onclick="deleteDocument('{{ doc.id }}')" class="p-2 text-balloon-red hover:bg-balloon-red/20 rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
{% endfor %}
{% if not documents %}
<p class="text-center text-text-main/30 text-sm py-4">No documents uploaded yet</p>
{% endif %}
</div>
</div>
</div>
<!-- Account info -->
<div class="mt-6 bg-surface/50 rounded-xl p-6 border border-balloon-purple/20">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-balloon-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Account Information
</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-text-main/50">Member since:</span>
<span class="ml-2">{{ user.created_at[:10] if user.created_at else 'N/A' }}</span>
</div>
<div>
<span class="text-text-main/50">Account type:</span>
<span class="ml-2">{{ 'Administrator' if user.is_admin else 'Free (5 req/day)' }}</span>
</div>
</div>
</div>
</main>
<script>
// Password change
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = {
current_password: form.current_password.value,
new_password: form.new_password.value
};
try {
const response = await fetch('/api/profile/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
alert('Password updated successfully!');
form.reset();
} else {
alert(result.error || 'Failed to update password');
}
} catch (error) {
alert('An error occurred. Please try again.');
}
});
// Document upload
const uploadArea = document.getElementById('uploadArea');
const docInput = document.getElementById('docInput');
uploadArea.addEventListener('click', () => docInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('border-balloon-blue');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('border-balloon-blue');
});
uploadArea.addEventListener('drop', async (e) => {
e.preventDefault();
uploadArea.classList.remove('border-balloon-blue');
const files = e.dataTransfer.files;
for (const file of files) {
await uploadDocument(file);
}
});
docInput.addEventListener('change', async (e) => {
for (const file of e.target.files) {
await uploadDocument(file);
}
});
async function uploadDocument(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/profile/documents', {
method: 'POST',
body: formData
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to upload document');
}
} catch (error) {
alert('An error occurred during upload');
}
}
async function deleteDocument(docId) {
if (!confirm('Delete this document?')) return;
try {
const response = await fetch(`/api/profile/documents/${docId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
}
} catch (error) {
alert('Failed to delete document');
}
}
</script>
</body>
</html>