| 1 | <!doctype html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8" /> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | <meta name="theme-color" content="#121113" /> |
| 7 | <link rel="stylesheet" href="/assets/darkmatter.css" /> |
| 8 | <link rel="stylesheet" href="/static/styles.css" /> |
| 9 | <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> |
| 10 | <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> |
| 11 | <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> |
| 12 | <link rel="manifest" href="/static/site.webmanifest"> |
| 13 | <title>Feeds | Admin</title> |
| 14 | </head> |
| 15 | <body> |
| 16 | <div class="header"> |
| 17 | <a href="/" class="logo"><h1>FEEDS</h1></a> |
| 18 | <nav class="links"><a href="/feeds?format=opml">opml</a><a href="/admin/logout">logout</a></nav> |
| 19 | </div> |
| 20 | |
| 21 | {{if .Success}}<p class="success">{{.Success}}</p>{{end}} |
| 22 | {{if .Error}}<p class="error">{{.Error}}</p>{{end}} |
| 23 | |
| 24 | <section class="admin-form"> |
| 25 | <h3>Discover</h3> |
| 26 | <div class="discover-row"> |
| 27 | <input type="url" id="base_url" placeholder="https://example.com" /> |
| 28 | <button type="button" id="discover-btn" onclick="discoverFeeds()">Discover</button> |
| 29 | </div> |
| 30 | <div id="discover-status" class="discover-status" style="display:none;"></div> |
| 31 | <div id="discover-results" class="discover-results" style="display:none;"></div> |
| 32 | </section> |
| 33 | |
| 34 | <form class="admin-form" id="add-feed-form" method="POST" action="/admin/add-feed"> |
| 35 | <h3>Add Feed</h3> |
| 36 | <label for="feed_url">Feed URL</label> |
| 37 | <input type="url" id="feed_url" name="feed_url" placeholder="https://example.com/feed.xml" required /> |
| 38 | <label for="category_name">Category (optional)</label> |
| 39 | <input type="text" id="category_name" name="category_name" placeholder="Tech" list="categories-list" /> |
| 40 | <datalist id="categories-list">{{range .Categories}}<option value="{{.Name}}"></option>{{end}}</datalist> |
| 41 | <button type="submit" id="add-feed-submit"><span id="add-feed-label">Add Feed</span></button> |
| 42 | </form> |
| 43 | |
| 44 | <form class="admin-form" id="opml-form" method="POST" action="/admin/import-opml" enctype="multipart/form-data"> |
| 45 | <h3>Import OPML</h3> |
| 46 | <input type="file" name="file" accept=".opml,.xml,application/xml,text/xml" required /> |
| 47 | <button type="submit" id="opml-submit"><span id="opml-submit-label">Import</span></button> |
| 48 | </form> |
| 49 | |
| 50 | <form class="admin-form" method="POST" action="/admin/settings"> |
| 51 | <h3>Settings</h3> |
| 52 | <label for="poll_interval_minutes">Poll interval (minutes)</label> |
| 53 | <input type="number" id="poll_interval_minutes" name="poll_interval_minutes" min="1" max="1440" value="{{.PollIntervalMinutes}}" required /> |
| 54 | <p class="hint">Item cap per feed: {{.ItemCap}} (set via ITEM_CAP_PER_FEED)</p> |
| 55 | <p class="hint">API key: {{if .APIKeyConfigured}}configured{{else}}not set{{end}}</p> |
| 56 | <button type="submit">Save</button> |
| 57 | </form> |
| 58 | |
| 59 | <section class="admin-subs"> |
| 60 | <h3>Categories ({{len .Categories}})</h3> |
| 61 | <form class="admin-form inline" method="POST" action="/admin/categories"> |
| 62 | <input type="text" name="name" placeholder="New category" required /> |
| 63 | <button type="submit">Add</button> |
| 64 | </form> |
| 65 | <ul class="category-list"> |
| 66 | {{range .Categories}} |
| 67 | <li> |
| 68 | <span>{{.Name}}</span> |
| 69 | <form method="POST" action="/admin/categories/{{.ID}}/delete" class="inline"><button type="submit" class="danger">Delete</button></form> |
| 70 | </li> |
| 71 | {{end}} |
| 72 | </ul> |
| 73 | </section> |
| 74 | |
| 75 | <section class="admin-subs"> |
| 76 | <h3>Subscriptions ({{len .Subscriptions}})</h3> |
| 77 | <div class="feeds-list"> |
| 78 | {{range .Subscriptions}} |
| 79 | <div class="feed-item"> |
| 80 | <h3 class="feed-title"><a href="{{.SiteURL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></h3> |
| 81 | {{if .LastFetchedAt}}<p class="feed-meta"><span class="feed-date">last: {{.LastFetchedAt}}</span>{{if .LastError}} <span class="error">· {{.LastError}}</span>{{end}}</p>{{end}} |
| 82 | <form method="POST" action="/admin/feeds/{{.ID}}/category" class="inline"> |
| 83 | <input type="text" name="category_name" placeholder="category" list="categories-list" value="{{.CategoryName}}" /> |
| 84 | <button type="submit">Save</button> |
| 85 | </form> |
| 86 | <form method="POST" action="/admin/feeds/{{.ID}}/delete" class="inline"><button type="submit" class="danger">Delete</button></form> |
| 87 | </div> |
| 88 | {{end}} |
| 89 | </div> |
| 90 | </section> |
| 91 | |
| 92 | <script> |
| 93 | (function() { |
| 94 | const form = document.getElementById('add-feed-form'); |
| 95 | if (!form) return; |
| 96 | form.addEventListener('submit', function() { |
| 97 | const btn = document.getElementById('add-feed-submit'); |
| 98 | const label = document.getElementById('add-feed-label'); |
| 99 | btn.disabled = true; |
| 100 | btn.classList.add('loading'); |
| 101 | label.innerHTML = 'Adding <span class="spinner"></span>'; |
| 102 | }); |
| 103 | })(); |
| 104 | |
| 105 | (function() { |
| 106 | const form = document.getElementById('opml-form'); |
| 107 | if (!form) return; |
| 108 | form.addEventListener('submit', function() { |
| 109 | const btn = document.getElementById('opml-submit'); |
| 110 | const label = document.getElementById('opml-submit-label'); |
| 111 | btn.disabled = true; |
| 112 | btn.classList.add('loading'); |
| 113 | label.innerHTML = 'Importing <span class="spinner"></span>'; |
| 114 | }); |
| 115 | })(); |
| 116 | |
| 117 | async function discoverFeeds() { |
| 118 | const baseUrl = document.getElementById('base_url').value.trim(); |
| 119 | if (!baseUrl) return; |
| 120 | const btn = document.getElementById('discover-btn'); |
| 121 | const status = document.getElementById('discover-status'); |
| 122 | const results = document.getElementById('discover-results'); |
| 123 | const feedInput = document.getElementById('feed_url'); |
| 124 | btn.disabled = true; |
| 125 | btn.textContent = 'Searching...'; |
| 126 | status.style.display = 'none'; |
| 127 | results.style.display = 'none'; |
| 128 | results.innerHTML = ''; |
| 129 | try { |
| 130 | const body = new URLSearchParams({ base_url: baseUrl }); |
| 131 | const resp = await fetch('/admin/discover-feeds', { method: 'POST', body }); |
| 132 | const data = await resp.json(); |
| 133 | if (!resp.ok) { |
| 134 | status.textContent = data.error || 'No feeds found'; |
| 135 | status.className = 'discover-status error'; |
| 136 | status.style.display = 'block'; |
| 137 | return; |
| 138 | } |
| 139 | feedInput.value = data[0]; |
| 140 | status.textContent = data.length + ' feed(s) found'; |
| 141 | status.className = 'discover-status success'; |
| 142 | status.style.display = 'block'; |
| 143 | if (data.length > 1) { |
| 144 | results.style.display = 'flex'; |
| 145 | data.forEach(function(url) { |
| 146 | const item = document.createElement('button'); |
| 147 | item.type = 'button'; |
| 148 | item.className = 'discover-result-item' + (url === data[0] ? ' active' : ''); |
| 149 | item.textContent = url; |
| 150 | item.onclick = function() { |
| 151 | feedInput.value = url; |
| 152 | results.querySelectorAll('.discover-result-item').forEach(function(el) { el.classList.remove('active'); }); |
| 153 | item.classList.add('active'); |
| 154 | }; |
| 155 | results.appendChild(item); |
| 156 | }); |
| 157 | } |
| 158 | } catch (e) { |
| 159 | status.textContent = 'Request failed'; |
| 160 | status.className = 'discover-status error'; |
| 161 | status.style.display = 'block'; |
| 162 | } finally { |
| 163 | btn.disabled = false; |
| 164 | btn.textContent = 'Discover'; |
| 165 | } |
| 166 | } |
| 167 | </script> |
| 168 | </body> |
| 169 | </html> |