mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-04 17:59:36 +00:00
Resolve the feeds.html conflict by keeping the branch's XSS-safe DOM construction of the feed-details view and re-adding dev's per-feed and reset-all error buttons via addEventListener instead of inline onclick, since /feeds is a nonce-CSP page where inline handlers are blocked. Update tests/test_feed_manager_extended.py::TestPollFeedPosting to patch the SafeUrlPolicy.validate_async path (this branch's SSRF refactor) rather than the removed module-level validate_external_url symbol, matching the idiom already used throughout that test file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1229 lines
55 KiB
HTML
1229 lines
55 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Feed Management - MeshCore Bot{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<h1 class="mb-4">
|
|
<i class="fas fa-rss"></i> Feed Management
|
|
</h1>
|
|
|
|
<!-- Statistics Cards -->
|
|
<div class="row mb-4">
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Total Subscriptions</h5>
|
|
<h2 id="total-subscriptions">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Active Feeds</h5>
|
|
<h2 id="active-feeds">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Items (24h)</h5>
|
|
<h2 id="items-24h">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Active Errors</h5>
|
|
<h2 id="active-errors">-</h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Feed List -->
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="mb-0">Feed Subscriptions</h5>
|
|
<div>
|
|
<button type="button" id="resetAllErrorsBtn" class="btn btn-outline-secondary me-2" title="Clear recorded errors for all feeds">
|
|
<i class="fas fa-eraser"></i> Reset Errors
|
|
</button>
|
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addFeedModal">
|
|
<i class="fas fa-plus"></i> Add Feed
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover" id="feedsTable">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Channel</th>
|
|
<th>Status</th>
|
|
<th>Last Check</th>
|
|
<th>Items</th>
|
|
<th>Errors</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="feedsTableBody">
|
|
<tr>
|
|
<td colspan="9" class="text-center">Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add/Edit Feed Modal -->
|
|
<div class="modal fade" id="addFeedModal" tabindex="-1">
|
|
<div class="modal-dialog modal-xl">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="feedModalTitle">Add Feed Subscription</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body" style="max-height: calc(100vh - 200px); overflow-y: auto;">
|
|
<form id="addFeedForm">
|
|
<input type="hidden" id="feedId" name="feed_id">
|
|
|
|
<!-- Basic Settings Row -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-3">
|
|
<label class="form-label">Feed Type</label>
|
|
<select class="form-select" id="feedType" name="feed_type" required>
|
|
<option value="rss">RSS Feed</option>
|
|
<option value="api">API Feed</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label">Feed URL</label>
|
|
<input type="url" class="form-control" id="feedUrl" name="feed_url" required>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Check Interval (seconds)</label>
|
|
<input type="number" class="form-control" id="checkInterval" name="check_interval_seconds" value="300" min="60">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Channel and Name Row -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Channel</label>
|
|
<div class="input-group">
|
|
<select class="form-select" id="channelSelect" name="channel_name" required>
|
|
<option value="">Select channel...</option>
|
|
</select>
|
|
<button type="button" class="btn btn-outline-secondary" id="createChannelBtn">
|
|
<i class="fas fa-plus"></i> New
|
|
</button>
|
|
</div>
|
|
<div id="newChannelGroup" style="display: none;" class="mt-2">
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control" id="newChannelName" placeholder="#channelname" autocomplete="off">
|
|
<button type="button" class="btn btn-primary" id="submitNewChannelBtn" title="Create channel on the bot (same as Radio page)">
|
|
<i class="fas fa-check"></i> Create channel
|
|
</button>
|
|
</div>
|
|
<small class="form-text text-muted">Creates a hashtag channel on the bot. <code>#</code> is added if missing.</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Feed Name <small class="text-muted">(Optional)</small></label>
|
|
<input type="text" class="form-control" id="feedName" name="feed_name" placeholder="My Feed">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label">Send Interval (seconds)</label>
|
|
<input type="number" class="form-control" id="messageSendInterval" name="message_send_interval_seconds" value="2.0" min="0.5" step="0.1">
|
|
<small class="form-text text-muted">Time between messages</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Output Format Row -->
|
|
<div class="row mb-3">
|
|
<div class="col-md-8">
|
|
<label class="form-label">
|
|
Output Format
|
|
<button type="button" class="btn btn-sm btn-link p-0 ms-2" data-bs-toggle="collapse" data-bs-target="#formatHelp" aria-expanded="false">
|
|
<i class="fas fa-question-circle"></i> Help
|
|
</button>
|
|
</label>
|
|
<textarea class="form-control font-monospace" id="outputFormat" name="output_format" rows="5" placeholder="{emoji} {body|truncate:100} - {date}\n{link|truncate:50}"></textarea>
|
|
<div class="collapse mt-2" id="formatHelp">
|
|
<div class="card card-body bg-light small" style="background-color: var(--bg-secondary) !important; color: var(--text-color) !important;">
|
|
<strong>Placeholders:</strong> {title}, {body}, {date}, {link}, {emoji}<br>
|
|
<strong>API Fields:</strong> {raw.field} or {raw.nested.field}<br>
|
|
<strong>Shortening:</strong> {field|truncate:N}, {field|word_wrap:N}, {field|first_words:N}<br>
|
|
<strong>Regex:</strong> {field|regex:pattern} or {field|regex:pattern:group}<br>
|
|
<strong>Conditional:</strong> {field|if_regex:pattern:then:else}<br>
|
|
<strong>Switch:</strong> {field|switch:value1:result1:value2:result2:...:default}<br>
|
|
<strong>Extract & Check:</strong> {field|regex_cond:extract_pattern:check_pattern:then:group}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label"> </label>
|
|
<div class="d-grid">
|
|
<button type="button" class="btn btn-outline-primary" id="previewBtn" title="Preview format with live feed">
|
|
<i class="fas fa-eye"></i> Preview Format
|
|
</button>
|
|
</div>
|
|
<div id="previewResults" style="display: none;" class="mt-3">
|
|
<div class="card">
|
|
<div class="card-header py-2">
|
|
<h6 class="mb-0 small">Preview (first 3 items)</h6>
|
|
</div>
|
|
<div class="card-body p-2" id="previewContent" style="max-height: 200px; overflow-y: auto; font-size: 0.85rem;">
|
|
<div class="spinner-border spinner-border-sm" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Options Accordion -->
|
|
<div class="accordion mb-3" id="advancedOptions">
|
|
<div class="accordion-item" id="apiConfigGroup" style="display: none;">
|
|
<h2 class="accordion-header">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#apiConfigCollapse">
|
|
<i class="fas fa-cog me-2"></i> API Configuration
|
|
</button>
|
|
</h2>
|
|
<div id="apiConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
|
|
<div class="accordion-body">
|
|
<textarea class="form-control font-monospace" id="apiConfig" name="api_config" rows="8" placeholder="{
|
|
"method": "GET",
|
|
"headers": {},
|
|
"params": {
|
|
"AccessCode": "YOUR_ACCESS_CODE"
|
|
},
|
|
"response_parser": {
|
|
"items_path": "",
|
|
"id_field": "AlertID",
|
|
"title_field": "HeadlineDescription",
|
|
"description_field": "ExtendedDescription",
|
|
"timestamp_field": "LastUpdatedTime",
|
|
"emoji_field": "emoji"
|
|
}
|
|
}"></textarea>
|
|
<small class="form-text text-muted mt-2 d-block">
|
|
Configure API request and parsing. Use <code>params</code> for query parameters (e.g., AccessCode for WSDOT) or <code>headers</code> for HTTP headers.
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#filterConfigCollapse">
|
|
<i class="fas fa-filter me-2"></i> Filter Configuration <small class="text-muted ms-2">(Optional)</small>
|
|
</button>
|
|
</h2>
|
|
<div id="filterConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
|
|
<div class="accordion-body">
|
|
<textarea class="form-control font-monospace" id="filterConfig" name="filter_config" rows="8" placeholder="{
|
|
"conditions": [
|
|
{
|
|
"field": "raw.Priority",
|
|
"operator": "in",
|
|
"values": ["highest", "high"]
|
|
},
|
|
{
|
|
"field": "raw.EventStatus",
|
|
"operator": "equals",
|
|
"value": "open"
|
|
}
|
|
],
|
|
"logic": "AND"
|
|
}"></textarea>
|
|
<small class="form-text text-muted mt-2 d-block">
|
|
Only send items that match these conditions. Leave empty to send all items.<br>
|
|
<strong>Operators:</strong> equals, not_equals, in, not_in, matches (regex), not_matches, contains, not_contains, within_days, within_weeks (see docs/FEEDS.md)<br>
|
|
<strong>Logic:</strong> AND (all conditions) or OR (any condition)<br>
|
|
<strong>Fields:</strong> Use raw.field or raw.nested.field for API fields; use <code>published</code> for RSS time filters
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sortConfigCollapse">
|
|
<i class="fas fa-sort me-2"></i> Sort Configuration <small class="text-muted ms-2">(Optional)</small>
|
|
</button>
|
|
</h2>
|
|
<div id="sortConfigCollapse" class="accordion-collapse collapse" data-bs-parent="#advancedOptions">
|
|
<div class="accordion-body">
|
|
<textarea class="form-control font-monospace" id="sortConfig" name="sort_config" rows="4" placeholder="{
|
|
"field": "raw.LastUpdatedTime",
|
|
"order": "desc"
|
|
}"></textarea>
|
|
<small class="form-text text-muted mt-2 d-block">
|
|
Sort items before processing. Leave empty to use default order (oldest first).<br>
|
|
<strong>Field:</strong> Field path to sort by (e.g., raw.LastUpdatedTime, raw.Priority, published)<br>
|
|
<strong>Order:</strong> asc (ascending) or desc (descending)<br>
|
|
<strong>Note:</strong> Supports Microsoft date format /Date(timestamp-offset)/ (e.g., WSDOT API)
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" id="saveFeedBtn">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Feed Details Modal -->
|
|
<div class="modal fade" id="feedDetailsModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Feed Details</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body" id="feedDetailsContent">
|
|
Loading...
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
class FeedManager {
|
|
constructor() {
|
|
this.feeds = [];
|
|
this.channels = [];
|
|
this.maxChannels = 40;
|
|
this.initialize();
|
|
}
|
|
|
|
async initialize() {
|
|
await this.loadChannelStats();
|
|
await this.loadChannels();
|
|
await this.loadFeeds();
|
|
await this.loadStatistics();
|
|
this.setupEventHandlers();
|
|
|
|
// Auto-refresh every 30 seconds
|
|
setInterval(() => this.loadFeeds(), 30000);
|
|
setInterval(() => this.loadStatistics(), 60000);
|
|
}
|
|
|
|
async loadChannelStats() {
|
|
try {
|
|
const response = await fetch('/api/channels/stats');
|
|
const data = await response.json();
|
|
this.maxChannels = data.max_channels || 40;
|
|
} catch (error) {
|
|
console.error('Error loading channel stats:', error);
|
|
}
|
|
}
|
|
|
|
async loadChannels() {
|
|
try {
|
|
const response = await fetch('/api/channels');
|
|
const data = await response.json();
|
|
this.channels = (data.channels || []).filter(c => !c.decode_only);
|
|
this.updateChannelSelect();
|
|
} catch (error) {
|
|
console.error('Error loading channels:', error);
|
|
}
|
|
}
|
|
|
|
getLowestAvailableChannelIndex() {
|
|
if (typeof MeshCoreChannelOps === 'undefined') {
|
|
return null;
|
|
}
|
|
return MeshCoreChannelOps.getLowestAvailableChannelIndex(this.channels, this.maxChannels);
|
|
}
|
|
|
|
/**
|
|
* Create a hashtag channel on the bot; refresh dropdown and select it.
|
|
* @param {string} channelName — e.g. #news
|
|
* @param {object} [opts] — optional .button (HTMLElement) for loading state
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async createMeshChannel(channelName, opts) {
|
|
opts = opts || {};
|
|
if (typeof MeshCoreChannelOps === 'undefined') {
|
|
this.showError('Channel helper not loaded. Refresh the page.');
|
|
return false;
|
|
}
|
|
const name = channelName.trim().startsWith('#') ? channelName.trim() : '#' + channelName.trim();
|
|
const idx = this.getLowestAvailableChannelIndex();
|
|
if (idx === null) {
|
|
const limitMsg = this.maxChannels < 40
|
|
? `No available channel slots. All ${this.maxChannels} channels are in use (limited by bot.max_channels). Increase [Bot] max_channels in config.ini (max 40).`
|
|
: `No available channel slots. All ${this.maxChannels} channels are in use.`;
|
|
this.showError(limitMsg);
|
|
return false;
|
|
}
|
|
const btn = opts.button || null;
|
|
const originalHtml = btn ? btn.innerHTML : '';
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Creating...';
|
|
}
|
|
try {
|
|
const pr = await MeshCoreChannelOps.postChannel({ name: name, channel_idx: idx });
|
|
const result = pr.result;
|
|
if (!pr.ok) {
|
|
this.showError(result.error || 'Failed to create channel');
|
|
return false;
|
|
}
|
|
if (result.pending && result.operation_id) {
|
|
if (btn) {
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Processing...';
|
|
}
|
|
const pollStatus = await MeshCoreChannelOps.pollChannelOperation(result.operation_id, {
|
|
button: btn,
|
|
originalButtonText: originalHtml,
|
|
onFailed: (msg) => { this.showError(msg); },
|
|
onSlowOperationWarning: () => {
|
|
this.showError('Channel operation is taking longer than expected. Check the Radio page if this stalls.');
|
|
},
|
|
onFinalTimeout: async () => {
|
|
this.showError('Channel operation timed out. Check the Radio page or channel list.');
|
|
await this.loadChannels();
|
|
}
|
|
});
|
|
if (pollStatus !== 'completed') {
|
|
return false;
|
|
}
|
|
}
|
|
await this.loadChannels();
|
|
await this.loadChannelStats();
|
|
this.updateChannelSelect();
|
|
document.getElementById('channelSelect').value = name;
|
|
document.getElementById('newChannelName').value = '';
|
|
document.getElementById('newChannelGroup').style.display = 'none';
|
|
this.showSuccess('Channel created');
|
|
return true;
|
|
} catch (e) {
|
|
this.showError('Error creating channel: ' + e.message);
|
|
return false;
|
|
} finally {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
}
|
|
}
|
|
}
|
|
|
|
async createChannelFromFeedModal() {
|
|
const raw = document.getElementById('newChannelName').value.trim();
|
|
if (!raw) {
|
|
this.showError('Enter a channel name (e.g. #onion)');
|
|
return;
|
|
}
|
|
const submitBtn = document.getElementById('submitNewChannelBtn');
|
|
await this.createMeshChannel(raw, { button: submitBtn });
|
|
}
|
|
|
|
/**
|
|
* If the user typed a new channel name, create it on the bot before saving the feed.
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async ensureChannelReadyForFeedSave() {
|
|
const raw = document.getElementById('newChannelName').value.trim();
|
|
const select = document.getElementById('channelSelect');
|
|
if (!raw) {
|
|
if (!select.value) {
|
|
this.showError('Select a channel or create a new one.');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
const name = raw.startsWith('#') ? raw : '#' + raw;
|
|
const exists = this.channels.some(c => (c.name || c.channel_name) === name);
|
|
if (exists) {
|
|
select.value = name;
|
|
return true;
|
|
}
|
|
return await this.createMeshChannel(name, { button: document.getElementById('saveFeedBtn') });
|
|
}
|
|
|
|
async loadFeeds() {
|
|
try {
|
|
const response = await fetch('/api/feeds');
|
|
const data = await response.json();
|
|
this.feeds = data.feeds || [];
|
|
this.renderFeeds();
|
|
} catch (error) {
|
|
console.error('Error loading feeds:', error);
|
|
this.showError('Failed to load feeds: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async loadStatistics() {
|
|
try {
|
|
const response = await fetch('/api/feeds/stats');
|
|
const data = await response.json();
|
|
|
|
document.getElementById('total-subscriptions').textContent = data.total_subscriptions || 0;
|
|
document.getElementById('active-feeds').textContent = data.enabled_subscriptions || 0;
|
|
document.getElementById('items-24h').textContent = data.items_24h || 0;
|
|
document.getElementById('active-errors').textContent = data.active_errors || 0;
|
|
} catch (error) {
|
|
console.error('Error loading statistics:', error);
|
|
}
|
|
}
|
|
|
|
updateChannelSelect() {
|
|
const select = document.getElementById('channelSelect');
|
|
select.replaceChildren();
|
|
const placeholder = document.createElement('option');
|
|
placeholder.value = '';
|
|
placeholder.textContent = 'Select channel...';
|
|
select.appendChild(placeholder);
|
|
|
|
this.channels.forEach(channel => {
|
|
const option = document.createElement('option');
|
|
option.value = channel.name || channel.channel_name;
|
|
option.textContent = channel.name || channel.channel_name;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
renderFeeds() {
|
|
const tbody = document.getElementById('feedsTableBody');
|
|
tbody.replaceChildren();
|
|
|
|
if (this.feeds.length === 0) {
|
|
const row = document.createElement('tr');
|
|
const cell = document.createElement('td');
|
|
cell.colSpan = 9;
|
|
cell.className = 'text-center';
|
|
cell.textContent = 'No feed subscriptions';
|
|
row.appendChild(cell);
|
|
tbody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
this.feeds.forEach(feed => {
|
|
// Parse timestamp and convert to local time
|
|
let lastCheck = 'Never';
|
|
if (feed.last_check_time) {
|
|
try {
|
|
// Handle ISO format with timezone, or SQLite format (treat as UTC)
|
|
let dateStr = feed.last_check_time;
|
|
// If it's SQLite format (YYYY-MM-DD HH:MM:SS), append 'Z' to indicate UTC
|
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateStr)) {
|
|
dateStr = dateStr.replace(' ', 'T') + 'Z';
|
|
}
|
|
const date = new Date(dateStr);
|
|
if (!isNaN(date.getTime())) {
|
|
lastCheck = date.toLocaleString();
|
|
}
|
|
} catch (e) {
|
|
lastCheck = feed.last_check_time; // Fallback to raw value
|
|
}
|
|
}
|
|
|
|
const row = document.createElement('tr');
|
|
const addTextCell = (value) => {
|
|
const cell = document.createElement('td');
|
|
cell.textContent = String(value ?? '');
|
|
row.appendChild(cell);
|
|
return cell;
|
|
};
|
|
|
|
addTextCell(feed.id);
|
|
addTextCell(feed.feed_name || String(feed.feed_url || '').substring(0, 30));
|
|
|
|
const typeCell = addTextCell('');
|
|
const typeBadge = document.createElement('span');
|
|
typeBadge.className = 'badge bg-info';
|
|
typeBadge.textContent = String(feed.feed_type || '').toUpperCase();
|
|
typeCell.appendChild(typeBadge);
|
|
|
|
addTextCell(feed.channel_name);
|
|
|
|
const statusCell = addTextCell('');
|
|
const statusBadge = document.createElement('span');
|
|
statusBadge.className = `badge ${feed.enabled ? 'bg-success' : 'bg-secondary'}`;
|
|
statusBadge.textContent = feed.enabled ? 'Enabled' : 'Disabled';
|
|
statusCell.appendChild(statusBadge);
|
|
|
|
addTextCell(lastCheck);
|
|
addTextCell(feed.item_count || 0);
|
|
|
|
const errorCell = addTextCell('');
|
|
if (Number(feed.error_count) > 0) {
|
|
const errorBadge = document.createElement('span');
|
|
errorBadge.className = 'badge bg-danger';
|
|
errorBadge.textContent = String(feed.error_count);
|
|
errorCell.appendChild(errorBadge);
|
|
} else {
|
|
errorCell.textContent = '0';
|
|
}
|
|
|
|
const actions = addTextCell('');
|
|
const addAction = (classes, iconClass, title, handler) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = `btn btn-sm ${classes}`;
|
|
button.title = title;
|
|
const icon = document.createElement('i');
|
|
icon.className = `fas ${iconClass}`;
|
|
button.appendChild(icon);
|
|
button.addEventListener('click', handler);
|
|
actions.appendChild(button);
|
|
actions.appendChild(document.createTextNode(' '));
|
|
};
|
|
addAction('btn-info', 'fa-eye', 'View Details', () => this.viewFeed(feed.id));
|
|
addAction('btn-primary', 'fa-edit', 'Edit', () => this.editFeed(feed.id));
|
|
addAction(
|
|
feed.enabled ? 'btn-warning' : 'btn-success',
|
|
feed.enabled ? 'fa-pause' : 'fa-play',
|
|
feed.enabled ? 'Disable' : 'Enable',
|
|
() => this.toggleFeed(feed.id, !feed.enabled),
|
|
);
|
|
addAction('btn-danger', 'fa-trash', 'Delete', () => this.deleteFeed(feed.id));
|
|
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
setupEventHandlers() {
|
|
// Feed type change
|
|
document.getElementById('feedType').addEventListener('change', (e) => {
|
|
const apiConfigGroup = document.getElementById('apiConfigGroup');
|
|
apiConfigGroup.style.display = e.target.value === 'api' ? 'block' : 'none';
|
|
// If switching to API, expand the API config accordion
|
|
if (e.target.value === 'api') {
|
|
const apiCollapseEl = document.getElementById('apiConfigCollapse');
|
|
if (apiCollapseEl) {
|
|
const apiCollapse = new bootstrap.Collapse(apiCollapseEl, {show: true});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Create channel button (toggle extra row)
|
|
document.getElementById('createChannelBtn').addEventListener('click', () => {
|
|
const newChannelGroup = document.getElementById('newChannelGroup');
|
|
newChannelGroup.style.display = newChannelGroup.style.display === 'none' ? 'block' : 'none';
|
|
});
|
|
|
|
document.getElementById('submitNewChannelBtn').addEventListener('click', () => {
|
|
this.createChannelFromFeedModal();
|
|
});
|
|
|
|
// Save feed
|
|
document.getElementById('saveFeedBtn').addEventListener('click', () => this.saveFeed());
|
|
|
|
// Preview button
|
|
document.getElementById('previewBtn').addEventListener('click', () => this.previewFormat());
|
|
|
|
// Reset-all-errors button (inline onclick removed for the nonce CSP)
|
|
const resetAllErrorsBtn = document.getElementById('resetAllErrorsBtn');
|
|
if (resetAllErrorsBtn) {
|
|
resetAllErrorsBtn.addEventListener('click', () => this.resetAllErrors());
|
|
}
|
|
|
|
// Load default format when opening add modal
|
|
document.getElementById('addFeedModal').addEventListener('show.bs.modal', () => {
|
|
this.loadChannelStats();
|
|
if (!document.getElementById('feedId').value) {
|
|
// Only load default if it's a new feed (not editing)
|
|
this.loadDefaultFormat();
|
|
}
|
|
});
|
|
|
|
// Reset form when modal is hidden
|
|
document.getElementById('addFeedModal').addEventListener('hidden.bs.modal', () => {
|
|
this.resetForm();
|
|
});
|
|
}
|
|
|
|
async editFeed(feedId) {
|
|
try {
|
|
const response = await fetch(`/api/feeds/${feedId}`);
|
|
const feed = await response.json();
|
|
|
|
// Populate form with feed data
|
|
document.getElementById('feedId').value = feed.id;
|
|
document.getElementById('feedModalTitle').textContent = 'Edit Feed Subscription';
|
|
document.getElementById('feedType').value = feed.feed_type;
|
|
document.getElementById('feedUrl').value = feed.feed_url;
|
|
document.getElementById('feedUrl').disabled = true; // Don't allow changing URL
|
|
document.getElementById('channelSelect').value = feed.channel_name;
|
|
document.getElementById('newChannelName').value = '';
|
|
document.getElementById('newChannelGroup').style.display = 'none';
|
|
document.getElementById('feedName').value = feed.feed_name || '';
|
|
document.getElementById('checkInterval').value = feed.check_interval_seconds || 300;
|
|
|
|
// Load default format if feed doesn't have a custom one
|
|
if (feed.output_format) {
|
|
document.getElementById('outputFormat').value = feed.output_format;
|
|
} else {
|
|
await this.loadDefaultFormat();
|
|
}
|
|
|
|
document.getElementById('messageSendInterval').value = feed.message_send_interval_seconds || 2.0;
|
|
|
|
if (feed.feed_type === 'api') {
|
|
document.getElementById('apiConfigGroup').style.display = 'block';
|
|
document.getElementById('apiConfig').value = feed.api_config ? JSON.stringify(JSON.parse(feed.api_config), null, 2) : '';
|
|
// Expand API config accordion when editing API feed
|
|
const apiCollapseEl = document.getElementById('apiConfigCollapse');
|
|
if (apiCollapseEl) {
|
|
const apiCollapse = new bootstrap.Collapse(apiCollapseEl, {show: true});
|
|
}
|
|
} else {
|
|
document.getElementById('apiConfigGroup').style.display = 'none';
|
|
}
|
|
|
|
// Load filter config if present
|
|
if (feed.filter_config) {
|
|
try {
|
|
document.getElementById('filterConfig').value = JSON.stringify(JSON.parse(feed.filter_config), null, 2);
|
|
} catch (e) {
|
|
document.getElementById('filterConfig').value = feed.filter_config;
|
|
}
|
|
} else {
|
|
document.getElementById('filterConfig').value = '';
|
|
}
|
|
|
|
// Load sort config if present
|
|
if (feed.sort_config) {
|
|
try {
|
|
document.getElementById('sortConfig').value = JSON.stringify(JSON.parse(feed.sort_config), null, 2);
|
|
} catch (e) {
|
|
document.getElementById('sortConfig').value = feed.sort_config;
|
|
}
|
|
} else {
|
|
document.getElementById('sortConfig').value = '';
|
|
}
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('addFeedModal'));
|
|
modal.show();
|
|
} catch (error) {
|
|
this.showError('Error loading feed: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async saveFeed() {
|
|
if (!(await this.ensureChannelReadyForFeedSave())) {
|
|
return;
|
|
}
|
|
const form = document.getElementById('addFeedForm');
|
|
const formData = new FormData(form);
|
|
const feedId = document.getElementById('feedId').value;
|
|
const isEdit = !!feedId;
|
|
|
|
const channelName = document.getElementById('channelSelect').value;
|
|
if (!channelName) {
|
|
this.showError('Select a channel');
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
feed_type: formData.get('feed_type'),
|
|
feed_url: formData.get('feed_url'),
|
|
channel_name: channelName,
|
|
feed_name: formData.get('feed_name') || null,
|
|
check_interval_seconds: parseInt(formData.get('check_interval_seconds')) || 300,
|
|
output_format: formData.get('output_format') || null,
|
|
message_send_interval_seconds: parseFloat(formData.get('message_send_interval_seconds')) || 2.0
|
|
};
|
|
|
|
if (data.feed_type === 'api') {
|
|
const apiConfigText = document.getElementById('apiConfig').value;
|
|
if (apiConfigText) {
|
|
try {
|
|
data.api_config = JSON.parse(apiConfigText);
|
|
} catch (e) {
|
|
this.showError('Invalid API config JSON');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse filter config if provided
|
|
const filterConfigText = document.getElementById('filterConfig').value;
|
|
if (filterConfigText && filterConfigText.trim()) {
|
|
try {
|
|
data.filter_config = JSON.parse(filterConfigText);
|
|
} catch (e) {
|
|
this.showError('Invalid filter config JSON: ' + e.message);
|
|
return;
|
|
}
|
|
} else {
|
|
data.filter_config = null;
|
|
}
|
|
|
|
// Parse sort config if provided
|
|
const sortConfigText = document.getElementById('sortConfig').value;
|
|
if (sortConfigText && sortConfigText.trim()) {
|
|
try {
|
|
data.sort_config = JSON.parse(sortConfigText);
|
|
} catch (e) {
|
|
this.showError('Invalid sort config JSON: ' + e.message);
|
|
return;
|
|
}
|
|
} else {
|
|
data.sort_config = null;
|
|
}
|
|
|
|
try {
|
|
const url = isEdit ? `/api/feeds/${feedId}` : '/api/feeds';
|
|
const method = isEdit ? 'PUT' : 'POST';
|
|
|
|
const response = await fetch(url, {
|
|
method: method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`Feed subscription ${isEdit ? 'updated' : 'created'}`);
|
|
bootstrap.Modal.getInstance(document.getElementById('addFeedModal')).hide();
|
|
this.resetForm();
|
|
await this.loadFeeds();
|
|
await this.loadStatistics();
|
|
} else {
|
|
this.showError(result.error || `Failed to ${isEdit ? 'update' : 'create'} feed`);
|
|
}
|
|
} catch (error) {
|
|
this.showError(`Error ${isEdit ? 'updating' : 'creating'} feed: ` + error.message);
|
|
}
|
|
}
|
|
|
|
async loadDefaultFormat() {
|
|
try {
|
|
const response = await fetch('/api/feeds/default-format');
|
|
const data = await response.json();
|
|
document.getElementById('outputFormat').value = data.default_format || '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}';
|
|
} catch (error) {
|
|
console.error('Error loading default format:', error);
|
|
// Fallback to hardcoded default
|
|
document.getElementById('outputFormat').value = '{emoji} {body|truncate:100} - {date}\n{link|truncate:50}';
|
|
}
|
|
}
|
|
|
|
async previewFormat() {
|
|
const feedUrl = document.getElementById('feedUrl').value;
|
|
const feedType = document.getElementById('feedType').value;
|
|
const outputFormat = document.getElementById('outputFormat').value;
|
|
const apiConfigText = document.getElementById('apiConfig').value;
|
|
const filterConfigText = document.getElementById('filterConfig').value;
|
|
const sortConfigText = document.getElementById('sortConfig').value;
|
|
|
|
if (!feedUrl) {
|
|
this.showError('Please enter a feed URL first');
|
|
return;
|
|
}
|
|
|
|
if (!outputFormat) {
|
|
this.showError('Please enter an output format');
|
|
return;
|
|
}
|
|
|
|
const previewResults = document.getElementById('previewResults');
|
|
const previewContent = document.getElementById('previewContent');
|
|
previewResults.style.display = 'block';
|
|
const spinner = document.createElement('div');
|
|
spinner.className = 'spinner-border spinner-border-sm';
|
|
spinner.setAttribute('role', 'status');
|
|
const spinnerText = document.createElement('span');
|
|
spinnerText.className = 'visually-hidden';
|
|
spinnerText.textContent = 'Loading...';
|
|
spinner.appendChild(spinnerText);
|
|
previewContent.replaceChildren(spinner);
|
|
|
|
try {
|
|
const data = {
|
|
feed_url: feedUrl,
|
|
feed_type: feedType,
|
|
output_format: outputFormat
|
|
};
|
|
|
|
if (feedType === 'api' && apiConfigText) {
|
|
try {
|
|
data.api_config = JSON.parse(apiConfigText);
|
|
} catch (e) {
|
|
this.showError('Invalid API config JSON');
|
|
previewResults.style.display = 'none';
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (filterConfigText && filterConfigText.trim()) {
|
|
try {
|
|
data.filter_config = JSON.parse(filterConfigText);
|
|
} catch (e) {
|
|
this.showError('Invalid filter config JSON: ' + e.message);
|
|
previewResults.style.display = 'none';
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (sortConfigText && sortConfigText.trim()) {
|
|
try {
|
|
data.sort_config = JSON.parse(sortConfigText);
|
|
} catch (e) {
|
|
this.showError('Invalid sort config JSON: ' + e.message);
|
|
previewResults.style.display = 'none';
|
|
return;
|
|
}
|
|
}
|
|
|
|
const response = await fetch('/api/feeds/preview', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok && result.items) {
|
|
previewContent.replaceChildren();
|
|
if (result.items.length === 0) {
|
|
const empty = document.createElement('p');
|
|
empty.className = 'text-muted small mb-0';
|
|
empty.textContent = 'No items found in feed';
|
|
previewContent.appendChild(empty);
|
|
} else {
|
|
result.items.forEach((item, index) => {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'mb-2 p-2 border rounded';
|
|
const label = document.createElement('small');
|
|
label.className = 'text-muted d-block mb-1 fw-bold';
|
|
label.textContent = `Item ${index + 1}:`;
|
|
const formatted = document.createElement('pre');
|
|
formatted.className = 'mb-1 bg-light p-2 rounded small feed-preview-text';
|
|
formatted.textContent = String(item.formatted ?? '');
|
|
wrapper.append(label, formatted);
|
|
previewContent.appendChild(wrapper);
|
|
});
|
|
}
|
|
} else {
|
|
const errorMessage = document.createElement('p');
|
|
errorMessage.className = 'text-danger small mb-0';
|
|
errorMessage.textContent = `Error: ${result.error || 'Failed to preview feed'}`;
|
|
previewContent.replaceChildren(errorMessage);
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = document.createElement('p');
|
|
errorMessage.className = 'text-danger';
|
|
errorMessage.textContent = `Error: ${error.message}`;
|
|
previewContent.replaceChildren(errorMessage);
|
|
}
|
|
}
|
|
|
|
resetForm() {
|
|
const form = document.getElementById('addFeedForm');
|
|
form.reset();
|
|
document.getElementById('feedId').value = '';
|
|
document.getElementById('feedModalTitle').textContent = 'Add Feed Subscription';
|
|
document.getElementById('feedUrl').disabled = false;
|
|
document.getElementById('apiConfigGroup').style.display = 'none';
|
|
document.getElementById('newChannelGroup').style.display = 'none';
|
|
document.getElementById('newChannelName').value = '';
|
|
document.getElementById('previewResults').style.display = 'none';
|
|
document.getElementById('filterConfig').value = '';
|
|
document.getElementById('sortConfig').value = '';
|
|
}
|
|
|
|
async viewFeed(feedId) {
|
|
try {
|
|
const response = await fetch(`/api/feeds/${feedId}`);
|
|
const feed = await response.json();
|
|
|
|
let lastCheck = 'Never';
|
|
if (feed.last_check_time) {
|
|
try {
|
|
let dateStr = feed.last_check_time;
|
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(dateStr)) {
|
|
dateStr = dateStr.replace(' ', 'T') + 'Z';
|
|
}
|
|
const date = new Date(dateStr);
|
|
lastCheck = !isNaN(date.getTime()) ? date.toLocaleString() : feed.last_check_time;
|
|
} catch (e) {
|
|
lastCheck = feed.last_check_time;
|
|
}
|
|
}
|
|
|
|
const content = document.getElementById('feedDetailsContent');
|
|
const row = document.createElement('div');
|
|
row.className = 'row';
|
|
const configColumn = document.createElement('div');
|
|
configColumn.className = 'col-md-6';
|
|
const activityColumn = document.createElement('div');
|
|
activityColumn.className = 'col-md-6';
|
|
const addHeading = (parent, text) => {
|
|
const heading = document.createElement('h6');
|
|
heading.textContent = text;
|
|
parent.appendChild(heading);
|
|
};
|
|
const addDetail = (parent, label, value) => {
|
|
const paragraph = document.createElement('p');
|
|
const strong = document.createElement('strong');
|
|
strong.textContent = `${label}:`;
|
|
paragraph.append(strong, document.createTextNode(` ${String(value ?? '')}`));
|
|
parent.appendChild(paragraph);
|
|
};
|
|
|
|
addHeading(configColumn, 'Configuration');
|
|
addDetail(configColumn, 'Name', feed.feed_name || 'N/A');
|
|
addDetail(configColumn, 'Type', String(feed.feed_type || '').toUpperCase());
|
|
addDetail(configColumn, 'URL', feed.feed_url);
|
|
addDetail(configColumn, 'Channel', feed.channel_name);
|
|
addDetail(configColumn, 'Check Interval', `${feed.check_interval_seconds}s`);
|
|
addDetail(configColumn, 'Send Interval', `${feed.message_send_interval_seconds || 2.0}s`);
|
|
addDetail(configColumn, 'Status', feed.enabled ? 'Enabled' : 'Disabled');
|
|
if (feed.output_format) {
|
|
const paragraph = document.createElement('p');
|
|
const strong = document.createElement('strong');
|
|
strong.textContent = 'Output Format:';
|
|
const format = document.createElement('code');
|
|
format.className = 'small d-block feed-output-format';
|
|
format.textContent = String(feed.output_format);
|
|
paragraph.append(strong, format);
|
|
configColumn.appendChild(paragraph);
|
|
}
|
|
|
|
addHeading(activityColumn, 'Activity');
|
|
addDetail(activityColumn, 'Last Check', lastCheck);
|
|
addDetail(activityColumn, 'Last Item', feed.last_item_id ? String(feed.last_item_id).substring(0, 30) + '...' : 'None');
|
|
addDetail(activityColumn, 'Total Items', feed.activity?.length || 0);
|
|
|
|
// Errors row: value plus, when non-zero, a per-feed reset button.
|
|
// Wired via addEventListener because /feeds runs under a nonce CSP
|
|
// (no unsafe-inline), so an inline onclick handler would be blocked.
|
|
const errorCount = feed.errors?.length || 0;
|
|
const errorParagraph = document.createElement('p');
|
|
const errorStrong = document.createElement('strong');
|
|
errorStrong.textContent = 'Errors:';
|
|
errorParagraph.append(errorStrong, document.createTextNode(` ${errorCount}`));
|
|
if (errorCount > 0) {
|
|
const resetButton = document.createElement('button');
|
|
resetButton.type = 'button';
|
|
resetButton.className = 'btn btn-sm btn-outline-secondary ms-2';
|
|
resetButton.title = 'Clear recorded errors for this feed';
|
|
const eraseIcon = document.createElement('i');
|
|
eraseIcon.className = 'fas fa-eraser';
|
|
resetButton.append(eraseIcon, document.createTextNode(' Reset'));
|
|
resetButton.addEventListener('click', () => this.resetFeedErrors(feed.id));
|
|
errorParagraph.append(document.createTextNode(' '), resetButton);
|
|
}
|
|
activityColumn.appendChild(errorParagraph);
|
|
|
|
row.append(configColumn, activityColumn);
|
|
content.replaceChildren(row);
|
|
const modal = new bootstrap.Modal(document.getElementById('feedDetailsModal'));
|
|
modal.show();
|
|
} catch (error) {
|
|
this.showError('Error loading feed details: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async toggleFeed(feedId, enabled) {
|
|
try {
|
|
const response = await fetch(`/api/feeds/${feedId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
body: JSON.stringify({enabled: enabled})
|
|
});
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`Feed ${enabled ? 'enabled' : 'disabled'}`);
|
|
await this.loadFeeds();
|
|
} else {
|
|
const result = await response.json();
|
|
this.showError(result.error || 'Failed to update feed');
|
|
}
|
|
} catch (error) {
|
|
this.showError('Error updating feed: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async deleteFeed(feedId) {
|
|
if (!confirm('Are you sure you want to delete this feed subscription?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/feeds/${feedId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
|
|
if (response.ok) {
|
|
this.showSuccess('Feed subscription deleted');
|
|
await this.loadFeeds();
|
|
await this.loadStatistics();
|
|
} else {
|
|
const result = await response.json();
|
|
this.showError(result.error || 'Failed to delete feed');
|
|
}
|
|
} catch (error) {
|
|
this.showError('Error deleting feed: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async resetAllErrors() {
|
|
if (!confirm('Reset error counts for ALL feeds? This clears the recorded error history.')) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch('/api/feeds/errors/reset', {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
if (response.ok) {
|
|
this.showSuccess('Feed errors reset');
|
|
await this.loadFeeds();
|
|
} else {
|
|
const result = await response.json();
|
|
this.showError(result.error || 'Failed to reset feed errors');
|
|
}
|
|
} catch (error) {
|
|
this.showError('Error resetting feed errors: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async resetFeedErrors(feedId) {
|
|
try {
|
|
const response = await fetch(`/api/feeds/${feedId}/errors/reset`, {
|
|
method: 'POST',
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
});
|
|
if (response.ok) {
|
|
this.showSuccess('Feed errors reset');
|
|
await this.loadFeeds();
|
|
await this.viewFeed(feedId);
|
|
} else {
|
|
const result = await response.json();
|
|
this.showError(result.error || 'Failed to reset feed errors');
|
|
}
|
|
} catch (error) {
|
|
this.showError('Error resetting feed errors: ' + error.message);
|
|
}
|
|
}
|
|
|
|
showError(message) {
|
|
this.showAlert(message, 'danger', 5000);
|
|
}
|
|
|
|
showSuccess(message) {
|
|
this.showAlert(message, 'success', 3000);
|
|
}
|
|
|
|
showAlert(message, kind, timeout) {
|
|
const alert = document.createElement('div');
|
|
alert.className = `alert alert-${kind} alert-dismissible fade show position-fixed`;
|
|
alert.style.cssText = 'top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
|
|
const messageNode = document.createElement('span');
|
|
messageNode.textContent = String(message ?? '');
|
|
const closeButton = document.createElement('button');
|
|
closeButton.type = 'button';
|
|
closeButton.className = 'btn-close';
|
|
closeButton.setAttribute('data-bs-dismiss', 'alert');
|
|
closeButton.setAttribute('aria-label', 'Close');
|
|
alert.append(messageNode, closeButton);
|
|
document.body.appendChild(alert);
|
|
setTimeout(() => alert.remove(), timeout);
|
|
}
|
|
}
|
|
|
|
// Initialize feed manager when page loads
|
|
let feedManager;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
feedManager = new FeedManager();
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.feed-preview-text {
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
font-size: 0.8rem;
|
|
background-color: var(--bg-secondary) !important;
|
|
color: var(--text-color) !important;
|
|
}
|
|
|
|
.feed-output-format {
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
/* Dark mode table styling with better contrast - matching contacts page */
|
|
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
|
|
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
|
|
}
|
|
|
|
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
|
|
background-color: #2d2d2d; /* Darker gray (card-bg) */
|
|
}
|
|
|
|
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
|
|
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
|
|
}
|
|
|
|
[data-theme="dark"] .table-dark {
|
|
background-color: var(--bg-tertiary);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .table-dark th {
|
|
background-color: var(--bg-tertiary);
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .table td {
|
|
border-color: var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
/* Ensure all text in table cells is light in dark mode */
|
|
[data-theme="dark"] .table td,
|
|
[data-theme="dark"] .table td * {
|
|
color: var(--text-color) !important;
|
|
}
|
|
|
|
/* Ensure small text is also light */
|
|
[data-theme="dark"] .table td small {
|
|
color: var(--text-muted) !important;
|
|
}
|
|
</style>
|
|
{% endblock %}
|