Add rich text links, language filter, and domain deny feature

- Use labeled links (Article · Audio) instead of raw URLs in posts
- Add language filter dropdown to dashboard with toggle selection
- Auto-deny feeds with no language on discovery
- Add deny/undeny buttons for domains to block crawling
- Denied domains set feeds to dead status, preventing future checks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
primal
2026-01-29 12:36:58 -05:00
parent 283a221efd
commit 254b751799
5 changed files with 366 additions and 61 deletions
+3
View File
@@ -250,7 +250,10 @@ func (c *Crawler) StartPublishLoop() {
itemToPublish := item
if item.Link != "" {
if shortURL, err := c.GetShortURLForPost(item.Link, &item.ID, item.FeedURL); err == nil {
fmt.Printf("Publish: shortened %s -> %s\n", item.Link[:min(40, len(item.Link))], shortURL)
itemToPublish.Link = shortURL
} else {
fmt.Printf("Publish: short URL failed for %s: %v\n", item.Link[:min(40, len(item.Link))], err)
}
}
if item.Enclosure != nil && item.Enclosure.URL != "" {
+142 -24
View File
@@ -240,11 +240,6 @@ func (c *Crawler) StartDashboard(addr string) error {
c.handleDashboard(w, r)
})
// URL shortener redirect handler (legacy /r/ path)
http.HandleFunc("/r/", func(w http.ResponseWriter, r *http.Request) {
c.handleRedirect(w, r)
})
// Root handler for url.1440.news short URLs
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host := r.Host
@@ -350,6 +345,15 @@ func (c *Crawler) StartDashboard(addr string) error {
http.HandleFunc("/api/updateProfile", func(w http.ResponseWriter, r *http.Request) {
c.handleAPIUpdateProfile(w, r)
})
http.HandleFunc("/api/languages", func(w http.ResponseWriter, r *http.Request) {
c.handleAPILanguages(w, r)
})
http.HandleFunc("/api/denyDomain", func(w http.ResponseWriter, r *http.Request) {
c.handleAPIDenyDomain(w, r)
})
http.HandleFunc("/api/undenyDomain", func(w http.ResponseWriter, r *http.Request) {
c.handleAPIUndenyDomain(w, r)
})
http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r)
})
@@ -1217,6 +1221,7 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) {
domain := r.URL.Query().Get("domain")
feedStatus := r.URL.Query().Get("feedStatus")
domainStatus := r.URL.Query().Get("domainStatus")
languages := r.URL.Query().Get("languages") // comma-separated list
show := r.URL.Query().Get("show") // "feeds" or "domains"
limit := 100
@@ -1231,9 +1236,20 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) {
fmt.Sscanf(o, "%d", &offset)
}
// Parse languages into slice
var langList []string
if languages != "" {
for _, lang := range strings.Split(languages, ",") {
lang = strings.TrimSpace(lang)
if lang != "" {
langList = append(langList, lang)
}
}
}
// Determine what to show based on filters
if show == "" {
if feedStatus != "" || domain != "" {
if feedStatus != "" || domain != "" || len(langList) > 0 {
show = "feeds"
} else {
show = "domains"
@@ -1241,7 +1257,7 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) {
}
if show == "feeds" {
c.filterFeeds(w, tld, domain, feedStatus, limit, offset)
c.filterFeeds(w, tld, domain, feedStatus, langList, limit, offset)
} else {
c.filterDomains(w, tld, domainStatus, limit, offset)
}
@@ -1308,11 +1324,11 @@ func (c *Crawler) filterDomains(w http.ResponseWriter, tld, status string, limit
})
}
func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, limit, offset int) {
func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, languages []string, limit, offset int) {
var args []interface{}
argNum := 1
query := `
SELECT url, title, type, category, source_host, tld, status, error_count, last_error, item_count
SELECT url, title, type, category, source_host, tld, status, error_count, last_error, item_count, language
FROM feeds
WHERE 1=1`
@@ -1331,6 +1347,20 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string,
args = append(args, status)
argNum++
}
if len(languages) > 0 {
// Build IN clause for languages, handling 'unknown' as empty string
placeholders := make([]string, len(languages))
for i, lang := range languages {
placeholders[i] = fmt.Sprintf("$%d", argNum)
if lang == "unknown" {
args = append(args, "")
} else {
args = append(args, lang)
}
argNum++
}
query += fmt.Sprintf(" AND COALESCE(language, '') IN (%s)", strings.Join(placeholders, ","))
}
query += fmt.Sprintf(" ORDER BY url ASC LIMIT $%d OFFSET $%d", argNum, argNum+1)
args = append(args, limit, offset)
@@ -1353,14 +1383,15 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string,
ErrorCount int `json:"error_count,omitempty"`
LastError string `json:"last_error,omitempty"`
ItemCount int `json:"item_count,omitempty"`
Language string `json:"language,omitempty"`
}
var feeds []FeedInfo
for rows.Next() {
var f FeedInfo
var title, category, sourceHost, tldVal, lastError *string
var title, category, sourceHost, tldVal, lastError, language *string
var errorCount, itemCount *int
if err := rows.Scan(&f.URL, &title, &f.Type, &category, &sourceHost, &tldVal, &f.Status, &errorCount, &lastError, &itemCount); err != nil {
if err := rows.Scan(&f.URL, &title, &f.Type, &category, &sourceHost, &tldVal, &f.Status, &errorCount, &lastError, &itemCount, &language); err != nil {
continue
}
f.Title = StringValue(title)
@@ -1378,6 +1409,7 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string,
if itemCount != nil {
f.ItemCount = *itemCount
}
f.Language = StringValue(language)
feeds = append(feeds, f)
}
@@ -2258,19 +2290,9 @@ func (c *Crawler) handleAPIStats(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(stats)
}
// handleRedirect handles short URL redirects
// Supports both /r/{code} (legacy) and /{code} (for url.1440.news)
// handleRedirect handles short URL redirects for url.1440.news
func (c *Crawler) handleRedirect(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
var code string
// Support both /r/{code} and /{code} formats
if strings.HasPrefix(path, "/r/") {
code = strings.TrimPrefix(path, "/r/")
} else {
code = strings.TrimPrefix(path, "/")
}
code := strings.TrimPrefix(r.URL.Path, "/")
if code == "" {
http.NotFound(w, r)
return
@@ -2294,13 +2316,105 @@ func (c *Crawler) handleRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, shortURL.OriginalURL, http.StatusFound)
}
// handleAPILanguages returns distinct languages with counts
func (c *Crawler) handleAPILanguages(w http.ResponseWriter, r *http.Request) {
rows, err := c.db.Query(`
SELECT COALESCE(NULLIF(language, ''), 'unknown') as lang, COUNT(*) as cnt
FROM feeds
GROUP BY lang
ORDER BY cnt DESC
`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
type LangInfo struct {
Language string `json:"language"`
Count int `json:"count"`
}
var languages []LangInfo
for rows.Next() {
var l LangInfo
if err := rows.Scan(&l.Language, &l.Count); err != nil {
continue
}
languages = append(languages, l)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(languages)
}
// handleAPIDenyDomain denies a domain and all its feeds
func (c *Crawler) handleAPIDenyDomain(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
if host == "" {
http.Error(w, "host parameter required", http.StatusBadRequest)
return
}
// Update domain status to denied
_, err := c.db.Exec(`UPDATE domains SET status = 'denied' WHERE host = $1`, host)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Deny all feeds from this domain
feedsAffected, err := c.db.Exec(`UPDATE feeds SET publish_status = 'deny', status = 'dead' WHERE source_host = $1`, host)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"host": host,
"feeds_denied": feedsAffected,
})
}
// handleAPIUndenyDomain removes denied status from a domain
func (c *Crawler) handleAPIUndenyDomain(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
if host == "" {
http.Error(w, "host parameter required", http.StatusBadRequest)
return
}
// Update domain status back to checked
_, err := c.db.Exec(`UPDATE domains SET status = 'checked' WHERE host = $1 AND status = 'denied'`, host)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Restore feeds to held status and active
feedsRestored, err := c.db.Exec(`UPDATE feeds SET publish_status = 'held', status = 'active' WHERE source_host = $1 AND status = 'dead'`, host)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"host": host,
"feeds_restored": feedsRestored,
})
}
const dashboardHTML = `<!DOCTYPE html>
<html>
<head>
<title>1440.news Feed Crawler</title>
<meta charset="utf-8">
<link rel="stylesheet" href="/static/dashboard.css">
<script src="/static/dashboard.js?v=17"></script>
<script src="/static/dashboard.js?v=19"></script>
</head>
<body>
<h1>1440.news Feed Crawler</h1>
@@ -2356,6 +2470,7 @@ const dashboardHTML = `<!DOCTYPE html>
<div id="commandButtons" style="margin-bottom: 10px;">
<button class="cmd-btn" data-cmd="/tlds">tlds</button>
<button class="cmd-btn" data-cmd="/publish">publish</button>
<button class="cmd-btn" id="langBtn">lang</button>
<span style="color: #333; margin: 0 4px;">|</span>
<button class="cmd-btn" data-cmd="/domains unchecked">domains:unchecked</button>
<button class="cmd-btn" data-cmd="/domains checked">domains:checked</button>
@@ -2365,6 +2480,9 @@ const dashboardHTML = `<!DOCTYPE html>
<button class="cmd-btn" data-cmd="/feeds error">feeds:error</button>
<button class="cmd-btn" data-cmd="/feeds dead">feeds:dead</button>
</div>
<div id="langDropdown" style="display: none; margin-bottom: 10px; padding: 10px; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; max-height: 200px; overflow-y: auto;">
<div id="langList"></div>
</div>
<input type="text" id="commandInput" value="/help"
style="width: 100%; padding: 12px; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 14px; font-family: monospace;">
</div>
+5
View File
@@ -178,10 +178,15 @@ type Feed struct {
// saveFeed stores a feed in PostgreSQL
func (c *Crawler) saveFeed(feed *Feed) error {
// Default publishStatus to "held" if not set
// Auto-deny feeds with no language specified
publishStatus := feed.PublishStatus
if publishStatus == "" {
if feed.Language == "" {
publishStatus = "deny"
} else {
publishStatus = "held"
}
}
_, err := c.db.Exec(`
INSERT INTO feeds (
+59 -20
View File
@@ -316,30 +316,61 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
}
}
// Build post text: title + all links
// Build post text: title + link labels
// Bluesky has 300 grapheme limit - use rune count as approximation
const maxGraphemes = 295 // Leave some margin
// Calculate space needed for URLs (in runes)
urlSpace := 0
for _, u := range allURLs {
urlSpace += utf8.RuneCountInString(u) + 2 // +2 for \n\n
// Create labeled links: "Article", "Audio", etc.
type labeledLink struct {
Label string
URL string
}
var links []labeledLink
for i, u := range allURLs {
if i == 0 {
// First URL is the article link
links = append(links, labeledLink{Label: "Article", URL: u})
} else if item.Enclosure != nil && u == item.Enclosure.URL {
// Enclosure URL - label based on type
encType := strings.ToLower(item.Enclosure.Type)
if strings.HasPrefix(encType, "audio/") {
links = append(links, labeledLink{Label: "Audio", URL: u})
} else if strings.HasPrefix(encType, "video/") {
links = append(links, labeledLink{Label: "Video", URL: u})
} else {
links = append(links, labeledLink{Label: "Media", URL: u})
}
} else if strings.Contains(u, "news.ycombinator.com") {
links = append(links, labeledLink{Label: "Comments", URL: u})
} else {
links = append(links, labeledLink{Label: "Link", URL: u})
}
}
// Calculate space needed for labels (in runes)
// Format: "Article · Audio" or just "Article"
labelSpace := 0
for i, link := range links {
labelSpace += utf8.RuneCountInString(link.Label)
if i > 0 {
labelSpace += 3 // " · " separator
}
}
labelSpace += 2 // \n\n before labels
// Truncate title if needed
title := item.Title
titleRunes := utf8.RuneCountInString(title)
maxTitleRunes := maxGraphemes - urlSpace - 3 // -3 for "..."
maxTitleRunes := maxGraphemes - labelSpace - 3 // -3 for "..."
if titleRunes+urlSpace > maxGraphemes {
if titleRunes+labelSpace > maxGraphemes {
if maxTitleRunes > 10 {
// Truncate title to fit
runes := []rune(title)
if len(runes) > maxTitleRunes {
title = string(runes[:maxTitleRunes]) + "..."
}
} else {
// Title too long even with minimal space - just truncate hard
runes := []rune(title)
if len(runes) > 50 {
title = string(runes[:50]) + "..."
@@ -347,12 +378,17 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
}
}
// Build final text
// Build final text with labels
var textBuilder strings.Builder
textBuilder.WriteString(title)
for _, u := range allURLs {
if len(links) > 0 {
textBuilder.WriteString("\n\n")
textBuilder.WriteString(u)
for i, link := range links {
if i > 0 {
textBuilder.WriteString(" · ")
}
textBuilder.WriteString(link.Label)
}
}
text := textBuilder.String()
@@ -368,13 +404,15 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
CreatedAt: createdAt.Format(time.RFC3339),
}
// Add facets for all URLs
for _, u := range allURLs {
linkStart := strings.Index(text, u)
if linkStart >= 0 {
// Use byte positions (for UTF-8 this matters)
byteStart := len(text[:linkStart])
byteEnd := byteStart + len(u)
// Add facets for labeled links
// Find each label in the text and create a facet linking to its URL
searchPos := len(title) + 2 // Start after title + \n\n
for _, link := range links {
labelStart := strings.Index(text[searchPos:], link.Label)
if labelStart >= 0 {
labelStart += searchPos
byteStart := len(text[:labelStart])
byteEnd := byteStart + len(link.Label)
post.Facets = append(post.Facets, BskyFacet{
Index: BskyByteSlice{
@@ -384,10 +422,11 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error)
Features: []BskyFeature{
{
Type: "app.bsky.richtext.facet#link",
URI: u,
URI: link.URL,
},
},
})
searchPos = labelStart + len(link.Label)
}
}
+155 -15
View File
@@ -14,6 +14,8 @@ function initDashboard() {
let currentFilters = {};
let infiniteScrollState = null;
let isLoadingMore = false;
let availableLanguages = [];
let selectedLanguages = new Set();
// Update command input to reflect current filters
function updateCommandInput() {
@@ -22,6 +24,7 @@ function initDashboard() {
if (currentFilters.domain) parts.push('domain:' + currentFilters.domain);
if (currentFilters.feedStatus) parts.push('feeds:' + currentFilters.feedStatus);
if (currentFilters.domainStatus) parts.push('domains:' + currentFilters.domainStatus);
if (selectedLanguages.size > 0) parts.push('lang:' + Array.from(selectedLanguages).join(','));
document.getElementById('commandInput').value = parts.length > 0 ? parts.join(' ') : '/help';
}
@@ -74,6 +77,9 @@ function initDashboard() {
if (currentFilters.domainStatus) {
parts.push('<span style="color: #f90;">domains:' + escapeHtml(currentFilters.domainStatus) + '</span>');
}
if (selectedLanguages.size > 0) {
parts.push('<span class="bc-item" data-action="clearLang" style="cursor: pointer; color: #f0f;">lang:' + escapeHtml(Array.from(selectedLanguages).join(',')) + ' ✕</span>');
}
breadcrumb.innerHTML = parts.join(' <span style="color: #666;">/</span> ');
breadcrumb.style.display = parts.length > 1 ? 'block' : 'none';
@@ -84,6 +90,8 @@ function initDashboard() {
const action = el.dataset.action;
if (action === 'home') {
currentFilters = {};
selectedLanguages.clear();
updateLangButton();
showHelp();
} else if (action === 'tld') {
delete currentFilters.domain;
@@ -93,6 +101,11 @@ function initDashboard() {
} else if (action === 'domain') {
delete currentFilters.feedStatus;
executeFilters();
} else if (action === 'clearLang') {
selectedLanguages.clear();
updateLangButton();
updateLangDropdown();
executeFilters();
}
});
});
@@ -123,12 +136,22 @@ function initDashboard() {
// Render helpers
function renderDomainRow(d) {
let html = '<div class="domain-row-cmd" data-host="' + escapeHtml(d.host) + '" data-tld="' + escapeHtml(d.tld || '') + '" style="padding: 8px 10px; border-bottom: 1px solid #202020;">';
const isDenied = d.status === 'denied';
const rowStyle = isDenied ? 'padding: 8px 10px; border-bottom: 1px solid #202020; opacity: 0.5;' : 'padding: 8px 10px; border-bottom: 1px solid #202020;';
let html = '<div class="domain-row-cmd" data-host="' + escapeHtml(d.host) + '" data-tld="' + escapeHtml(d.tld || '') + '" data-status="' + escapeHtml(d.status) + '" style="' + rowStyle + '">';
html += '<div style="display: flex; align-items: center;">';
html += '<span class="domain-name" style="color: #0af; cursor: pointer;">' + escapeHtml(d.host) + '</span>';
html += '<a href="https://' + escapeHtml(d.host) + '" target="_blank" class="visit-link" style="color: #666; margin-left: 8px; font-size: 12px; text-decoration: none;" title="Visit site">↗</a>';
if (isDenied) {
html += '<span style="color: #f66; margin-left: 8px; font-size: 11px;">DENIED</span>';
}
html += '<span style="color: #666; margin-left: auto;">' + commaFormat(d.feed_count) + ' feeds</span>';
html += '<button class="revisit-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 10px; padding: 2px 8px; font-size: 11px; background: #252525; border: 1px solid #444; border-radius: 3px; color: #888; cursor: pointer;">revisit</button>';
if (isDenied) {
html += '<button class="undeny-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 10px; padding: 2px 8px; font-size: 11px; background: #030; border: 1px solid #0a0; border-radius: 3px; color: #0f0; cursor: pointer;">undeny</button>';
} else {
html += '<button class="deny-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 10px; padding: 2px 8px; font-size: 11px; background: #300; border: 1px solid #a00; border-radius: 3px; color: #f66; cursor: pointer;">deny</button>';
}
html += '<button class="revisit-btn" data-host="' + escapeHtml(d.host) + '" style="margin-left: 6px; padding: 2px 8px; font-size: 11px; background: #252525; border: 1px solid #444; border-radius: 3px; color: #888; cursor: pointer;">revisit</button>';
html += '</div>';
if (d.status === 'error' && d.last_error) {
html += '<div style="color: #f66; font-size: 0.8em; margin-top: 4px;">Error: ' + escapeHtml(d.last_error) + '</div>';
@@ -181,19 +204,59 @@ function initDashboard() {
});
el.addEventListener('mouseenter', () => el.style.background = '#1a1a1a');
el.addEventListener('mouseleave', () => el.style.background = 'transparent');
const btn = el.querySelector('.revisit-btn');
if (btn) {
btn.addEventListener('click', async (e) => {
const revisitBtn = el.querySelector('.revisit-btn');
if (revisitBtn) {
revisitBtn.addEventListener('click', async (e) => {
e.stopPropagation();
btn.disabled = true;
btn.textContent = '...';
revisitBtn.disabled = true;
revisitBtn.textContent = '...';
try {
await fetch('/api/revisitDomain?host=' + encodeURIComponent(btn.dataset.host));
btn.textContent = 'queued';
btn.style.color = '#0a0';
await fetch('/api/revisitDomain?host=' + encodeURIComponent(revisitBtn.dataset.host));
revisitBtn.textContent = 'queued';
revisitBtn.style.color = '#0a0';
} catch (err) {
btn.textContent = 'error';
btn.style.color = '#f66';
revisitBtn.textContent = 'error';
revisitBtn.style.color = '#f66';
}
});
}
const denyBtn = el.querySelector('.deny-btn');
if (denyBtn) {
denyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
denyBtn.disabled = true;
denyBtn.textContent = '...';
try {
const resp = await fetch('/api/denyDomain?host=' + encodeURIComponent(denyBtn.dataset.host));
const result = await resp.json();
denyBtn.textContent = 'denied (' + result.feeds_denied + ')';
denyBtn.style.background = '#000';
el.style.opacity = '0.5';
// Update status in data
el.dataset.status = 'denied';
} catch (err) {
denyBtn.textContent = 'error';
denyBtn.style.color = '#f66';
}
});
}
const undenyBtn = el.querySelector('.undeny-btn');
if (undenyBtn) {
undenyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
undenyBtn.disabled = true;
undenyBtn.textContent = '...';
try {
const resp = await fetch('/api/undenyDomain?host=' + encodeURIComponent(undenyBtn.dataset.host));
const result = await resp.json();
undenyBtn.textContent = 'restored (' + result.feeds_restored + ')';
undenyBtn.style.background = '#000';
el.style.opacity = '1';
// Update status in data
el.dataset.status = 'checked';
} catch (err) {
undenyBtn.textContent = 'error';
undenyBtn.style.color = '#f66';
}
});
}
@@ -272,10 +335,11 @@ function initDashboard() {
const output = document.getElementById('output');
// Determine what to show
const showFeeds = currentFilters.feedStatus || currentFilters.domain;
const hasLang = selectedLanguages.size > 0;
const showFeeds = currentFilters.feedStatus || currentFilters.domain || hasLang;
const showDomains = currentFilters.domainStatus || (!showFeeds && currentFilters.tld);
if (!currentFilters.tld && !currentFilters.domain && !currentFilters.feedStatus && !currentFilters.domainStatus) {
if (!currentFilters.tld && !currentFilters.domain && !currentFilters.feedStatus && !currentFilters.domainStatus && !hasLang) {
showHelp();
return;
}
@@ -286,6 +350,7 @@ function initDashboard() {
if (currentFilters.domain) params.set('domain', currentFilters.domain);
if (currentFilters.feedStatus) params.set('feedStatus', currentFilters.feedStatus);
if (currentFilters.domainStatus) params.set('domainStatus', currentFilters.domainStatus);
if (hasLang) params.set('languages', Array.from(selectedLanguages).join(','));
if (showFeeds) params.set('show', 'feeds');
else if (showDomains) params.set('show', 'domains');
@@ -635,12 +700,85 @@ function initDashboard() {
}
}
// Language dropdown functions
async function loadLanguages() {
try {
const response = await fetch('/api/languages');
availableLanguages = await response.json();
updateLangDropdown();
} catch (err) {
console.error('Failed to load languages:', err);
}
}
function updateLangDropdown() {
const langList = document.getElementById('langList');
if (!langList) return;
langList.innerHTML = availableLanguages.map(l => {
const checked = selectedLanguages.has(l.language) ? 'checked' : '';
const displayLang = l.language === 'unknown' ? '(unknown)' : l.language;
return `<label style="display: inline-block; margin: 2px 8px 2px 0; cursor: pointer; color: ${checked ? '#0f0' : '#aaa'};">
<input type="checkbox" data-lang="${escapeHtml(l.language)}" ${checked} style="margin-right: 4px; cursor: pointer;">
${escapeHtml(displayLang)} <span style="color: #666; font-size: 11px;">(${commaFormat(l.count)})</span>
</label>`;
}).join('');
// Add change handlers
langList.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
const lang = cb.dataset.lang;
if (cb.checked) {
selectedLanguages.add(lang);
} else {
selectedLanguages.delete(lang);
}
updateLangButton();
updateLangDropdown();
executeFilters();
});
});
}
function updateLangButton() {
const btn = document.getElementById('langBtn');
if (!btn) return;
if (selectedLanguages.size > 0) {
btn.textContent = 'lang (' + selectedLanguages.size + ')';
btn.style.background = '#030';
btn.style.borderColor = '#0f0';
} else {
btn.textContent = 'lang';
btn.style.background = '';
btn.style.borderColor = '';
}
}
function setupLangDropdown() {
const langBtn = document.getElementById('langBtn');
const langDropdown = document.getElementById('langDropdown');
if (!langBtn || !langDropdown) return;
langBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = langDropdown.style.display !== 'none';
langDropdown.style.display = isVisible ? 'none' : 'block';
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!langDropdown.contains(e.target) && e.target !== langBtn) {
langDropdown.style.display = 'none';
}
});
}
// Setup command input
function setupCommandInput() {
const input = document.getElementById('commandInput');
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') processCommand(input.value); });
input.addEventListener('focus', () => input.select());
document.querySelectorAll('.cmd-btn').forEach(btn => {
document.querySelectorAll('.cmd-btn[data-cmd]').forEach(btn => {
btn.addEventListener('click', () => {
const cmd = btn.dataset.cmd;
// Special commands that reset filters
@@ -692,6 +830,8 @@ function initDashboard() {
// Initialize
setupCommandInput();
setupLangDropdown();
loadLanguages();
showHelp();
updateStats();
setInterval(updateStats, 1000);