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:
BIN
docs/md_v2/assets/images/logo.png
Normal file
BIN
docs/md_v2/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
66
docs/md_v2/marketplace/README.md
Normal file
66
docs/md_v2/marketplace/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Crawl4AI Marketplace
|
||||
|
||||
A terminal-themed marketplace for tools, integrations, and resources related to Crawl4AI.
|
||||
|
||||
## Setup
|
||||
|
||||
### Backend
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Generate dummy data:
|
||||
```bash
|
||||
python dummy_data.py
|
||||
```
|
||||
|
||||
3. Run the server:
|
||||
```bash
|
||||
python server.py
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:8100
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Open `frontend/index.html` in your browser
|
||||
2. Or serve via MkDocs as part of the documentation site
|
||||
|
||||
## Database Schema
|
||||
|
||||
The marketplace uses SQLite with automatic migration from `schema.yaml`. Tables include:
|
||||
- **apps**: Tools and integrations
|
||||
- **articles**: Reviews, tutorials, and news
|
||||
- **categories**: App categories
|
||||
- **sponsors**: Sponsored content
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/apps` - List apps with filters
|
||||
- `GET /api/articles` - List articles
|
||||
- `GET /api/categories` - Get all categories
|
||||
- `GET /api/sponsors` - Get active sponsors
|
||||
- `GET /api/search?q=query` - Search across content
|
||||
- `GET /api/stats` - Marketplace statistics
|
||||
|
||||
## Features
|
||||
|
||||
- **Smart caching**: LocalStorage with TTL (1 hour)
|
||||
- **Terminal theme**: Consistent with Crawl4AI branding
|
||||
- **Responsive design**: Works on all devices
|
||||
- **Fast search**: Debounced with 300ms delay
|
||||
- **CORS protected**: Only crawl4ai.com and localhost
|
||||
|
||||
## Admin Panel
|
||||
|
||||
Coming soon - for now, edit the database directly or modify `dummy_data.py`
|
||||
|
||||
## Deployment
|
||||
|
||||
For production deployment on EC2:
|
||||
1. Update `API_BASE` in `marketplace.js` to production URL
|
||||
2. Run FastAPI with proper production settings (use gunicorn/uvicorn)
|
||||
3. Set up nginx proxy if needed
|
||||
650
docs/md_v2/marketplace/admin/admin.css
Normal file
650
docs/md_v2/marketplace/admin/admin.css
Normal 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;
|
||||
}
|
||||
}
|
||||
757
docs/md_v2/marketplace/admin/admin.js
Normal file
757
docs/md_v2/marketplace/admin/admin.js
Normal 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();
|
||||
215
docs/md_v2/marketplace/admin/index.html
Normal file
215
docs/md_v2/marketplace/admin/index.html
Normal 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>
|
||||
14
docs/md_v2/marketplace/backend/.env.example
Normal file
14
docs/md_v2/marketplace/backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Marketplace Configuration
|
||||
# Copy this to .env and update with your values
|
||||
|
||||
# Admin password (required)
|
||||
MARKETPLACE_ADMIN_PASSWORD=change_this_password
|
||||
|
||||
# JWT secret key (required) - generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
MARKETPLACE_JWT_SECRET=change_this_to_a_secure_random_key
|
||||
|
||||
# Database path (optional, defaults to ./marketplace.db)
|
||||
MARKETPLACE_DB_PATH=./marketplace.db
|
||||
|
||||
# Token expiry in hours (optional, defaults to 4)
|
||||
MARKETPLACE_TOKEN_EXPIRY=4
|
||||
59
docs/md_v2/marketplace/backend/config.py
Normal file
59
docs/md_v2/marketplace/backend/config.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Marketplace Configuration - Loads from .env file
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file
|
||||
env_path = Path(__file__).parent / '.env'
|
||||
if not env_path.exists():
|
||||
print("\n❌ ERROR: No .env file found!")
|
||||
print("Please copy .env.example to .env and update with your values:")
|
||||
print(f" cp {Path(__file__).parent}/.env.example {Path(__file__).parent}/.env")
|
||||
print("\nThen edit .env with your secure values.")
|
||||
sys.exit(1)
|
||||
|
||||
load_dotenv(env_path)
|
||||
|
||||
# Required environment variables
|
||||
required_vars = ['MARKETPLACE_ADMIN_PASSWORD', 'MARKETPLACE_JWT_SECRET']
|
||||
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||||
|
||||
if missing_vars:
|
||||
print(f"\n❌ ERROR: Missing required environment variables: {', '.join(missing_vars)}")
|
||||
print("Please check your .env file and ensure all required variables are set.")
|
||||
sys.exit(1)
|
||||
|
||||
class Config:
|
||||
"""Configuration loaded from environment variables"""
|
||||
|
||||
# Admin authentication - hashed from password in .env
|
||||
ADMIN_PASSWORD_HASH = hashlib.sha256(
|
||||
os.getenv('MARKETPLACE_ADMIN_PASSWORD').encode()
|
||||
).hexdigest()
|
||||
|
||||
# JWT secret for token generation
|
||||
JWT_SECRET_KEY = os.getenv('MARKETPLACE_JWT_SECRET')
|
||||
|
||||
# Database path
|
||||
DATABASE_PATH = os.getenv('MARKETPLACE_DB_PATH', './marketplace.db')
|
||||
|
||||
# Token expiry in hours
|
||||
TOKEN_EXPIRY_HOURS = int(os.getenv('MARKETPLACE_TOKEN_EXPIRY', '4'))
|
||||
|
||||
# CORS origins - hardcoded as they don't contain secrets
|
||||
ALLOWED_ORIGINS = [
|
||||
"http://localhost:8000",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8100",
|
||||
"http://127.0.0.1:8000",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:8100",
|
||||
"https://crawl4ai.com",
|
||||
"https://www.crawl4ai.com",
|
||||
"https://docs.crawl4ai.com",
|
||||
"https://market.crawl4ai.com"
|
||||
]
|
||||
117
docs/md_v2/marketplace/backend/database.py
Normal file
117
docs/md_v2/marketplace/backend/database.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import sqlite3
|
||||
import yaml
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
|
||||
class DatabaseManager:
|
||||
def __init__(self, db_path=None, schema_path='schema.yaml'):
|
||||
self.schema = self._load_schema(schema_path)
|
||||
# Use provided path or fallback to schema default
|
||||
self.db_path = db_path or self.schema['database']['name']
|
||||
self.conn = None
|
||||
self._init_database()
|
||||
|
||||
def _load_schema(self, path: str) -> Dict:
|
||||
with open(path, 'r') as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def _init_database(self):
|
||||
"""Auto-create/migrate database from schema"""
|
||||
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
|
||||
for table_name, table_def in self.schema['tables'].items():
|
||||
self._create_or_update_table(table_name, table_def['columns'])
|
||||
|
||||
def _create_or_update_table(self, table_name: str, columns: Dict):
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Check if table exists
|
||||
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
|
||||
table_exists = cursor.fetchone() is not None
|
||||
|
||||
if not table_exists:
|
||||
# Create table
|
||||
col_defs = []
|
||||
for col_name, col_spec in columns.items():
|
||||
col_def = f"{col_name} {col_spec['type']}"
|
||||
if col_spec.get('primary'):
|
||||
col_def += " PRIMARY KEY"
|
||||
if col_spec.get('autoincrement'):
|
||||
col_def += " AUTOINCREMENT"
|
||||
if col_spec.get('unique'):
|
||||
col_def += " UNIQUE"
|
||||
if col_spec.get('required'):
|
||||
col_def += " NOT NULL"
|
||||
if 'default' in col_spec:
|
||||
default = col_spec['default']
|
||||
if default == 'CURRENT_TIMESTAMP':
|
||||
col_def += f" DEFAULT {default}"
|
||||
elif isinstance(default, str):
|
||||
col_def += f" DEFAULT '{default}'"
|
||||
else:
|
||||
col_def += f" DEFAULT {default}"
|
||||
col_defs.append(col_def)
|
||||
|
||||
create_sql = f"CREATE TABLE {table_name} ({', '.join(col_defs)})"
|
||||
cursor.execute(create_sql)
|
||||
else:
|
||||
# Check for new columns and add them
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
existing_columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
for col_name, col_spec in columns.items():
|
||||
if col_name not in existing_columns:
|
||||
col_def = f"{col_spec['type']}"
|
||||
if 'default' in col_spec:
|
||||
default = col_spec['default']
|
||||
if default == 'CURRENT_TIMESTAMP':
|
||||
col_def += f" DEFAULT {default}"
|
||||
elif isinstance(default, str):
|
||||
col_def += f" DEFAULT '{default}'"
|
||||
else:
|
||||
col_def += f" DEFAULT {default}"
|
||||
|
||||
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_def}")
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def get_all(self, table: str, limit: int = 100, offset: int = 0, where: str = None) -> List[Dict]:
|
||||
cursor = self.conn.cursor()
|
||||
query = f"SELECT * FROM {table}"
|
||||
if where:
|
||||
query += f" WHERE {where}"
|
||||
query += f" LIMIT {limit} OFFSET {offset}"
|
||||
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def search(self, query: str, tables: List[str] = None) -> Dict[str, List[Dict]]:
|
||||
if not tables:
|
||||
tables = list(self.schema['tables'].keys())
|
||||
|
||||
results = {}
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
for table in tables:
|
||||
# Search in text columns
|
||||
columns = self.schema['tables'][table]['columns']
|
||||
text_cols = [col for col, spec in columns.items()
|
||||
if spec['type'] == 'TEXT' and col != 'id']
|
||||
|
||||
if text_cols:
|
||||
where_clause = ' OR '.join([f"{col} LIKE ?" for col in text_cols])
|
||||
params = [f'%{query}%'] * len(text_cols)
|
||||
|
||||
cursor.execute(f"SELECT * FROM {table} WHERE {where_clause} LIMIT 10", params)
|
||||
rows = cursor.fetchall()
|
||||
if rows:
|
||||
results[table] = [dict(row) for row in rows]
|
||||
|
||||
return results
|
||||
|
||||
def close(self):
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
267
docs/md_v2/marketplace/backend/dummy_data.py
Normal file
267
docs/md_v2/marketplace/backend/dummy_data.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import sqlite3
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from database import DatabaseManager
|
||||
|
||||
def generate_slug(text):
|
||||
return text.lower().replace(' ', '-').replace('&', 'and')
|
||||
|
||||
def generate_dummy_data():
|
||||
db = DatabaseManager()
|
||||
conn = db.conn
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Clear existing data
|
||||
for table in ['apps', 'articles', 'categories', 'sponsors']:
|
||||
cursor.execute(f"DELETE FROM {table}")
|
||||
|
||||
# Categories
|
||||
categories = [
|
||||
("Browser Automation", "⚙", "Tools for browser automation and control"),
|
||||
("Proxy Services", "🔒", "Proxy providers and rotation services"),
|
||||
("LLM Integration", "🤖", "AI/LLM tools and integrations"),
|
||||
("Data Processing", "📊", "Data extraction and processing tools"),
|
||||
("Cloud Infrastructure", "☁", "Cloud browser and computing services"),
|
||||
("Developer Tools", "🛠", "Development and testing utilities")
|
||||
]
|
||||
|
||||
for i, (name, icon, desc) in enumerate(categories):
|
||||
cursor.execute("""
|
||||
INSERT INTO categories (name, slug, icon, description, order_index)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (name, generate_slug(name), icon, desc, i))
|
||||
|
||||
# Apps with real Unsplash images
|
||||
apps_data = [
|
||||
# Browser Automation
|
||||
("Playwright Cloud", "Browser Automation", "Paid", True, True,
|
||||
"Scalable browser automation in the cloud with Playwright", "https://playwright.cloud",
|
||||
None, "$99/month starter", 4.8, 12500,
|
||||
"https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&h=400&fit=crop"),
|
||||
|
||||
("Selenium Grid Hub", "Browser Automation", "Freemium", False, False,
|
||||
"Distributed Selenium grid for parallel testing", "https://seleniumhub.io",
|
||||
"https://github.com/seleniumhub/grid", "Free - $299/month", 4.2, 8400,
|
||||
"https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&h=400&fit=crop"),
|
||||
|
||||
("Puppeteer Extra", "Browser Automation", "Open Source", True, False,
|
||||
"Enhanced Puppeteer with stealth plugins and more", "https://puppeteer-extra.dev",
|
||||
"https://github.com/berstend/puppeteer-extra", "Free", 4.6, 15200,
|
||||
"https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=800&h=400&fit=crop"),
|
||||
|
||||
# Proxy Services
|
||||
("BrightData", "Proxy Services", "Paid", True, True,
|
||||
"Premium proxy network with 72M+ IPs worldwide", "https://brightdata.com",
|
||||
None, "Starting $500/month", 4.7, 9800,
|
||||
"https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800&h=400&fit=crop"),
|
||||
|
||||
("SmartProxy", "Proxy Services", "Paid", False, True,
|
||||
"Residential and datacenter proxies with rotation", "https://smartproxy.com",
|
||||
None, "Starting $75/month", 4.3, 7600,
|
||||
"https://images.unsplash.com/photo-1544197150-b99a580bb7a8?w=800&h=400&fit=crop"),
|
||||
|
||||
("ProxyMesh", "Proxy Services", "Freemium", False, False,
|
||||
"Rotating proxy servers with sticky sessions", "https://proxymesh.com",
|
||||
None, "$10-$50/month", 4.0, 4200,
|
||||
"https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&h=400&fit=crop"),
|
||||
|
||||
# LLM Integration
|
||||
("LangChain Crawl", "LLM Integration", "Open Source", True, False,
|
||||
"LangChain integration for Crawl4AI workflows", "https://langchain-crawl.dev",
|
||||
"https://github.com/langchain/crawl", "Free", 4.5, 18900,
|
||||
"https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&h=400&fit=crop"),
|
||||
|
||||
("GPT Scraper", "LLM Integration", "Freemium", False, False,
|
||||
"Extract structured data using GPT models", "https://gptscraper.ai",
|
||||
None, "Free - $99/month", 4.1, 5600,
|
||||
"https://images.unsplash.com/photo-1655720828018-edd2daec9349?w=800&h=400&fit=crop"),
|
||||
|
||||
("Claude Extract", "LLM Integration", "Paid", True, True,
|
||||
"Professional extraction using Claude AI", "https://claude-extract.com",
|
||||
None, "$199/month", 4.9, 3200,
|
||||
"https://images.unsplash.com/photo-1686191128892-3b09ad503b4f?w=800&h=400&fit=crop"),
|
||||
|
||||
# Data Processing
|
||||
("DataMiner Pro", "Data Processing", "Paid", False, False,
|
||||
"Advanced data extraction and transformation", "https://dataminer.pro",
|
||||
None, "$149/month", 4.2, 6700,
|
||||
"https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&h=400&fit=crop"),
|
||||
|
||||
("ScraperAPI", "Data Processing", "Freemium", True, True,
|
||||
"Simple API for web scraping with proxy rotation", "https://scraperapi.com",
|
||||
None, "Free - $299/month", 4.6, 22300,
|
||||
"https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&h=400&fit=crop"),
|
||||
|
||||
("Apify", "Data Processing", "Freemium", False, False,
|
||||
"Web scraping and automation platform", "https://apify.com",
|
||||
None, "$49-$499/month", 4.4, 14500,
|
||||
"https://images.unsplash.com/photo-1504639725590-34d0984388bd?w=800&h=400&fit=crop"),
|
||||
|
||||
# Cloud Infrastructure
|
||||
("BrowserCloud", "Cloud Infrastructure", "Paid", True, True,
|
||||
"Managed headless browsers in the cloud", "https://browsercloud.io",
|
||||
None, "$199/month", 4.5, 8900,
|
||||
"https://images.unsplash.com/photo-1667372393119-3d4c48d07fc9?w=800&h=400&fit=crop"),
|
||||
|
||||
("LambdaTest", "Cloud Infrastructure", "Freemium", False, False,
|
||||
"Cross-browser testing on cloud", "https://lambdatest.com",
|
||||
None, "Free - $99/month", 4.1, 11200,
|
||||
"https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=800&h=400&fit=crop"),
|
||||
|
||||
("Browserless", "Cloud Infrastructure", "Freemium", True, False,
|
||||
"Headless browser automation API", "https://browserless.io",
|
||||
None, "$50-$500/month", 4.7, 19800,
|
||||
"https://images.unsplash.com/photo-1639762681485-074b7f938ba0?w=800&h=400&fit=crop"),
|
||||
|
||||
# Developer Tools
|
||||
("Crawl4AI VSCode", "Developer Tools", "Open Source", True, False,
|
||||
"VSCode extension for Crawl4AI development", "https://marketplace.visualstudio.com",
|
||||
"https://github.com/crawl4ai/vscode", "Free", 4.8, 34500,
|
||||
"https://images.unsplash.com/photo-1629654297299-c8506221ca97?w=800&h=400&fit=crop"),
|
||||
|
||||
("Postman Collection", "Developer Tools", "Open Source", False, False,
|
||||
"Postman collection for Crawl4AI API testing", "https://postman.com/crawl4ai",
|
||||
"https://github.com/crawl4ai/postman", "Free", 4.3, 7800,
|
||||
"https://images.unsplash.com/photo-1599507593499-a3f7d7d97667?w=800&h=400&fit=crop"),
|
||||
|
||||
("Debug Toolkit", "Developer Tools", "Open Source", False, False,
|
||||
"Debugging tools for crawler development", "https://debug.crawl4ai.com",
|
||||
"https://github.com/crawl4ai/debug", "Free", 4.0, 4300,
|
||||
"https://images.unsplash.com/photo-1515879218367-8466d910aaa4?w=800&h=400&fit=crop"),
|
||||
]
|
||||
|
||||
for name, category, type_, featured, sponsored, desc, url, github, pricing, rating, downloads, image in apps_data:
|
||||
screenshots = json.dumps([
|
||||
f"https://images.unsplash.com/photo-{random.randint(1500000000000, 1700000000000)}-{random.randint(1000000000000, 9999999999999)}?w=800&h=600&fit=crop",
|
||||
f"https://images.unsplash.com/photo-{random.randint(1500000000000, 1700000000000)}-{random.randint(1000000000000, 9999999999999)}?w=800&h=600&fit=crop"
|
||||
])
|
||||
cursor.execute("""
|
||||
INSERT INTO apps (name, slug, description, category, type, featured, sponsored,
|
||||
website_url, github_url, pricing, rating, downloads, image, screenshots, logo_url,
|
||||
integration_guide, contact_email, views)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (name, generate_slug(name), desc, category, type_, featured, sponsored,
|
||||
url, github, pricing, rating, downloads, image, screenshots,
|
||||
f"https://ui-avatars.com/api/?name={name}&background=50ffff&color=070708&size=128",
|
||||
f"# {name} Integration\n\n```python\nfrom crawl4ai import AsyncWebCrawler\n# Integration code coming soon...\n```",
|
||||
f"contact@{generate_slug(name)}.com",
|
||||
random.randint(100, 5000)))
|
||||
|
||||
# Articles with real images
|
||||
articles_data = [
|
||||
("Browser Automation Showdown: Playwright vs Puppeteer vs Selenium",
|
||||
"Review", "John Doe", ["Playwright Cloud", "Puppeteer Extra"],
|
||||
["browser-automation", "comparison", "2024"],
|
||||
"https://images.unsplash.com/photo-1587620962725-abab7fe55159?w=1200&h=630&fit=crop"),
|
||||
|
||||
("Top 5 Proxy Services for Web Scraping in 2024",
|
||||
"Comparison", "Jane Smith", ["BrightData", "SmartProxy", "ProxyMesh"],
|
||||
["proxy", "web-scraping", "guide"],
|
||||
"https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1200&h=630&fit=crop"),
|
||||
|
||||
("Integrating LLMs with Crawl4AI: A Complete Guide",
|
||||
"Tutorial", "Crawl4AI Team", ["LangChain Crawl", "GPT Scraper", "Claude Extract"],
|
||||
["llm", "integration", "tutorial"],
|
||||
"https://images.unsplash.com/photo-1677442136019-21780ecad995?w=1200&h=630&fit=crop"),
|
||||
|
||||
("Building Scalable Crawlers with Cloud Infrastructure",
|
||||
"Tutorial", "Mike Johnson", ["BrowserCloud", "Browserless"],
|
||||
["cloud", "scalability", "architecture"],
|
||||
"https://images.unsplash.com/photo-1667372393119-3d4c48d07fc9?w=1200&h=630&fit=crop"),
|
||||
|
||||
("What's New in Crawl4AI Marketplace",
|
||||
"News", "Crawl4AI Team", [],
|
||||
["marketplace", "announcement", "news"],
|
||||
"https://images.unsplash.com/photo-1556075798-4825dfaaf498?w=1200&h=630&fit=crop"),
|
||||
|
||||
("Cost Analysis: Self-Hosted vs Cloud Browser Solutions",
|
||||
"Comparison", "Sarah Chen", ["BrowserCloud", "LambdaTest", "Browserless"],
|
||||
["cost", "cloud", "comparison"],
|
||||
"https://images.unsplash.com/photo-1554224155-8d04cb21cd6c?w=1200&h=630&fit=crop"),
|
||||
|
||||
("Getting Started with Browser Automation",
|
||||
"Tutorial", "Crawl4AI Team", ["Playwright Cloud", "Selenium Grid Hub"],
|
||||
["beginner", "tutorial", "automation"],
|
||||
"https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=1200&h=630&fit=crop"),
|
||||
|
||||
("The Future of Web Scraping: AI-Powered Extraction",
|
||||
"News", "Dr. Alan Turing", ["Claude Extract", "GPT Scraper"],
|
||||
["ai", "future", "trends"],
|
||||
"https://images.unsplash.com/photo-1593720213428-28a5b9e94613?w=1200&h=630&fit=crop")
|
||||
]
|
||||
|
||||
for title, category, author, related_apps, tags, image in articles_data:
|
||||
# Get app IDs for related apps
|
||||
related_ids = []
|
||||
for app_name in related_apps:
|
||||
cursor.execute("SELECT id FROM apps WHERE name = ?", (app_name,))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
related_ids.append(result[0])
|
||||
|
||||
content = f"""# {title}
|
||||
|
||||
By {author} | {datetime.now().strftime('%B %d, %Y')}
|
||||
|
||||
## Introduction
|
||||
|
||||
This is a comprehensive article about {title.lower()}. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
|
||||
## Key Points
|
||||
|
||||
- Important point about the topic
|
||||
- Another crucial insight
|
||||
- Technical details and specifications
|
||||
- Performance comparisons
|
||||
|
||||
## Conclusion
|
||||
|
||||
In summary, this article explored various aspects of the topic. Stay tuned for more updates!
|
||||
"""
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO articles (title, slug, content, author, category, related_apps,
|
||||
featured_image, tags, views)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (title, generate_slug(title), content, author, category,
|
||||
json.dumps(related_ids), image, json.dumps(tags),
|
||||
random.randint(200, 10000)))
|
||||
|
||||
# Sponsors
|
||||
sponsors_data = [
|
||||
("BrightData", "Gold", "https://brightdata.com",
|
||||
"https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=728&h=90&fit=crop"),
|
||||
("ScraperAPI", "Gold", "https://scraperapi.com",
|
||||
"https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=728&h=90&fit=crop"),
|
||||
("BrowserCloud", "Silver", "https://browsercloud.io",
|
||||
"https://images.unsplash.com/photo-1667372393119-3d4c48d07fc9?w=728&h=90&fit=crop"),
|
||||
("Claude Extract", "Silver", "https://claude-extract.com",
|
||||
"https://images.unsplash.com/photo-1686191128892-3b09ad503b4f?w=728&h=90&fit=crop"),
|
||||
("SmartProxy", "Bronze", "https://smartproxy.com",
|
||||
"https://images.unsplash.com/photo-1544197150-b99a580bb7a8?w=728&h=90&fit=crop")
|
||||
]
|
||||
|
||||
for company, tier, landing_url, banner in sponsors_data:
|
||||
start_date = datetime.now() - timedelta(days=random.randint(1, 30))
|
||||
end_date = datetime.now() + timedelta(days=random.randint(30, 180))
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO sponsors (company_name, logo_url, tier, banner_url,
|
||||
landing_url, active, start_date, end_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (company,
|
||||
f"https://ui-avatars.com/api/?name={company}&background=09b5a5&color=fff&size=200",
|
||||
tier, banner, landing_url, 1,
|
||||
start_date.isoformat(), end_date.isoformat()))
|
||||
|
||||
conn.commit()
|
||||
print("✓ Dummy data generated successfully!")
|
||||
print(f" - {len(categories)} categories")
|
||||
print(f" - {len(apps_data)} apps")
|
||||
print(f" - {len(articles_data)} articles")
|
||||
print(f" - {len(sponsors_data)} sponsors")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_dummy_data()
|
||||
5
docs/md_v2/marketplace/backend/requirements.txt
Normal file
5
docs/md_v2/marketplace/backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
pyyaml
|
||||
python-multipart
|
||||
python-dotenv
|
||||
75
docs/md_v2/marketplace/backend/schema.yaml
Normal file
75
docs/md_v2/marketplace/backend/schema.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
database:
|
||||
name: marketplace.db
|
||||
|
||||
tables:
|
||||
apps:
|
||||
columns:
|
||||
id: {type: INTEGER, primary: true, autoincrement: true}
|
||||
name: {type: TEXT, required: true}
|
||||
slug: {type: TEXT, unique: true}
|
||||
description: {type: TEXT}
|
||||
long_description: {type: TEXT}
|
||||
logo_url: {type: TEXT}
|
||||
image: {type: TEXT}
|
||||
screenshots: {type: JSON, default: '[]'}
|
||||
category: {type: TEXT}
|
||||
type: {type: TEXT, default: 'Open Source'}
|
||||
status: {type: TEXT, default: 'Active'}
|
||||
website_url: {type: TEXT}
|
||||
github_url: {type: TEXT}
|
||||
demo_url: {type: TEXT}
|
||||
video_url: {type: TEXT}
|
||||
documentation_url: {type: TEXT}
|
||||
support_url: {type: TEXT}
|
||||
discord_url: {type: TEXT}
|
||||
pricing: {type: TEXT}
|
||||
rating: {type: REAL, default: 0.0}
|
||||
downloads: {type: INTEGER, default: 0}
|
||||
featured: {type: BOOLEAN, default: 0}
|
||||
sponsored: {type: BOOLEAN, default: 0}
|
||||
integration_guide: {type: TEXT}
|
||||
documentation: {type: TEXT}
|
||||
examples: {type: TEXT}
|
||||
installation_command: {type: TEXT}
|
||||
requirements: {type: TEXT}
|
||||
changelog: {type: TEXT}
|
||||
tags: {type: JSON, default: '[]'}
|
||||
added_date: {type: DATETIME, default: CURRENT_TIMESTAMP}
|
||||
updated_date: {type: DATETIME, default: CURRENT_TIMESTAMP}
|
||||
contact_email: {type: TEXT}
|
||||
views: {type: INTEGER, default: 0}
|
||||
|
||||
articles:
|
||||
columns:
|
||||
id: {type: INTEGER, primary: true, autoincrement: true}
|
||||
title: {type: TEXT, required: true}
|
||||
slug: {type: TEXT, unique: true}
|
||||
content: {type: TEXT}
|
||||
author: {type: TEXT, default: 'Crawl4AI Team'}
|
||||
category: {type: TEXT}
|
||||
related_apps: {type: JSON, default: '[]'}
|
||||
featured_image: {type: TEXT}
|
||||
published_date: {type: DATETIME, default: CURRENT_TIMESTAMP}
|
||||
tags: {type: JSON, default: '[]'}
|
||||
views: {type: INTEGER, default: 0}
|
||||
|
||||
categories:
|
||||
columns:
|
||||
id: {type: INTEGER, primary: true, autoincrement: true}
|
||||
name: {type: TEXT, unique: true}
|
||||
slug: {type: TEXT, unique: true}
|
||||
icon: {type: TEXT}
|
||||
description: {type: TEXT}
|
||||
order_index: {type: INTEGER, default: 0}
|
||||
|
||||
sponsors:
|
||||
columns:
|
||||
id: {type: INTEGER, primary: true, autoincrement: true}
|
||||
company_name: {type: TEXT, required: true}
|
||||
logo_url: {type: TEXT}
|
||||
tier: {type: TEXT, default: 'Bronze'}
|
||||
banner_url: {type: TEXT}
|
||||
landing_url: {type: TEXT}
|
||||
active: {type: BOOLEAN, default: 1}
|
||||
start_date: {type: DATETIME}
|
||||
end_date: {type: DATETIME}
|
||||
390
docs/md_v2/marketplace/backend/server.py
Normal file
390
docs/md_v2/marketplace/backend/server.py
Normal file
@@ -0,0 +1,390 @@
|
||||
from fastapi import FastAPI, HTTPException, Query, Depends, Body
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
from database import DatabaseManager
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Import configuration (will exit if .env not found or invalid)
|
||||
from config import Config
|
||||
|
||||
app = FastAPI(title="Crawl4AI Marketplace API")
|
||||
|
||||
# Security setup
|
||||
security = HTTPBearer()
|
||||
tokens = {} # In production, use Redis or database for token storage
|
||||
|
||||
# CORS configuration
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=Config.ALLOWED_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
max_age=3600
|
||||
)
|
||||
|
||||
# Initialize database with configurable path
|
||||
db = DatabaseManager(Config.DATABASE_PATH)
|
||||
|
||||
def json_response(data, cache_time=3600):
|
||||
"""Helper to return JSON with cache headers"""
|
||||
return JSONResponse(
|
||||
content=data,
|
||||
headers={
|
||||
"Cache-Control": f"public, max-age={cache_time}",
|
||||
"X-Content-Type-Options": "nosniff"
|
||||
}
|
||||
)
|
||||
|
||||
# ============= PUBLIC ENDPOINTS =============
|
||||
|
||||
@app.get("/api/apps")
|
||||
async def get_apps(
|
||||
category: Optional[str] = None,
|
||||
type: Optional[str] = None,
|
||||
featured: Optional[bool] = None,
|
||||
sponsored: Optional[bool] = None,
|
||||
limit: int = Query(default=20, le=10000),
|
||||
offset: int = Query(default=0)
|
||||
):
|
||||
"""Get apps with optional filters"""
|
||||
where_clauses = []
|
||||
if category:
|
||||
where_clauses.append(f"category = '{category}'")
|
||||
if type:
|
||||
where_clauses.append(f"type = '{type}'")
|
||||
if featured is not None:
|
||||
where_clauses.append(f"featured = {1 if featured else 0}")
|
||||
if sponsored is not None:
|
||||
where_clauses.append(f"sponsored = {1 if sponsored else 0}")
|
||||
|
||||
where = " AND ".join(where_clauses) if where_clauses else None
|
||||
apps = db.get_all('apps', limit=limit, offset=offset, where=where)
|
||||
|
||||
# Parse JSON fields
|
||||
for app in apps:
|
||||
if app.get('screenshots'):
|
||||
app['screenshots'] = json.loads(app['screenshots'])
|
||||
|
||||
return json_response(apps)
|
||||
|
||||
@app.get("/api/apps/{slug}")
|
||||
async def get_app(slug: str):
|
||||
"""Get single app by slug"""
|
||||
apps = db.get_all('apps', where=f"slug = '{slug}'", limit=1)
|
||||
if not apps:
|
||||
raise HTTPException(status_code=404, detail="App not found")
|
||||
|
||||
app = apps[0]
|
||||
if app.get('screenshots'):
|
||||
app['screenshots'] = json.loads(app['screenshots'])
|
||||
|
||||
return json_response(app)
|
||||
|
||||
@app.get("/api/articles")
|
||||
async def get_articles(
|
||||
category: Optional[str] = None,
|
||||
limit: int = Query(default=20, le=10000),
|
||||
offset: int = Query(default=0)
|
||||
):
|
||||
"""Get articles with optional category filter"""
|
||||
where = f"category = '{category}'" if category else None
|
||||
articles = db.get_all('articles', limit=limit, offset=offset, where=where)
|
||||
|
||||
# Parse JSON fields
|
||||
for article in articles:
|
||||
if article.get('related_apps'):
|
||||
article['related_apps'] = json.loads(article['related_apps'])
|
||||
if article.get('tags'):
|
||||
article['tags'] = json.loads(article['tags'])
|
||||
|
||||
return json_response(articles)
|
||||
|
||||
@app.get("/api/articles/{slug}")
|
||||
async def get_article(slug: str):
|
||||
"""Get single article by slug"""
|
||||
articles = db.get_all('articles', where=f"slug = '{slug}'", limit=1)
|
||||
if not articles:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
article = articles[0]
|
||||
if article.get('related_apps'):
|
||||
article['related_apps'] = json.loads(article['related_apps'])
|
||||
if article.get('tags'):
|
||||
article['tags'] = json.loads(article['tags'])
|
||||
|
||||
return json_response(article)
|
||||
|
||||
@app.get("/api/categories")
|
||||
async def get_categories():
|
||||
"""Get all categories ordered by index"""
|
||||
categories = db.get_all('categories', limit=50)
|
||||
categories.sort(key=lambda x: x.get('order_index', 0))
|
||||
return json_response(categories, cache_time=7200)
|
||||
|
||||
@app.get("/api/sponsors")
|
||||
async def get_sponsors(active: Optional[bool] = True):
|
||||
"""Get sponsors, default active only"""
|
||||
where = f"active = {1 if active else 0}" if active is not None else None
|
||||
sponsors = db.get_all('sponsors', where=where, limit=20)
|
||||
|
||||
# Filter by date if active
|
||||
if active:
|
||||
now = datetime.now().isoformat()
|
||||
sponsors = [s for s in sponsors
|
||||
if (not s.get('start_date') or s['start_date'] <= now) and
|
||||
(not s.get('end_date') or s['end_date'] >= now)]
|
||||
|
||||
return json_response(sponsors)
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search(q: str = Query(min_length=2)):
|
||||
"""Search across apps and articles"""
|
||||
if len(q) < 2:
|
||||
return json_response({})
|
||||
|
||||
results = db.search(q, tables=['apps', 'articles'])
|
||||
|
||||
# Parse JSON fields in results
|
||||
for table, items in results.items():
|
||||
for item in items:
|
||||
if table == 'apps' and item.get('screenshots'):
|
||||
item['screenshots'] = json.loads(item['screenshots'])
|
||||
elif table == 'articles':
|
||||
if item.get('related_apps'):
|
||||
item['related_apps'] = json.loads(item['related_apps'])
|
||||
if item.get('tags'):
|
||||
item['tags'] = json.loads(item['tags'])
|
||||
|
||||
return json_response(results, cache_time=1800)
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def get_stats():
|
||||
"""Get marketplace statistics"""
|
||||
stats = {
|
||||
"total_apps": len(db.get_all('apps', limit=10000)),
|
||||
"total_articles": len(db.get_all('articles', limit=10000)),
|
||||
"total_categories": len(db.get_all('categories', limit=1000)),
|
||||
"active_sponsors": len(db.get_all('sponsors', where="active = 1", limit=1000))
|
||||
}
|
||||
return json_response(stats, cache_time=1800)
|
||||
|
||||
# ============= ADMIN AUTHENTICATION =============
|
||||
|
||||
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""Verify admin authentication token"""
|
||||
token = credentials.credentials
|
||||
if token not in tokens or tokens[token] < datetime.now():
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
return token
|
||||
|
||||
@app.post("/api/admin/login")
|
||||
async def admin_login(password: str = Body(..., embed=True)):
|
||||
"""Admin login with password"""
|
||||
provided_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
if provided_hash != Config.ADMIN_PASSWORD_HASH:
|
||||
# Log failed attempt in production
|
||||
print(f"Failed login attempt at {datetime.now()}")
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
# Generate secure token
|
||||
token = secrets.token_urlsafe(32)
|
||||
tokens[token] = datetime.now() + timedelta(hours=Config.TOKEN_EXPIRY_HOURS)
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"expires_in": Config.TOKEN_EXPIRY_HOURS * 3600
|
||||
}
|
||||
|
||||
# ============= ADMIN ENDPOINTS =============
|
||||
|
||||
@app.get("/api/admin/stats", dependencies=[Depends(verify_token)])
|
||||
async def get_admin_stats():
|
||||
"""Get detailed admin statistics"""
|
||||
stats = {
|
||||
"apps": {
|
||||
"total": len(db.get_all('apps', limit=10000)),
|
||||
"featured": len(db.get_all('apps', where="featured = 1", limit=10000)),
|
||||
"sponsored": len(db.get_all('apps', where="sponsored = 1", limit=10000))
|
||||
},
|
||||
"articles": len(db.get_all('articles', limit=10000)),
|
||||
"categories": len(db.get_all('categories', limit=1000)),
|
||||
"sponsors": {
|
||||
"active": len(db.get_all('sponsors', where="active = 1", limit=1000)),
|
||||
"total": len(db.get_all('sponsors', limit=10000))
|
||||
},
|
||||
"total_views": sum(app.get('views', 0) for app in db.get_all('apps', limit=10000))
|
||||
}
|
||||
return stats
|
||||
|
||||
# Apps CRUD
|
||||
@app.post("/api/admin/apps", dependencies=[Depends(verify_token)])
|
||||
async def create_app(app_data: Dict[str, Any]):
|
||||
"""Create new app"""
|
||||
try:
|
||||
# Handle JSON fields
|
||||
for field in ['screenshots', 'tags']:
|
||||
if field in app_data and isinstance(app_data[field], list):
|
||||
app_data[field] = json.dumps(app_data[field])
|
||||
|
||||
cursor = db.conn.cursor()
|
||||
columns = ', '.join(app_data.keys())
|
||||
placeholders = ', '.join(['?' for _ in app_data])
|
||||
cursor.execute(f"INSERT INTO apps ({columns}) VALUES ({placeholders})",
|
||||
list(app_data.values()))
|
||||
db.conn.commit()
|
||||
return {"id": cursor.lastrowid, "message": "App created"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.put("/api/admin/apps/{app_id}", dependencies=[Depends(verify_token)])
|
||||
async def update_app(app_id: int, app_data: Dict[str, Any]):
|
||||
"""Update app"""
|
||||
try:
|
||||
# Handle JSON fields
|
||||
for field in ['screenshots', 'tags']:
|
||||
if field in app_data and isinstance(app_data[field], list):
|
||||
app_data[field] = json.dumps(app_data[field])
|
||||
|
||||
set_clause = ', '.join([f"{k} = ?" for k in app_data.keys()])
|
||||
cursor = db.conn.cursor()
|
||||
cursor.execute(f"UPDATE apps SET {set_clause} WHERE id = ?",
|
||||
list(app_data.values()) + [app_id])
|
||||
db.conn.commit()
|
||||
return {"message": "App updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.delete("/api/admin/apps/{app_id}", dependencies=[Depends(verify_token)])
|
||||
async def delete_app(app_id: int):
|
||||
"""Delete app"""
|
||||
cursor = db.conn.cursor()
|
||||
cursor.execute("DELETE FROM apps WHERE id = ?", (app_id,))
|
||||
db.conn.commit()
|
||||
return {"message": "App deleted"}
|
||||
|
||||
# Articles CRUD
|
||||
@app.post("/api/admin/articles", dependencies=[Depends(verify_token)])
|
||||
async def create_article(article_data: Dict[str, Any]):
|
||||
"""Create new article"""
|
||||
try:
|
||||
for field in ['related_apps', 'tags']:
|
||||
if field in article_data and isinstance(article_data[field], list):
|
||||
article_data[field] = json.dumps(article_data[field])
|
||||
|
||||
cursor = db.conn.cursor()
|
||||
columns = ', '.join(article_data.keys())
|
||||
placeholders = ', '.join(['?' for _ in article_data])
|
||||
cursor.execute(f"INSERT INTO articles ({columns}) VALUES ({placeholders})",
|
||||
list(article_data.values()))
|
||||
db.conn.commit()
|
||||
return {"id": cursor.lastrowid, "message": "Article created"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.put("/api/admin/articles/{article_id}", dependencies=[Depends(verify_token)])
|
||||
async def update_article(article_id: int, article_data: Dict[str, Any]):
|
||||
"""Update article"""
|
||||
try:
|
||||
for field in ['related_apps', 'tags']:
|
||||
if field in article_data and isinstance(article_data[field], list):
|
||||
article_data[field] = json.dumps(article_data[field])
|
||||
|
||||
set_clause = ', '.join([f"{k} = ?" for k in article_data.keys()])
|
||||
cursor = db.conn.cursor()
|
||||
cursor.execute(f"UPDATE articles SET {set_clause} WHERE id = ?",
|
||||
list(article_data.values()) + [article_id])
|
||||
db.conn.commit()
|
||||
return {"message": "Article updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.delete("/api/admin/articles/{article_id}", dependencies=[Depends(verify_token)])
|
||||
async def delete_article(article_id: int):
|
||||
"""Delete article"""
|
||||
cursor = db.conn.cursor()
|
||||
cursor.execute("DELETE FROM articles WHERE id = ?", (article_id,))
|
||||
db.conn.commit()
|
||||
return {"message": "Article deleted"}
|
||||
|
||||
# Categories CRUD
|
||||
@app.post("/api/admin/categories", dependencies=[Depends(verify_token)])
|
||||
async def create_category(category_data: Dict[str, Any]):
|
||||
"""Create new category"""
|
||||
try:
|
||||
cursor = db.conn.cursor()
|
||||
columns = ', '.join(category_data.keys())
|
||||
placeholders = ', '.join(['?' for _ in category_data])
|
||||
cursor.execute(f"INSERT INTO categories ({columns}) VALUES ({placeholders})",
|
||||
list(category_data.values()))
|
||||
db.conn.commit()
|
||||
return {"id": cursor.lastrowid, "message": "Category created"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.put("/api/admin/categories/{cat_id}", dependencies=[Depends(verify_token)])
|
||||
async def update_category(cat_id: int, category_data: Dict[str, Any]):
|
||||
"""Update category"""
|
||||
try:
|
||||
set_clause = ', '.join([f"{k} = ?" for k in category_data.keys()])
|
||||
cursor = db.conn.cursor()
|
||||
cursor.execute(f"UPDATE categories SET {set_clause} WHERE id = ?",
|
||||
list(category_data.values()) + [cat_id])
|
||||
db.conn.commit()
|
||||
return {"message": "Category updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Sponsors CRUD
|
||||
@app.post("/api/admin/sponsors", dependencies=[Depends(verify_token)])
|
||||
async def create_sponsor(sponsor_data: Dict[str, Any]):
|
||||
"""Create new sponsor"""
|
||||
try:
|
||||
cursor = db.conn.cursor()
|
||||
columns = ', '.join(sponsor_data.keys())
|
||||
placeholders = ', '.join(['?' for _ in sponsor_data])
|
||||
cursor.execute(f"INSERT INTO sponsors ({columns}) VALUES ({placeholders})",
|
||||
list(sponsor_data.values()))
|
||||
db.conn.commit()
|
||||
return {"id": cursor.lastrowid, "message": "Sponsor created"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.put("/api/admin/sponsors/{sponsor_id}", dependencies=[Depends(verify_token)])
|
||||
async def update_sponsor(sponsor_id: int, sponsor_data: Dict[str, Any]):
|
||||
"""Update sponsor"""
|
||||
try:
|
||||
set_clause = ', '.join([f"{k} = ?" for k in sponsor_data.keys()])
|
||||
cursor = db.conn.cursor()
|
||||
cursor.execute(f"UPDATE sponsors SET {set_clause} WHERE id = ?",
|
||||
list(sponsor_data.values()) + [sponsor_id])
|
||||
db.conn.commit()
|
||||
return {"message": "Sponsor updated"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""API info"""
|
||||
return {
|
||||
"name": "Crawl4AI Marketplace API",
|
||||
"version": "1.0.0",
|
||||
"endpoints": [
|
||||
"/api/apps",
|
||||
"/api/articles",
|
||||
"/api/categories",
|
||||
"/api/sponsors",
|
||||
"/api/search?q=query",
|
||||
"/api/stats"
|
||||
]
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="127.0.0.1", port=8100)
|
||||
462
docs/md_v2/marketplace/frontend/app-detail.css
Normal file
462
docs/md_v2/marketplace/frontend/app-detail.css
Normal file
@@ -0,0 +1,462 @@
|
||||
/* App Detail Page Styles */
|
||||
|
||||
.app-detail-container {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* Back Button */
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--primary-cyan);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
background: rgba(80, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* App Hero Section */
|
||||
.app-hero {
|
||||
max-width: 1800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.app-hero-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 3rem;
|
||||
background: linear-gradient(135deg, #1a1a2e, #0f0f1e);
|
||||
border: 2px solid var(--primary-cyan);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 0 30px rgba(80, 255, 255, 0.15),
|
||||
inset 0 0 20px rgba(80, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.app-hero-image {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
background: linear-gradient(135deg, rgba(80, 255, 255, 0.1), rgba(243, 128, 245, 0.05));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.app-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.app-badge {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-badge.featured {
|
||||
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 2px 10px rgba(80, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.app-badge.sponsored {
|
||||
background: linear-gradient(135deg, var(--warning), #ff8c00);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 2px 10px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.app-hero-info h1 {
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary-cyan);
|
||||
margin: 0.5rem 0;
|
||||
text-shadow: 0 0 20px rgba(80, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.app-tagline {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.app-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.app-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
|
||||
color: var(--bg-dark);
|
||||
border-color: var(--primary-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
box-shadow: 0 4px 15px rgba(80, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
border-color: var(--accent-pink);
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: rgba(243, 128, 245, 0.1);
|
||||
box-shadow: 0 4px 15px rgba(243, 128, 245, 0.2);
|
||||
}
|
||||
|
||||
.action-btn.ghost {
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.action-btn.ghost:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
/* Pricing */
|
||||
.pricing-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.pricing-label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.pricing-value {
|
||||
color: var(--warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Navigation Tabs */
|
||||
.app-nav {
|
||||
max-width: 1800px;
|
||||
margin: 2rem auto 0;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 1rem 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
color: var(--primary-cyan);
|
||||
border-bottom-color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
/* Content Sections */
|
||||
.app-content {
|
||||
max-width: 1800px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
max-width: 1200px;
|
||||
padding: 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.docs-content h2 {
|
||||
font-size: 1.8rem;
|
||||
color: var(--primary-cyan);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.docs-content h3 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-primary);
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
|
||||
.docs-content h4 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent-pink);
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.docs-content p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.docs-content code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 0.2rem 0.4rem;
|
||||
color: var(--primary-cyan);
|
||||
font-family: 'Dank Mono', Monaco, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Code Blocks */
|
||||
.code-block {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.code-lang {
|
||||
color: var(--primary-cyan);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Feature Grid */
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
background: rgba(80, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.feature-card h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.info-box {
|
||||
background: linear-gradient(135deg, rgba(80, 255, 255, 0.05), rgba(243, 128, 245, 0.03));
|
||||
border: 1px solid var(--primary-cyan);
|
||||
border-left: 4px solid var(--primary-cyan);
|
||||
padding: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
margin-top: 0;
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
/* Support Grid */
|
||||
.support-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.support-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-card h3 {
|
||||
color: var(--primary-cyan);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Related Apps */
|
||||
.related-apps {
|
||||
max-width: 1800px;
|
||||
margin: 4rem auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.related-apps h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.related-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.related-app-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.related-app-card:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.app-hero-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-stats {
|
||||
justify-content: space-around;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-hero-info h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
overflow-x: auto;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-grid,
|
||||
.support-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
234
docs/md_v2/marketplace/frontend/app-detail.html
Normal file
234
docs/md_v2/marketplace/frontend/app-detail.html
Normal file
@@ -0,0 +1,234 @@
|
||||
<!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>App Details - Crawl4AI Marketplace</title>
|
||||
<link rel="stylesheet" href="marketplace.css">
|
||||
<link rel="stylesheet" href="app-detail.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-detail-container">
|
||||
<!-- Header -->
|
||||
<header class="marketplace-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="logo-title">
|
||||
<img src="../../assets/images/logo.png" alt="Crawl4AI" class="header-logo">
|
||||
<h1>
|
||||
<span class="ascii-border">[</span>
|
||||
Marketplace
|
||||
<span class="ascii-border">]</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-nav">
|
||||
<a href="index.html" class="back-btn">← Back to Marketplace</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- App Hero Section -->
|
||||
<section class="app-hero">
|
||||
<div class="app-hero-content">
|
||||
<div class="app-hero-image" id="app-image">
|
||||
<!-- Dynamic image -->
|
||||
</div>
|
||||
<div class="app-hero-info">
|
||||
<div class="app-badges">
|
||||
<span class="app-badge" id="app-type">Open Source</span>
|
||||
<span class="app-badge featured" id="app-featured" style="display:none">FEATURED</span>
|
||||
<span class="app-badge sponsored" id="app-sponsored" style="display:none">SPONSORED</span>
|
||||
</div>
|
||||
<h1 id="app-name">App Name</h1>
|
||||
<p id="app-description" class="app-tagline">App description goes here</p>
|
||||
|
||||
<div class="app-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="app-rating">★★★★★</span>
|
||||
<span class="stat-label">Rating</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="app-downloads">0</span>
|
||||
<span class="stat-label">Downloads</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="app-category">Category</span>
|
||||
<span class="stat-label">Category</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-actions">
|
||||
<a href="#" id="app-website" class="action-btn primary" target="_blank">
|
||||
<span>→</span> Visit Website
|
||||
</a>
|
||||
<a href="#" id="app-github" class="action-btn secondary" target="_blank">
|
||||
<span>⚡</span> View on GitHub
|
||||
</a>
|
||||
<button id="copy-integration" class="action-btn ghost">
|
||||
<span>📋</span> Copy Integration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pricing-info">
|
||||
<span class="pricing-label">Pricing:</span>
|
||||
<span id="app-pricing" class="pricing-value">Free</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<nav class="app-nav">
|
||||
<button class="nav-tab active" data-tab="integration">Integration Guide</button>
|
||||
<button class="nav-tab" data-tab="docs">Documentation</button>
|
||||
<button class="nav-tab" data-tab="examples">Examples</button>
|
||||
<button class="nav-tab" data-tab="support">Support</button>
|
||||
</nav>
|
||||
|
||||
<!-- Content Sections -->
|
||||
<main class="app-content">
|
||||
<!-- Integration Guide Tab -->
|
||||
<section id="integration-tab" class="tab-content active">
|
||||
<div class="docs-content">
|
||||
<h2>Quick Start</h2>
|
||||
<p>Get started with this integration in just a few steps.</p>
|
||||
|
||||
<h3>Installation</h3>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span class="code-lang">bash</span>
|
||||
<button class="copy-btn">Copy</button>
|
||||
</div>
|
||||
<pre><code id="install-code">pip install crawl4ai</code></pre>
|
||||
</div>
|
||||
|
||||
<h3>Basic Usage</h3>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span class="code-lang">python</span>
|
||||
<button class="copy-btn">Copy</button>
|
||||
</div>
|
||||
<pre><code id="usage-code">from crawl4ai import AsyncWebCrawler
|
||||
|
||||
async def main():
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
result = await crawler.arun(
|
||||
url="https://example.com",
|
||||
# Your configuration here
|
||||
)
|
||||
print(result.markdown)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(main())</code></pre>
|
||||
</div>
|
||||
|
||||
<h3>Advanced Configuration</h3>
|
||||
<p>Customize the crawler with these advanced options:</p>
|
||||
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<h4>🚀 Performance</h4>
|
||||
<p>Optimize crawling speed with parallel processing and caching strategies.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h4>🔒 Authentication</h4>
|
||||
<p>Handle login forms, cookies, and session management automatically.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h4>🎯 Extraction</h4>
|
||||
<p>Use CSS selectors, XPath, or AI-powered content extraction.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h4>🔄 Proxy Support</h4>
|
||||
<p>Rotate proxies and bypass rate limiting with built-in proxy management.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Integration Example</h3>
|
||||
<div class="code-block">
|
||||
<div class="code-header">
|
||||
<span class="code-lang">python</span>
|
||||
<button class="copy-btn">Copy</button>
|
||||
</div>
|
||||
<pre><code id="integration-code">from crawl4ai import AsyncWebCrawler
|
||||
from crawl4ai.extraction_strategy import LLMExtractionStrategy
|
||||
|
||||
async def extract_with_llm():
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
result = await crawler.arun(
|
||||
url="https://example.com",
|
||||
extraction_strategy=LLMExtractionStrategy(
|
||||
provider="openai",
|
||||
api_key="your-api-key",
|
||||
instruction="Extract product information"
|
||||
),
|
||||
bypass_cache=True
|
||||
)
|
||||
return result.extracted_content
|
||||
|
||||
# Run the extraction
|
||||
data = await extract_with_llm()
|
||||
print(data)</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h4>💡 Pro Tip</h4>
|
||||
<p>Use the <code>bypass_cache=True</code> parameter when you need fresh data, or set <code>cache_mode="write"</code> to update the cache with new content.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Documentation Tab -->
|
||||
<section id="docs-tab" class="tab-content">
|
||||
<div class="docs-content">
|
||||
<h2>Documentation</h2>
|
||||
<p>Complete documentation and API reference.</p>
|
||||
<!-- Dynamic content loaded here -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Examples Tab -->
|
||||
<section id="examples-tab" class="tab-content">
|
||||
<div class="docs-content">
|
||||
<h2>Examples</h2>
|
||||
<p>Real-world examples and use cases.</p>
|
||||
<!-- Dynamic content loaded here -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Support Tab -->
|
||||
<section id="support-tab" class="tab-content">
|
||||
<div class="docs-content">
|
||||
<h2>Support</h2>
|
||||
<div class="support-grid">
|
||||
<div class="support-card">
|
||||
<h3>📧 Contact</h3>
|
||||
<p id="app-contact">contact@example.com</p>
|
||||
</div>
|
||||
<div class="support-card">
|
||||
<h3>🐛 Report Issues</h3>
|
||||
<p>Found a bug? Report it on GitHub Issues.</p>
|
||||
</div>
|
||||
<div class="support-card">
|
||||
<h3>💬 Community</h3>
|
||||
<p>Join our Discord for help and discussions.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Related Apps -->
|
||||
<section class="related-apps">
|
||||
<h2>Related Apps</h2>
|
||||
<div id="related-apps-grid" class="related-grid">
|
||||
<!-- Dynamic related apps -->
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="app-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
324
docs/md_v2/marketplace/frontend/app-detail.js
Normal file
324
docs/md_v2/marketplace/frontend/app-detail.js
Normal file
@@ -0,0 +1,324 @@
|
||||
// App Detail Page JavaScript
|
||||
const API_BASE = 'http://localhost:8100/api';
|
||||
|
||||
class AppDetailPage {
|
||||
constructor() {
|
||||
this.appSlug = this.getAppSlugFromURL();
|
||||
this.appData = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
getAppSlugFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('app') || '';
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.appSlug) {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadAppDetails();
|
||||
this.setupEventListeners();
|
||||
await this.loadRelatedApps();
|
||||
}
|
||||
|
||||
async loadAppDetails() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/apps/${this.appSlug}`);
|
||||
if (!response.ok) throw new Error('App not found');
|
||||
|
||||
this.appData = await response.json();
|
||||
this.renderAppDetails();
|
||||
} catch (error) {
|
||||
console.error('Error loading app details:', error);
|
||||
// Fallback to loading all apps and finding the right one
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/apps`);
|
||||
const apps = await response.json();
|
||||
this.appData = apps.find(app => app.slug === this.appSlug || app.name.toLowerCase().replace(/\s+/g, '-') === this.appSlug);
|
||||
if (this.appData) {
|
||||
this.renderAppDetails();
|
||||
} else {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading apps:', err);
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderAppDetails() {
|
||||
if (!this.appData) return;
|
||||
|
||||
// Update title
|
||||
document.title = `${this.appData.name} - Crawl4AI Marketplace`;
|
||||
|
||||
// Hero image
|
||||
const appImage = document.getElementById('app-image');
|
||||
if (this.appData.image) {
|
||||
appImage.style.backgroundImage = `url('${this.appData.image}')`;
|
||||
appImage.innerHTML = '';
|
||||
} else {
|
||||
appImage.innerHTML = `[${this.appData.category || 'APP'}]`;
|
||||
}
|
||||
|
||||
// Basic info
|
||||
document.getElementById('app-name').textContent = this.appData.name;
|
||||
document.getElementById('app-description').textContent = this.appData.description;
|
||||
document.getElementById('app-type').textContent = this.appData.type || 'Open Source';
|
||||
document.getElementById('app-category').textContent = this.appData.category;
|
||||
document.getElementById('app-pricing').textContent = this.appData.pricing || 'Free';
|
||||
|
||||
// Badges
|
||||
if (this.appData.featured) {
|
||||
document.getElementById('app-featured').style.display = 'inline-block';
|
||||
}
|
||||
if (this.appData.sponsored) {
|
||||
document.getElementById('app-sponsored').style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// Stats
|
||||
const rating = this.appData.rating || 0;
|
||||
const stars = '★'.repeat(Math.floor(rating)) + '☆'.repeat(5 - Math.floor(rating));
|
||||
document.getElementById('app-rating').textContent = stars + ` ${rating}/5`;
|
||||
document.getElementById('app-downloads').textContent = this.formatNumber(this.appData.downloads || 0);
|
||||
|
||||
// Action buttons
|
||||
const websiteBtn = document.getElementById('app-website');
|
||||
const githubBtn = document.getElementById('app-github');
|
||||
|
||||
if (this.appData.website_url) {
|
||||
websiteBtn.href = this.appData.website_url;
|
||||
} else {
|
||||
websiteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
if (this.appData.github_url) {
|
||||
githubBtn.href = this.appData.github_url;
|
||||
} else {
|
||||
githubBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Contact
|
||||
document.getElementById('app-contact').textContent = this.appData.contact_email || 'Not available';
|
||||
|
||||
// Integration guide
|
||||
this.renderIntegrationGuide();
|
||||
}
|
||||
|
||||
renderIntegrationGuide() {
|
||||
// Installation code
|
||||
const installCode = document.getElementById('install-code');
|
||||
if (this.appData.type === 'Open Source' && this.appData.github_url) {
|
||||
installCode.textContent = `# Clone from GitHub
|
||||
git clone ${this.appData.github_url}
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt`;
|
||||
} else if (this.appData.name.toLowerCase().includes('api')) {
|
||||
installCode.textContent = `# Install via pip
|
||||
pip install ${this.appData.slug}
|
||||
|
||||
# Or install from source
|
||||
pip install git+${this.appData.github_url || 'https://github.com/example/repo'}`;
|
||||
}
|
||||
|
||||
// Usage code - customize based on category
|
||||
const usageCode = document.getElementById('usage-code');
|
||||
if (this.appData.category === 'Browser Automation') {
|
||||
usageCode.textContent = `from crawl4ai import AsyncWebCrawler
|
||||
from ${this.appData.slug.replace(/-/g, '_')} import ${this.appData.name.replace(/\s+/g, '')}
|
||||
|
||||
async def main():
|
||||
# Initialize ${this.appData.name}
|
||||
automation = ${this.appData.name.replace(/\s+/g, '')}()
|
||||
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
result = await crawler.arun(
|
||||
url="https://example.com",
|
||||
browser_config=automation.config,
|
||||
wait_for="css:body"
|
||||
)
|
||||
print(result.markdown)`;
|
||||
} else if (this.appData.category === 'Proxy Services') {
|
||||
usageCode.textContent = `from crawl4ai import AsyncWebCrawler
|
||||
import ${this.appData.slug.replace(/-/g, '_')}
|
||||
|
||||
# Configure proxy
|
||||
proxy_config = {
|
||||
"server": "${this.appData.website_url || 'https://proxy.example.com'}",
|
||||
"username": "your_username",
|
||||
"password": "your_password"
|
||||
}
|
||||
|
||||
async with AsyncWebCrawler(proxy=proxy_config) as crawler:
|
||||
result = await crawler.arun(
|
||||
url="https://example.com",
|
||||
bypass_cache=True
|
||||
)
|
||||
print(result.status_code)`;
|
||||
} else if (this.appData.category === 'LLM Integration') {
|
||||
usageCode.textContent = `from crawl4ai import AsyncWebCrawler
|
||||
from crawl4ai.extraction_strategy import LLMExtractionStrategy
|
||||
|
||||
# Configure LLM extraction
|
||||
strategy = LLMExtractionStrategy(
|
||||
provider="${this.appData.name.toLowerCase().includes('gpt') ? 'openai' : 'anthropic'}",
|
||||
api_key="your-api-key",
|
||||
model="${this.appData.name.toLowerCase().includes('gpt') ? 'gpt-4' : 'claude-3'}",
|
||||
instruction="Extract structured data"
|
||||
)
|
||||
|
||||
async with AsyncWebCrawler() as crawler:
|
||||
result = await crawler.arun(
|
||||
url="https://example.com",
|
||||
extraction_strategy=strategy
|
||||
)
|
||||
print(result.extracted_content)`;
|
||||
}
|
||||
|
||||
// Integration example
|
||||
const integrationCode = document.getElementById('integration-code');
|
||||
integrationCode.textContent = this.appData.integration_guide ||
|
||||
`# Complete ${this.appData.name} Integration Example
|
||||
|
||||
from crawl4ai import AsyncWebCrawler
|
||||
from crawl4ai.extraction_strategy import JsonCssExtractionStrategy
|
||||
import json
|
||||
|
||||
async def crawl_with_${this.appData.slug.replace(/-/g, '_')}():
|
||||
"""
|
||||
Complete example showing how to use ${this.appData.name}
|
||||
with Crawl4AI for production web scraping
|
||||
"""
|
||||
|
||||
# Define extraction schema
|
||||
schema = {
|
||||
"name": "ProductList",
|
||||
"baseSelector": "div.product",
|
||||
"fields": [
|
||||
{"name": "title", "selector": "h2", "type": "text"},
|
||||
{"name": "price", "selector": ".price", "type": "text"},
|
||||
{"name": "image", "selector": "img", "type": "attribute", "attribute": "src"},
|
||||
{"name": "link", "selector": "a", "type": "attribute", "attribute": "href"}
|
||||
]
|
||||
}
|
||||
|
||||
# Initialize crawler with ${this.appData.name}
|
||||
async with AsyncWebCrawler(
|
||||
browser_type="chromium",
|
||||
headless=True,
|
||||
verbose=True
|
||||
) as crawler:
|
||||
|
||||
# Crawl with extraction
|
||||
result = await crawler.arun(
|
||||
url="https://example.com/products",
|
||||
extraction_strategy=JsonCssExtractionStrategy(schema),
|
||||
cache_mode="bypass",
|
||||
wait_for="css:.product",
|
||||
screenshot=True
|
||||
)
|
||||
|
||||
# Process results
|
||||
if result.success:
|
||||
products = json.loads(result.extracted_content)
|
||||
print(f"Found {len(products)} products")
|
||||
|
||||
for product in products[:5]:
|
||||
print(f"- {product['title']}: {product['price']}")
|
||||
|
||||
return products
|
||||
|
||||
# Run the crawler
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(crawl_with_${this.appData.slug.replace(/-/g, '_')}())`;
|
||||
}
|
||||
|
||||
formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Tab switching
|
||||
const tabs = document.querySelectorAll('.nav-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
// Update active tab
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
// Show corresponding content
|
||||
const tabName = tab.dataset.tab;
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`${tabName}-tab`).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Copy integration code
|
||||
document.getElementById('copy-integration').addEventListener('click', () => {
|
||||
const code = document.getElementById('integration-code').textContent;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const btn = document.getElementById('copy-integration');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<span>✓</span> Copied!';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// Copy code buttons
|
||||
document.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const codeBlock = e.target.closest('.code-block');
|
||||
const code = codeBlock.querySelector('code').textContent;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copy';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async loadRelatedApps() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/apps?category=${encodeURIComponent(this.appData.category)}&limit=4`);
|
||||
const apps = await response.json();
|
||||
|
||||
const relatedApps = apps.filter(app => app.slug !== this.appSlug).slice(0, 3);
|
||||
const grid = document.getElementById('related-apps-grid');
|
||||
|
||||
grid.innerHTML = relatedApps.map(app => `
|
||||
<div class="related-app-card" onclick="window.location.href='app-detail.html?app=${app.slug || app.name.toLowerCase().replace(/\s+/g, '-')}'">
|
||||
<h4>${app.name}</h4>
|
||||
<p>${app.description.substring(0, 100)}...</p>
|
||||
<div style="display: flex; justify-content: space-between; margin-top: 0.5rem; font-size: 0.75rem;">
|
||||
<span style="color: var(--primary-cyan)">${app.type}</span>
|
||||
<span style="color: var(--warning)">★ ${app.rating}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading related apps:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new AppDetailPage();
|
||||
});
|
||||
147
docs/md_v2/marketplace/frontend/index.html
Normal file
147
docs/md_v2/marketplace/frontend/index.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!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>Marketplace - Crawl4AI</title>
|
||||
<link rel="stylesheet" href="marketplace.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="marketplace-container">
|
||||
<!-- Header -->
|
||||
<header class="marketplace-header">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<div class="logo-title">
|
||||
<img src="../../assets/images/logo.png" alt="Crawl4AI" class="header-logo">
|
||||
<h1>
|
||||
<span class="ascii-border">[</span>
|
||||
Marketplace
|
||||
<span class="ascii-border">]</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p class="tagline">Tools, Integrations & Resources for Web Crawling</p>
|
||||
</div>
|
||||
<div class="header-stats" id="stats">
|
||||
<span class="stat-item">Apps: <span id="total-apps">--</span></span>
|
||||
<span class="stat-item">Articles: <span id="total-articles">--</span></span>
|
||||
<span class="stat-item">Downloads: <span id="total-downloads">--</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Search and Category Bar -->
|
||||
<div class="search-filter-bar">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">></span>
|
||||
<input type="text" id="search-input" placeholder="Search apps, articles, tools..." />
|
||||
<kbd>/</kbd>
|
||||
</div>
|
||||
<div class="category-filter" id="category-filter">
|
||||
<button class="filter-btn active" data-category="all">All</button>
|
||||
<!-- Categories will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Magazine Grid Layout -->
|
||||
<main class="magazine-layout">
|
||||
<!-- Hero Featured Section -->
|
||||
<section class="hero-featured">
|
||||
<div id="featured-hero" class="featured-hero-card">
|
||||
<!-- Large featured card with big image -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Secondary Featured -->
|
||||
<section class="secondary-featured">
|
||||
<div id="featured-secondary" class="featured-secondary-cards">
|
||||
<!-- 2-3 medium featured cards with images -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sponsored Section -->
|
||||
<section class="sponsored-section">
|
||||
<div class="section-label">SPONSORED</div>
|
||||
<div id="sponsored-content" class="sponsored-cards">
|
||||
<!-- Sponsored content cards -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<section class="main-content">
|
||||
<!-- Apps Column -->
|
||||
<div class="apps-column">
|
||||
<div class="column-header">
|
||||
<h2><span class="ascii-icon">></span> Latest Apps</h2>
|
||||
<select id="type-filter" class="mini-filter">
|
||||
<option value="">All</option>
|
||||
<option value="Open Source">Open Source</option>
|
||||
<option value="Paid">Paid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="apps-grid" class="apps-compact-grid">
|
||||
<!-- Compact app cards -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Articles Column -->
|
||||
<div class="articles-column">
|
||||
<div class="column-header">
|
||||
<h2><span class="ascii-icon">></span> Latest Articles</h2>
|
||||
</div>
|
||||
<div id="articles-list" class="articles-compact-list">
|
||||
<!-- Article items -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trending/Tools Column -->
|
||||
<div class="trending-column">
|
||||
<div class="column-header">
|
||||
<h2><span class="ascii-icon">#</span> Trending</h2>
|
||||
</div>
|
||||
<div id="trending-list" class="trending-items">
|
||||
<!-- Trending items -->
|
||||
</div>
|
||||
|
||||
<div class="submit-box">
|
||||
<h3><span class="ascii-icon">+</span> Submit Your Tool</h3>
|
||||
<p>Share your integration</p>
|
||||
<a href="mailto:marketplace@crawl4ai.com" class="submit-btn">Submit →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- More Apps Grid -->
|
||||
<section class="more-apps">
|
||||
<div class="section-header">
|
||||
<h2><span class="ascii-icon">></span> More Apps</h2>
|
||||
<button id="load-more" class="load-more-btn">Load More ↓</button>
|
||||
</div>
|
||||
<div id="more-apps-grid" class="more-apps-grid">
|
||||
<!-- Additional app cards -->
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="marketplace-footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h3>About Marketplace</h3>
|
||||
<p>Discover tools and integrations built by the Crawl4AI community.</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h3>Become a Sponsor</h3>
|
||||
<p>Reach developers building with Crawl4AI</p>
|
||||
<a href="mailto:sponsors@crawl4ai.com" class="sponsor-btn">Learn More →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>[ Crawl4AI Marketplace · Updated <span id="last-update">--</span> ]</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="marketplace.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
957
docs/md_v2/marketplace/frontend/marketplace.css
Normal file
957
docs/md_v2/marketplace/frontend/marketplace.css
Normal file
@@ -0,0 +1,957 @@
|
||||
/* Marketplace CSS - Magazine Style Terminal Theme */
|
||||
@import url('../../assets/styles.css');
|
||||
|
||||
:root {
|
||||
--primary-cyan: #50ffff;
|
||||
--primary-teal: #09b5a5;
|
||||
--accent-pink: #f380f5;
|
||||
--bg-dark: #070708;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #3f3f44;
|
||||
--text-primary: #e8e9ed;
|
||||
--text-secondary: #d5cec0;
|
||||
--text-tertiary: #a3abba;
|
||||
--border-color: #3f3f44;
|
||||
--success: #50ff50;
|
||||
--error: #ff3c74;
|
||||
--warning: #f59e0b;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Dank Mono', Monaco, monospace;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Global link styles */
|
||||
a {
|
||||
color: var(--primary-cyan);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.marketplace-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.marketplace-header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.marketplace-header h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-cyan);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ascii-border {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
color: var(--primary-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Search and Filter Bar */
|
||||
.search-filter-bar {
|
||||
max-width: 1800px;
|
||||
margin: 1.5rem auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
border-color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--text-tertiary);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-box kbd {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.category-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--primary-cyan);
|
||||
color: var(--bg-dark);
|
||||
border-color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
/* Magazine Layout */
|
||||
.magazine-layout {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Hero Featured Section */
|
||||
.hero-featured {
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-featured::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: -20px;
|
||||
right: -20px;
|
||||
bottom: -20px;
|
||||
background: radial-gradient(ellipse at center, rgba(80, 255, 255, 0.05), transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.featured-hero-card {
|
||||
background: linear-gradient(135deg, #1a1a2e, #0f0f1e);
|
||||
border: 2px solid var(--primary-cyan);
|
||||
box-shadow: 0 0 30px rgba(80, 255, 255, 0.15),
|
||||
inset 0 0 20px rgba(80, 255, 255, 0.05);
|
||||
height: 380px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.featured-hero-card:hover {
|
||||
border-color: var(--accent-pink);
|
||||
box-shadow: 0 0 40px rgba(243, 128, 245, 0.2),
|
||||
inset 0 0 30px rgba(243, 128, 245, 0.05);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
background: linear-gradient(135deg, rgba(80, 255, 255, 0.1), rgba(243, 128, 245, 0.05));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
color: var(--primary-cyan);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
filter: brightness(1.1) contrast(1.1);
|
||||
}
|
||||
|
||||
.hero-image::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60%;
|
||||
background: linear-gradient(to top, rgba(10, 10, 20, 0.95), transparent);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
|
||||
color: var(--bg-dark);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(80, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.6rem;
|
||||
color: var(--primary-cyan);
|
||||
margin: 0.5rem 0;
|
||||
text-shadow: 0 0 20px rgba(80, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero-meta span {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.hero-meta span:first-child {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Secondary Featured */
|
||||
.secondary-featured {
|
||||
grid-column: 1 / -1;
|
||||
height: 380px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.featured-secondary-cards {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.secondary-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);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: calc((380px - 1.5rem) / 3);
|
||||
flex: 1;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.secondary-card:hover {
|
||||
border-color: var(--accent-pink);
|
||||
background: linear-gradient(135deg, rgba(243, 128, 245, 0.05), rgba(80, 255, 255, 0.03));
|
||||
box-shadow: 0 4px 15px rgba(243, 128, 245, 0.2);
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
|
||||
.secondary-image {
|
||||
width: 120px;
|
||||
background: linear-gradient(135deg, var(--bg-tertiary), var(--bg-secondary));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.secondary-content {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.secondary-title {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.secondary-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.secondary-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.secondary-meta span:last-child {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Sponsored Section */
|
||||
.sponsored-section {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--warning);
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
left: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0 0.5rem;
|
||||
color: var(--warning);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.sponsored-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sponsor-card {
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sponsor-card h4 {
|
||||
color: var(--accent-pink);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sponsor-card p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sponsor-card a {
|
||||
color: var(--primary-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sponsor-card a:hover {
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
/* Main Content Grid */
|
||||
.main-content {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Column Headers */
|
||||
.column-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mini-filter {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ascii-icon {
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
/* Apps Column */
|
||||
.apps-compact-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-compact {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--border-color);
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.app-compact:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
border-left-color: var(--accent-pink);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.app-compact-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.app-compact-header span:first-child {
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.app-compact-header span:last-child {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.app-compact-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.app-compact-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Articles Column */
|
||||
.articles-compact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.article-compact {
|
||||
border-left: 2px solid var(--border-color);
|
||||
padding-left: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.article-compact:hover {
|
||||
border-left-color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.article-meta span:first-child {
|
||||
color: var(--accent-pink);
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.article-author {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Trending Column */
|
||||
.trending-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trending-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.trending-item:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.trending-rank {
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary-cyan);
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.trending-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trending-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trending-stats {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Submit Box */
|
||||
.submit-box {
|
||||
margin-top: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--primary-cyan);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submit-box h3 {
|
||||
font-size: 1rem;
|
||||
color: var(--primary-cyan);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-box p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary-cyan);
|
||||
color: var(--primary-cyan);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: var(--primary-cyan);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* More Apps Section */
|
||||
.more-apps {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.more-apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.load-more-btn:hover {
|
||||
border-color: var(--primary-cyan);
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.marketplace-footer {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 4rem;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.footer-section h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sponsor-btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary-cyan);
|
||||
color: var(--primary-cyan);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sponsor-btn:hover {
|
||||
background: var(--primary-cyan);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
max-width: 1800px;
|
||||
margin: 2rem auto 0;
|
||||
padding: 1rem 2rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--primary-cyan);
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.app-detail {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-detail h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary-cyan);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Responsive - Tablet */
|
||||
@media (min-width: 768px) {
|
||||
.magazine-layout {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.hero-featured {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.secondary-featured {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sponsored-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.magazine-layout {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.hero-featured {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.secondary-featured {
|
||||
grid-column: 3 / 4;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.featured-secondary-cards {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sponsored-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Wide Desktop */
|
||||
@media (min-width: 1400px) {
|
||||
.magazine-layout {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.hero-featured {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.secondary-featured {
|
||||
grid-column: 3 / 5;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.featured-secondary-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.apps-column {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.more-apps-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Ultra Wide Desktop (for coders with wide monitors) */
|
||||
@media (min-width: 1800px) {
|
||||
.magazine-layout {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.hero-featured {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.secondary-featured {
|
||||
grid-column: 3 / 6;
|
||||
}
|
||||
|
||||
.featured-secondary-cards {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.sponsored-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sponsored-cards {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.apps-column {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.articles-column {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.more-apps-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.magazine-layout {
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.secondary-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.secondary-image {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
395
docs/md_v2/marketplace/frontend/marketplace.js
Normal file
395
docs/md_v2/marketplace/frontend/marketplace.js
Normal file
@@ -0,0 +1,395 @@
|
||||
// Marketplace JS - Magazine Layout
|
||||
const API_BASE = 'http://localhost:8100/api';
|
||||
const CACHE_TTL = 3600000; // 1 hour in ms
|
||||
|
||||
class MarketplaceCache {
|
||||
constructor() {
|
||||
this.prefix = 'c4ai_market_';
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const item = localStorage.getItem(this.prefix + key);
|
||||
if (!item) return null;
|
||||
|
||||
const data = JSON.parse(item);
|
||||
if (Date.now() > data.expires) {
|
||||
localStorage.removeItem(this.prefix + key);
|
||||
return null;
|
||||
}
|
||||
return data.value;
|
||||
}
|
||||
|
||||
set(key, value, ttl = CACHE_TTL) {
|
||||
const data = {
|
||||
value: value,
|
||||
expires: Date.now() + ttl
|
||||
};
|
||||
localStorage.setItem(this.prefix + key, JSON.stringify(data));
|
||||
}
|
||||
|
||||
clear() {
|
||||
Object.keys(localStorage)
|
||||
.filter(k => k.startsWith(this.prefix))
|
||||
.forEach(k => localStorage.removeItem(k));
|
||||
}
|
||||
}
|
||||
|
||||
class MarketplaceAPI {
|
||||
constructor() {
|
||||
this.cache = new MarketplaceCache();
|
||||
this.searchTimeout = null;
|
||||
}
|
||||
|
||||
async fetch(endpoint, useCache = true) {
|
||||
const cacheKey = endpoint.replace(/[^\w]/g, '_');
|
||||
|
||||
if (useCache) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data = await response.json();
|
||||
this.cache.set(cacheKey, data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
return this.fetch('/stats');
|
||||
}
|
||||
|
||||
async getCategories() {
|
||||
return this.fetch('/categories');
|
||||
}
|
||||
|
||||
async getApps(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return this.fetch(`/apps${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getArticles(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return this.fetch(`/articles${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
async getSponsors() {
|
||||
return this.fetch('/sponsors');
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
if (query.length < 2) return {};
|
||||
return this.fetch(`/search?q=${encodeURIComponent(query)}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
class MarketplaceUI {
|
||||
constructor() {
|
||||
this.api = new MarketplaceAPI();
|
||||
this.currentCategory = 'all';
|
||||
this.currentType = '';
|
||||
this.searchTimeout = null;
|
||||
this.loadedApps = 10;
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadStats();
|
||||
await this.loadCategories();
|
||||
await this.loadFeaturedContent();
|
||||
await this.loadSponsors();
|
||||
await this.loadMainContent();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async loadStats() {
|
||||
const stats = await this.api.getStats();
|
||||
if (stats) {
|
||||
document.getElementById('total-apps').textContent = stats.total_apps || '0';
|
||||
document.getElementById('total-articles').textContent = stats.total_articles || '0';
|
||||
document.getElementById('total-downloads').textContent = stats.total_downloads || '0';
|
||||
document.getElementById('last-update').textContent = new Date().toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
async loadCategories() {
|
||||
const categories = await this.api.getCategories();
|
||||
if (!categories) return;
|
||||
|
||||
const filter = document.getElementById('category-filter');
|
||||
categories.forEach(cat => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'filter-btn';
|
||||
btn.dataset.category = cat.slug;
|
||||
btn.textContent = cat.name;
|
||||
btn.onclick = () => this.filterByCategory(cat.slug);
|
||||
filter.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
async loadFeaturedContent() {
|
||||
// Load hero featured
|
||||
const featured = await this.api.getApps({ featured: true, limit: 4 });
|
||||
if (!featured || !featured.length) return;
|
||||
|
||||
// Hero card (first featured)
|
||||
const hero = featured[0];
|
||||
const heroCard = document.getElementById('featured-hero');
|
||||
if (hero) {
|
||||
const imageUrl = hero.image || '';
|
||||
heroCard.innerHTML = `
|
||||
<div class="hero-image" ${imageUrl ? `style="background-image: url('${imageUrl}')"` : ''}>
|
||||
${!imageUrl ? `[${hero.category || 'APP'}]` : ''}
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<span class="hero-badge">${hero.type || 'PAID'}</span>
|
||||
<h2 class="hero-title">${hero.name}</h2>
|
||||
<p class="hero-description">${hero.description}</p>
|
||||
<div class="hero-meta">
|
||||
<span>★ ${hero.rating || 0}/5</span>
|
||||
<span>${hero.downloads || 0} downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
heroCard.onclick = () => this.showAppDetail(hero);
|
||||
}
|
||||
|
||||
// Secondary featured cards
|
||||
const secondary = document.getElementById('featured-secondary');
|
||||
secondary.innerHTML = '';
|
||||
if (featured.length > 1) {
|
||||
featured.slice(1, 4).forEach(app => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'secondary-card';
|
||||
const imageUrl = app.image || '';
|
||||
card.innerHTML = `
|
||||
<div class="secondary-image" ${imageUrl ? `style="background-image: url('${imageUrl}')"` : ''}>
|
||||
${!imageUrl ? `[${app.category || 'APP'}]` : ''}
|
||||
</div>
|
||||
<div class="secondary-content">
|
||||
<h3 class="secondary-title">${app.name}</h3>
|
||||
<p class="secondary-desc">${(app.description || '').substring(0, 100)}...</p>
|
||||
<div class="secondary-meta">
|
||||
<span>${app.type || 'Open Source'}</span> · <span>★ ${app.rating || 0}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => this.showAppDetail(app);
|
||||
secondary.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadSponsors() {
|
||||
const sponsors = await this.api.getSponsors();
|
||||
if (!sponsors || !sponsors.length) {
|
||||
// Show placeholder if no sponsors
|
||||
const container = document.getElementById('sponsored-content');
|
||||
container.innerHTML = `
|
||||
<div class="sponsor-card">
|
||||
<h4>Become a Sponsor</h4>
|
||||
<p>Reach thousands of developers using Crawl4AI</p>
|
||||
<a href="mailto:sponsors@crawl4ai.com">Contact Us →</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('sponsored-content');
|
||||
container.innerHTML = sponsors.slice(0, 5).map(sponsor => `
|
||||
<div class="sponsor-card">
|
||||
<h4>${sponsor.company_name}</h4>
|
||||
<p>${sponsor.tier} Sponsor - Premium Solutions</p>
|
||||
<a href="${sponsor.landing_url}" target="_blank">Learn More →</a>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async loadMainContent() {
|
||||
// Load apps column
|
||||
const apps = await this.api.getApps({ limit: 8 });
|
||||
if (apps && apps.length) {
|
||||
const appsGrid = document.getElementById('apps-grid');
|
||||
appsGrid.innerHTML = apps.map(app => `
|
||||
<div class="app-compact" onclick="marketplace.showAppDetail(${JSON.stringify(app).replace(/"/g, '"')})">
|
||||
<div class="app-compact-header">
|
||||
<span>${app.category}</span>
|
||||
<span>★ ${app.rating}/5</span>
|
||||
</div>
|
||||
<div class="app-compact-title">${app.name}</div>
|
||||
<div class="app-compact-desc">${app.description}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Load articles column
|
||||
const articles = await this.api.getArticles({ limit: 6 });
|
||||
if (articles && articles.length) {
|
||||
const articlesList = document.getElementById('articles-list');
|
||||
articlesList.innerHTML = articles.map(article => `
|
||||
<div class="article-compact" onclick="marketplace.showArticle('${article.id}')">
|
||||
<div class="article-meta">
|
||||
<span>${article.category}</span> · <span>${new Date(article.published_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div class="article-title">${article.title}</div>
|
||||
<div class="article-author">by ${article.author}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Load trending
|
||||
if (apps && apps.length) {
|
||||
const trending = apps.slice(0, 5);
|
||||
const trendingList = document.getElementById('trending-list');
|
||||
trendingList.innerHTML = trending.map((app, i) => `
|
||||
<div class="trending-item" onclick="marketplace.showAppDetail(${JSON.stringify(app).replace(/"/g, '"')})">
|
||||
<div class="trending-rank">${i + 1}</div>
|
||||
<div class="trending-info">
|
||||
<div class="trending-name">${app.name}</div>
|
||||
<div class="trending-stats">${app.downloads} downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Load more apps grid
|
||||
const moreApps = await this.api.getApps({ offset: 8, limit: 12 });
|
||||
if (moreApps && moreApps.length) {
|
||||
const moreGrid = document.getElementById('more-apps-grid');
|
||||
moreGrid.innerHTML = moreApps.map(app => `
|
||||
<div class="app-compact" onclick="marketplace.showAppDetail(${JSON.stringify(app).replace(/"/g, '"')})">
|
||||
<div class="app-compact-header">
|
||||
<span>${app.category}</span>
|
||||
<span>${app.type}</span>
|
||||
</div>
|
||||
<div class="app-compact-title">${app.name}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Search
|
||||
const searchInput = document.getElementById('search-input');
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => this.search(e.target.value), 300);
|
||||
});
|
||||
|
||||
// Keyboard shortcut
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !searchInput.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
}
|
||||
if (e.key === 'Escape' && searchInput.contains(document.activeElement)) {
|
||||
searchInput.blur();
|
||||
searchInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Type filter
|
||||
const typeFilter = document.getElementById('type-filter');
|
||||
typeFilter.addEventListener('change', (e) => {
|
||||
this.currentType = e.target.value;
|
||||
this.loadMainContent();
|
||||
});
|
||||
|
||||
// Load more
|
||||
const loadMore = document.getElementById('load-more');
|
||||
loadMore.addEventListener('click', () => this.loadMoreApps());
|
||||
}
|
||||
|
||||
async filterByCategory(category) {
|
||||
// Update active state
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.category === category);
|
||||
});
|
||||
|
||||
this.currentCategory = category;
|
||||
await this.loadMainContent();
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
if (!query) {
|
||||
await this.loadMainContent();
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await this.api.search(query);
|
||||
if (!results) return;
|
||||
|
||||
// Update apps grid with search results
|
||||
if (results.apps && results.apps.length) {
|
||||
const appsGrid = document.getElementById('apps-grid');
|
||||
appsGrid.innerHTML = results.apps.map(app => `
|
||||
<div class="app-compact" onclick="marketplace.showAppDetail(${JSON.stringify(app).replace(/"/g, '"')})">
|
||||
<div class="app-compact-header">
|
||||
<span>${app.category}</span>
|
||||
<span>★ ${app.rating}/5</span>
|
||||
</div>
|
||||
<div class="app-compact-title">${app.name}</div>
|
||||
<div class="app-compact-desc">${app.description}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update articles with search results
|
||||
if (results.articles && results.articles.length) {
|
||||
const articlesList = document.getElementById('articles-list');
|
||||
articlesList.innerHTML = results.articles.map(article => `
|
||||
<div class="article-compact" onclick="marketplace.showArticle('${article.id}')">
|
||||
<div class="article-meta">
|
||||
<span>${article.category}</span> · <span>${new Date(article.published_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div class="article-title">${article.title}</div>
|
||||
<div class="article-author">by ${article.author}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreApps() {
|
||||
this.loadedApps += 12;
|
||||
const moreApps = await this.api.getApps({ offset: this.loadedApps, limit: 12 });
|
||||
if (moreApps && moreApps.length) {
|
||||
const moreGrid = document.getElementById('more-apps-grid');
|
||||
moreApps.forEach(app => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'app-compact';
|
||||
card.innerHTML = `
|
||||
<div class="app-compact-header">
|
||||
<span>${app.category}</span>
|
||||
<span>${app.type}</span>
|
||||
</div>
|
||||
<div class="app-compact-title">${app.name}</div>
|
||||
`;
|
||||
card.onclick = () => this.showAppDetail(app);
|
||||
moreGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showAppDetail(app) {
|
||||
// Navigate to detail page instead of showing modal
|
||||
const slug = app.slug || app.name.toLowerCase().replace(/\s+/g, '-');
|
||||
window.location.href = `app-detail.html?app=${slug}`;
|
||||
}
|
||||
|
||||
showArticle(articleId) {
|
||||
// Could create article detail page similarly
|
||||
console.log('Show article:', articleId);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize marketplace
|
||||
let marketplace;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
marketplace = new MarketplaceUI();
|
||||
});
|
||||
Reference in New Issue
Block a user