Add clickable reference badges and improve UI layout

- Add clickable badges for backlinks and references that trigger query string searches
- Improve toggle checkbox layout with better styling
- Add description block styling with scrollable container
- Update results styling with bordered cards and shadows
- Add favicon support across pages
- Enhance .env loading with logging for debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
knight 2025-11-05 14:56:43 -05:00
parent d8d2c5e34c
commit 14d37f23e4
7 changed files with 255 additions and 99 deletions

View File

@ -19,9 +19,14 @@ from typing import Optional
# Load .env file if it exists # Load .env file if it exists
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
import logging
_logger = logging.getLogger(__name__)
_env_path = Path(__file__).parent / ".env" _env_path = Path(__file__).parent / ".env"
if _env_path.exists(): if _env_path.exists():
load_dotenv(_env_path) _logger.info(f"Loading .env from: {_env_path}")
result = load_dotenv(_env_path, override=True)
_logger.info(f"load_dotenv result: {result}")
except ImportError: except ImportError:
pass # python-dotenv not installed pass # python-dotenv not installed

View File

@ -748,7 +748,9 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
"secondary": bool(highlight_map.get("transcript_secondary_full")), "secondary": bool(highlight_map.get("transcript_secondary_full")),
}, },
"internal_references_count": source.get("internal_references_count", 0), "internal_references_count": source.get("internal_references_count", 0),
"internal_references": source.get("internal_references", []),
"referenced_by_count": source.get("referenced_by_count", 0), "referenced_by_count": source.get("referenced_by_count", 0),
"referenced_by": source.get("referenced_by", []),
} }
) )
@ -802,48 +804,50 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
start = request.args.get("start", type=str) start = request.args.get("start", type=str)
end = request.args.get("end", type=str) end = request.args.get("end", type=str)
filters: List[Dict] = [] def parse_flag(name: str, default: bool = True) -> bool:
channel_filter = build_channel_filter(channels) value = request.args.get(name)
if channel_filter: if value is None:
filters.append(channel_filter) return default
year_filter = build_year_filter(year) lowered = value.lower()
if year_filter: return lowered not in {"0", "false", "no"}
filters.append(year_filter)
use_exact = parse_flag("exact", True)
use_fuzzy = parse_flag("fuzzy", True)
use_phrase = parse_flag("phrase", True)
if use_query_string:
use_exact = use_fuzzy = use_phrase = False
search_payload = build_query_payload(
term,
channels=channels,
year=year,
sort="relevant",
use_exact=use_exact,
use_fuzzy=use_fuzzy,
use_phrase=use_phrase,
use_query_string=use_query_string,
)
query = search_payload.get("query", {"match_all": {}})
if start or end: if start or end:
range_filter: Dict[str, Dict[str, Dict[str, str]]] = {"range": {"date": {}}} range_filter: Dict[str, Dict[str, Dict[str, str]]] = {"range": {"date": {}}}
if start: if start:
range_filter["range"]["date"]["gte"] = start range_filter["range"]["date"]["gte"] = start
if end: if end:
range_filter["range"]["date"]["lte"] = end range_filter["range"]["date"]["lte"] = end
filters.append(range_filter) if "bool" in query:
bool_clause = query.setdefault("bool", {})
base_fields = ["title^3", "description^2", "transcript_full", "transcript_secondary_full"] existing_filter = bool_clause.get("filter")
if use_query_string: if existing_filter is None:
qs_query = term or "*" bool_clause["filter"] = [range_filter]
must_clause: List[Dict[str, Any]] = [ elif isinstance(existing_filter, list):
{ bool_clause["filter"].append(range_filter)
"query_string": {
"query": qs_query,
"default_operator": "AND",
"fields": base_fields,
}
}
]
else: else:
must_clause = [ bool_clause["filter"] = [existing_filter, range_filter]
{ elif query.get("match_all") is not None:
"multi_match": { query = {"bool": {"filter": [range_filter]}}
"query": term, else:
"fields": base_fields, query = {"bool": {"must": [query], "filter": [range_filter]}}
"type": "best_fields",
"operator": "and",
}
}
]
query: Dict[str, Any] = {"bool": {"must": must_clause}}
if filters:
query["bool"]["filter"] = filters
histogram: Dict[str, Any] = { histogram: Dict[str, Any] = {
"field": "date", "field": "date",

View File

@ -212,6 +212,36 @@
} }
} }
function ensureQueryStringMode() {
if (!queryToggle) return;
if (!queryToggle.checked) {
rememberToggleState();
queryToggle.checked = true;
applyQueryMode();
}
}
function escapeQueryValue(value) {
return value.replace(/(["\\])/g, "\\$1");
}
function buildFieldClause(field, ids) {
if (!Array.isArray(ids)) return null;
const seen = new Set();
const collected = [];
ids.forEach((raw) => {
if (!raw && raw !== 0) return;
const value = String(raw).trim();
if (!value) return;
if (seen.has(value)) return;
seen.add(value);
collected.push(value);
});
if (!collected.length) return null;
const escaped = collected.map((id) => `"${escapeQueryValue(id)}"`);
return `${field}:(${escaped.join(" OR ")})`;
}
if (channelOptions) { if (channelOptions) {
channelOptions.addEventListener("change", (event) => { channelOptions.addEventListener("change", (event) => {
const target = event.target; const target = event.target;
@ -920,7 +950,7 @@ function renderFrequencyChart(buckets, channelTotals) {
freqChart.appendChild(legend); freqChart.appendChild(legend);
} }
async function updateFrequencyChart(term, channels, year, queryMode) { async function updateFrequencyChart(term, channels, year, queryMode, toggles = {}) {
if (!freqChart || typeof d3 === "undefined") { if (!freqChart || typeof d3 === "undefined") {
return; return;
} }
@ -944,6 +974,10 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
if (queryMode) { if (queryMode) {
params.set("query_string", "1"); params.set("query_string", "1");
} }
const { exact = true, fuzzy = true, phrase = true } = toggles || {};
params.set("exact", exact ? "1" : "0");
params.set("fuzzy", fuzzy ? "1" : "0");
params.set("phrase", phrase ? "1" : "0");
clearFrequency("Loading timeline…"); clearFrequency("Loading timeline…");
try { try {
@ -962,11 +996,16 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"}`; freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"}`;
} }
} }
const buckets = payload.buckets || [];
if (total === 0) { if (total === 0) {
freqChart.innerHTML = ""; freqChart.innerHTML = "";
return; return;
} }
renderFrequencyChart(payload.buckets || [], payload.channels || []); if (!buckets.length) {
clearFrequency("Timeline unavailable for this query (missing video dates).");
return;
}
renderFrequencyChart(buckets, payload.channels || []);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
clearFrequency("Timeline unavailable."); clearFrequency("Timeline unavailable.");
@ -988,46 +1027,97 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
item.descriptionHtml || escapeHtml(item.description || ""); item.descriptionHtml || escapeHtml(item.description || "");
const header = document.createElement("div"); const header = document.createElement("div");
const badges = []; const badgeDefs = [];
if (item.highlightSource && item.highlightSource.primary) badges.push('primary transcript'); if (item.highlightSource && item.highlightSource.primary) {
if (item.highlightSource && item.highlightSource.secondary) badges.push('secondary transcript'); badgeDefs.push({ label: "primary transcript" });
}
if (item.highlightSource && item.highlightSource.secondary) {
badgeDefs.push({ label: "secondary transcript" });
}
// Add reference count badges // Add reference count badges
const refByCount = item.referenced_by_count || 0; const refByCount = item.referenced_by_count || 0;
const refToCount = item.internal_references_count || 0; const refToCount = item.internal_references_count || 0;
const refByIds = Array.isArray(item.referenced_by) ? item.referenced_by : [];
const refToIds = Array.isArray(item.internal_references) ? item.internal_references : [];
// Debug: log reference counts for first item if (refByCount > 0) {
if (badges.length === 0 || badges.length <= 2) { let query = null;
console.log('Reference counts:', { if (item.video_id) {
title: item.title, query = buildFieldClause("internal_references", [item.video_id]);
referenced_by_count: refByCount, }
internal_references_count: refToCount, if (!query) {
hasReferencedBy: 'referenced_by_count' in item, query = buildFieldClause("video_id", refByIds);
hasInternalRefs: 'internal_references_count' in item }
badgeDefs.push({
label: `${refByCount} backlink${refByCount !== 1 ? "s" : ""}`,
query,
title: query
? "Show videos that reference this one"
: "Reference list unavailable in this result",
});
}
if (refToCount > 0) {
const query = buildFieldClause("video_id", refToIds);
badgeDefs.push({
label: `${refToCount} reference${refToCount !== 1 ? "s" : ""}`,
query,
title: query
? "Show videos referenced by this one"
: "Reference list unavailable in this result",
}); });
} }
if (refByCount > 0) badges.push(`${refByCount} backlink${refByCount !== 1 ? 's' : ''}`);
if (refToCount > 0) badges.push(`${refToCount} reference${refToCount !== 1 ? 's' : ''}`);
const badgeHtml = badges.length
? `<div class="badge-row">${badges
.map((b) => `<span class="badge">${escapeHtml(b)}</span>` )
.join('')}</div>`
: '';
header.innerHTML = ` header.innerHTML = `
<strong>${titleHtml}</strong> <strong>${titleHtml}</strong>
<div class="muted">${escapeHtml(item.channel_name || "")} ${fmtDate( <div class="muted">${escapeHtml(item.channel_name || "")} ${fmtDate(
item.date item.date
)}</div> )}</div>
<div class="muted"><a href="${item.url}" target="_blank" rel="noopener">Open on YouTube</a></div> <div class="muted"><a href="${item.url}" target="_blank" rel="noopener">Open on YouTube</a></div>
${badgeHtml}
`; `;
if (badgeDefs.length) {
const badgeRow = document.createElement("div");
badgeRow.className = "badge-row";
badgeDefs.forEach((badge) => {
if (!badge || !badge.label) return;
const badgeEl = document.createElement("span");
badgeEl.className = "badge";
badgeEl.textContent = badge.label;
if (badge.title) {
badgeEl.title = badge.title;
}
if (badge.query) {
badgeEl.classList.add("badge-clickable");
badgeEl.setAttribute("role", "button");
badgeEl.tabIndex = 0;
const triggerSearch = () => {
if (!badge.query) return;
qInput.value = badge.query;
ensureQueryStringMode();
runSearch(0);
};
badgeEl.addEventListener("click", (event) => {
event.preventDefault();
triggerSearch();
});
badgeEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
triggerSearch();
}
});
}
badgeRow.appendChild(badgeEl);
});
if (badgeRow.childElementCount) {
header.appendChild(badgeRow);
}
}
el.appendChild(header); el.appendChild(header);
if (descriptionHtml) { if (descriptionHtml) {
const desc = document.createElement("div"); const desc = document.createElement("div");
desc.className = "muted"; desc.className = "muted description-block";
desc.innerHTML = descriptionHtml; desc.innerHTML = descriptionHtml;
el.appendChild(desc); el.appendChild(desc);
} }
@ -1130,7 +1220,7 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
const res = await fetch(`/api/search?${params.toString()}`); const res = await fetch(`/api/search?${params.toString()}`);
const payload = await res.json(); const payload = await res.json();
renderResults(payload, page); renderResults(payload, page);
updateFrequencyChart(q, channels, year, queryMode); updateFrequencyChart(q, channels, year, queryMode, { exact, fuzzy, phrase });
} }
searchBtn.addEventListener("click", () => runSearch(0)); searchBtn.addEventListener("click", () => runSearch(0));

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Term Frequency Explorer</title> <title>Term Frequency Explorer</title>
<link rel="icon" href="/static/favicon.png" type="image/png" />
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<style> <style>
#chart { #chart {
@ -65,4 +66,3 @@
<script src="/static/frequency.js"></script> <script src="/static/frequency.js"></script>
</body> </body>
</html> </html>

View File

@ -3,7 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>This Little Corner (Python)</title> <title>TLC Search</title>
<link rel="icon" href="/static/favicon.png" type="image/png" />
<link rel="stylesheet" href="https://unpkg.com/xp.css" /> <link rel="stylesheet" href="https://unpkg.com/xp.css" />
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
@ -11,7 +12,7 @@
<body> <body>
<div class="window" style="max-width: 1200px; margin: 20px auto;"> <div class="window" style="max-width: 1200px; margin: 20px auto;">
<div class="title-bar"> <div class="title-bar">
<div class="title-bar-text">This Little Corner — Elastic Search</div> <div class="title-bar-text">This Little Corner</div>
<div class="title-bar-controls"> <div class="title-bar-controls">
<button id="minimizeBtn" aria-label="Minimize"></button> <button id="minimizeBtn" aria-label="Minimize"></button>
<button aria-label="Maximize"></button> <button aria-label="Maximize"></button>
@ -59,19 +60,27 @@
</select> </select>
</div> </div>
<div class="field-row"> <div class="field-row toggle-row">
<div class="toggle-item toggle-item--first">
<input type="checkbox" id="exactToggle" checked /> <input type="checkbox" id="exactToggle" checked />
<label for="exactToggle">Exact</label> <label for="exactToggle">Exact</label>
</div>
<div class="toggle-item">
<input type="checkbox" id="fuzzyToggle" checked /> <input type="checkbox" id="fuzzyToggle" checked />
<label for="fuzzyToggle">Fuzzy</label> <label for="fuzzyToggle">Fuzzy</label>
</div>
<div class="toggle-item">
<input type="checkbox" id="phraseToggle" checked /> <input type="checkbox" id="phraseToggle" checked />
<label for="phraseToggle">Phrase</label> <label for="phraseToggle">Phrase</label>
</div>
<div class="toggle-item">
<input type="checkbox" id="queryStringToggle" /> <input type="checkbox" id="queryStringToggle" />
<label for="queryStringToggle">Query string mode</label> <label for="queryStringToggle">Query string mode</label>
</div> </div>
</div>
</fieldset> </fieldset>
<div class="summary-row"> <div class="summary-row">
@ -84,7 +93,7 @@
</fieldset> </fieldset>
</div> </div>
<div class="summary-right"> <div class="summary-right">
<fieldset style="height: 100%;"> <fieldset>
<legend>Timeline</legend> <legend>Timeline</legend>
<div id="frequencySummary" style="font-size: 11px; margin-bottom: 8px;"></div> <div id="frequencySummary" style="font-size: 11px; margin-bottom: 8px;"></div>
<div id="frequencyChart"></div> <div id="frequencyChart"></div>

View File

@ -119,6 +119,61 @@ body.dimmed {
content: ' ▲'; content: ' ▲';
} }
.toggle-row {
flex-direction: column;
align-items: flex-start;
gap: 4px;
margin-top: 8px;
}
.toggle-row > * {
margin-left: 0 !important;
}
.toggle-item {
display: flex;
align-items: center;
gap: 6px;
user-select: none;
}
.toggle-item label {
cursor: pointer;
width: auto !important;
}
.toggle-item--first {
margin-left: 0;
}
.toggle-item input[type="checkbox"] {
margin: 0;
}
.toggle-item input[type="checkbox"]:disabled + label {
color: GrayText;
opacity: 0.7;
}
.toggle-item input[type="checkbox"]:disabled {
cursor: not-allowed;
}
.toggle-item input[type="checkbox"]:disabled + label {
cursor: not-allowed;
}
.description-block {
background: Window;
border: 1px solid #919b9c;
padding: 6px 8px;
margin-top: 6px;
font-size: 11px;
white-space: pre-wrap;
max-height: 6em;
overflow-y: auto;
}
.channel-options { .channel-options {
position: absolute; position: absolute;
margin-top: 2px; margin-top: 2px;
@ -165,16 +220,18 @@ body.dimmed {
/* Results styling */ /* Results styling */
#results .item { #results .item {
border-bottom: 1px solid ButtonShadow; background: Window;
padding: 12px 0; border: 2px solid #919b9c;
padding: 12px;
margin-bottom: 8px; margin-bottom: 8px;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
word-wrap: break-word; word-wrap: break-word;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
} }
#results .item:last-child { #results .item:last-child {
border-bottom: none; margin-bottom: 0;
} }
#results .item strong { #results .item strong {
@ -186,6 +243,7 @@ body.dimmed {
.window-body { .window-body {
max-width: 100%; max-width: 100%;
overflow-x: hidden; overflow-x: hidden;
margin: 1rem;
} }
/* Badges */ /* Badges */
@ -209,6 +267,15 @@ body.dimmed {
word-break: keep-all; word-break: keep-all;
} }
.badge-clickable {
cursor: pointer;
}
.badge-clickable:focus {
outline: 2px solid rgba(11, 110, 253, 0.6);
outline-offset: 1px;
}
/* Transcript and highlights */ /* Transcript and highlights */
.transcript { .transcript {
background: Window; background: Window;
@ -255,8 +322,7 @@ mark {
margin-top: 12px; margin-top: 12px;
padding: 8px; padding: 8px;
background: Window; background: Window;
border: 2px solid; border: 2px solid #919b9c;
border-color: ButtonShadow ButtonHighlight ButtonHighlight ButtonShadow;
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
font-size: 11px; font-size: 11px;
@ -312,27 +378,9 @@ mark {
line-height: 1.4; line-height: 1.4;
} }
.transcript-header { .transcript-header,
font-weight: bold;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
background: ActiveCaption;
color: CaptionText;
padding: 2px 4px;
}
.transcript-close { .transcript-close {
cursor: pointer; display: none;
font-size: 16px;
padding: 0 4px;
font-weight: bold;
}
.transcript-close:hover {
background: Highlight;
color: HighlightText;
} }
/* Chart styling */ /* Chart styling */