Merge branch 'develop' of https://github.com/unclecode/crawl4ai into develop
This commit is contained in:
@@ -201,18 +201,6 @@ ul>li.page-action-item::after{
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Badge */
|
/* Badge */
|
||||||
.page-action-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: #f59e0b;
|
|
||||||
color: #070708;
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* External link indicator */
|
/* External link indicator */
|
||||||
.page-action-external::after {
|
.page-action-external::after {
|
||||||
content: '→';
|
content: '→';
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
githubRepo: 'unclecode/crawl4ai',
|
githubRepo: 'unclecode/crawl4ai',
|
||||||
githubBranch: 'main',
|
githubBranch: 'main',
|
||||||
docsPath: 'docs/md_v2',
|
docsPath: 'docs/md_v2',
|
||||||
excludePaths: ['/apps/c4a-script/', '/apps/llmtxt/', '/apps/crawl4ai-assistant/'], // Don't show on app pages
|
excludePaths: ['/apps/c4a-script/', '/apps/llmtxt/', '/apps/crawl4ai-assistant/', '/core/ask-ai/'], // Don't show on app pages
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cachedMarkdown = null;
|
||||||
|
let cachedMarkdownPath = null;
|
||||||
|
|
||||||
// Check if we should show the button on this page
|
// Check if we should show the button on this page
|
||||||
function shouldShowButton() {
|
function shouldShowButton() {
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
@@ -19,6 +22,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't show on 404 pages
|
||||||
|
if (document.title && document.title.toLowerCase().includes('404')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require mkdocs main content container
|
||||||
|
const mainContent = document.getElementById('terminal-mkdocs-main-content');
|
||||||
|
if (!mainContent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't show on excluded paths (apps)
|
// Don't show on excluded paths (apps)
|
||||||
for (const excludePath of config.excludePaths) {
|
for (const excludePath of config.excludePaths) {
|
||||||
if (currentPath.includes(excludePath)) {
|
if (currentPath.includes(excludePath)) {
|
||||||
@@ -53,6 +67,56 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return `${path}.md`;
|
return `${path}.md`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMarkdownContent() {
|
||||||
|
const mdPath = getCurrentMarkdownPath();
|
||||||
|
|
||||||
|
if (!mdPath) {
|
||||||
|
throw new Error('Invalid markdown path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUrl = getGithubRawUrl();
|
||||||
|
const response = await fetch(rawUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch markdown: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = await response.text();
|
||||||
|
cachedMarkdown = markdown;
|
||||||
|
cachedMarkdownPath = mdPath;
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMarkdownCached() {
|
||||||
|
const mdPath = getCurrentMarkdownPath();
|
||||||
|
|
||||||
|
if (!mdPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedMarkdown && cachedMarkdownPath === mdPath) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadMarkdownContent();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Page Actions: Markdown not available for this page.', error);
|
||||||
|
cachedMarkdown = null;
|
||||||
|
cachedMarkdownPath = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMarkdownContent() {
|
||||||
|
const available = await ensureMarkdownCached();
|
||||||
|
if (!available) {
|
||||||
|
throw new Error('Markdown not available for this page.');
|
||||||
|
}
|
||||||
|
return cachedMarkdown;
|
||||||
|
}
|
||||||
|
|
||||||
// Get GitHub raw URL for current page
|
// Get GitHub raw URL for current page
|
||||||
function getGithubRawUrl() {
|
function getGithubRawUrl() {
|
||||||
const mdPath = getCurrentMarkdownPath();
|
const mdPath = getCurrentMarkdownPath();
|
||||||
@@ -112,13 +176,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</li>
|
</li>
|
||||||
<div class="page-actions-divider"></div>
|
<div class="page-actions-divider"></div>
|
||||||
<li class="page-action-item">
|
<li class="page-action-item">
|
||||||
<a href="#" class="page-action-link disabled" id="action-ask-ai" role="menuitem">
|
<a href="#" class="page-action-link page-action-external" id="action-open-chatgpt" role="menuitem">
|
||||||
<span class="page-action-icon icon-ai"></span>
|
<span class="page-action-icon icon-ai"></span>
|
||||||
<span class="page-action-text">
|
<span class="page-action-text">
|
||||||
<span class="page-action-label">Ask AI about page</span>
|
<span class="page-action-label">Open in ChatGPT</span>
|
||||||
<span class="page-action-description">
|
<span class="page-action-description">Ask questions about this page</span>
|
||||||
<span class="page-action-badge">Coming Soon</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -180,19 +242,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Copy markdown to clipboard
|
// Copy markdown to clipboard
|
||||||
async function copyMarkdownToClipboard(link) {
|
async function copyMarkdownToClipboard(link) {
|
||||||
const rawUrl = getGithubRawUrl();
|
|
||||||
|
|
||||||
// Add loading state
|
// Add loading state
|
||||||
link.classList.add('loading');
|
link.classList.add('loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(rawUrl);
|
const markdown = await getMarkdownContent();
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch markdown: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const markdown = await response.text();
|
|
||||||
|
|
||||||
// Copy to clipboard
|
// Copy to clipboard
|
||||||
await navigator.clipboard.writeText(markdown);
|
await navigator.clipboard.writeText(markdown);
|
||||||
@@ -221,126 +275,153 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
window.open(githubUrl, '_blank', 'noopener,noreferrer');
|
window.open(githubUrl, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
function getCurrentPageUrl() {
|
||||||
const { button, dropdown, overlay } = createPageActionsUI();
|
const { href } = window.location;
|
||||||
|
return href.split('#')[0];
|
||||||
|
}
|
||||||
|
|
||||||
// Event listeners
|
function openChatGPT() {
|
||||||
button.addEventListener('click', (e) => {
|
const pageUrl = getCurrentPageUrl();
|
||||||
e.stopPropagation();
|
const prompt = encodeURIComponent(`Read ${pageUrl} so I can ask questions about it.`);
|
||||||
toggleDropdown(button, dropdown, overlay);
|
const chatUrl = `https://chatgpt.com/?hint=search&prompt=${prompt}`;
|
||||||
});
|
window.open(chatUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
overlay.addEventListener('click', () => {
|
(async () => {
|
||||||
closeDropdown(button, dropdown, overlay);
|
if (!shouldShowButton()) {
|
||||||
});
|
return;
|
||||||
|
|
||||||
// Copy markdown action
|
|
||||||
document.getElementById('action-copy-markdown').addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
await copyMarkdownToClipboard(e.currentTarget);
|
|
||||||
});
|
|
||||||
|
|
||||||
// View markdown action
|
|
||||||
document.getElementById('action-view-markdown').addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
viewMarkdown();
|
|
||||||
closeDropdown(button, dropdown, overlay);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ask AI action (disabled for now)
|
|
||||||
document.getElementById('action-ask-ai').addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
// Future: Integrate with Ask AI feature
|
|
||||||
// For now, do nothing (disabled state)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close on ESC key
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && dropdown.classList.contains('active')) {
|
|
||||||
closeDropdown(button, dropdown, overlay);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Close when clicking outside
|
const markdownAvailable = await ensureMarkdownCached();
|
||||||
document.addEventListener('click', (e) => {
|
if (!markdownAvailable) {
|
||||||
if (!dropdown.contains(e.target) && !button.contains(e.target)) {
|
return;
|
||||||
closeDropdown(button, dropdown, overlay);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent dropdown from closing when clicking inside
|
const ui = createPageActionsUI();
|
||||||
dropdown.addEventListener('click', (e) => {
|
if (!ui) {
|
||||||
// Only stop propagation if not clicking on a link
|
return;
|
||||||
if (!e.target.closest('.page-action-link')) {
|
}
|
||||||
|
|
||||||
|
const { button, dropdown, overlay } = ui;
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
toggleDropdown(button, dropdown, overlay);
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown on link click (except for copy which handles itself)
|
|
||||||
dropdown.querySelectorAll('.page-action-link:not(#action-copy-markdown)').forEach(link => {
|
|
||||||
link.addEventListener('click', () => {
|
|
||||||
if (!link.classList.contains('disabled')) {
|
|
||||||
setTimeout(() => {
|
|
||||||
closeDropdown(button, dropdown, overlay);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Handle window resize
|
overlay.addEventListener('click', () => {
|
||||||
let resizeTimer;
|
closeDropdown(button, dropdown, overlay);
|
||||||
window.addEventListener('resize', () => {
|
});
|
||||||
clearTimeout(resizeTimer);
|
|
||||||
resizeTimer = setTimeout(() => {
|
// Copy markdown action
|
||||||
// Close dropdown on resize to prevent positioning issues
|
document.getElementById('action-copy-markdown').addEventListener('click', async (e) => {
|
||||||
if (dropdown.classList.contains('active')) {
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
await copyMarkdownToClipboard(e.currentTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
// View markdown action
|
||||||
|
document.getElementById('action-view-markdown').addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
viewMarkdown();
|
||||||
|
closeDropdown(button, dropdown, overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open in ChatGPT action
|
||||||
|
document.getElementById('action-open-chatgpt').addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openChatGPT();
|
||||||
|
closeDropdown(button, dropdown, overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on ESC key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && dropdown.classList.contains('active')) {
|
||||||
closeDropdown(button, dropdown, overlay);
|
closeDropdown(button, dropdown, overlay);
|
||||||
}
|
}
|
||||||
}, 250);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Accessibility: Focus management
|
// Close when clicking outside
|
||||||
button.addEventListener('keydown', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (!dropdown.contains(e.target) && !button.contains(e.target)) {
|
||||||
e.preventDefault();
|
closeDropdown(button, dropdown, overlay);
|
||||||
toggleDropdown(button, dropdown, overlay);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Focus first menu item when opening
|
// Prevent dropdown from closing when clicking inside
|
||||||
if (dropdown.classList.contains('active')) {
|
dropdown.addEventListener('click', (e) => {
|
||||||
const firstLink = dropdown.querySelector('.page-action-link:not(.disabled)');
|
// Only stop propagation if not clicking on a link
|
||||||
if (firstLink) {
|
if (!e.target.closest('.page-action-link')) {
|
||||||
setTimeout(() => firstLink.focus(), 100);
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown on link click (except for copy which handles itself)
|
||||||
|
dropdown.querySelectorAll('.page-action-link:not(#action-copy-markdown)').forEach(link => {
|
||||||
|
link.addEventListener('click', () => {
|
||||||
|
if (!link.classList.contains('disabled')) {
|
||||||
|
setTimeout(() => {
|
||||||
|
closeDropdown(button, dropdown, overlay);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
// Close dropdown on resize to prevent positioning issues
|
||||||
|
if (dropdown.classList.contains('active')) {
|
||||||
|
closeDropdown(button, dropdown, overlay);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accessibility: Focus management
|
||||||
|
button.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleDropdown(button, dropdown, overlay);
|
||||||
|
|
||||||
|
// Focus first menu item when opening
|
||||||
|
if (dropdown.classList.contains('active')) {
|
||||||
|
const firstLink = dropdown.querySelector('.page-action-link:not(.disabled)');
|
||||||
|
if (firstLink) {
|
||||||
|
setTimeout(() => firstLink.focus(), 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Arrow key navigation within menu
|
// Arrow key navigation within menu
|
||||||
dropdown.addEventListener('keydown', (e) => {
|
dropdown.addEventListener('keydown', (e) => {
|
||||||
if (!dropdown.classList.contains('active')) return;
|
if (!dropdown.classList.contains('active')) return;
|
||||||
|
|
||||||
const links = Array.from(dropdown.querySelectorAll('.page-action-link:not(.disabled)'));
|
const links = Array.from(dropdown.querySelectorAll('.page-action-link:not(.disabled)'));
|
||||||
const currentIndex = links.indexOf(document.activeElement);
|
const currentIndex = links.indexOf(document.activeElement);
|
||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const nextIndex = (currentIndex + 1) % links.length;
|
const nextIndex = (currentIndex + 1) % links.length;
|
||||||
links[nextIndex].focus();
|
links[nextIndex].focus();
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prevIndex = (currentIndex - 1 + links.length) % links.length;
|
const prevIndex = (currentIndex - 1 + links.length) % links.length;
|
||||||
links[prevIndex].focus();
|
links[prevIndex].focus();
|
||||||
} else if (e.key === 'Home') {
|
} else if (e.key === 'Home') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
links[0].focus();
|
links[0].focus();
|
||||||
} else if (e.key === 'End') {
|
} else if (e.key === 'End') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
links[links.length - 1].focus();
|
links[links.length - 1].focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Page Actions initialized for:', getCurrentMarkdownPath());
|
console.log('Page Actions initialized for:', getCurrentMarkdownPath());
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
// Admin Dashboard - Smart & Powerful
|
// Admin Dashboard - Smart & Powerful
|
||||||
const API_BASE = '/api';
|
const { API_BASE, API_ORIGIN } = (() => {
|
||||||
|
const cleanOrigin = (value) => value ? value.replace(/\/$/, '') : '';
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const overrideParam = cleanOrigin(params.get('api_origin'));
|
||||||
|
|
||||||
|
let storedOverride = '';
|
||||||
|
try {
|
||||||
|
storedOverride = cleanOrigin(localStorage.getItem('marketplace_api_origin'));
|
||||||
|
} catch (error) {
|
||||||
|
storedOverride = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let origin = overrideParam || storedOverride;
|
||||||
|
|
||||||
|
if (overrideParam && overrideParam !== storedOverride) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('marketplace_api_origin', overrideParam);
|
||||||
|
} catch (error) {
|
||||||
|
// ignore storage errors (private mode, etc.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { protocol, hostname, port } = window.location;
|
||||||
|
const isLocalHost = ['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname);
|
||||||
|
|
||||||
|
if (!origin && isLocalHost && port !== '8100') {
|
||||||
|
origin = `${protocol}//127.0.0.1:8100`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin) {
|
||||||
|
const normalized = cleanOrigin(origin);
|
||||||
|
return { API_BASE: `${normalized}/api`, API_ORIGIN: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +188,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) {
|
||||||
@@ -163,7 +213,9 @@ class AdminDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadStats() {
|
async loadStats() {
|
||||||
const stats = await this.apiCall('/admin/stats');
|
const stats = await this.apiCall(`/admin/stats?_=${Date.now()}`, {
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('stat-apps').textContent = stats.apps.total;
|
document.getElementById('stat-apps').textContent = stats.apps.total;
|
||||||
document.getElementById('stat-featured').textContent = stats.apps.featured;
|
document.getElementById('stat-featured').textContent = stats.apps.featured;
|
||||||
@@ -174,22 +226,32 @@ class AdminDashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadApps() {
|
async loadApps() {
|
||||||
this.data.apps = await this.apiCall('/apps?limit=100');
|
this.data.apps = await this.apiCall(`/apps?limit=100&_=${Date.now()}`, {
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
this.renderAppsTable(this.data.apps);
|
this.renderAppsTable(this.data.apps);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadArticles() {
|
async loadArticles() {
|
||||||
this.data.articles = await this.apiCall('/articles?limit=100');
|
this.data.articles = await this.apiCall(`/articles?limit=100&_=${Date.now()}`, {
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
this.renderArticlesTable(this.data.articles);
|
this.renderArticlesTable(this.data.articles);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCategories() {
|
async loadCategories() {
|
||||||
this.data.categories = await this.apiCall('/categories');
|
const cacheBuster = Date.now();
|
||||||
|
this.data.categories = await this.apiCall(`/categories?_=${cacheBuster}`, {
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
this.renderCategoriesTable(this.data.categories);
|
this.renderCategoriesTable(this.data.categories);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +376,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 +389,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 +453,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 +592,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 +648,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',
|
||||||
@@ -599,8 +701,10 @@ class AdminDashboard {
|
|||||||
data.description = document.getElementById('form-description').value;
|
data.description = document.getElementById('form-description').value;
|
||||||
data.category = document.getElementById('form-category').value;
|
data.category = document.getElementById('form-category').value;
|
||||||
data.type = document.getElementById('form-type').value;
|
data.type = document.getElementById('form-type').value;
|
||||||
data.rating = parseFloat(document.getElementById('form-rating').value);
|
const rating = parseFloat(document.getElementById('form-rating').value);
|
||||||
data.downloads = parseInt(document.getElementById('form-downloads').value);
|
const downloads = parseInt(document.getElementById('form-downloads').value, 10);
|
||||||
|
data.rating = Number.isFinite(rating) ? rating : 0;
|
||||||
|
data.downloads = Number.isFinite(downloads) ? downloads : 0;
|
||||||
data.image = document.getElementById('form-image').value;
|
data.image = document.getElementById('form-image').value;
|
||||||
data.website_url = document.getElementById('form-website').value;
|
data.website_url = document.getElementById('form-website').value;
|
||||||
data.github_url = document.getElementById('form-github').value;
|
data.github_url = document.getElementById('form-github').value;
|
||||||
@@ -621,9 +725,11 @@ class AdminDashboard {
|
|||||||
data.slug = this.generateSlug(data.name);
|
data.slug = this.generateSlug(data.name);
|
||||||
data.icon = document.getElementById('form-icon').value;
|
data.icon = document.getElementById('form-icon').value;
|
||||||
data.description = document.getElementById('form-description').value;
|
data.description = document.getElementById('form-description').value;
|
||||||
data.order_index = parseInt(document.getElementById('form-order').value);
|
const orderIndex = parseInt(document.getElementById('form-order').value, 10);
|
||||||
|
data.order_index = Number.isFinite(orderIndex) ? orderIndex : 0;
|
||||||
} 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 +741,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;
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="admin.js?v=1759327900"></script>
|
<script src="admin.js?v=1759334000"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
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
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from database import DatabaseManager
|
from database import DatabaseManager
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -31,6 +34,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(
|
||||||
@@ -41,6 +59,29 @@ def json_response(data, cache_time=3600):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def to_int(value, default=0):
|
||||||
|
"""Coerce incoming values to integers, falling back to default."""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return int(value)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
if not stripped:
|
||||||
|
return default
|
||||||
|
|
||||||
|
match = re.match(r"^-?\d+", stripped)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return int(match.group())
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return default
|
||||||
|
|
||||||
# ============= PUBLIC ENDPOINTS =============
|
# ============= PUBLIC ENDPOINTS =============
|
||||||
|
|
||||||
@app.get("/api/apps")
|
@app.get("/api/apps")
|
||||||
@@ -124,6 +165,8 @@ async def get_article(slug: str):
|
|||||||
async def get_categories():
|
async def get_categories():
|
||||||
"""Get all categories ordered by index"""
|
"""Get all categories ordered by index"""
|
||||||
categories = db.get_all('categories', limit=50)
|
categories = db.get_all('categories', limit=50)
|
||||||
|
for category in categories:
|
||||||
|
category['order_index'] = to_int(category.get('order_index'), 0)
|
||||||
categories.sort(key=lambda x: x.get('order_index', 0))
|
categories.sort(key=lambda x: x.get('order_index', 0))
|
||||||
return json_response(categories, cache_time=7200)
|
return json_response(categories, cache_time=7200)
|
||||||
|
|
||||||
@@ -183,6 +226,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"""
|
||||||
@@ -318,6 +386,9 @@ async def delete_article(article_id: int):
|
|||||||
async def create_category(category_data: Dict[str, Any]):
|
async def create_category(category_data: Dict[str, Any]):
|
||||||
"""Create new category"""
|
"""Create new category"""
|
||||||
try:
|
try:
|
||||||
|
category_data = dict(category_data)
|
||||||
|
category_data['order_index'] = to_int(category_data.get('order_index'), 0)
|
||||||
|
|
||||||
cursor = db.conn.cursor()
|
cursor = db.conn.cursor()
|
||||||
columns = ', '.join(category_data.keys())
|
columns = ', '.join(category_data.keys())
|
||||||
placeholders = ', '.join(['?' for _ in category_data])
|
placeholders = ', '.join(['?' for _ in category_data])
|
||||||
@@ -332,6 +403,10 @@ async def create_category(category_data: Dict[str, Any]):
|
|||||||
async def update_category(cat_id: int, category_data: Dict[str, Any]):
|
async def update_category(cat_id: int, category_data: Dict[str, Any]):
|
||||||
"""Update category"""
|
"""Update category"""
|
||||||
try:
|
try:
|
||||||
|
category_data = dict(category_data)
|
||||||
|
if 'order_index' in category_data:
|
||||||
|
category_data['order_index'] = to_int(category_data.get('order_index'), 0)
|
||||||
|
|
||||||
set_clause = ', '.join([f"{k} = ?" for k in category_data.keys()])
|
set_clause = ', '.join([f"{k} = ?" for k in category_data.keys()])
|
||||||
cursor = db.conn.cursor()
|
cursor = db.conn.cursor()
|
||||||
cursor.execute(f"UPDATE categories SET {set_clause} WHERE id = ?",
|
cursor.execute(f"UPDATE categories SET {set_clause} WHERE id = ?",
|
||||||
@@ -341,6 +416,18 @@ async def update_category(cat_id: int, category_data: Dict[str, Any]):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/categories/{cat_id}", dependencies=[Depends(verify_token)])
|
||||||
|
async def delete_category(cat_id: int):
|
||||||
|
"""Delete category"""
|
||||||
|
try:
|
||||||
|
cursor = db.conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM categories WHERE id = ?", (cat_id,))
|
||||||
|
db.conn.commit()
|
||||||
|
return {"message": "Category deleted"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
# Sponsors CRUD
|
# Sponsors CRUD
|
||||||
@app.post("/api/admin/sponsors", dependencies=[Depends(verify_token)])
|
@app.post("/api/admin/sponsors", dependencies=[Depends(verify_token)])
|
||||||
async def create_sponsor(sponsor_data: Dict[str, Any]):
|
async def create_sponsor(sponsor_data: Dict[str, Any]):
|
||||||
@@ -369,6 +456,18 @@ async def update_sponsor(sponsor_id: int, sponsor_data: Dict[str, Any]):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/sponsors/{sponsor_id}", dependencies=[Depends(verify_token)])
|
||||||
|
async def delete_sponsor(sponsor_id: int):
|
||||||
|
"""Delete sponsor"""
|
||||||
|
try:
|
||||||
|
cursor = db.conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM sponsors WHERE id = ?", (sponsor_id,))
|
||||||
|
db.conn.commit()
|
||||||
|
return {"message": "Sponsor deleted"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""API info"""
|
"""API info"""
|
||||||
|
|||||||
2
docs/md_v2/marketplace/backend/uploads/.gitignore
vendored
Normal file
2
docs/md_v2/marketplace/backend/uploads/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -115,4 +115,4 @@ extra_javascript:
|
|||||||
- assets/copy_code.js
|
- assets/copy_code.js
|
||||||
- assets/floating_ask_ai_button.js
|
- assets/floating_ask_ai_button.js
|
||||||
- assets/mobile_menu.js
|
- assets/mobile_menu.js
|
||||||
- assets/page_actions.js
|
- assets/page_actions.js?v=20251006
|
||||||
Reference in New Issue
Block a user