The Law of Fives - organized into 5 sections: I. CHAOS - The void from which all emerges II. DISCORD - Navigation through the confusion III. CONFUSION - Forms that capture intent IV. BUREAUCRACY - The grid that contains V. AFTERMATH - Side quests for the worthy fnord
217 lines
9.8 KiB
Django/Jinja
217 lines
9.8 KiB
Django/Jinja
{% extends "base.html.j2" %}
|
|
<!-- fnord -->
|
|
{% block title %}neopig SERP{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
/* Search page specific */
|
|
.result-hash { font-family: monospace; font-size: 11px; color: #ff6b6b; text-decoration: none; word-break: break-all; }
|
|
.result-hash:hover { text-decoration: underline; }
|
|
.result-keywords { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
|
.tag { background: #333; padding: 2px 8px; border-radius: 4px; font-size: 11px; color: #aaa; }
|
|
.no-results { text-align: center; padding: 60px; color: #666; }
|
|
.result-count { padding: 10px 15px; color: #888; font-size: 14px; }
|
|
.result-count.over-9000 { color: #ff6b6b; font-weight: bold; font-size: 18px; text-shadow: 0 0 10px rgba(255,107,107,0.5); animation: powerUp 0.5s ease-out; }
|
|
@keyframes powerUp { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } }
|
|
.media-link { display: block; cursor: pointer; }
|
|
.media-link:hover img, .media-link:hover video { opacity: 0.8; }
|
|
/* Page results */
|
|
.page-results { display: flex; flex-direction: column; gap: 10px; }
|
|
.page-result { background: #1a1a1a; border-radius: 8px; padding: 15px; transition: background 0.2s; }
|
|
.page-result:hover { background: #252525; }
|
|
.page-result a { color: #ff6b6b; text-decoration: none; font-size: 16px; font-weight: 500; }
|
|
.page-result a:hover { text-decoration: underline; }
|
|
.page-path { font-size: 12px; color: #4ade80; margin-top: 4px; font-family: monospace; }
|
|
.page-snippet { font-size: 13px; color: #999; margin-top: 8px; line-height: 1.5; }
|
|
.page-snippet mark { background: #ff6b6b33; color: #ff9999; padding: 1px 3px; border-radius: 2px; }
|
|
/* Two-column layout for media + pages */
|
|
.search-columns { display: grid; grid-template-columns: 1fr; gap: 30px; align-items: start; }
|
|
.search-columns.has-pages { grid-template-columns: 1fr 2fr; }
|
|
.search-columns .page-column { display: none; }
|
|
.search-columns.has-pages .page-column { display: block; }
|
|
@media (max-width: 1000px) { .search-columns.has-pages { grid-template-columns: 1fr; } }
|
|
@media (max-width: 768px) { .results { grid-template-columns: repeat(2, 1fr); } }
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container">
|
|
<div class="search-box">
|
|
<input type="text" id="query" placeholder="{{ t.search_placeholder }}" autofocus>
|
|
<select id="type">
|
|
<option value="">{{ t.all_types }}</option>
|
|
<option value="image">{{ t.images }}</option>
|
|
<option value="video">{{ t.videos }}</option>
|
|
<option value="audio">{{ t.audio }}</option>
|
|
</select>
|
|
<button onclick="search()">{{ t.search }}</button>
|
|
</div>
|
|
|
|
<div class="stats" id="stats">{{ t.loading_stats }}</div>
|
|
|
|
<div class="search-columns">
|
|
<div class="page-column" id="page-section">
|
|
<h2 class="section-title">{{ t.pages_label }}</h2>
|
|
<div class="page-results" id="page-results"></div>
|
|
</div>
|
|
|
|
<div class="media-column" id="media-section">
|
|
<h2 class="section-title">{{ t.media }}</h2>
|
|
<div id="results-count"></div>
|
|
<div class="results" id="results"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
const T = {{ t_json|safe }};
|
|
const OVER_9000 = 9000;
|
|
let currentOffset = 0;
|
|
|
|
async function loadStats() {
|
|
const res = await fetch('/api/stats');
|
|
const stats = await res.json();
|
|
document.getElementById('stats').innerHTML =
|
|
`<strong>${stats.total_media}</strong> ${T.media} | ` +
|
|
`<strong>${stats.by_type?.image || 0}</strong> ${T.images} | ` +
|
|
`<strong>${stats.by_type?.video || 0}</strong> ${T.videos} | ` +
|
|
`<strong>${stats.total_sources}</strong> ${T.sources} | ` +
|
|
`<strong>${stats.total_pages || 0}</strong> ${T.pages_label}`;
|
|
}
|
|
|
|
async function search(offset = 0) {
|
|
currentOffset = offset;
|
|
const query = document.getElementById('query').value;
|
|
const type = document.getElementById('type').value;
|
|
|
|
let mediaUrl = `/api/search?q=${encodeURIComponent(query)}&limit=${OVER_9000}&offset=${offset}`;
|
|
if (type) mediaUrl += `&type=${type}`;
|
|
|
|
const mediaRes = await fetch(mediaUrl);
|
|
const mediaResults = await mediaRes.json();
|
|
|
|
const mediaContainer = document.getElementById('results');
|
|
const countContainer = document.getElementById('results-count');
|
|
|
|
let countDisplay = '';
|
|
if (mediaResults.length >= OVER_9000) {
|
|
countDisplay = `<div class="result-count over-9000">${T.over_9000}</div>`;
|
|
} else if (mediaResults.length > 0) {
|
|
const start = offset + 1;
|
|
const end = offset + mediaResults.length;
|
|
countDisplay = `<div class="result-count">${start.toLocaleString()}-${end.toLocaleString()} of ${offset > 0 ? 'many' : mediaResults.length.toLocaleString()}</div>`;
|
|
}
|
|
|
|
let pagination = '';
|
|
if (offset > 0 || mediaResults.length >= OVER_9000) {
|
|
pagination = '<div class="pagination">';
|
|
if (offset > 0) pagination += `<button onclick="search(${Math.max(0, offset - OVER_9000)})">← ${T.previous} ${OVER_9000.toLocaleString()}</button>`;
|
|
if (mediaResults.length >= OVER_9000) pagination += `<button onclick="search(${offset + OVER_9000})">${T.next} ${OVER_9000.toLocaleString()} →</button>`;
|
|
pagination += '</div>';
|
|
}
|
|
|
|
if (mediaResults.length === 0 && offset === 0) {
|
|
countContainer.innerHTML = '';
|
|
mediaContainer.innerHTML = `<div class="no-results">${T.no_media}</div>`;
|
|
} else if (mediaResults.length === 0) {
|
|
countContainer.innerHTML = '';
|
|
mediaContainer.innerHTML = `<div class="no-results">${T.no_more}</div>` + pagination;
|
|
} else {
|
|
countContainer.innerHTML = countDisplay;
|
|
mediaContainer.innerHTML = mediaResults.map(r => renderMediaCard(r)).join('') + pagination;
|
|
}
|
|
|
|
// Search pages
|
|
const pageContainer = document.getElementById('page-results');
|
|
const searchColumns = document.querySelector('.search-columns');
|
|
|
|
if (query.trim()) {
|
|
const pageRes = await fetch(`/api/search/pages?q=${encodeURIComponent(query)}&limit=30`);
|
|
const pageResults = await pageRes.json();
|
|
|
|
if (pageResults.length > 0) {
|
|
searchColumns.classList.add('has-pages');
|
|
pageContainer.innerHTML = pageResults.map(p => `
|
|
<div class="page-result">
|
|
<a href="/page/${p.uri_hash}">${p.title || p.uri}</a>
|
|
<div class="page-path">${p.path || p.uri}</div>
|
|
<div class="page-snippet">${p.snippet || ''}</div>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
searchColumns.classList.remove('has-pages');
|
|
pageContainer.innerHTML = '';
|
|
}
|
|
} else {
|
|
searchColumns.classList.remove('has-pages');
|
|
pageContainer.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
function renderMediaCard(r) {
|
|
const isVideo = r.media_type === 'video';
|
|
const isCode = r.media_type === 'code';
|
|
const isStyle = r.media_type === 'style';
|
|
const isFont = r.media_type === 'font';
|
|
|
|
const mimeToExt = {
|
|
'text/javascript': '.js', 'application/javascript': '.js',
|
|
'text/css': '.css', 'text/html': '.html.j2', 'text/xml': '.xml',
|
|
'application/json': '.json', 'text/markdown': '.md',
|
|
'font/woff': '.woff', 'font/woff2': '.woff2', 'font/ttf': '.ttf',
|
|
};
|
|
const getExt = (mime) => mimeToExt[mime] || (r.alt_text ? '.' + r.alt_text : '');
|
|
|
|
const placeholder = (ext, icon, color) => `<svg viewBox="0 0 100 100" style="width:100%;height:100%;background:#1a1a1a;border-radius:4px;">
|
|
<text x="50" y="40" text-anchor="middle" fill="${color}" font-size="24">${icon}</text>
|
|
<text x="50" y="65" text-anchor="middle" fill="#888" font-family="monospace" font-size="14" font-weight="bold">${ext}</text>
|
|
</svg>`;
|
|
|
|
const getFilename = (uri) => {
|
|
if (!uri) return '';
|
|
try {
|
|
const path = new URL(uri).pathname;
|
|
const name = decodeURIComponent(path.split('/').pop() || '');
|
|
return name.length > 40 ? name.slice(0, 37) + '...' : name;
|
|
} catch { return ''; }
|
|
};
|
|
const filename = getFilename(r.media_uri);
|
|
const displayName = filename ? filename.slice(0, 20) : getExt(r.mime_type);
|
|
|
|
let mediaEl;
|
|
if (isVideo) mediaEl = `<video src="/media/${r.md5_hash}" muted loop preload="metadata" onmouseenter="this.play()" onmouseleave="this.pause()"></video>`;
|
|
else if (isCode) mediaEl = placeholder(displayName || getExt(r.mime_type), '{ }', '#6af');
|
|
else if (isStyle) mediaEl = placeholder(displayName || '.css', '#', '#f6a');
|
|
else if (isFont) mediaEl = placeholder(displayName || getExt(r.mime_type), 'Aa', '#af6');
|
|
else mediaEl = `<img src="/media/${r.md5_hash}" alt="${r.alt_text || ''}" loading="lazy">`;
|
|
|
|
const keywords = JSON.parse(r.keywords || '[]');
|
|
const tagsHtml = keywords.map(k => `<span class="tag">${k}</span>`).join('');
|
|
|
|
return `<div class="result">
|
|
<a href="/view/${r.md5_hash}" class="media-link">${mediaEl}</a>
|
|
<div class="result-info">
|
|
${filename ? `<div class="result-filename">${filename}</div>` : ''}
|
|
<a href="/view/${r.md5_hash}" class="result-hash">${r.md5_hash}</a>
|
|
<div class="result-meta">${r.media_type} · ${formatBytes(r.file_size)}${r.alt_text ? ` · ${r.alt_text.substring(0, 50)}` : ''}</div>
|
|
<div class="result-keywords">${tagsHtml}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return '?';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
|
|
return (bytes/1024/1024).toFixed(1) + ' MB';
|
|
}
|
|
|
|
document.getElementById('query').addEventListener('keypress', e => { if (e.key === 'Enter') search(); });
|
|
loadStats();
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const q = urlParams.get('q');
|
|
if (q) document.getElementById('query').value = q;
|
|
search();
|
|
</script>
|
|
{% endblock %}
|