diff --git a/crawler.go b/crawler.go
index f0338b8..65cb636 100644
--- a/crawler.go
+++ b/crawler.go
@@ -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 != "" {
diff --git a/dashboard.go b/dashboard.go
index 0099ffe..5cc8732 100644
--- a/dashboard.go
+++ b/dashboard.go
@@ -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,7 +1221,8 @@ 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")
- show := r.URL.Query().Get("show") // "feeds" or "domains"
+ languages := r.URL.Query().Get("languages") // comma-separated list
+ show := r.URL.Query().Get("show") // "feeds" or "domains"
limit := 100
offset := 0
@@ -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 = `
1440.news Feed Crawler
-
+
1440.news Feed Crawler
@@ -2356,6 +2470,7 @@ const dashboardHTML = `
+
|
@@ -2365,6 +2480,9 @@ const dashboardHTML = `
+
diff --git a/feed.go b/feed.go
index de55287..810643d 100644
--- a/feed.go
+++ b/feed.go
@@ -178,9 +178,14 @@ 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 == "" {
- publishStatus = "held"
+ if feed.Language == "" {
+ publishStatus = "deny"
+ } else {
+ publishStatus = "held"
+ }
}
_, err := c.db.Exec(`
diff --git a/publisher.go b/publisher.go
index bb625e1..b00b950 100644
--- a/publisher.go
+++ b/publisher.go
@@ -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)
}
}
diff --git a/static/dashboard.js b/static/dashboard.js
index 2501cb0..947f671 100644
--- a/static/dashboard.js
+++ b/static/dashboard.js
@@ -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('domains:' + escapeHtml(currentFilters.domainStatus) + '');
}
+ if (selectedLanguages.size > 0) {
+ parts.push('lang:' + escapeHtml(Array.from(selectedLanguages).join(',')) + ' ✕');
+ }
breadcrumb.innerHTML = parts.join(' / ');
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 = '';
+ 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 = '
';
html += '
';
html += '
' + escapeHtml(d.host) + '';
html += '
↗';
+ if (isDenied) {
+ html += '
DENIED';
+ }
html += '
' + commaFormat(d.feed_count) + ' feeds';
- html += '
';
+ if (isDenied) {
+ html += '
';
+ } else {
+ html += '
';
+ }
+ html += '
';
html += '
';
if (d.status === 'error' && d.last_error) {
html += '
Error: ' + escapeHtml(d.last_error) + '
';
@@ -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 `
`;
+ }).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);