pig.py/templates/search.html.j2
Russell Ballestrini 0fec9abdd5 One CSS to rule them all: discordia.css
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
2026-01-05 20:52:02 -05:00

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 %}