Implement Web frontend Phase 2: Tab switching + search box UI
- New category_view.html with Apple-style design - Tab switching between Category and Series views - Search box with API integration - Navigation stack for back button - Routes: /downloads and / (root) - All tests pass (135 passed)
This commit is contained in:
351
markbase-core/src/category_view.html
Normal file
351
markbase-core/src/category_view.html
Normal file
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MarkBase Download Service</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
h1 { color: #1d1d1f; font-size: 28px; margin-bottom: 20px; }
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.search-btn {
|
||||
padding: 12px 24px;
|
||||
background: #0071e3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
.search-btn:hover { background: #0077ed; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
color: #86868b;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab.active {
|
||||
background: #0071e3;
|
||||
color: white;
|
||||
}
|
||||
.tab:hover:not(.active) {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
flex: 1;
|
||||
}
|
||||
.stat-number { font-size: 24px; font-weight: bold; color: #0071e3; }
|
||||
.stat-label { font-size: 14px; color: #86868b; margin-top: 5px; }
|
||||
|
||||
.content-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.item-list { display: grid; gap: 16px; }
|
||||
.item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.item-card:hover {
|
||||
border-color: #0071e3;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
.item-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: #0071e3;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.item-info { flex: 1; }
|
||||
.item-name { font-weight: 600; color: #1d1d1f; margin-bottom: 4px; }
|
||||
.item-count { font-size: 14px; color: #86868b; }
|
||||
|
||||
.file-list { display: grid; gap: 12px; }
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.file-name { flex: 1; color: #0071e3; font-weight: 500; }
|
||||
.file-size { color: #86868b; font-size: 14px; }
|
||||
.download-link {
|
||||
padding: 8px 16px;
|
||||
background: #0071e3;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.download-link:hover { background: #0077ed; }
|
||||
|
||||
.loading { text-align: center; padding: 40px; color: #86868b; }
|
||||
.empty { text-align: center; padding: 40px; color: #86868b; }
|
||||
.back-btn {
|
||||
padding: 8px 16px;
|
||||
background: #f5f5f7;
|
||||
border: 1px solid #d2d2d7;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.back-btn:hover { background: #e8e8ed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MarkBase Download Service</h1>
|
||||
|
||||
<div class="search-box">
|
||||
<input type="text" class="search-input" id="search-input" placeholder="Search files...">
|
||||
<button class="search-btn" onclick="searchFiles()">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-view="category" onclick="switchTab('category')">By Category</div>
|
||||
<div class="tab" data-view="series" onclick="switchTab('series')">By Series</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-number" id="total-items">-</div>
|
||||
<div class="stat-label" id="items-label">Categories</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-number" id="total-files">-</div>
|
||||
<div class="stat-label">Total Files</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-panel">
|
||||
<button class="back-btn" id="back-btn" onclick="goBack()" style="display: none;">← Back</button>
|
||||
<div id="content">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const apiBase = window.location.protocol + '//' + window.location.host;
|
||||
let currentView = 'category';
|
||||
let currentItem = null;
|
||||
let navigationStack = [];
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function switchTab(view) {
|
||||
currentView = view;
|
||||
currentItem = null;
|
||||
navigationStack = [];
|
||||
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelector(`.tab[data-view="${view}"]`).classList.add('active');
|
||||
document.getElementById('back-btn').style.display = 'none';
|
||||
document.getElementById('items-label').textContent = view === 'category' ? 'Categories' : 'Series';
|
||||
|
||||
loadItems();
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const endpoint = currentView === 'category' ? '/api/v2/categories' : '/api/v2/series';
|
||||
const response = await fetch(apiBase + endpoint);
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('total-items').textContent = data.length;
|
||||
const totalFiles = data.reduce((sum, item) => sum + (item.file_count || 0), 0);
|
||||
document.getElementById('total-files').textContent = totalFiles;
|
||||
|
||||
if (data.length === 0) {
|
||||
content.innerHTML = '<div class="empty">No items found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="item-list">';
|
||||
data.forEach(item => {
|
||||
const name = currentView === 'category' ? item.category : item.series;
|
||||
const count = item.file_count || 0;
|
||||
html += `
|
||||
<div class="item-card" onclick="loadItemDetail('${name}')">
|
||||
<div class="item-icon">${currentView === 'category' ? '📁' : '📦'}</div>
|
||||
<div class="item-info">
|
||||
<div class="item-name">${name}</div>
|
||||
<div class="item-count">${count} files</div>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
} catch (err) {
|
||||
content.innerHTML = '<div class="empty">Error loading data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItemDetail(name) {
|
||||
navigationStack.push({ view: currentView, item: currentItem });
|
||||
currentItem = name;
|
||||
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = '<div class="loading">Loading...</div>';
|
||||
document.getElementById('back-btn').style.display = 'inline-block';
|
||||
|
||||
try {
|
||||
const endpoint = currentView === 'category'
|
||||
? `/api/v2/categories/${encodeURIComponent(name)}`
|
||||
: `/api/v2/series/${encodeURIComponent(name)}`;
|
||||
const response = await fetch(apiBase + endpoint);
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('total-items').textContent = '1';
|
||||
document.getElementById('total-files').textContent = data.files?.length || 0;
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
content.innerHTML = '<div class="empty">No files in this ' + currentView + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="file-list">';
|
||||
data.files.forEach(file => {
|
||||
const downloadUrl = file.download_url || '';
|
||||
const size = file.file_size || 0;
|
||||
html += `
|
||||
<div class="file-item">
|
||||
<div class="file-name">${file.filename}</div>
|
||||
<div class="file-size">${formatSize(size)}</div>
|
||||
${downloadUrl ? `<a class="download-link" href="${downloadUrl}" target="_blank">Download</a>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
} catch (err) {
|
||||
content.innerHTML = '<div class="empty">Error loading files</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function searchFiles() {
|
||||
const query = document.getElementById('search-input').value.trim();
|
||||
if (!query) {
|
||||
loadItems();
|
||||
return;
|
||||
}
|
||||
|
||||
const content = document.getElementById('content');
|
||||
content.innerHTML = '<div class="loading">Searching...</div>';
|
||||
document.getElementById('back-btn').style.display = 'inline-block';
|
||||
navigationStack.push({ view: currentView, item: currentItem });
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/api/v2/files/search?q=${encodeURIComponent(query)}&view=${currentView}`);
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('total-items').textContent = '-';
|
||||
document.getElementById('total-files').textContent = data.files?.length || 0;
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
content.innerHTML = '<div class="empty">No files found matching "' + query + '"</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="file-list">';
|
||||
data.files.forEach(file => {
|
||||
html += `
|
||||
<div class="file-item">
|
||||
<div class="file-name">${file.filename}</div>
|
||||
<div class="file-size">${formatSize(file.file_size || 0)}</div>
|
||||
${file.download_url ? `<a class="download-link" href="${file.download_url}" target="_blank">Download</a>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
} catch (err) {
|
||||
content.innerHTML = '<div class="empty">Error searching files</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (navigationStack.length === 0) {
|
||||
currentItem = null;
|
||||
document.getElementById('back-btn').style.display = 'none';
|
||||
loadItems();
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = navigationStack.pop();
|
||||
if (prev.item === null) {
|
||||
currentItem = null;
|
||||
document.getElementById('back-btn').style.display = 'none';
|
||||
loadItems();
|
||||
} else {
|
||||
currentItem = prev.item;
|
||||
loadItemDetail(currentItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
loadItems();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -232,6 +232,8 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
|
||||
.route("/upload", get(|| async { Html(include_str!("upload.html")) }))
|
||||
.route("/files", get(|| async { Html(include_str!("file_list.html")) }))
|
||||
.route("/products", get(|| async { Html(include_str!("product_manager.html")) }))
|
||||
.route("/downloads", get(|| async { Html(include_str!("category_view.html")) }))
|
||||
.route("/", get(|| async { Html(include_str!("category_view.html")) }))
|
||||
.layer(DefaultBodyLimit::disable()) // Disable body size limit for large file uploads
|
||||
.with_state(state);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user