feat(marketplace): add sponsor logo uploads

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
unclecode
2025-10-06 20:58:35 +08:00
parent 5145d42df7
commit 8c62277718
6 changed files with 321 additions and 13 deletions

View File

@@ -431,6 +431,16 @@
gap: 0.5rem; gap: 0.5rem;
} }
.table-logo {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
padding: 4px;
}
.btn-edit, .btn-delete, .btn-duplicate { .btn-edit, .btn-delete, .btn-duplicate {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: transparent; background: transparent;
@@ -585,6 +595,105 @@
cursor: pointer; cursor: pointer;
} }
.sponsor-form {
grid-template-columns: 200px repeat(2, minmax(220px, 1fr));
align-items: flex-start;
grid-auto-flow: dense;
}
.sponsor-logo-group {
grid-row: span 3;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.span-two {
grid-column: span 2;
}
.logo-upload {
position: relative;
width: 180px;
}
.image-preview {
width: 180px;
height: 180px;
border: 1px dashed var(--border-color);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
overflow: hidden;
}
.image-preview.empty {
color: var(--text-secondary);
font-size: 0.75rem;
text-align: center;
padding: 0.75rem;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.upload-btn {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
padding: 0.35rem 1rem;
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-teal));
border: none;
border-radius: 999px;
color: var(--bg-dark);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 6px 18px rgba(80, 255, 255, 0.25);
}
.upload-btn:hover {
box-shadow: 0 8px 22px rgba(80, 255, 255, 0.35);
}
.logo-upload input[type="file"] {
display: none;
}
.upload-hint {
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0;
}
@media (max-width: 960px) {
.sponsor-form {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.sponsor-logo-group {
grid-column: 1 / -1;
grid-row: auto;
flex-direction: row;
align-items: center;
gap: 1.5rem;
}
.logo-upload {
width: 160px;
}
.span-two {
grid-column: 1 / -1;
}
}
/* Rich Text Editor */ /* Rich Text Editor */
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;

View File

@@ -1,5 +1,21 @@
// Admin Dashboard - Smart & Powerful // Admin Dashboard - Smart & Powerful
const API_BASE = '/api'; const { API_BASE, API_ORIGIN } = (() => {
const { hostname, port } = window.location;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port === '8000') {
const origin = 'http://127.0.0.1:8100';
return { API_BASE: `${origin}/api`, API_ORIGIN: origin };
}
return { API_BASE: '/api', API_ORIGIN: '' };
})();
const resolveAssetUrl = (path) => {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/') && API_ORIGIN) {
return `${API_ORIGIN}${path}`;
}
return path;
};
class AdminDashboard { class AdminDashboard {
constructor() { constructor() {
@@ -144,13 +160,19 @@ class AdminDashboard {
} }
async apiCall(endpoint, options = {}) { async apiCall(endpoint, options = {}) {
const isFormData = options.body instanceof FormData;
const headers = {
'Authorization': `Bearer ${this.token}`,
...options.headers
};
if (!isFormData && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
...options, ...options,
headers: { headers
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
...options.headers
}
}); });
if (response.status === 401) { if (response.status === 401) {
@@ -189,7 +211,10 @@ class AdminDashboard {
} }
async loadSponsors() { async loadSponsors() {
this.data.sponsors = await this.apiCall('/sponsors'); const cacheBuster = Date.now();
this.data.sponsors = await this.apiCall(`/sponsors?limit=100&_=${cacheBuster}`, {
cache: 'no-store'
});
this.renderSponsorsTable(this.data.sponsors); this.renderSponsorsTable(this.data.sponsors);
} }
@@ -314,6 +339,7 @@ class AdminDashboard {
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Logo</th>
<th>Company</th> <th>Company</th>
<th>Tier</th> <th>Tier</th>
<th>Start</th> <th>Start</th>
@@ -326,6 +352,7 @@ class AdminDashboard {
${sponsors.map(sponsor => ` ${sponsors.map(sponsor => `
<tr> <tr>
<td>${sponsor.id}</td> <td>${sponsor.id}</td>
<td>${sponsor.logo_url ? `<img class="table-logo" src="${resolveAssetUrl(sponsor.logo_url)}" alt="${sponsor.company_name} logo">` : '-'}</td>
<td>${sponsor.company_name}</td> <td>${sponsor.company_name}</td>
<td>${sponsor.tier}</td> <td>${sponsor.tier}</td>
<td>${new Date(sponsor.start_date).toLocaleDateString()}</td> <td>${new Date(sponsor.start_date).toLocaleDateString()}</td>
@@ -389,6 +416,10 @@ class AdminDashboard {
modal.classList.remove('hidden'); modal.classList.remove('hidden');
modal.dataset.type = type; modal.dataset.type = type;
if (type === 'sponsors') {
this.setupLogoUploadHandlers();
}
} }
getAppForm(app) { getAppForm(app) {
@@ -524,9 +555,22 @@ class AdminDashboard {
} }
getSponsorForm(sponsor) { getSponsorForm(sponsor) {
const existingFile = sponsor?.logo_url ? sponsor.logo_url.split('/').pop().split('?')[0] : '';
return ` return `
<div class="form-grid"> <div class="form-grid sponsor-form">
<div class="form-group"> <div class="form-group sponsor-logo-group">
<label>Logo</label>
<input type="hidden" id="form-logo-url" value="${sponsor?.logo_url || ''}">
<div class="logo-upload">
<div class="image-preview ${sponsor?.logo_url ? '' : 'empty'}" id="form-logo-preview">
${sponsor?.logo_url ? `<img src="${resolveAssetUrl(sponsor.logo_url)}" alt="Logo preview">` : '<span>No logo uploaded</span>'}
</div>
<button type="button" class="upload-btn" id="form-logo-button">Upload Logo</button>
<input type="file" id="form-logo-file" accept="image/png,image/jpeg,image/webp,image/svg+xml" hidden>
</div>
<p class="upload-hint" id="form-logo-filename">${existingFile ? `Current: ${existingFile}` : 'No file selected'}</p>
</div>
<div class="form-group span-two">
<label>Company Name *</label> <label>Company Name *</label>
<input type="text" id="form-name" value="${sponsor?.company_name || ''}" required> <input type="text" id="form-name" value="${sponsor?.company_name || ''}" required>
</div> </div>
@@ -567,9 +611,30 @@ class AdminDashboard {
async saveItem() { async saveItem() {
const modal = document.getElementById('form-modal'); const modal = document.getElementById('form-modal');
const type = modal.dataset.type; const type = modal.dataset.type;
const data = this.collectFormData(type);
try { try {
if (type === 'sponsors') {
const fileInput = document.getElementById('form-logo-file');
if (fileInput && fileInput.files && fileInput.files[0]) {
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('folder', 'sponsors');
const uploadResponse = await this.apiCall('/admin/upload-image', {
method: 'POST',
body: formData
});
if (!uploadResponse.url) {
throw new Error('Image upload failed');
}
document.getElementById('form-logo-url').value = uploadResponse.url;
}
}
const data = this.collectFormData(type);
if (this.editingItem) { if (this.editingItem) {
await this.apiCall(`/admin/${type}/${this.editingItem.id}`, { await this.apiCall(`/admin/${type}/${this.editingItem.id}`, {
method: 'PUT', method: 'PUT',
@@ -624,6 +689,7 @@ class AdminDashboard {
data.order_index = parseInt(document.getElementById('form-order').value); data.order_index = parseInt(document.getElementById('form-order').value);
} else if (type === 'sponsors') { } else if (type === 'sponsors') {
data.company_name = document.getElementById('form-name').value; data.company_name = document.getElementById('form-name').value;
data.logo_url = document.getElementById('form-logo-url').value;
data.tier = document.getElementById('form-tier').value; data.tier = document.getElementById('form-tier').value;
data.landing_url = document.getElementById('form-landing').value; data.landing_url = document.getElementById('form-landing').value;
data.banner_url = document.getElementById('form-banner').value; data.banner_url = document.getElementById('form-banner').value;
@@ -635,6 +701,63 @@ class AdminDashboard {
return data; return data;
} }
setupLogoUploadHandlers() {
const fileInput = document.getElementById('form-logo-file');
const preview = document.getElementById('form-logo-preview');
const logoUrlInput = document.getElementById('form-logo-url');
const trigger = document.getElementById('form-logo-button');
const fileNameEl = document.getElementById('form-logo-filename');
if (!fileInput || !preview || !logoUrlInput) return;
const setFileName = (text) => {
if (fileNameEl) {
fileNameEl.textContent = text;
}
};
const setEmptyState = () => {
preview.innerHTML = '<span>No logo uploaded</span>';
preview.classList.add('empty');
setFileName('No file selected');
};
const setExistingState = () => {
if (logoUrlInput.value) {
const existingFile = logoUrlInput.value.split('/').pop().split('?')[0];
preview.innerHTML = `<img src="${resolveAssetUrl(logoUrlInput.value)}" alt="Logo preview">`;
preview.classList.remove('empty');
setFileName(existingFile ? `Current: ${existingFile}` : 'Current logo');
} else {
setEmptyState();
}
};
setExistingState();
if (trigger) {
trigger.onclick = () => fileInput.click();
}
fileInput.addEventListener('change', (event) => {
const file = event.target.files && event.target.files[0];
if (!file) {
setExistingState();
return;
}
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => {
preview.innerHTML = `<img src="${reader.result}" alt="Logo preview">`;
preview.classList.remove('empty');
};
reader.readAsDataURL(file);
});
}
async deleteItem(type, id) { async deleteItem(type, id) {
if (!confirm(`Are you sure you want to delete this ${type.slice(0, -1)}?`)) return; if (!confirm(`Are you sure you want to delete this ${type.slice(0, -1)}?`)) return;

View File

@@ -1,11 +1,13 @@
from fastapi import FastAPI, HTTPException, Query, Depends, Body from fastapi import FastAPI, HTTPException, Query, Depends, Body, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional, List, Dict, Any from typing import Optional, Dict, Any
import json import json
import hashlib import hashlib
import secrets import secrets
from pathlib import Path
from database import DatabaseManager from database import DatabaseManager
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -31,6 +33,21 @@ app.add_middleware(
# Initialize database with configurable path # Initialize database with configurable path
db = DatabaseManager(Config.DATABASE_PATH) db = DatabaseManager(Config.DATABASE_PATH)
BASE_DIR = Path(__file__).parent
UPLOAD_ROOT = BASE_DIR / "uploads"
UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=UPLOAD_ROOT), name="uploads")
ALLOWED_IMAGE_TYPES = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
"image/svg+xml": ".svg"
}
ALLOWED_UPLOAD_FOLDERS = {"sponsors"}
MAX_UPLOAD_SIZE = 2 * 1024 * 1024 # 2 MB
def json_response(data, cache_time=3600): def json_response(data, cache_time=3600):
"""Helper to return JSON with cache headers""" """Helper to return JSON with cache headers"""
return JSONResponse( return JSONResponse(
@@ -183,6 +200,31 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
raise HTTPException(status_code=401, detail="Invalid or expired token") raise HTTPException(status_code=401, detail="Invalid or expired token")
return token return token
@app.post("/api/admin/upload-image", dependencies=[Depends(verify_token)])
async def upload_image(file: UploadFile = File(...), folder: str = Form("sponsors")):
"""Upload image files for admin assets"""
folder = (folder or "").strip().lower()
if folder not in ALLOWED_UPLOAD_FOLDERS:
raise HTTPException(status_code=400, detail="Invalid upload folder")
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=400, detail="Unsupported file type")
contents = await file.read()
if len(contents) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 2MB)")
extension = ALLOWED_IMAGE_TYPES[file.content_type]
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{secrets.token_hex(8)}{extension}"
target_dir = UPLOAD_ROOT / folder
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / filename
target_path.write_bytes(contents)
return {"url": f"/uploads/{folder}/{filename}"}
@app.post("/api/admin/login") @app.post("/api/admin/login")
async def admin_login(password: str = Body(..., embed=True)): async def admin_login(password: str = Body(..., embed=True)):
"""Admin login with password""" """Admin login with password"""

View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -410,6 +410,21 @@ a:hover {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
.sponsor-logo {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
margin-bottom: 0.75rem;
}
.sponsor-logo img {
max-height: 60px;
max-width: 100%;
width: auto;
object-fit: contain;
}
.sponsor-card h4 { .sponsor-card h4 {
color: var(--accent-pink); color: var(--accent-pink);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;

View File

@@ -1,5 +1,21 @@
// Marketplace JS - Magazine Layout // Marketplace JS - Magazine Layout
const API_BASE = '/api'; const { API_BASE, API_ORIGIN } = (() => {
const { hostname, port } = window.location;
if ((hostname === 'localhost' || hostname === '127.0.0.1') && port === '8000') {
const origin = 'http://127.0.0.1:8100';
return { API_BASE: `${origin}/api`, API_ORIGIN: origin };
}
return { API_BASE: '/api', API_ORIGIN: '' };
})();
const resolveAssetUrl = (path) => {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/') && API_ORIGIN) {
return `${API_ORIGIN}${path}`;
}
return path;
};
const CACHE_TTL = 3600000; // 1 hour in ms const CACHE_TTL = 3600000; // 1 hour in ms
class MarketplaceCache { class MarketplaceCache {
@@ -204,6 +220,7 @@ class MarketplaceUI {
const container = document.getElementById('sponsored-content'); const container = document.getElementById('sponsored-content');
container.innerHTML = sponsors.slice(0, 5).map(sponsor => ` container.innerHTML = sponsors.slice(0, 5).map(sponsor => `
<div class="sponsor-card"> <div class="sponsor-card">
${sponsor.logo_url ? `<div class="sponsor-logo"><img src="${resolveAssetUrl(sponsor.logo_url)}" alt="${sponsor.company_name} logo"></div>` : ''}
<h4>${sponsor.company_name}</h4> <h4>${sponsor.company_name}</h4>
<p>${sponsor.tier} Sponsor - Premium Solutions</p> <p>${sponsor.tier} Sponsor - Premium Solutions</p>
<a href="${sponsor.landing_url}" target="_blank">Learn More →</a> <a href="${sponsor.landing_url}" target="_blank">Learn More →</a>