Implement Web frontend Phase 2: Tab switching + search box UI
Some checks failed
Test / build (push) Has been cancelled
Test / test (push) Has been cancelled

- 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:
Warren
2026-06-19 01:25:44 +08:00
parent f7cfff27c0
commit ea156b65f1
2 changed files with 353 additions and 0 deletions

View 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>

View File

@@ -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("/upload", get(|| async { Html(include_str!("upload.html")) }))
.route("/files", get(|| async { Html(include_str!("file_list.html")) })) .route("/files", get(|| async { Html(include_str!("file_list.html")) }))
.route("/products", get(|| async { Html(include_str!("product_manager.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 .layer(DefaultBodyLimit::disable()) // Disable body size limit for large file uploads
.with_state(state); .with_state(state);