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
try:
from dotenv import load_dotenv
import logging
_logger = logging.getLogger(__name__)
_env_path = Path(__file__).parent / ".env"
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:
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")),
},
"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": source.get("referenced_by", []),
}
)
@ -802,48 +804,50 @@ def create_app(config: AppConfig = CONFIG) -> Flask:
start = request.args.get("start", type=str)
end = request.args.get("end", type=str)
filters: List[Dict] = []
channel_filter = build_channel_filter(channels)
if channel_filter:
filters.append(channel_filter)
year_filter = build_year_filter(year)
if year_filter:
filters.append(year_filter)
def parse_flag(name: str, default: bool = True) -> bool:
value = request.args.get(name)
if value is None:
return default
lowered = value.lower()
return lowered not in {"0", "false", "no"}
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:
range_filter: Dict[str, Dict[str, Dict[str, str]]] = {"range": {"date": {}}}
if start:
range_filter["range"]["date"]["gte"] = start
if end:
range_filter["range"]["date"]["lte"] = end
filters.append(range_filter)
base_fields = ["title^3", "description^2", "transcript_full", "transcript_secondary_full"]
if use_query_string:
qs_query = term or "*"
must_clause: List[Dict[str, Any]] = [
{
"query_string": {
"query": qs_query,
"default_operator": "AND",
"fields": base_fields,
}
}
]
if "bool" in query:
bool_clause = query.setdefault("bool", {})
existing_filter = bool_clause.get("filter")
if existing_filter is None:
bool_clause["filter"] = [range_filter]
elif isinstance(existing_filter, list):
bool_clause["filter"].append(range_filter)
else:
must_clause = [
{
"multi_match": {
"query": term,
"fields": base_fields,
"type": "best_fields",
"operator": "and",
}
}
]
query: Dict[str, Any] = {"bool": {"must": must_clause}}
if filters:
query["bool"]["filter"] = filters
bool_clause["filter"] = [existing_filter, range_filter]
elif query.get("match_all") is not None:
query = {"bool": {"filter": [range_filter]}}
else:
query = {"bool": {"must": [query], "filter": [range_filter]}}
histogram: Dict[str, Any] = {
"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) {
channelOptions.addEventListener("change", (event) => {
const target = event.target;
@ -920,7 +950,7 @@ function renderFrequencyChart(buckets, channelTotals) {
freqChart.appendChild(legend);
}
async function updateFrequencyChart(term, channels, year, queryMode) {
async function updateFrequencyChart(term, channels, year, queryMode, toggles = {}) {
if (!freqChart || typeof d3 === "undefined") {
return;
}
@ -944,6 +974,10 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
if (queryMode) {
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…");
try {
@ -962,11 +996,16 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"}`;
}
}
const buckets = payload.buckets || [];
if (total === 0) {
freqChart.innerHTML = "";
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) {
console.error(err);
clearFrequency("Timeline unavailable.");
@ -988,46 +1027,97 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
item.descriptionHtml || escapeHtml(item.description || "");
const header = document.createElement("div");
const badges = [];
if (item.highlightSource && item.highlightSource.primary) badges.push('primary transcript');
if (item.highlightSource && item.highlightSource.secondary) badges.push('secondary transcript');
const badgeDefs = [];
if (item.highlightSource && item.highlightSource.primary) {
badgeDefs.push({ label: "primary transcript" });
}
if (item.highlightSource && item.highlightSource.secondary) {
badgeDefs.push({ label: "secondary transcript" });
}
// Add reference count badges
const refByCount = item.referenced_by_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 (badges.length === 0 || badges.length <= 2) {
console.log('Reference counts:', {
title: item.title,
referenced_by_count: refByCount,
internal_references_count: refToCount,
hasReferencedBy: 'referenced_by_count' in item,
hasInternalRefs: 'internal_references_count' in item
if (refByCount > 0) {
let query = null;
if (item.video_id) {
query = buildFieldClause("internal_references", [item.video_id]);
}
if (!query) {
query = buildFieldClause("video_id", refByIds);
}
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 = `
<strong>${titleHtml}</strong>
<div class="muted">${escapeHtml(item.channel_name || "")} ${fmtDate(
item.date
)}</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);
if (descriptionHtml) {
const desc = document.createElement("div");
desc.className = "muted";
desc.className = "muted description-block";
desc.innerHTML = descriptionHtml;
el.appendChild(desc);
}
@ -1130,7 +1220,7 @@ async function updateFrequencyChart(term, channels, year, queryMode) {
const res = await fetch(`/api/search?${params.toString()}`);
const payload = await res.json();
renderResults(payload, page);
updateFrequencyChart(q, channels, year, queryMode);
updateFrequencyChart(q, channels, year, queryMode, { exact, fuzzy, phrase });
}
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 name="viewport" content="width=device-width, initial-scale=1" />
<title>Term Frequency Explorer</title>
<link rel="icon" href="/static/favicon.png" type="image/png" />
<link rel="stylesheet" href="/static/style.css" />
<style>
#chart {
@ -65,4 +66,3 @@
<script src="/static/frequency.js"></script>
</body>
</html>

View File

@ -3,7 +3,8 @@
<head>
<meta charset="utf-8" />
<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="/static/style.css" />
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
@ -11,7 +12,7 @@
<body>
<div class="window" style="max-width: 1200px; margin: 20px auto;">
<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">
<button id="minimizeBtn" aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
@ -59,19 +60,27 @@
</select>
</div>
<div class="field-row">
<div class="field-row toggle-row">
<div class="toggle-item toggle-item--first">
<input type="checkbox" id="exactToggle" checked />
<label for="exactToggle">Exact</label>
</div>
<div class="toggle-item">
<input type="checkbox" id="fuzzyToggle" checked />
<label for="fuzzyToggle">Fuzzy</label>
</div>
<div class="toggle-item">
<input type="checkbox" id="phraseToggle" checked />
<label for="phraseToggle">Phrase</label>
</div>
<div class="toggle-item">
<input type="checkbox" id="queryStringToggle" />
<label for="queryStringToggle">Query string mode</label>
</div>
</div>
</fieldset>
<div class="summary-row">
@ -84,7 +93,7 @@
</fieldset>
</div>
<div class="summary-right">
<fieldset style="height: 100%;">
<fieldset>
<legend>Timeline</legend>
<div id="frequencySummary" style="font-size: 11px; margin-bottom: 8px;"></div>
<div id="frequencyChart"></div>

View File

@ -119,6 +119,61 @@ body.dimmed {
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 {
position: absolute;
margin-top: 2px;
@ -165,16 +220,18 @@ body.dimmed {
/* Results styling */
#results .item {
border-bottom: 1px solid ButtonShadow;
padding: 12px 0;
background: Window;
border: 2px solid #919b9c;
padding: 12px;
margin-bottom: 8px;
max-width: 100%;
overflow: hidden;
word-wrap: break-word;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
}
#results .item:last-child {
border-bottom: none;
margin-bottom: 0;
}
#results .item strong {
@ -186,6 +243,7 @@ body.dimmed {
.window-body {
max-width: 100%;
overflow-x: hidden;
margin: 1rem;
}
/* Badges */
@ -209,6 +267,15 @@ body.dimmed {
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 {
background: Window;
@ -255,8 +322,7 @@ mark {
margin-top: 12px;
padding: 8px;
background: Window;
border: 2px solid;
border-color: ButtonShadow ButtonHighlight ButtonHighlight ButtonShadow;
border: 2px solid #919b9c;
max-height: 400px;
overflow-y: auto;
font-size: 11px;
@ -312,27 +378,9 @@ mark {
line-height: 1.4;
}
.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-header,
.transcript-close {
cursor: pointer;
font-size: 16px;
padding: 0 4px;
font-weight: bold;
}
.transcript-close:hover {
background: Highlight;
color: HighlightText;
display: none;
}
/* Chart styling */