feat(marketplace): Add Crawl4AI marketplace with secure configuration

- Implement marketplace frontend and admin dashboard
- Add FastAPI backend with environment-based configuration
- Use .env file for secrets management
- Include data generation scripts
- Add proper CORS configuration
- Remove hardcoded password from admin login
- Update gitignore for security
This commit is contained in:
unclecode
2025-10-02 16:41:11 +08:00
parent ef46df10da
commit 408ad1b750
20 changed files with 5143 additions and 0 deletions

View File

@@ -0,0 +1,650 @@
/* Admin Dashboard - C4AI Terminal Style */
/* Utility Classes */
.hidden {
display: none !important;
}
/* Brand Colors */
:root {
--c4ai-cyan: #50ffff;
--c4ai-green: #50ff50;
--c4ai-yellow: #ffff50;
--c4ai-pink: #ff50ff;
--c4ai-blue: #5050ff;
}
.admin-container {
min-height: 100vh;
background: var(--bg-dark);
}
/* Login Screen */
.login-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #070708 0%, #1a1a2e 100%);
}
.login-box {
background: var(--bg-secondary);
border: 2px solid var(--primary-cyan);
padding: 3rem;
width: 400px;
box-shadow: 0 0 40px rgba(80, 255, 255, 0.2);
text-align: center;
}
.login-logo {
height: 60px;
margin-bottom: 2rem;
filter: brightness(1.2);
}
.login-box h1 {
color: var(--primary-cyan);
font-size: 1.5rem;
margin-bottom: 2rem;
}
#login-form input {
width: 100%;
padding: 0.75rem;
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: inherit;
margin-bottom: 1rem;
}
#login-form input:focus {
outline: none;
border-color: var(--primary-cyan);
}
#login-form button {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
border: none;
color: var(--bg-dark);
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
#login-form button:hover {
box-shadow: 0 4px 15px rgba(80, 255, 255, 0.3);
transform: translateY(-2px);
}
.error-msg {
color: var(--error);
font-size: 0.875rem;
margin-top: 1rem;
}
/* Admin Dashboard */
.admin-dashboard.hidden {
display: none;
}
.admin-header {
background: var(--bg-secondary);
border-bottom: 2px solid var(--primary-cyan);
padding: 1rem 0;
}
.header-content {
max-width: 1800px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-logo {
height: 35px;
}
.admin-header h1 {
font-size: 1.25rem;
color: var(--primary-cyan);
}
.header-right {
display: flex;
align-items: center;
gap: 2rem;
}
.admin-user {
color: var(--text-secondary);
}
.logout-btn {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--error);
color: var(--error);
cursor: pointer;
transition: all 0.2s;
}
.logout-btn:hover {
background: rgba(255, 60, 116, 0.1);
}
/* Layout */
.admin-layout {
display: flex;
max-width: 1800px;
margin: 0 auto;
min-height: calc(100vh - 60px);
}
/* Sidebar */
.admin-sidebar {
width: 250px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.sidebar-nav {
padding: 1rem 0;
}
.nav-btn {
width: 100%;
padding: 1rem 1.5rem;
background: transparent;
border: none;
border-left: 3px solid transparent;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.75rem;
}
.nav-btn:hover {
background: rgba(80, 255, 255, 0.05);
color: var(--primary-cyan);
}
.nav-btn.active {
border-left-color: var(--primary-cyan);
background: rgba(80, 255, 255, 0.1);
color: var(--primary-cyan);
}
.nav-icon {
font-size: 1.25rem;
margin-right: 0.25rem;
display: inline-block;
width: 1.5rem;
text-align: center;
}
.nav-btn[data-section="stats"] .nav-icon {
color: var(--c4ai-cyan);
}
.nav-btn[data-section="apps"] .nav-icon {
color: var(--c4ai-green);
}
.nav-btn[data-section="articles"] .nav-icon {
color: var(--c4ai-yellow);
}
.nav-btn[data-section="categories"] .nav-icon {
color: var(--c4ai-pink);
}
.nav-btn[data-section="sponsors"] .nav-icon {
color: var(--c4ai-blue);
}
.sidebar-actions {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
.action-btn {
width: 100%;
padding: 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
margin-bottom: 0.5rem;
transition: all 0.2s;
}
.action-btn:hover {
border-color: var(--primary-cyan);
color: var(--primary-cyan);
}
/* Main Content */
.admin-main {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.content-section {
display: none;
}
.content-section.active {
display: block;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 3rem;
}
.stat-card {
background: linear-gradient(135deg, rgba(80, 255, 255, 0.03), rgba(243, 128, 245, 0.02));
border: 1px solid rgba(80, 255, 255, 0.3);
padding: 1.5rem;
display: flex;
gap: 1.5rem;
}
.stat-icon {
font-size: 2rem;
width: 3rem;
height: 3rem;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid;
border-radius: 4px;
}
.stat-card:nth-child(1) .stat-icon {
color: var(--c4ai-cyan);
border-color: var(--c4ai-cyan);
}
.stat-card:nth-child(2) .stat-icon {
color: var(--c4ai-green);
border-color: var(--c4ai-green);
}
.stat-card:nth-child(3) .stat-icon {
color: var(--c4ai-yellow);
border-color: var(--c4ai-yellow);
}
.stat-card:nth-child(4) .stat-icon {
color: var(--c4ai-pink);
border-color: var(--c4ai-pink);
}
.stat-number {
font-size: 2rem;
color: var(--primary-cyan);
font-weight: 600;
}
.stat-label {
color: var(--text-secondary);
}
.stat-detail {
font-size: 0.875rem;
color: var(--text-tertiary);
margin-top: 0.5rem;
}
/* Quick Actions */
.quick-actions {
display: flex;
gap: 1rem;
}
.quick-btn {
padding: 0.75rem 1.5rem;
background: transparent;
border: 1px solid var(--primary-cyan);
color: var(--primary-cyan);
cursor: pointer;
transition: all 0.2s;
}
.quick-btn:hover {
background: rgba(80, 255, 255, 0.1);
transform: translateY(-2px);
}
/* Section Headers */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.section-header h2 {
font-size: 1.5rem;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 1rem;
}
.search-input {
padding: 0.5rem 1rem;
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
width: 250px;
}
.search-input:focus {
outline: none;
border-color: var(--primary-cyan);
}
.filter-select {
padding: 0.5rem;
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.add-btn {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
border: none;
color: var(--bg-dark);
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.add-btn:hover {
box-shadow: 0 4px 15px rgba(80, 255, 255, 0.3);
transform: translateY(-2px);
}
/* Data Tables */
.data-table {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
overflow-x: auto;
}
.data-table table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
background: var(--bg-tertiary);
padding: 1rem;
text-align: left;
color: var(--primary-cyan);
font-weight: 600;
border-bottom: 2px solid var(--border-color);
position: sticky;
top: 0;
z-index: 10;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.data-table tr:hover {
background: rgba(80, 255, 255, 0.03);
}
/* Table Actions */
.table-actions {
display: flex;
gap: 0.5rem;
}
.btn-edit, .btn-delete, .btn-duplicate {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.875rem;
}
.btn-edit:hover {
border-color: var(--primary-cyan);
color: var(--primary-cyan);
}
.btn-delete:hover {
border-color: var(--error);
color: var(--error);
}
.btn-duplicate:hover {
border-color: var(--accent-pink);
color: var(--accent-pink);
}
/* Badges in Tables */
.badge {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
}
.badge.featured {
background: var(--primary-cyan);
color: var(--bg-dark);
}
.badge.sponsored {
background: var(--warning);
color: var(--bg-dark);
}
.badge.active {
background: var(--success);
color: var(--bg-dark);
}
/* Modal Enhancements */
.modal-content.large {
max-width: 1000px;
width: 90%;
max-height: 90vh;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
max-height: calc(90vh - 140px);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
}
.btn-cancel, .btn-save {
padding: 0.5rem 1.5rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-cancel:hover {
border-color: var(--error);
color: var(--error);
}
.btn-save {
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
border: none;
color: var(--bg-dark);
font-weight: 600;
}
.btn-save:hover {
box-shadow: 0 4px 15px rgba(80, 255, 255, 0.3);
}
/* Form Styles */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
color: var(--text-secondary);
font-size: 0.875rem;
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 0.5rem;
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-cyan);
}
.form-group.full-width {
grid-column: 1 / -1;
}
.checkbox-group {
display: flex;
gap: 2rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Rich Text Editor */
.editor-toolbar {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-bottom: none;
}
.editor-btn {
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
}
.editor-btn:hover {
background: rgba(80, 255, 255, 0.1);
border-color: var(--primary-cyan);
}
.editor-content {
min-height: 300px;
padding: 1rem;
background: var(--bg-dark);
border: 1px solid var(--border-color);
font-family: 'Dank Mono', Monaco, monospace;
}
/* Responsive */
@media (max-width: 1024px) {
.admin-layout {
flex-direction: column;
}
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
.sidebar-nav {
display: flex;
overflow-x: auto;
padding: 0;
}
.nav-btn {
border-left: none;
border-bottom: 3px solid transparent;
white-space: nowrap;
}
.nav-btn.active {
border-bottom-color: var(--primary-cyan);
}
.sidebar-actions {
display: none;
}
}

View File

@@ -0,0 +1,757 @@
// Admin Dashboard - Smart & Powerful
const API_BASE = 'http://localhost:8100/api';
class AdminDashboard {
constructor() {
this.token = localStorage.getItem('admin_token');
this.currentSection = 'stats';
this.data = {
apps: [],
articles: [],
categories: [],
sponsors: []
};
this.editingItem = null;
this.init();
}
async init() {
// Check auth
if (!this.token) {
this.showLogin();
return;
}
// Try to load stats to verify token
try {
await this.loadStats();
this.showDashboard();
this.setupEventListeners();
await this.loadAllData();
} catch (error) {
if (error.status === 401) {
this.showLogin();
}
}
}
showLogin() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('admin-dashboard').classList.add('hidden');
// Set up login button click handler
const loginBtn = document.getElementById('login-btn');
if (loginBtn) {
loginBtn.onclick = async () => {
const password = document.getElementById('password').value;
await this.login(password);
};
}
}
async login(password) {
try {
const response = await fetch(`${API_BASE}/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (!response.ok) throw new Error('Invalid password');
const data = await response.json();
this.token = data.token;
localStorage.setItem('admin_token', this.token);
document.getElementById('login-screen').classList.add('hidden');
this.showDashboard();
this.setupEventListeners();
await this.loadAllData();
} catch (error) {
document.getElementById('login-error').textContent = 'Invalid password';
document.getElementById('password').value = '';
}
}
showDashboard() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('admin-dashboard').classList.remove('hidden');
}
setupEventListeners() {
// Navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.onclick = () => this.switchSection(btn.dataset.section);
});
// Logout
document.getElementById('logout-btn').onclick = () => this.logout();
// Export/Backup
document.getElementById('export-btn').onclick = () => this.exportData();
document.getElementById('backup-btn').onclick = () => this.backupDatabase();
// Search
['apps', 'articles'].forEach(type => {
const searchInput = document.getElementById(`${type}-search`);
if (searchInput) {
searchInput.oninput = (e) => this.filterTable(type, e.target.value);
}
});
// Category filter
const categoryFilter = document.getElementById('apps-filter');
if (categoryFilter) {
categoryFilter.onchange = (e) => this.filterByCategory(e.target.value);
}
// Save button in modal
document.getElementById('save-btn').onclick = () => this.saveItem();
}
async loadAllData() {
try {
await this.loadStats();
} catch (e) {
console.error('Failed to load stats:', e);
}
try {
await this.loadApps();
} catch (e) {
console.error('Failed to load apps:', e);
}
try {
await this.loadArticles();
} catch (e) {
console.error('Failed to load articles:', e);
}
try {
await this.loadCategories();
} catch (e) {
console.error('Failed to load categories:', e);
}
try {
await this.loadSponsors();
} catch (e) {
console.error('Failed to load sponsors:', e);
}
this.populateCategoryFilter();
}
async apiCall(endpoint, options = {}) {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
...options.headers
}
});
if (response.status === 401) {
this.logout();
throw { status: 401 };
}
if (!response.ok) throw new Error(`API Error: ${response.status}`);
return response.json();
}
async loadStats() {
const stats = await this.apiCall('/admin/stats');
document.getElementById('stat-apps').textContent = stats.apps.total;
document.getElementById('stat-featured').textContent = stats.apps.featured;
document.getElementById('stat-sponsored').textContent = stats.apps.sponsored;
document.getElementById('stat-articles').textContent = stats.articles;
document.getElementById('stat-sponsors').textContent = stats.sponsors.active;
document.getElementById('stat-views').textContent = this.formatNumber(stats.total_views);
}
async loadApps() {
this.data.apps = await this.apiCall('/apps?limit=100');
this.renderAppsTable(this.data.apps);
}
async loadArticles() {
this.data.articles = await this.apiCall('/articles?limit=100');
this.renderArticlesTable(this.data.articles);
}
async loadCategories() {
this.data.categories = await this.apiCall('/categories');
this.renderCategoriesTable(this.data.categories);
}
async loadSponsors() {
this.data.sponsors = await this.apiCall('/sponsors');
this.renderSponsorsTable(this.data.sponsors);
}
renderAppsTable(apps) {
const table = document.getElementById('apps-table');
table.innerHTML = `
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Type</th>
<th>Rating</th>
<th>Downloads</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${apps.map(app => `
<tr>
<td>${app.id}</td>
<td>${app.name}</td>
<td>${app.category}</td>
<td>${app.type}</td>
<td>◆ ${app.rating}/5</td>
<td>${this.formatNumber(app.downloads)}</td>
<td>
${app.featured ? '<span class="badge featured">Featured</span>' : ''}
${app.sponsored ? '<span class="badge sponsored">Sponsored</span>' : ''}
</td>
<td>
<div class="table-actions">
<button class="btn-edit" onclick="admin.editItem('apps', ${app.id})">Edit</button>
<button class="btn-duplicate" onclick="admin.duplicateItem('apps', ${app.id})">Duplicate</button>
<button class="btn-delete" onclick="admin.deleteItem('apps', ${app.id})">Delete</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
renderArticlesTable(articles) {
const table = document.getElementById('articles-table');
table.innerHTML = `
<table>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Category</th>
<th>Author</th>
<th>Published</th>
<th>Views</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${articles.map(article => `
<tr>
<td>${article.id}</td>
<td>${article.title}</td>
<td>${article.category}</td>
<td>${article.author}</td>
<td>${new Date(article.published_date).toLocaleDateString()}</td>
<td>${this.formatNumber(article.views)}</td>
<td>
<div class="table-actions">
<button class="btn-edit" onclick="admin.editItem('articles', ${article.id})">Edit</button>
<button class="btn-duplicate" onclick="admin.duplicateItem('articles', ${article.id})">Duplicate</button>
<button class="btn-delete" onclick="admin.deleteItem('articles', ${article.id})">Delete</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
renderCategoriesTable(categories) {
const table = document.getElementById('categories-table');
table.innerHTML = `
<table>
<thead>
<tr>
<th>Order</th>
<th>Icon</th>
<th>Name</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${categories.map(cat => `
<tr>
<td>${cat.order_index}</td>
<td>${cat.icon}</td>
<td>${cat.name}</td>
<td>${cat.description}</td>
<td>
<div class="table-actions">
<button class="btn-edit" onclick="admin.editItem('categories', ${cat.id})">Edit</button>
<button class="btn-delete" onclick="admin.deleteCategory(${cat.id})">Delete</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
renderSponsorsTable(sponsors) {
const table = document.getElementById('sponsors-table');
table.innerHTML = `
<table>
<thead>
<tr>
<th>ID</th>
<th>Company</th>
<th>Tier</th>
<th>Start</th>
<th>End</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${sponsors.map(sponsor => `
<tr>
<td>${sponsor.id}</td>
<td>${sponsor.company_name}</td>
<td>${sponsor.tier}</td>
<td>${new Date(sponsor.start_date).toLocaleDateString()}</td>
<td>${new Date(sponsor.end_date).toLocaleDateString()}</td>
<td>${sponsor.active ? '<span class="badge active">Active</span>' : 'Inactive'}</td>
<td>
<div class="table-actions">
<button class="btn-edit" onclick="admin.editItem('sponsors', ${sponsor.id})">Edit</button>
<button class="btn-delete" onclick="admin.deleteItem('sponsors', ${sponsor.id})">Delete</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
showAddForm(type) {
this.editingItem = null;
this.showModal(type, null);
}
async editItem(type, id) {
const item = this.data[type].find(i => i.id === id);
if (item) {
this.editingItem = item;
this.showModal(type, item);
}
}
async duplicateItem(type, id) {
const item = this.data[type].find(i => i.id === id);
if (item) {
const newItem = { ...item };
delete newItem.id;
newItem.name = `${newItem.name || newItem.title} (Copy)`;
if (newItem.slug) newItem.slug = `${newItem.slug}-copy-${Date.now()}`;
this.editingItem = null;
this.showModal(type, newItem);
}
}
showModal(type, item) {
const modal = document.getElementById('form-modal');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
title.textContent = item ? `Edit ${type.slice(0, -1)}` : `Add New ${type.slice(0, -1)}`;
if (type === 'apps') {
body.innerHTML = this.getAppForm(item);
} else if (type === 'articles') {
body.innerHTML = this.getArticleForm(item);
} else if (type === 'categories') {
body.innerHTML = this.getCategoryForm(item);
} else if (type === 'sponsors') {
body.innerHTML = this.getSponsorForm(item);
}
modal.classList.remove('hidden');
modal.dataset.type = type;
}
getAppForm(app) {
return `
<div class="form-grid">
<div class="form-group">
<label>Name *</label>
<input type="text" id="form-name" value="${app?.name || ''}" required>
</div>
<div class="form-group">
<label>Slug</label>
<input type="text" id="form-slug" value="${app?.slug || ''}" placeholder="auto-generated">
</div>
<div class="form-group">
<label>Category</label>
<select id="form-category">
${this.data.categories.map(cat =>
`<option value="${cat.name}" ${app?.category === cat.name ? 'selected' : ''}>${cat.name}</option>`
).join('')}
</select>
</div>
<div class="form-group">
<label>Type</label>
<select id="form-type">
<option value="Open Source" ${app?.type === 'Open Source' ? 'selected' : ''}>Open Source</option>
<option value="Paid" ${app?.type === 'Paid' ? 'selected' : ''}>Paid</option>
<option value="Freemium" ${app?.type === 'Freemium' ? 'selected' : ''}>Freemium</option>
</select>
</div>
<div class="form-group">
<label>Rating</label>
<input type="number" id="form-rating" value="${app?.rating || 4.5}" min="0" max="5" step="0.1">
</div>
<div class="form-group">
<label>Downloads</label>
<input type="number" id="form-downloads" value="${app?.downloads || 0}">
</div>
<div class="form-group full-width">
<label>Description</label>
<textarea id="form-description" rows="3">${app?.description || ''}</textarea>
</div>
<div class="form-group full-width">
<label>Image URL</label>
<input type="text" id="form-image" value="${app?.image || ''}" placeholder="https://...">
</div>
<div class="form-group">
<label>Website URL</label>
<input type="text" id="form-website" value="${app?.website_url || ''}">
</div>
<div class="form-group">
<label>GitHub URL</label>
<input type="text" id="form-github" value="${app?.github_url || ''}">
</div>
<div class="form-group">
<label>Pricing</label>
<input type="text" id="form-pricing" value="${app?.pricing || 'Free'}">
</div>
<div class="form-group">
<label>Contact Email</label>
<input type="email" id="form-email" value="${app?.contact_email || ''}">
</div>
<div class="form-group full-width checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="form-featured" ${app?.featured ? 'checked' : ''}>
Featured
</label>
<label class="checkbox-label">
<input type="checkbox" id="form-sponsored" ${app?.sponsored ? 'checked' : ''}>
Sponsored
</label>
</div>
<div class="form-group full-width">
<label>Integration Guide</label>
<textarea id="form-integration" rows="10">${app?.integration_guide || ''}</textarea>
</div>
</div>
`;
}
getArticleForm(article) {
return `
<div class="form-grid">
<div class="form-group full-width">
<label>Title *</label>
<input type="text" id="form-title" value="${article?.title || ''}" required>
</div>
<div class="form-group">
<label>Author</label>
<input type="text" id="form-author" value="${article?.author || 'Crawl4AI Team'}">
</div>
<div class="form-group">
<label>Category</label>
<select id="form-category">
<option value="News" ${article?.category === 'News' ? 'selected' : ''}>News</option>
<option value="Tutorial" ${article?.category === 'Tutorial' ? 'selected' : ''}>Tutorial</option>
<option value="Review" ${article?.category === 'Review' ? 'selected' : ''}>Review</option>
<option value="Comparison" ${article?.category === 'Comparison' ? 'selected' : ''}>Comparison</option>
</select>
</div>
<div class="form-group full-width">
<label>Featured Image URL</label>
<input type="text" id="form-image" value="${article?.featured_image || ''}">
</div>
<div class="form-group full-width">
<label>Content</label>
<textarea id="form-content" rows="20">${article?.content || ''}</textarea>
</div>
</div>
`;
}
getCategoryForm(category) {
return `
<div class="form-grid">
<div class="form-group">
<label>Name *</label>
<input type="text" id="form-name" value="${category?.name || ''}" required>
</div>
<div class="form-group">
<label>Icon</label>
<input type="text" id="form-icon" value="${category?.icon || '📁'}" maxlength="2">
</div>
<div class="form-group">
<label>Order</label>
<input type="number" id="form-order" value="${category?.order_index || 0}">
</div>
<div class="form-group full-width">
<label>Description</label>
<textarea id="form-description" rows="3">${category?.description || ''}</textarea>
</div>
</div>
`;
}
getSponsorForm(sponsor) {
return `
<div class="form-grid">
<div class="form-group">
<label>Company Name *</label>
<input type="text" id="form-name" value="${sponsor?.company_name || ''}" required>
</div>
<div class="form-group">
<label>Tier</label>
<select id="form-tier">
<option value="Bronze" ${sponsor?.tier === 'Bronze' ? 'selected' : ''}>Bronze</option>
<option value="Silver" ${sponsor?.tier === 'Silver' ? 'selected' : ''}>Silver</option>
<option value="Gold" ${sponsor?.tier === 'Gold' ? 'selected' : ''}>Gold</option>
</select>
</div>
<div class="form-group">
<label>Landing URL</label>
<input type="text" id="form-landing" value="${sponsor?.landing_url || ''}">
</div>
<div class="form-group">
<label>Banner URL</label>
<input type="text" id="form-banner" value="${sponsor?.banner_url || ''}">
</div>
<div class="form-group">
<label>Start Date</label>
<input type="date" id="form-start" value="${sponsor?.start_date?.split('T')[0] || ''}">
</div>
<div class="form-group">
<label>End Date</label>
<input type="date" id="form-end" value="${sponsor?.end_date?.split('T')[0] || ''}">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="form-active" ${sponsor?.active ? 'checked' : ''}>
Active
</label>
</div>
</div>
`;
}
async saveItem() {
const modal = document.getElementById('form-modal');
const type = modal.dataset.type;
const data = this.collectFormData(type);
try {
if (this.editingItem) {
await this.apiCall(`/admin/${type}/${this.editingItem.id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
} else {
await this.apiCall(`/admin/${type}`, {
method: 'POST',
body: JSON.stringify(data)
});
}
this.closeModal();
await this[`load${type.charAt(0).toUpperCase() + type.slice(1)}`]();
await this.loadStats();
} catch (error) {
alert('Error saving item: ' + error.message);
}
}
collectFormData(type) {
const data = {};
if (type === 'apps') {
data.name = document.getElementById('form-name').value;
data.slug = document.getElementById('form-slug').value || this.generateSlug(data.name);
data.description = document.getElementById('form-description').value;
data.category = document.getElementById('form-category').value;
data.type = document.getElementById('form-type').value;
data.rating = parseFloat(document.getElementById('form-rating').value);
data.downloads = parseInt(document.getElementById('form-downloads').value);
data.image = document.getElementById('form-image').value;
data.website_url = document.getElementById('form-website').value;
data.github_url = document.getElementById('form-github').value;
data.pricing = document.getElementById('form-pricing').value;
data.contact_email = document.getElementById('form-email').value;
data.featured = document.getElementById('form-featured').checked ? 1 : 0;
data.sponsored = document.getElementById('form-sponsored').checked ? 1 : 0;
data.integration_guide = document.getElementById('form-integration').value;
} else if (type === 'articles') {
data.title = document.getElementById('form-title').value;
data.slug = this.generateSlug(data.title);
data.author = document.getElementById('form-author').value;
data.category = document.getElementById('form-category').value;
data.featured_image = document.getElementById('form-image').value;
data.content = document.getElementById('form-content').value;
} else if (type === 'categories') {
data.name = document.getElementById('form-name').value;
data.slug = this.generateSlug(data.name);
data.icon = document.getElementById('form-icon').value;
data.description = document.getElementById('form-description').value;
data.order_index = parseInt(document.getElementById('form-order').value);
} else if (type === 'sponsors') {
data.company_name = document.getElementById('form-name').value;
data.tier = document.getElementById('form-tier').value;
data.landing_url = document.getElementById('form-landing').value;
data.banner_url = document.getElementById('form-banner').value;
data.start_date = document.getElementById('form-start').value;
data.end_date = document.getElementById('form-end').value;
data.active = document.getElementById('form-active').checked ? 1 : 0;
}
return data;
}
async deleteItem(type, id) {
if (!confirm(`Are you sure you want to delete this ${type.slice(0, -1)}?`)) return;
try {
await this.apiCall(`/admin/${type}/${id}`, { method: 'DELETE' });
await this[`load${type.charAt(0).toUpperCase() + type.slice(1)}`]();
await this.loadStats();
} catch (error) {
alert('Error deleting item: ' + error.message);
}
}
async deleteCategory(id) {
const hasApps = this.data.apps.some(app =>
app.category === this.data.categories.find(c => c.id === id)?.name
);
if (hasApps) {
alert('Cannot delete category with existing apps');
return;
}
await this.deleteItem('categories', id);
}
closeModal() {
document.getElementById('form-modal').classList.add('hidden');
this.editingItem = null;
}
switchSection(section) {
// Update navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.section === section);
});
// Show section
document.querySelectorAll('.content-section').forEach(sec => {
sec.classList.remove('active');
});
document.getElementById(`${section}-section`).classList.add('active');
this.currentSection = section;
}
filterTable(type, query) {
const items = this.data[type].filter(item => {
const searchText = Object.values(item).join(' ').toLowerCase();
return searchText.includes(query.toLowerCase());
});
if (type === 'apps') {
this.renderAppsTable(items);
} else if (type === 'articles') {
this.renderArticlesTable(items);
}
}
filterByCategory(category) {
const apps = category
? this.data.apps.filter(app => app.category === category)
: this.data.apps;
this.renderAppsTable(apps);
}
populateCategoryFilter() {
const filter = document.getElementById('apps-filter');
if (!filter) return;
filter.innerHTML = '<option value="">All Categories</option>';
this.data.categories.forEach(cat => {
filter.innerHTML += `<option value="${cat.name}">${cat.name}</option>`;
});
}
async exportData() {
const data = {
apps: this.data.apps,
articles: this.data.articles,
categories: this.data.categories,
sponsors: this.data.sponsors,
exported: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `marketplace-export-${Date.now()}.json`;
a.click();
}
async backupDatabase() {
// In production, this would download the SQLite file
alert('Database backup would be implemented on the server side');
}
generateSlug(text) {
return text.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
logout() {
localStorage.removeItem('admin_token');
this.token = null;
this.showLogin();
}
}
// Initialize
const admin = new AdminDashboard();

View File

@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Crawl4AI Marketplace</title>
<link rel="stylesheet" href="../frontend/marketplace.css?v=1759329000">
<link rel="stylesheet" href="admin.css?v=1759329000">
</head>
<body>
<div class="admin-container">
<!-- Login Screen -->
<div id="login-screen" class="login-screen">
<div class="login-box">
<img src="../../assets/images/logo.png" alt="Crawl4AI" class="login-logo">
<h1>[ Admin Access ]</h1>
<div id="login-form">
<input type="password" id="password" placeholder="Enter admin password" autofocus onkeypress="if(event.key==='Enter'){document.getElementById('login-btn').click()}">
<button type="button" id="login-btn">→ Login</button>
</div>
<div id="login-error" class="error-msg"></div>
</div>
</div>
<!-- Admin Dashboard -->
<div id="admin-dashboard" class="admin-dashboard hidden">
<!-- Header -->
<header class="admin-header">
<div class="header-content">
<div class="header-left">
<img src="../../assets/images/logo.png" alt="Crawl4AI" class="header-logo">
<h1>[ Admin Dashboard ]</h1>
</div>
<div class="header-right">
<span class="admin-user">Administrator</span>
<button id="logout-btn" class="logout-btn">↗ Logout</button>
</div>
</div>
</header>
<!-- Main Layout -->
<div class="admin-layout">
<!-- Sidebar -->
<aside class="admin-sidebar">
<nav class="sidebar-nav">
<button class="nav-btn active" data-section="stats">
<span class="nav-icon"></span> Dashboard
</button>
<button class="nav-btn" data-section="apps">
<span class="nav-icon"></span> Apps
</button>
<button class="nav-btn" data-section="articles">
<span class="nav-icon"></span> Articles
</button>
<button class="nav-btn" data-section="categories">
<span class="nav-icon"></span> Categories
</button>
<button class="nav-btn" data-section="sponsors">
<span class="nav-icon"></span> Sponsors
</button>
</nav>
<div class="sidebar-actions">
<button id="export-btn" class="action-btn">
<span></span> Export Data
</button>
<button id="backup-btn" class="action-btn">
<span></span> Backup DB
</button>
</div>
</aside>
<!-- Main Content -->
<main class="admin-main">
<!-- Stats Section -->
<section id="stats-section" class="content-section active">
<h2>Dashboard Overview</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-number" id="stat-apps">--</div>
<div class="stat-label">Total Apps</div>
<div class="stat-detail">
<span id="stat-featured">--</span> featured,
<span id="stat-sponsored">--</span> sponsored
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-number" id="stat-articles">--</div>
<div class="stat-label">Articles</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-number" id="stat-sponsors">--</div>
<div class="stat-label">Active Sponsors</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-number" id="stat-views">--</div>
<div class="stat-label">Total Views</div>
</div>
</div>
</div>
<h3>Quick Actions</h3>
<div class="quick-actions">
<button class="quick-btn" onclick="admin.showAddForm('apps')">
<span></span> Add New App
</button>
<button class="quick-btn" onclick="admin.showAddForm('articles')">
<span></span> Write Article
</button>
<button class="quick-btn" onclick="admin.showAddForm('sponsors')">
<span></span> Add Sponsor
</button>
</div>
</section>
<!-- Apps Section -->
<section id="apps-section" class="content-section">
<div class="section-header">
<h2>Apps Management</h2>
<div class="header-actions">
<input type="text" id="apps-search" class="search-input" placeholder="Search apps...">
<select id="apps-filter" class="filter-select">
<option value="">All Categories</option>
</select>
<button class="add-btn" onclick="admin.showAddForm('apps')">
<span></span> Add App
</button>
</div>
</div>
<div class="data-table" id="apps-table">
<!-- Apps table will be populated here -->
</div>
</section>
<!-- Articles Section -->
<section id="articles-section" class="content-section">
<div class="section-header">
<h2>Articles Management</h2>
<div class="header-actions">
<input type="text" id="articles-search" class="search-input" placeholder="Search articles...">
<button class="add-btn" onclick="admin.showAddForm('articles')">
<span></span> Add Article
</button>
</div>
</div>
<div class="data-table" id="articles-table">
<!-- Articles table will be populated here -->
</div>
</section>
<!-- Categories Section -->
<section id="categories-section" class="content-section">
<div class="section-header">
<h2>Categories Management</h2>
<div class="header-actions">
<button class="add-btn" onclick="admin.showAddForm('categories')">
<span></span> Add Category
</button>
</div>
</div>
<div class="data-table" id="categories-table">
<!-- Categories table will be populated here -->
</div>
</section>
<!-- Sponsors Section -->
<section id="sponsors-section" class="content-section">
<div class="section-header">
<h2>Sponsors Management</h2>
<div class="header-actions">
<button class="add-btn" onclick="admin.showAddForm('sponsors')">
<span></span> Add Sponsor
</button>
</div>
</div>
<div class="data-table" id="sponsors-table">
<!-- Sponsors table will be populated here -->
</div>
</section>
</main>
</div>
</div>
<!-- Modal for Add/Edit Forms -->
<div id="form-modal" class="modal hidden">
<div class="modal-content large">
<div class="modal-header">
<h2 id="modal-title">Add/Edit</h2>
<button class="modal-close" onclick="admin.closeModal()"></button>
</div>
<div class="modal-body" id="modal-body">
<!-- Dynamic form content -->
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="admin.closeModal()">Cancel</button>
<button class="btn-save" id="save-btn">Save</button>
</div>
</div>
</div>
</div>
<script src="admin.js?v=1759327900"></script>
</body>
</html>