Require Flutter web bundle and improve feed management
cd64c81 parent: 5057c55 modified
app/analysis_options.yaml +3 -0 | @@ -15,6 +15,9 @@ analyzer: | ||
| 15 | 15 | - android/** |
| 16 | 16 | - web/** |
| 17 | 17 | - linux/** |
| 18 | + - ios/** | |
| 19 | + - windows/** | |
| 20 | + - macos/** | |
| 18 | 21 | |
| 19 | 22 | linter: |
| 20 | 23 | # The lint rules applied to this project can be customized in the |
| @@ -15,6 +15,9 @@ analyzer: | |||
| 15 | - android/** | 15 | - android/** |
| 16 | - web/** | 16 | - web/** |
| 17 | - linux/** | 17 | - linux/** |
| 18 | + - ios/** | ||
| 19 | + - windows/** | ||
| 20 | + - macos/** | ||
| 18 | 21 | ||
| 19 | linter: | 22 | linter: |
| 20 | # The lint rules applied to this project can be customized in the | 23 | # The lint rules applied to this project can be customized in the |
modified
app/lib/src/api/client.dart +4 -1 | @@ -36,7 +36,10 @@ class GleanClient { | ||
| 36 | 36 | |
| 37 | 37 | Uri _uri(String path, [Map<String, String>? query]) { |
| 38 | 38 | final q = query?.entries.where((e) => e.value.isNotEmpty); |
| 39 | - return Uri.parse('$baseUrl$path').replace( | |
| 39 | + // The web app is served by the reader. An empty base means same origin, | |
| 40 | + // rather than the browser's own 127.0.0.1 (which is never the Modal app). | |
| 41 | + final base = baseUrl.isEmpty ? Uri.base.resolve(path) : Uri.parse('$baseUrl$path'); | |
| 42 | + return base.replace( | |
| 40 | 43 | queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q), |
| 41 | 44 | ); |
| 42 | 45 | } |
| @@ -36,7 +36,10 @@ class GleanClient { | |||
| 36 | 36 | ||
| 37 | Uri _uri(String path, [Map<String, String>? query]) { | 37 | Uri _uri(String path, [Map<String, String>? query]) { |
| 38 | final q = query?.entries.where((e) => e.value.isNotEmpty); | 38 | final q = query?.entries.where((e) => e.value.isNotEmpty); |
| 39 | - return Uri.parse('$baseUrl$path').replace( | 39 | + // The web app is served by the reader. An empty base means same origin, |
| 40 | + // rather than the browser's own 127.0.0.1 (which is never the Modal app). | ||
| 41 | + final base = baseUrl.isEmpty ? Uri.base.resolve(path) : Uri.parse('$baseUrl$path'); | ||
| 42 | + return base.replace( | ||
| 40 | queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q), | 43 | queryParameters: q == null || q.isEmpty ? null : Map.fromEntries(q), |
| 41 | ); | 44 | ); |
| 42 | } | 45 | } |
modified
app/lib/src/app_state.dart +9 -4 | @@ -1,3 +1,4 @@ | ||
| 1 | +import 'package:flutter/foundation.dart' show kIsWeb; | |
| 1 | 2 | import 'package:flutter/widgets.dart'; |
| 2 | 3 | |
| 3 | 4 | import 'api/client.dart'; |
| @@ -12,11 +13,15 @@ import 'models/models.dart'; | ||
| 12 | 13 | /// The web build is served *by* the reader, so it leaves this empty and uses |
| 13 | 14 | /// relative paths: same origin, which means no CORS preflight and the |
| 14 | 15 | /// reader's own cookie on every request. |
| 15 | -const kDefaultBaseUrl = String.fromEnvironment( | |
| 16 | +const kConfiguredBaseUrl = String.fromEnvironment( | |
| 16 | 17 | 'GLEAN_BASE_URL', |
| 17 | - defaultValue: 'http://127.0.0.1:8080', | |
| 18 | + defaultValue: '', | |
| 18 | 19 | ); |
| 19 | 20 | |
| 21 | +String get kDefaultBaseUrl => kConfiguredBaseUrl.isNotEmpty | |
| 22 | + ? kConfiguredBaseUrl | |
| 23 | + : (kIsWeb ? '' : 'http://127.0.0.1:8080'); | |
| 24 | + | |
| 20 | 25 | /// Shared secret, for a reader running with GLEAN_TOKEN set. |
| 21 | 26 | /// |
| 22 | 27 | /// Empty by default, which is right for a local reader and for the web build |
| @@ -31,10 +36,10 @@ const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: ''); | ||
| 31 | 36 | /// not set them. |
| 32 | 37 | class AppState extends ChangeNotifier { |
| 33 | 38 | AppState({ |
| 34 | - String baseUrl = kDefaultBaseUrl, | |
| 39 | + String? baseUrl, | |
| 35 | 40 | String token = kToken, |
| 36 | 41 | GleanClient? client, |
| 37 | - }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token); | |
| 42 | + }) : client = client ?? GleanClient(baseUrl: baseUrl ?? kDefaultBaseUrl, token: token); | |
| 38 | 43 | |
| 39 | 44 | final GleanClient client; |
| 40 | 45 | |
| @@ -1,3 +1,4 @@ | |||
| 1 | +import 'package:flutter/foundation.dart' show kIsWeb; | ||
| 1 | import 'package:flutter/widgets.dart'; | 2 | import 'package:flutter/widgets.dart'; |
| 2 | 3 | ||
| 3 | import 'api/client.dart'; | 4 | import 'api/client.dart'; |
| @@ -12,11 +13,15 @@ import 'models/models.dart'; | |||
| 12 | /// The web build is served *by* the reader, so it leaves this empty and uses | 13 | /// The web build is served *by* the reader, so it leaves this empty and uses |
| 13 | /// relative paths: same origin, which means no CORS preflight and the | 14 | /// relative paths: same origin, which means no CORS preflight and the |
| 14 | /// reader's own cookie on every request. | 15 | /// reader's own cookie on every request. |
| 15 | -const kDefaultBaseUrl = String.fromEnvironment( | 16 | +const kConfiguredBaseUrl = String.fromEnvironment( |
| 16 | 'GLEAN_BASE_URL', | 17 | 'GLEAN_BASE_URL', |
| 17 | - defaultValue: 'http://127.0.0.1:8080', | 18 | + defaultValue: '', |
| 18 | ); | 19 | ); |
| 19 | 20 | ||
| 21 | +String get kDefaultBaseUrl => kConfiguredBaseUrl.isNotEmpty | ||
| 22 | + ? kConfiguredBaseUrl | ||
| 23 | + : (kIsWeb ? '' : 'http://127.0.0.1:8080'); | ||
| 24 | + | ||
| 20 | /// Shared secret, for a reader running with GLEAN_TOKEN set. | 25 | /// Shared secret, for a reader running with GLEAN_TOKEN set. |
| 21 | /// | 26 | /// |
| 22 | /// Empty by default, which is right for a local reader and for the web build | 27 | /// Empty by default, which is right for a local reader and for the web build |
| @@ -31,10 +36,10 @@ const kToken = String.fromEnvironment('GLEAN_TOKEN', defaultValue: ''); | |||
| 31 | /// not set them. | 36 | /// not set them. |
| 32 | class AppState extends ChangeNotifier { | 37 | class AppState extends ChangeNotifier { |
| 33 | AppState({ | 38 | AppState({ |
| 34 | - String baseUrl = kDefaultBaseUrl, | 39 | + String? baseUrl, |
| 35 | String token = kToken, | 40 | String token = kToken, |
| 36 | GleanClient? client, | 41 | GleanClient? client, |
| 37 | - }) : client = client ?? GleanClient(baseUrl: baseUrl, token: token); | 42 | + }) : client = client ?? GleanClient(baseUrl: baseUrl ?? kDefaultBaseUrl, token: token); |
| 38 | 43 | ||
| 39 | final GleanClient client; | 44 | final GleanClient client; |
| 40 | 45 | ||
added
app/lib/src/screens/feed_manager_screen.dart +292 -0 | new file mode 100644 | ||
| @@ -0,0 +1,292 @@ | ||
| 1 | +import 'package:flutter/material.dart'; | |
| 2 | +import 'package:flutter/services.dart'; | |
| 3 | + | |
| 4 | +import '../api/client.dart'; | |
| 5 | +import '../app_state.dart'; | |
| 6 | +import '../models/models.dart'; | |
| 7 | +import '../theme.dart'; | |
| 8 | +import '../widgets/common.dart'; | |
| 9 | + | |
| 10 | +/// A dedicated, visible place to maintain subscriptions and OPML backups. | |
| 11 | +class FeedManagerScreen extends StatefulWidget { | |
| 12 | + const FeedManagerScreen({super.key, required this.onClose}); | |
| 13 | + | |
| 14 | + final ValueChanged<bool> onClose; | |
| 15 | + | |
| 16 | + @override | |
| 17 | + State<FeedManagerScreen> createState() => _FeedManagerScreenState(); | |
| 18 | +} | |
| 19 | + | |
| 20 | +class _FeedManagerScreenState extends State<FeedManagerScreen> { | |
| 21 | + final _url = TextEditingController(); | |
| 22 | + final _filter = TextEditingController(); | |
| 23 | + bool _adding = false; | |
| 24 | + bool _changed = false; | |
| 25 | + | |
| 26 | + @override | |
| 27 | + void dispose() { | |
| 28 | + _url.dispose(); | |
| 29 | + _filter.dispose(); | |
| 30 | + super.dispose(); | |
| 31 | + } | |
| 32 | + | |
| 33 | + Future<void> _add() async { | |
| 34 | + final url = _url.text.trim(); | |
| 35 | + if (url.isEmpty || _adding) return; | |
| 36 | + setState(() => _adding = true); | |
| 37 | + try { | |
| 38 | + await AppScope.read(context).addFeed(url); | |
| 39 | + _url.clear(); | |
| 40 | + _changed = true; | |
| 41 | + if (mounted) showToast(context, 'Feed added'); | |
| 42 | + } on ApiException catch (e) { | |
| 43 | + if (mounted) showToast(context, e.message); | |
| 44 | + } finally { | |
| 45 | + if (mounted) setState(() => _adding = false); | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + Future<void> _remove(Feed feed) async { | |
| 50 | + final confirmed = await showDialog<bool>( | |
| 51 | + context: context, | |
| 52 | + builder: (dialogContext) => AlertDialog( | |
| 53 | + shape: const RoundedRectangleBorder(), | |
| 54 | + title: const Text('Remove feed?'), | |
| 55 | + content: Text('Stop following ${feed.title}?'), | |
| 56 | + actions: [ | |
| 57 | + TextButton( | |
| 58 | + onPressed: () => Navigator.pop(dialogContext, false), | |
| 59 | + child: const Text('Keep'), | |
| 60 | + ), | |
| 61 | + TextButton( | |
| 62 | + onPressed: () => Navigator.pop(dialogContext, true), | |
| 63 | + child: const Text('Remove'), | |
| 64 | + ), | |
| 65 | + ], | |
| 66 | + ), | |
| 67 | + ); | |
| 68 | + if (confirmed != true || !mounted) return; | |
| 69 | + try { | |
| 70 | + await AppScope.read(context).removeFeed(feed.feedUrl); | |
| 71 | + _changed = true; | |
| 72 | + if (mounted) showToast(context, 'Feed removed'); | |
| 73 | + } on ApiException catch (e) { | |
| 74 | + if (mounted) showToast(context, e.message); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + Future<void> _importOpml() async { | |
| 79 | + final source = await showDialog<String>( | |
| 80 | + context: context, | |
| 81 | + builder: (_) => const _ImportOpmlDialog(), | |
| 82 | + ); | |
| 83 | + if (source == null || source.trim().isEmpty || !mounted) return; | |
| 84 | + try { | |
| 85 | + final result = await AppScope.read(context).importOpml(source); | |
| 86 | + _changed = result.added > 0; | |
| 87 | + if (mounted) { | |
| 88 | + showToast( | |
| 89 | + context, | |
| 90 | + '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}', | |
| 91 | + ); | |
| 92 | + } | |
| 93 | + } on ApiException catch (e) { | |
| 94 | + if (mounted) showToast(context, e.message); | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + Future<void> _exportOpml() async { | |
| 99 | + try { | |
| 100 | + final opml = await AppScope.read(context).exportOpml(); | |
| 101 | + await Clipboard.setData(ClipboardData(text: opml)); | |
| 102 | + if (mounted) showToast(context, 'OPML backup copied to clipboard'); | |
| 103 | + } on ApiException catch (e) { | |
| 104 | + if (mounted) showToast(context, e.message); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + @override | |
| 109 | + Widget build(BuildContext context) { | |
| 110 | + final app = AppScope.of(context); | |
| 111 | + final c = GleanColors.of(context); | |
| 112 | + final filter = _filter.text.trim().toLowerCase(); | |
| 113 | + final feeds = app.feeds.where((feed) { | |
| 114 | + return filter.isEmpty || | |
| 115 | + feed.title.toLowerCase().contains(filter) || | |
| 116 | + feed.feedUrl.toLowerCase().contains(filter); | |
| 117 | + }).toList(); | |
| 118 | + | |
| 119 | + return Scaffold( | |
| 120 | + appBar: AppBar( | |
| 121 | + leading: IconButton( | |
| 122 | + tooltip: 'Back', | |
| 123 | + icon: const Icon(Icons.arrow_back), | |
| 124 | + onPressed: () => widget.onClose(_changed), | |
| 125 | + ), | |
| 126 | + title: const Text('Manage feeds'), | |
| 127 | + ), | |
| 128 | + body: ListView( | |
| 129 | + padding: const EdgeInsets.all(16), | |
| 130 | + children: [ | |
| 131 | + Text( | |
| 132 | + 'Add a subscription', | |
| 133 | + style: Theme.of(context).textTheme.titleMedium, | |
| 134 | + ), | |
| 135 | + const SizedBox(height: 8), | |
| 136 | + Row( | |
| 137 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 138 | + children: [ | |
| 139 | + Expanded( | |
| 140 | + child: TextField( | |
| 141 | + controller: _url, | |
| 142 | + keyboardType: TextInputType.url, | |
| 143 | + textInputAction: TextInputAction.done, | |
| 144 | + onSubmitted: (_) => _add(), | |
| 145 | + decoration: const InputDecoration( | |
| 146 | + hintText: 'https://example.com/feed.xml', | |
| 147 | + ), | |
| 148 | + ), | |
| 149 | + ), | |
| 150 | + const SizedBox(width: 8), | |
| 151 | + FilledButton.icon( | |
| 152 | + onPressed: _adding ? null : _add, | |
| 153 | + icon: _adding | |
| 154 | + ? const SizedBox( | |
| 155 | + width: 16, | |
| 156 | + height: 16, | |
| 157 | + child: CircularProgressIndicator(strokeWidth: 2), | |
| 158 | + ) | |
| 159 | + : const Icon(Icons.add), | |
| 160 | + label: const Text('Add'), | |
| 161 | + ), | |
| 162 | + ], | |
| 163 | + ), | |
| 164 | + const SizedBox(height: 28), | |
| 165 | + Row( | |
| 166 | + children: [ | |
| 167 | + Text( | |
| 168 | + 'Subscriptions', | |
| 169 | + style: Theme.of(context).textTheme.titleMedium, | |
| 170 | + ), | |
| 171 | + const Spacer(), | |
| 172 | + Text( | |
| 173 | + '${app.feeds.length}', | |
| 174 | + style: Theme.of(context).textTheme.bodySmall, | |
| 175 | + ), | |
| 176 | + ], | |
| 177 | + ), | |
| 178 | + const SizedBox(height: 8), | |
| 179 | + TextField( | |
| 180 | + controller: _filter, | |
| 181 | + onChanged: (_) => setState(() {}), | |
| 182 | + decoration: const InputDecoration( | |
| 183 | + prefixIcon: Icon(Icons.search), | |
| 184 | + hintText: 'Filter feeds', | |
| 185 | + ), | |
| 186 | + ), | |
| 187 | + const SizedBox(height: 8), | |
| 188 | + if (feeds.isEmpty) | |
| 189 | + Padding( | |
| 190 | + padding: const EdgeInsets.symmetric(vertical: 32), | |
| 191 | + child: Text( | |
| 192 | + filter.isEmpty ? 'No subscriptions yet.' : 'No matching feeds.', | |
| 193 | + textAlign: TextAlign.center, | |
| 194 | + style: Theme.of(context).textTheme.bodySmall, | |
| 195 | + ), | |
| 196 | + ) | |
| 197 | + else | |
| 198 | + ...feeds.map( | |
| 199 | + (feed) => Container( | |
| 200 | + margin: const EdgeInsets.only(bottom: 8), | |
| 201 | + decoration: BoxDecoration( | |
| 202 | + border: Border.all(color: c.border, width: 2), | |
| 203 | + color: c.surface, | |
| 204 | + ), | |
| 205 | + child: ListTile( | |
| 206 | + contentPadding: const EdgeInsets.only(left: 14, right: 4), | |
| 207 | + title: Text( | |
| 208 | + feed.title, | |
| 209 | + maxLines: 1, | |
| 210 | + overflow: TextOverflow.ellipsis, | |
| 211 | + ), | |
| 212 | + subtitle: Text( | |
| 213 | + feed.feedUrl, | |
| 214 | + maxLines: 1, | |
| 215 | + overflow: TextOverflow.ellipsis, | |
| 216 | + ), | |
| 217 | + trailing: Row( | |
| 218 | + mainAxisSize: MainAxisSize.min, | |
| 219 | + children: [ | |
| 220 | + if (feed.unread > 0) Text('${feed.unread} unread'), | |
| 221 | + IconButton( | |
| 222 | + tooltip: 'Remove ${feed.title}', | |
| 223 | + icon: Icon(Icons.delete_outline, color: c.danger), | |
| 224 | + onPressed: () => _remove(feed), | |
| 225 | + ), | |
| 226 | + ], | |
| 227 | + ), | |
| 228 | + ), | |
| 229 | + ), | |
| 230 | + ), | |
| 231 | + const SizedBox(height: 28), | |
| 232 | + Text( | |
| 233 | + 'Backup & restore', | |
| 234 | + style: Theme.of(context).textTheme.titleMedium, | |
| 235 | + ), | |
| 236 | + const SizedBox(height: 8), | |
| 237 | + OutlinedButton.icon( | |
| 238 | + onPressed: _importOpml, | |
| 239 | + icon: const Icon(Icons.upload_file), | |
| 240 | + label: const Text('Import OPML'), | |
| 241 | + ), | |
| 242 | + const SizedBox(height: 8), | |
| 243 | + OutlinedButton.icon( | |
| 244 | + onPressed: _exportOpml, | |
| 245 | + icon: const Icon(Icons.content_copy), | |
| 246 | + label: const Text('Copy OPML backup'), | |
| 247 | + ), | |
| 248 | + ], | |
| 249 | + ), | |
| 250 | + ); | |
| 251 | + } | |
| 252 | +} | |
| 253 | + | |
| 254 | +class _ImportOpmlDialog extends StatefulWidget { | |
| 255 | + const _ImportOpmlDialog(); | |
| 256 | + | |
| 257 | + @override | |
| 258 | + State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState(); | |
| 259 | +} | |
| 260 | + | |
| 261 | +class _ImportOpmlDialogState extends State<_ImportOpmlDialog> { | |
| 262 | + final _controller = TextEditingController(); | |
| 263 | + | |
| 264 | + @override | |
| 265 | + void dispose() { | |
| 266 | + _controller.dispose(); | |
| 267 | + super.dispose(); | |
| 268 | + } | |
| 269 | + | |
| 270 | + @override | |
| 271 | + Widget build(BuildContext context) => AlertDialog( | |
| 272 | + shape: const RoundedRectangleBorder(), | |
| 273 | + title: const Text('Import OPML'), | |
| 274 | + content: TextField( | |
| 275 | + controller: _controller, | |
| 276 | + autofocus: true, | |
| 277 | + minLines: 8, | |
| 278 | + maxLines: 14, | |
| 279 | + decoration: const InputDecoration(hintText: 'Paste your OPML here'), | |
| 280 | + ), | |
| 281 | + actions: [ | |
| 282 | + TextButton( | |
| 283 | + onPressed: () => Navigator.pop(context), | |
| 284 | + child: const Text('Cancel'), | |
| 285 | + ), | |
| 286 | + TextButton( | |
| 287 | + onPressed: () => Navigator.pop(context, _controller.text), | |
| 288 | + child: const Text('Import'), | |
| 289 | + ), | |
| 290 | + ], | |
| 291 | + ); | |
| 292 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,292 @@ | |||
| 1 | +import 'package:flutter/material.dart'; | ||
| 2 | +import 'package:flutter/services.dart'; | ||
| 3 | + | ||
| 4 | +import '../api/client.dart'; | ||
| 5 | +import '../app_state.dart'; | ||
| 6 | +import '../models/models.dart'; | ||
| 7 | +import '../theme.dart'; | ||
| 8 | +import '../widgets/common.dart'; | ||
| 9 | + | ||
| 10 | +/// A dedicated, visible place to maintain subscriptions and OPML backups. | ||
| 11 | +class FeedManagerScreen extends StatefulWidget { | ||
| 12 | + const FeedManagerScreen({super.key, required this.onClose}); | ||
| 13 | + | ||
| 14 | + final ValueChanged<bool> onClose; | ||
| 15 | + | ||
| 16 | + @override | ||
| 17 | + State<FeedManagerScreen> createState() => _FeedManagerScreenState(); | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +class _FeedManagerScreenState extends State<FeedManagerScreen> { | ||
| 21 | + final _url = TextEditingController(); | ||
| 22 | + final _filter = TextEditingController(); | ||
| 23 | + bool _adding = false; | ||
| 24 | + bool _changed = false; | ||
| 25 | + | ||
| 26 | + @override | ||
| 27 | + void dispose() { | ||
| 28 | + _url.dispose(); | ||
| 29 | + _filter.dispose(); | ||
| 30 | + super.dispose(); | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + Future<void> _add() async { | ||
| 34 | + final url = _url.text.trim(); | ||
| 35 | + if (url.isEmpty || _adding) return; | ||
| 36 | + setState(() => _adding = true); | ||
| 37 | + try { | ||
| 38 | + await AppScope.read(context).addFeed(url); | ||
| 39 | + _url.clear(); | ||
| 40 | + _changed = true; | ||
| 41 | + if (mounted) showToast(context, 'Feed added'); | ||
| 42 | + } on ApiException catch (e) { | ||
| 43 | + if (mounted) showToast(context, e.message); | ||
| 44 | + } finally { | ||
| 45 | + if (mounted) setState(() => _adding = false); | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + Future<void> _remove(Feed feed) async { | ||
| 50 | + final confirmed = await showDialog<bool>( | ||
| 51 | + context: context, | ||
| 52 | + builder: (dialogContext) => AlertDialog( | ||
| 53 | + shape: const RoundedRectangleBorder(), | ||
| 54 | + title: const Text('Remove feed?'), | ||
| 55 | + content: Text('Stop following ${feed.title}?'), | ||
| 56 | + actions: [ | ||
| 57 | + TextButton( | ||
| 58 | + onPressed: () => Navigator.pop(dialogContext, false), | ||
| 59 | + child: const Text('Keep'), | ||
| 60 | + ), | ||
| 61 | + TextButton( | ||
| 62 | + onPressed: () => Navigator.pop(dialogContext, true), | ||
| 63 | + child: const Text('Remove'), | ||
| 64 | + ), | ||
| 65 | + ], | ||
| 66 | + ), | ||
| 67 | + ); | ||
| 68 | + if (confirmed != true || !mounted) return; | ||
| 69 | + try { | ||
| 70 | + await AppScope.read(context).removeFeed(feed.feedUrl); | ||
| 71 | + _changed = true; | ||
| 72 | + if (mounted) showToast(context, 'Feed removed'); | ||
| 73 | + } on ApiException catch (e) { | ||
| 74 | + if (mounted) showToast(context, e.message); | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + Future<void> _importOpml() async { | ||
| 79 | + final source = await showDialog<String>( | ||
| 80 | + context: context, | ||
| 81 | + builder: (_) => const _ImportOpmlDialog(), | ||
| 82 | + ); | ||
| 83 | + if (source == null || source.trim().isEmpty || !mounted) return; | ||
| 84 | + try { | ||
| 85 | + final result = await AppScope.read(context).importOpml(source); | ||
| 86 | + _changed = result.added > 0; | ||
| 87 | + if (mounted) { | ||
| 88 | + showToast( | ||
| 89 | + context, | ||
| 90 | + '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}', | ||
| 91 | + ); | ||
| 92 | + } | ||
| 93 | + } on ApiException catch (e) { | ||
| 94 | + if (mounted) showToast(context, e.message); | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + Future<void> _exportOpml() async { | ||
| 99 | + try { | ||
| 100 | + final opml = await AppScope.read(context).exportOpml(); | ||
| 101 | + await Clipboard.setData(ClipboardData(text: opml)); | ||
| 102 | + if (mounted) showToast(context, 'OPML backup copied to clipboard'); | ||
| 103 | + } on ApiException catch (e) { | ||
| 104 | + if (mounted) showToast(context, e.message); | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + @override | ||
| 109 | + Widget build(BuildContext context) { | ||
| 110 | + final app = AppScope.of(context); | ||
| 111 | + final c = GleanColors.of(context); | ||
| 112 | + final filter = _filter.text.trim().toLowerCase(); | ||
| 113 | + final feeds = app.feeds.where((feed) { | ||
| 114 | + return filter.isEmpty || | ||
| 115 | + feed.title.toLowerCase().contains(filter) || | ||
| 116 | + feed.feedUrl.toLowerCase().contains(filter); | ||
| 117 | + }).toList(); | ||
| 118 | + | ||
| 119 | + return Scaffold( | ||
| 120 | + appBar: AppBar( | ||
| 121 | + leading: IconButton( | ||
| 122 | + tooltip: 'Back', | ||
| 123 | + icon: const Icon(Icons.arrow_back), | ||
| 124 | + onPressed: () => widget.onClose(_changed), | ||
| 125 | + ), | ||
| 126 | + title: const Text('Manage feeds'), | ||
| 127 | + ), | ||
| 128 | + body: ListView( | ||
| 129 | + padding: const EdgeInsets.all(16), | ||
| 130 | + children: [ | ||
| 131 | + Text( | ||
| 132 | + 'Add a subscription', | ||
| 133 | + style: Theme.of(context).textTheme.titleMedium, | ||
| 134 | + ), | ||
| 135 | + const SizedBox(height: 8), | ||
| 136 | + Row( | ||
| 137 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 138 | + children: [ | ||
| 139 | + Expanded( | ||
| 140 | + child: TextField( | ||
| 141 | + controller: _url, | ||
| 142 | + keyboardType: TextInputType.url, | ||
| 143 | + textInputAction: TextInputAction.done, | ||
| 144 | + onSubmitted: (_) => _add(), | ||
| 145 | + decoration: const InputDecoration( | ||
| 146 | + hintText: 'https://example.com/feed.xml', | ||
| 147 | + ), | ||
| 148 | + ), | ||
| 149 | + ), | ||
| 150 | + const SizedBox(width: 8), | ||
| 151 | + FilledButton.icon( | ||
| 152 | + onPressed: _adding ? null : _add, | ||
| 153 | + icon: _adding | ||
| 154 | + ? const SizedBox( | ||
| 155 | + width: 16, | ||
| 156 | + height: 16, | ||
| 157 | + child: CircularProgressIndicator(strokeWidth: 2), | ||
| 158 | + ) | ||
| 159 | + : const Icon(Icons.add), | ||
| 160 | + label: const Text('Add'), | ||
| 161 | + ), | ||
| 162 | + ], | ||
| 163 | + ), | ||
| 164 | + const SizedBox(height: 28), | ||
| 165 | + Row( | ||
| 166 | + children: [ | ||
| 167 | + Text( | ||
| 168 | + 'Subscriptions', | ||
| 169 | + style: Theme.of(context).textTheme.titleMedium, | ||
| 170 | + ), | ||
| 171 | + const Spacer(), | ||
| 172 | + Text( | ||
| 173 | + '${app.feeds.length}', | ||
| 174 | + style: Theme.of(context).textTheme.bodySmall, | ||
| 175 | + ), | ||
| 176 | + ], | ||
| 177 | + ), | ||
| 178 | + const SizedBox(height: 8), | ||
| 179 | + TextField( | ||
| 180 | + controller: _filter, | ||
| 181 | + onChanged: (_) => setState(() {}), | ||
| 182 | + decoration: const InputDecoration( | ||
| 183 | + prefixIcon: Icon(Icons.search), | ||
| 184 | + hintText: 'Filter feeds', | ||
| 185 | + ), | ||
| 186 | + ), | ||
| 187 | + const SizedBox(height: 8), | ||
| 188 | + if (feeds.isEmpty) | ||
| 189 | + Padding( | ||
| 190 | + padding: const EdgeInsets.symmetric(vertical: 32), | ||
| 191 | + child: Text( | ||
| 192 | + filter.isEmpty ? 'No subscriptions yet.' : 'No matching feeds.', | ||
| 193 | + textAlign: TextAlign.center, | ||
| 194 | + style: Theme.of(context).textTheme.bodySmall, | ||
| 195 | + ), | ||
| 196 | + ) | ||
| 197 | + else | ||
| 198 | + ...feeds.map( | ||
| 199 | + (feed) => Container( | ||
| 200 | + margin: const EdgeInsets.only(bottom: 8), | ||
| 201 | + decoration: BoxDecoration( | ||
| 202 | + border: Border.all(color: c.border, width: 2), | ||
| 203 | + color: c.surface, | ||
| 204 | + ), | ||
| 205 | + child: ListTile( | ||
| 206 | + contentPadding: const EdgeInsets.only(left: 14, right: 4), | ||
| 207 | + title: Text( | ||
| 208 | + feed.title, | ||
| 209 | + maxLines: 1, | ||
| 210 | + overflow: TextOverflow.ellipsis, | ||
| 211 | + ), | ||
| 212 | + subtitle: Text( | ||
| 213 | + feed.feedUrl, | ||
| 214 | + maxLines: 1, | ||
| 215 | + overflow: TextOverflow.ellipsis, | ||
| 216 | + ), | ||
| 217 | + trailing: Row( | ||
| 218 | + mainAxisSize: MainAxisSize.min, | ||
| 219 | + children: [ | ||
| 220 | + if (feed.unread > 0) Text('${feed.unread} unread'), | ||
| 221 | + IconButton( | ||
| 222 | + tooltip: 'Remove ${feed.title}', | ||
| 223 | + icon: Icon(Icons.delete_outline, color: c.danger), | ||
| 224 | + onPressed: () => _remove(feed), | ||
| 225 | + ), | ||
| 226 | + ], | ||
| 227 | + ), | ||
| 228 | + ), | ||
| 229 | + ), | ||
| 230 | + ), | ||
| 231 | + const SizedBox(height: 28), | ||
| 232 | + Text( | ||
| 233 | + 'Backup & restore', | ||
| 234 | + style: Theme.of(context).textTheme.titleMedium, | ||
| 235 | + ), | ||
| 236 | + const SizedBox(height: 8), | ||
| 237 | + OutlinedButton.icon( | ||
| 238 | + onPressed: _importOpml, | ||
| 239 | + icon: const Icon(Icons.upload_file), | ||
| 240 | + label: const Text('Import OPML'), | ||
| 241 | + ), | ||
| 242 | + const SizedBox(height: 8), | ||
| 243 | + OutlinedButton.icon( | ||
| 244 | + onPressed: _exportOpml, | ||
| 245 | + icon: const Icon(Icons.content_copy), | ||
| 246 | + label: const Text('Copy OPML backup'), | ||
| 247 | + ), | ||
| 248 | + ], | ||
| 249 | + ), | ||
| 250 | + ); | ||
| 251 | + } | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +class _ImportOpmlDialog extends StatefulWidget { | ||
| 255 | + const _ImportOpmlDialog(); | ||
| 256 | + | ||
| 257 | + @override | ||
| 258 | + State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState(); | ||
| 259 | +} | ||
| 260 | + | ||
| 261 | +class _ImportOpmlDialogState extends State<_ImportOpmlDialog> { | ||
| 262 | + final _controller = TextEditingController(); | ||
| 263 | + | ||
| 264 | + @override | ||
| 265 | + void dispose() { | ||
| 266 | + _controller.dispose(); | ||
| 267 | + super.dispose(); | ||
| 268 | + } | ||
| 269 | + | ||
| 270 | + @override | ||
| 271 | + Widget build(BuildContext context) => AlertDialog( | ||
| 272 | + shape: const RoundedRectangleBorder(), | ||
| 273 | + title: const Text('Import OPML'), | ||
| 274 | + content: TextField( | ||
| 275 | + controller: _controller, | ||
| 276 | + autofocus: true, | ||
| 277 | + minLines: 8, | ||
| 278 | + maxLines: 14, | ||
| 279 | + decoration: const InputDecoration(hintText: 'Paste your OPML here'), | ||
| 280 | + ), | ||
| 281 | + actions: [ | ||
| 282 | + TextButton( | ||
| 283 | + onPressed: () => Navigator.pop(context), | ||
| 284 | + child: const Text('Cancel'), | ||
| 285 | + ), | ||
| 286 | + TextButton( | ||
| 287 | + onPressed: () => Navigator.pop(context, _controller.text), | ||
| 288 | + child: const Text('Import'), | ||
| 289 | + ), | ||
| 290 | + ], | ||
| 291 | + ); | ||
| 292 | +} | ||
modified
app/lib/src/screens/home_shell.dart +24 -171 | @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import 'package:flutter/material.dart'; |
| 2 | -import 'package:flutter/services.dart'; | |
| 3 | 2 | |
| 4 | 3 | import '../api/client.dart'; |
| 5 | 4 | import '../app_state.dart'; |
| @@ -7,6 +6,7 @@ import '../models/models.dart'; | ||
| 7 | 6 | import '../theme.dart'; |
| 8 | 7 | import '../widgets/common.dart'; |
| 9 | 8 | import 'articles_screen.dart'; |
| 9 | +import 'feed_manager_screen.dart'; | |
| 10 | 10 | |
| 11 | 11 | /// Articles, with the feed list in a drawer. |
| 12 | 12 | /// |
| @@ -23,6 +23,7 @@ class HomeShell extends StatefulWidget { | ||
| 23 | 23 | class _HomeShellState extends State<HomeShell> { |
| 24 | 24 | Feed _selected = Feed.all(0); |
| 25 | 25 | bool _refreshing = false; |
| 26 | + bool _managingFeeds = false; | |
| 26 | 27 | |
| 27 | 28 | @override |
| 28 | 29 | void initState() { |
| @@ -32,19 +33,6 @@ class _HomeShellState extends State<HomeShell> { | ||
| 32 | 33 | }); |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | - Future<void> _addFeed() async { | |
| 36 | - final url = await showDialog<String>( | |
| 37 | - context: context, | |
| 38 | - builder: (_) => const _AddFeedDialog(), | |
| 39 | - ); | |
| 40 | - if (url == null || url.isEmpty || !mounted) return; | |
| 41 | - try { | |
| 42 | - await AppScope.read(context).addFeed(url); | |
| 43 | - } on ApiException catch (e) { | |
| 44 | - if (mounted) showToast(context, e.message); | |
| 45 | - } | |
| 46 | - } | |
| 47 | - | |
| 48 | 36 | Future<void> _refresh() async { |
| 49 | 37 | setState(() => _refreshing = true); |
| 50 | 38 | try { |
| @@ -86,79 +74,27 @@ class _HomeShellState extends State<HomeShell> { | ||
| 86 | 74 | await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl); |
| 87 | 75 | } |
| 88 | 76 | |
| 89 | - Future<void> _remove(Feed f) async { | |
| 90 | - final ok = await showDialog<bool>( | |
| 91 | - context: context, | |
| 92 | - builder: (ctx) => AlertDialog( | |
| 93 | - shape: const RoundedRectangleBorder(), | |
| 94 | - title: const Text('Unsubscribe?'), | |
| 95 | - content: Text(f.title), | |
| 96 | - actions: [ | |
| 97 | - TextButton( | |
| 98 | - onPressed: () => Navigator.pop(ctx, false), | |
| 99 | - child: const Text('Cancel'), | |
| 100 | - ), | |
| 101 | - TextButton( | |
| 102 | - onPressed: () => Navigator.pop(ctx, true), | |
| 103 | - child: const Text('Unsubscribe'), | |
| 104 | - ), | |
| 105 | - ], | |
| 106 | - ), | |
| 107 | - ); | |
| 108 | - if (ok != true || !mounted) return; | |
| 109 | - await AppScope.read(context).removeFeed(f.feedUrl); | |
| 110 | - if (mounted && _selected.feedUrl == f.feedUrl) { | |
| 111 | - setState(() => _selected = Feed.all(0)); | |
| 112 | - } | |
| 113 | - } | |
| 77 | + void _manageFeeds() => setState(() => _managingFeeds = true); | |
| 114 | 78 | |
| 115 | - Future<void> _manageFeeds() async { | |
| 116 | - final action = await showModalBottomSheet<String>( | |
| 117 | - context: context, | |
| 118 | - builder: (ctx) => SafeArea( | |
| 119 | - child: Column( | |
| 120 | - mainAxisSize: MainAxisSize.min, | |
| 121 | - children: [ | |
| 122 | - ListTile( | |
| 123 | - leading: const Icon(Icons.upload_file), | |
| 124 | - title: const Text('Import OPML'), | |
| 125 | - onTap: () => Navigator.pop(ctx, 'import'), | |
| 126 | - ), | |
| 127 | - ListTile( | |
| 128 | - leading: const Icon(Icons.download), | |
| 129 | - title: const Text('Copy OPML backup'), | |
| 130 | - onTap: () => Navigator.pop(ctx, 'export'), | |
| 131 | - ), | |
| 132 | - ], | |
| 133 | - ), | |
| 134 | - ), | |
| 135 | - ); | |
| 136 | - if (!mounted || action == null) return; | |
| 137 | - try { | |
| 138 | - if (action == 'export') { | |
| 139 | - final opml = await AppScope.read(context).exportOpml(); | |
| 140 | - await Clipboard.setData(ClipboardData(text: opml)); | |
| 141 | - if (mounted) showToast(context, 'OPML backup copied to clipboard'); | |
| 142 | - } else { | |
| 143 | - final source = await showDialog<String>( | |
| 144 | - context: context, | |
| 145 | - builder: (_) => const _ImportOpmlDialog(), | |
| 146 | - ); | |
| 147 | - if (source == null || source.trim().isEmpty || !mounted) return; | |
| 148 | - final result = await AppScope.read(context).importOpml(source); | |
| 149 | - if (mounted) | |
| 150 | - showToast( | |
| 151 | - context, | |
| 152 | - '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}', | |
| 153 | - ); | |
| 154 | - } | |
| 155 | - } on ApiException catch (e) { | |
| 156 | - if (mounted) showToast(context, e.message); | |
| 79 | + void _closeManageFeeds(bool changed) { | |
| 80 | + if (!changed) { | |
| 81 | + setState(() => _managingFeeds = false); | |
| 82 | + return; | |
| 157 | 83 | } |
| 84 | + final currentExists = | |
| 85 | + _selected.isAll || | |
| 86 | + AppScope.read(context).feeds.any((f) => f.feedUrl == _selected.feedUrl); | |
| 87 | + setState(() { | |
| 88 | + _managingFeeds = false; | |
| 89 | + if (!currentExists) _selected = Feed.all(0); | |
| 90 | + }); | |
| 158 | 91 | } |
| 159 | 92 | |
| 160 | 93 | @override |
| 161 | 94 | Widget build(BuildContext context) { |
| 95 | + if (_managingFeeds) { | |
| 96 | + return FeedManagerScreen(onClose: _closeManageFeeds); | |
| 97 | + } | |
| 162 | 98 | final app = AppScope.of(context); |
| 163 | 99 | final c = GleanColors.of(context); |
| 164 | 100 | |
| @@ -166,6 +102,11 @@ class _HomeShellState extends State<HomeShell> { | ||
| 166 | 102 | appBar: AppBar( |
| 167 | 103 | title: Text(_selected.isAll ? 'glean' : _selected.title), |
| 168 | 104 | actions: [ |
| 105 | + IconButton( | |
| 106 | + tooltip: 'Manage feeds', | |
| 107 | + icon: const Icon(Icons.rss_feed), | |
| 108 | + onPressed: _manageFeeds, | |
| 109 | + ), | |
| 169 | 110 | IconButton( |
| 170 | 111 | tooltip: 'Mark all read', |
| 171 | 112 | icon: const Icon(Icons.done_all), |
| @@ -198,16 +139,11 @@ class _HomeShellState extends State<HomeShell> { | ||
| 198 | 139 | mainAxisSize: MainAxisSize.min, |
| 199 | 140 | children: [ |
| 200 | 141 | IconButton( |
| 201 | - icon: const Icon(Icons.more_horiz), | |
| 142 | + icon: const Icon(Icons.tune), | |
| 202 | 143 | tooltip: 'Manage feeds', |
| 203 | - onPressed: _manageFeeds, | |
| 204 | - ), | |
| 205 | - IconButton( | |
| 206 | - icon: const Icon(Icons.add), | |
| 207 | - tooltip: 'Add feed', | |
| 208 | 144 | onPressed: () { |
| 209 | 145 | Navigator.of(context).pop(); |
| 210 | - _addFeed(); | |
| 146 | + _manageFeeds(); | |
| 211 | 147 | }, |
| 212 | 148 | ), |
| 213 | 149 | ], |
| @@ -236,7 +172,6 @@ class _HomeShellState extends State<HomeShell> { | ||
| 236 | 172 | trailing: f.unread > 0 |
| 237 | 173 | ? GleanTag('${f.unread}', emphasis: !f.isAll) |
| 238 | 174 | : null, |
| 239 | - onLongPress: f.isAll ? null : () => _remove(f), | |
| 240 | 175 | onTap: () { |
| 241 | 176 | setState(() => _selected = f); |
| 242 | 177 | Navigator.of(context).pop(); |
| @@ -253,85 +188,3 @@ class _HomeShellState extends State<HomeShell> { | ||
| 253 | 188 | ); |
| 254 | 189 | } |
| 255 | 190 | } |
| 256 | - | |
| 257 | -class _AddFeedDialog extends StatefulWidget { | |
| 258 | - const _AddFeedDialog(); | |
| 259 | - | |
| 260 | - @override | |
| 261 | - State<_AddFeedDialog> createState() => _AddFeedDialogState(); | |
| 262 | -} | |
| 263 | - | |
| 264 | -class _ImportOpmlDialog extends StatefulWidget { | |
| 265 | - const _ImportOpmlDialog(); | |
| 266 | - @override | |
| 267 | - State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState(); | |
| 268 | -} | |
| 269 | - | |
| 270 | -class _ImportOpmlDialogState extends State<_ImportOpmlDialog> { | |
| 271 | - final _controller = TextEditingController(); | |
| 272 | - @override | |
| 273 | - void dispose() { | |
| 274 | - _controller.dispose(); | |
| 275 | - super.dispose(); | |
| 276 | - } | |
| 277 | - | |
| 278 | - @override | |
| 279 | - Widget build(BuildContext context) => AlertDialog( | |
| 280 | - shape: const RoundedRectangleBorder(), | |
| 281 | - title: const Text('Import OPML'), | |
| 282 | - content: TextField( | |
| 283 | - controller: _controller, | |
| 284 | - autofocus: true, | |
| 285 | - minLines: 8, | |
| 286 | - maxLines: 14, | |
| 287 | - decoration: const InputDecoration(hintText: 'Paste your OPML here'), | |
| 288 | - ), | |
| 289 | - actions: [ | |
| 290 | - TextButton( | |
| 291 | - onPressed: () => Navigator.pop(context), | |
| 292 | - child: const Text('Cancel'), | |
| 293 | - ), | |
| 294 | - TextButton( | |
| 295 | - onPressed: () => Navigator.pop(context, _controller.text), | |
| 296 | - child: const Text('Import'), | |
| 297 | - ), | |
| 298 | - ], | |
| 299 | - ); | |
| 300 | -} | |
| 301 | - | |
| 302 | -class _AddFeedDialogState extends State<_AddFeedDialog> { | |
| 303 | - final _controller = TextEditingController(); | |
| 304 | - | |
| 305 | - @override | |
| 306 | - void dispose() { | |
| 307 | - _controller.dispose(); | |
| 308 | - super.dispose(); | |
| 309 | - } | |
| 310 | - | |
| 311 | - @override | |
| 312 | - Widget build(BuildContext context) { | |
| 313 | - return AlertDialog( | |
| 314 | - shape: const RoundedRectangleBorder(), | |
| 315 | - title: const Text('Add feed'), | |
| 316 | - content: TextField( | |
| 317 | - controller: _controller, | |
| 318 | - autofocus: true, | |
| 319 | - keyboardType: TextInputType.url, | |
| 320 | - decoration: const InputDecoration( | |
| 321 | - hintText: 'https://example.com/feed.xml', | |
| 322 | - ), | |
| 323 | - onSubmitted: (v) => Navigator.pop(context, v.trim()), | |
| 324 | - ), | |
| 325 | - actions: [ | |
| 326 | - TextButton( | |
| 327 | - onPressed: () => Navigator.pop(context), | |
| 328 | - child: const Text('Cancel'), | |
| 329 | - ), | |
| 330 | - TextButton( | |
| 331 | - onPressed: () => Navigator.pop(context, _controller.text.trim()), | |
| 332 | - child: const Text('Add'), | |
| 333 | - ), | |
| 334 | - ], | |
| 335 | - ); | |
| 336 | - } | |
| 337 | -} | |
| @@ -1,5 +1,4 @@ | |||
| 1 | import 'package:flutter/material.dart'; | 1 | import 'package:flutter/material.dart'; |
| 2 | -import 'package:flutter/services.dart'; | ||
| 3 | 2 | ||
| 4 | import '../api/client.dart'; | 3 | import '../api/client.dart'; |
| 5 | import '../app_state.dart'; | 4 | import '../app_state.dart'; |
| @@ -7,6 +6,7 @@ import '../models/models.dart'; | |||
| 7 | import '../theme.dart'; | 6 | import '../theme.dart'; |
| 8 | import '../widgets/common.dart'; | 7 | import '../widgets/common.dart'; |
| 9 | import 'articles_screen.dart'; | 8 | import 'articles_screen.dart'; |
| 9 | +import 'feed_manager_screen.dart'; | ||
| 10 | 10 | ||
| 11 | /// Articles, with the feed list in a drawer. | 11 | /// Articles, with the feed list in a drawer. |
| 12 | /// | 12 | /// |
| @@ -23,6 +23,7 @@ class HomeShell extends StatefulWidget { | |||
| 23 | class _HomeShellState extends State<HomeShell> { | 23 | class _HomeShellState extends State<HomeShell> { |
| 24 | Feed _selected = Feed.all(0); | 24 | Feed _selected = Feed.all(0); |
| 25 | bool _refreshing = false; | 25 | bool _refreshing = false; |
| 26 | + bool _managingFeeds = false; | ||
| 26 | 27 | ||
| 27 | @override | 28 | @override |
| 28 | void initState() { | 29 | void initState() { |
| @@ -32,19 +33,6 @@ class _HomeShellState extends State<HomeShell> { | |||
| 32 | }); | 33 | }); |
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | - Future<void> _addFeed() async { | ||
| 36 | - final url = await showDialog<String>( | ||
| 37 | - context: context, | ||
| 38 | - builder: (_) => const _AddFeedDialog(), | ||
| 39 | - ); | ||
| 40 | - if (url == null || url.isEmpty || !mounted) return; | ||
| 41 | - try { | ||
| 42 | - await AppScope.read(context).addFeed(url); | ||
| 43 | - } on ApiException catch (e) { | ||
| 44 | - if (mounted) showToast(context, e.message); | ||
| 45 | - } | ||
| 46 | - } | ||
| 47 | - | ||
| 48 | Future<void> _refresh() async { | 36 | Future<void> _refresh() async { |
| 49 | setState(() => _refreshing = true); | 37 | setState(() => _refreshing = true); |
| 50 | try { | 38 | try { |
| @@ -86,79 +74,27 @@ class _HomeShellState extends State<HomeShell> { | |||
| 86 | await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl); | 74 | await AppScope.read(context).markAllRead(feedUrl: _selected.feedUrl); |
| 87 | } | 75 | } |
| 88 | 76 | ||
| 89 | - Future<void> _remove(Feed f) async { | 77 | + void _manageFeeds() => setState(() => _managingFeeds = true); |
| 90 | - final ok = await showDialog<bool>( | ||
| 91 | - context: context, | ||
| 92 | - builder: (ctx) => AlertDialog( | ||
| 93 | - shape: const RoundedRectangleBorder(), | ||
| 94 | - title: const Text('Unsubscribe?'), | ||
| 95 | - content: Text(f.title), | ||
| 96 | - actions: [ | ||
| 97 | - TextButton( | ||
| 98 | - onPressed: () => Navigator.pop(ctx, false), | ||
| 99 | - child: const Text('Cancel'), | ||
| 100 | - ), | ||
| 101 | - TextButton( | ||
| 102 | - onPressed: () => Navigator.pop(ctx, true), | ||
| 103 | - child: const Text('Unsubscribe'), | ||
| 104 | - ), | ||
| 105 | - ], | ||
| 106 | - ), | ||
| 107 | - ); | ||
| 108 | - if (ok != true || !mounted) return; | ||
| 109 | - await AppScope.read(context).removeFeed(f.feedUrl); | ||
| 110 | - if (mounted && _selected.feedUrl == f.feedUrl) { | ||
| 111 | - setState(() => _selected = Feed.all(0)); | ||
| 112 | - } | ||
| 113 | - } | ||
| 114 | 78 | ||
| 115 | - Future<void> _manageFeeds() async { | 79 | + void _closeManageFeeds(bool changed) { |
| 116 | - final action = await showModalBottomSheet<String>( | 80 | + if (!changed) { |
| 117 | - context: context, | 81 | + setState(() => _managingFeeds = false); |
| 118 | - builder: (ctx) => SafeArea( | 82 | + return; |
| 119 | - child: Column( | ||
| 120 | - mainAxisSize: MainAxisSize.min, | ||
| 121 | - children: [ | ||
| 122 | - ListTile( | ||
| 123 | - leading: const Icon(Icons.upload_file), | ||
| 124 | - title: const Text('Import OPML'), | ||
| 125 | - onTap: () => Navigator.pop(ctx, 'import'), | ||
| 126 | - ), | ||
| 127 | - ListTile( | ||
| 128 | - leading: const Icon(Icons.download), | ||
| 129 | - title: const Text('Copy OPML backup'), | ||
| 130 | - onTap: () => Navigator.pop(ctx, 'export'), | ||
| 131 | - ), | ||
| 132 | - ], | ||
| 133 | - ), | ||
| 134 | - ), | ||
| 135 | - ); | ||
| 136 | - if (!mounted || action == null) return; | ||
| 137 | - try { | ||
| 138 | - if (action == 'export') { | ||
| 139 | - final opml = await AppScope.read(context).exportOpml(); | ||
| 140 | - await Clipboard.setData(ClipboardData(text: opml)); | ||
| 141 | - if (mounted) showToast(context, 'OPML backup copied to clipboard'); | ||
| 142 | - } else { | ||
| 143 | - final source = await showDialog<String>( | ||
| 144 | - context: context, | ||
| 145 | - builder: (_) => const _ImportOpmlDialog(), | ||
| 146 | - ); | ||
| 147 | - if (source == null || source.trim().isEmpty || !mounted) return; | ||
| 148 | - final result = await AppScope.read(context).importOpml(source); | ||
| 149 | - if (mounted) | ||
| 150 | - showToast( | ||
| 151 | - context, | ||
| 152 | - '${result.added} feed(s) added${result.errors.isEmpty ? '' : ', ${result.errors.length} failed'}', | ||
| 153 | - ); | ||
| 154 | - } | ||
| 155 | - } on ApiException catch (e) { | ||
| 156 | - if (mounted) showToast(context, e.message); | ||
| 157 | } | 83 | } |
| 84 | + final currentExists = | ||
| 85 | + _selected.isAll || | ||
| 86 | + AppScope.read(context).feeds.any((f) => f.feedUrl == _selected.feedUrl); | ||
| 87 | + setState(() { | ||
| 88 | + _managingFeeds = false; | ||
| 89 | + if (!currentExists) _selected = Feed.all(0); | ||
| 90 | + }); | ||
| 158 | } | 91 | } |
| 159 | 92 | ||
| 160 | @override | 93 | @override |
| 161 | Widget build(BuildContext context) { | 94 | Widget build(BuildContext context) { |
| 95 | + if (_managingFeeds) { | ||
| 96 | + return FeedManagerScreen(onClose: _closeManageFeeds); | ||
| 97 | + } | ||
| 162 | final app = AppScope.of(context); | 98 | final app = AppScope.of(context); |
| 163 | final c = GleanColors.of(context); | 99 | final c = GleanColors.of(context); |
| 164 | 100 | ||
| @@ -166,6 +102,11 @@ class _HomeShellState extends State<HomeShell> { | |||
| 166 | appBar: AppBar( | 102 | appBar: AppBar( |
| 167 | title: Text(_selected.isAll ? 'glean' : _selected.title), | 103 | title: Text(_selected.isAll ? 'glean' : _selected.title), |
| 168 | actions: [ | 104 | actions: [ |
| 105 | + IconButton( | ||
| 106 | + tooltip: 'Manage feeds', | ||
| 107 | + icon: const Icon(Icons.rss_feed), | ||
| 108 | + onPressed: _manageFeeds, | ||
| 109 | + ), | ||
| 169 | IconButton( | 110 | IconButton( |
| 170 | tooltip: 'Mark all read', | 111 | tooltip: 'Mark all read', |
| 171 | icon: const Icon(Icons.done_all), | 112 | icon: const Icon(Icons.done_all), |
| @@ -198,16 +139,11 @@ class _HomeShellState extends State<HomeShell> { | |||
| 198 | mainAxisSize: MainAxisSize.min, | 139 | mainAxisSize: MainAxisSize.min, |
| 199 | children: [ | 140 | children: [ |
| 200 | IconButton( | 141 | IconButton( |
| 201 | - icon: const Icon(Icons.more_horiz), | 142 | + icon: const Icon(Icons.tune), |
| 202 | tooltip: 'Manage feeds', | 143 | tooltip: 'Manage feeds', |
| 203 | - onPressed: _manageFeeds, | ||
| 204 | - ), | ||
| 205 | - IconButton( | ||
| 206 | - icon: const Icon(Icons.add), | ||
| 207 | - tooltip: 'Add feed', | ||
| 208 | onPressed: () { | 144 | onPressed: () { |
| 209 | Navigator.of(context).pop(); | 145 | Navigator.of(context).pop(); |
| 210 | - _addFeed(); | 146 | + _manageFeeds(); |
| 211 | }, | 147 | }, |
| 212 | ), | 148 | ), |
| 213 | ], | 149 | ], |
| @@ -236,7 +172,6 @@ class _HomeShellState extends State<HomeShell> { | |||
| 236 | trailing: f.unread > 0 | 172 | trailing: f.unread > 0 |
| 237 | ? GleanTag('${f.unread}', emphasis: !f.isAll) | 173 | ? GleanTag('${f.unread}', emphasis: !f.isAll) |
| 238 | : null, | 174 | : null, |
| 239 | - onLongPress: f.isAll ? null : () => _remove(f), | ||
| 240 | onTap: () { | 175 | onTap: () { |
| 241 | setState(() => _selected = f); | 176 | setState(() => _selected = f); |
| 242 | Navigator.of(context).pop(); | 177 | Navigator.of(context).pop(); |
| @@ -253,85 +188,3 @@ class _HomeShellState extends State<HomeShell> { | |||
| 253 | ); | 188 | ); |
| 254 | } | 189 | } |
| 255 | } | 190 | } |
| 256 | - | ||
| 257 | -class _AddFeedDialog extends StatefulWidget { | ||
| 258 | - const _AddFeedDialog(); | ||
| 259 | - | ||
| 260 | - @override | ||
| 261 | - State<_AddFeedDialog> createState() => _AddFeedDialogState(); | ||
| 262 | -} | ||
| 263 | - | ||
| 264 | -class _ImportOpmlDialog extends StatefulWidget { | ||
| 265 | - const _ImportOpmlDialog(); | ||
| 266 | - @override | ||
| 267 | - State<_ImportOpmlDialog> createState() => _ImportOpmlDialogState(); | ||
| 268 | -} | ||
| 269 | - | ||
| 270 | -class _ImportOpmlDialogState extends State<_ImportOpmlDialog> { | ||
| 271 | - final _controller = TextEditingController(); | ||
| 272 | - @override | ||
| 273 | - void dispose() { | ||
| 274 | - _controller.dispose(); | ||
| 275 | - super.dispose(); | ||
| 276 | - } | ||
| 277 | - | ||
| 278 | - @override | ||
| 279 | - Widget build(BuildContext context) => AlertDialog( | ||
| 280 | - shape: const RoundedRectangleBorder(), | ||
| 281 | - title: const Text('Import OPML'), | ||
| 282 | - content: TextField( | ||
| 283 | - controller: _controller, | ||
| 284 | - autofocus: true, | ||
| 285 | - minLines: 8, | ||
| 286 | - maxLines: 14, | ||
| 287 | - decoration: const InputDecoration(hintText: 'Paste your OPML here'), | ||
| 288 | - ), | ||
| 289 | - actions: [ | ||
| 290 | - TextButton( | ||
| 291 | - onPressed: () => Navigator.pop(context), | ||
| 292 | - child: const Text('Cancel'), | ||
| 293 | - ), | ||
| 294 | - TextButton( | ||
| 295 | - onPressed: () => Navigator.pop(context, _controller.text), | ||
| 296 | - child: const Text('Import'), | ||
| 297 | - ), | ||
| 298 | - ], | ||
| 299 | - ); | ||
| 300 | -} | ||
| 301 | - | ||
| 302 | -class _AddFeedDialogState extends State<_AddFeedDialog> { | ||
| 303 | - final _controller = TextEditingController(); | ||
| 304 | - | ||
| 305 | - @override | ||
| 306 | - void dispose() { | ||
| 307 | - _controller.dispose(); | ||
| 308 | - super.dispose(); | ||
| 309 | - } | ||
| 310 | - | ||
| 311 | - @override | ||
| 312 | - Widget build(BuildContext context) { | ||
| 313 | - return AlertDialog( | ||
| 314 | - shape: const RoundedRectangleBorder(), | ||
| 315 | - title: const Text('Add feed'), | ||
| 316 | - content: TextField( | ||
| 317 | - controller: _controller, | ||
| 318 | - autofocus: true, | ||
| 319 | - keyboardType: TextInputType.url, | ||
| 320 | - decoration: const InputDecoration( | ||
| 321 | - hintText: 'https://example.com/feed.xml', | ||
| 322 | - ), | ||
| 323 | - onSubmitted: (v) => Navigator.pop(context, v.trim()), | ||
| 324 | - ), | ||
| 325 | - actions: [ | ||
| 326 | - TextButton( | ||
| 327 | - onPressed: () => Navigator.pop(context), | ||
| 328 | - child: const Text('Cancel'), | ||
| 329 | - ), | ||
| 330 | - TextButton( | ||
| 331 | - onPressed: () => Navigator.pop(context, _controller.text.trim()), | ||
| 332 | - child: const Text('Add'), | ||
| 333 | - ), | ||
| 334 | - ], | ||
| 335 | - ); | ||
| 336 | - } | ||
| 337 | -} | ||
modified
app/pubspec.lock +1 -1 | @@ -510,5 +510,5 @@ packages: | ||
| 510 | 510 | source: hosted |
| 511 | 511 | version: "1.1.0" |
| 512 | 512 | sdks: |
| 513 | - dart: ">=3.13.4 <4.0.0" | |
| 513 | + dart: ">=3.13.0 <4.0.0" | |
| 514 | 514 | flutter: ">=3.44.0" |
| @@ -510,5 +510,5 @@ packages: | |||
| 510 | source: hosted | 510 | source: hosted |
| 511 | version: "1.1.0" | 511 | version: "1.1.0" |
| 512 | sdks: | 512 | sdks: |
| 513 | - dart: ">=3.13.4 <4.0.0" | 513 | + dart: ">=3.13.0 <4.0.0" |
| 514 | flutter: ">=3.44.0" | 514 | flutter: ">=3.44.0" |
modified
app/pubspec.yaml +3 -1 | @@ -19,7 +19,9 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||
| 19 | 19 | version: 1.0.0+1 |
| 20 | 20 | |
| 21 | 21 | environment: |
| 22 | - sdk: ^3.13.4 | |
| 22 | + # The app does not depend on a Dart 3.13 patch-only API. Accept the | |
| 23 | + # available Flutter SDK's 3.13.0 while keeping the Dart 3.x upper bound. | |
| 24 | + sdk: '>=3.13.0 <4.0.0' | |
| 23 | 25 | |
| 24 | 26 | # Dependencies specify other packages that your package needs in order to work. |
| 25 | 27 | # To automatically upgrade your package dependencies to the latest versions |
| @@ -19,7 +19,9 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | |||
| 19 | version: 1.0.0+1 | 19 | version: 1.0.0+1 |
| 20 | 20 | ||
| 21 | environment: | 21 | environment: |
| 22 | - sdk: ^3.13.4 | 22 | + # The app does not depend on a Dart 3.13 patch-only API. Accept the |
| 23 | + # available Flutter SDK's 3.13.0 while keeping the Dart 3.x upper bound. | ||
| 24 | + sdk: '>=3.13.0 <4.0.0' | ||
| 23 | 25 | ||
| 24 | # Dependencies specify other packages that your package needs in order to work. | 26 | # Dependencies specify other packages that your package needs in order to work. |
| 25 | # To automatically upgrade your package dependencies to the latest versions | 27 | # To automatically upgrade your package dependencies to the latest versions |
modified
app/test/widget_test.dart +26 -0 | @@ -123,6 +123,32 @@ void main() { | ||
| 123 | 123 | expect(find.textContaining('4 new'), findsOneWidget); |
| 124 | 124 | }); |
| 125 | 125 | |
| 126 | + testWidgets('feed management exposes add and removal controls', | |
| 127 | + (tester) async { | |
| 128 | + await pump(tester); | |
| 129 | + | |
| 130 | + await tester.tap(find.byTooltip('Manage feeds').first); | |
| 131 | + await tester.pumpAndSettle(); | |
| 132 | + | |
| 133 | + expect(find.text('Manage feeds'), findsOneWidget); | |
| 134 | + expect(find.text('Add a subscription'), findsOneWidget); | |
| 135 | + expect(find.text('Subscriptions'), findsOneWidget); | |
| 136 | + expect(find.byTooltip('Remove Feed A'), findsOneWidget); | |
| 137 | + expect(find.text('Backup & restore'), findsOneWidget); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + testWidgets('returning from feed management restores the reader', | |
| 141 | + (tester) async { | |
| 142 | + await pump(tester); | |
| 143 | + | |
| 144 | + await tester.tap(find.byTooltip('Manage feeds').first); | |
| 145 | + await tester.pumpAndSettle(); | |
| 146 | + await tester.tap(find.byTooltip('Back')); | |
| 147 | + await tester.pumpAndSettle(); | |
| 148 | + | |
| 149 | + expect(find.text('First article'), findsOneWidget); | |
| 150 | + }); | |
| 151 | + | |
| 126 | 152 | testWidgets('marking an article read updates the count without a refetch', |
| 127 | 153 | (tester) async { |
| 128 | 154 | final state = await pump(tester); |
| @@ -123,6 +123,32 @@ void main() { | |||
| 123 | expect(find.textContaining('4 new'), findsOneWidget); | 123 | expect(find.textContaining('4 new'), findsOneWidget); |
| 124 | }); | 124 | }); |
| 125 | 125 | ||
| 126 | + testWidgets('feed management exposes add and removal controls', | ||
| 127 | + (tester) async { | ||
| 128 | + await pump(tester); | ||
| 129 | + | ||
| 130 | + await tester.tap(find.byTooltip('Manage feeds').first); | ||
| 131 | + await tester.pumpAndSettle(); | ||
| 132 | + | ||
| 133 | + expect(find.text('Manage feeds'), findsOneWidget); | ||
| 134 | + expect(find.text('Add a subscription'), findsOneWidget); | ||
| 135 | + expect(find.text('Subscriptions'), findsOneWidget); | ||
| 136 | + expect(find.byTooltip('Remove Feed A'), findsOneWidget); | ||
| 137 | + expect(find.text('Backup & restore'), findsOneWidget); | ||
| 138 | + }); | ||
| 139 | + | ||
| 140 | + testWidgets('returning from feed management restores the reader', | ||
| 141 | + (tester) async { | ||
| 142 | + await pump(tester); | ||
| 143 | + | ||
| 144 | + await tester.tap(find.byTooltip('Manage feeds').first); | ||
| 145 | + await tester.pumpAndSettle(); | ||
| 146 | + await tester.tap(find.byTooltip('Back')); | ||
| 147 | + await tester.pumpAndSettle(); | ||
| 148 | + | ||
| 149 | + expect(find.text('First article'), findsOneWidget); | ||
| 150 | + }); | ||
| 151 | + | ||
| 126 | testWidgets('marking an article read updates the count without a refetch', | 152 | testWidgets('marking an article read updates the count without a refetch', |
| 127 | (tester) async { | 153 | (tester) async { |
| 128 | final state = await pump(tester); | 154 | final state = await pump(tester); |
modified
deploy/modal_app.py +40 -1 | @@ -31,6 +31,7 @@ REPO = pathlib.Path(__file__).parent.parent | ||
| 31 | 31 | |
| 32 | 32 | APP_NAME = "glean-reader" |
| 33 | 33 | PORT = 8080 |
| 34 | +PUBLIC_URL = "https://codegod100--glean-reader-serve.modal.run" | |
| 34 | 35 | |
| 35 | 36 | VOLUME_PATH = "/data" |
| 36 | 37 | SNAPSHOT_DIR = f"{VOLUME_PATH}/db" |
| @@ -45,7 +46,7 @@ SNAPSHOT_INTERVAL = 300 | ||
| 45 | 46 | |
| 46 | 47 | image = ( |
| 47 | 48 | modal.Image.debian_slim(python_version="3.12") |
| 48 | - .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates") | |
| 49 | + .apt_install("curl", "xz-utils", "gcc", "sqlite3", "libsqlite3-dev", "ca-certificates") | |
| 49 | 50 | .run_commands( |
| 50 | 51 | # choosenim is the supported installer and pins a known version; |
| 51 | 52 | # Debian's Nim is far behind what this code needs. |
| @@ -75,6 +76,44 @@ secret = modal.Secret.from_name("glean-reader") | ||
| 75 | 76 | app = modal.App(APP_NAME) |
| 76 | 77 | |
| 77 | 78 | |
| 79 | +@app.function(image=image, secrets=[secret], timeout=300) | |
| 80 | +def import_opml(opml: str) -> str: | |
| 81 | + """Import a backup through the live API without revealing GLEAN_TOKEN. | |
| 82 | + | |
| 83 | + Run with ``modal run deploy/modal_app.py::import_opml --opml "$(<backup.opml)"``. | |
| 84 | + Keeping this as a Modal function means only workspace members can invoke | |
| 85 | + it, while the browser-facing reader remains protected by its shared token. | |
| 86 | + """ | |
| 87 | + from urllib.parse import urlencode | |
| 88 | + from urllib.request import Request, urlopen | |
| 89 | + | |
| 90 | + token = os.environ["GLEAN_TOKEN"] | |
| 91 | + request = Request( | |
| 92 | + f"{PUBLIC_URL}/opml", | |
| 93 | + data=urlencode({"opml": opml}).encode(), | |
| 94 | + headers={"X-Glean-Token": token}, | |
| 95 | + method="POST", | |
| 96 | + ) | |
| 97 | + with urlopen(request, timeout=240) as response: | |
| 98 | + return response.read().decode() | |
| 99 | + | |
| 100 | + | |
| 101 | +@app.function(image=image, secrets=[secret], timeout=60) | |
| 102 | +def inspect_feeds() -> str: | |
| 103 | + """Return live feed and unread-article data for a recovery check.""" | |
| 104 | + from urllib.request import Request, urlopen | |
| 105 | + | |
| 106 | + headers = {"X-Glean-Token": os.environ["GLEAN_TOKEN"]} | |
| 107 | + results = {} | |
| 108 | + for path in ("/feeds", "/articles?status=unread&limit=5"): | |
| 109 | + with urlopen(Request(f"{PUBLIC_URL}{path}", headers=headers), timeout=50) as response: | |
| 110 | + results[path] = response.read().decode() | |
| 111 | + import json | |
| 112 | + body = json.dumps(results) | |
| 113 | + print(body, flush=True) | |
| 114 | + return body | |
| 115 | + | |
| 116 | + | |
| 78 | 117 | def _snapshot_once() -> None: |
| 79 | 118 | """Copy each live database to the Volume, via a local staging file. |
| 80 | 119 | |
| @@ -31,6 +31,7 @@ REPO = pathlib.Path(__file__).parent.parent | |||
| 31 | 31 | ||
| 32 | APP_NAME = "glean-reader" | 32 | APP_NAME = "glean-reader" |
| 33 | PORT = 8080 | 33 | PORT = 8080 |
| 34 | +PUBLIC_URL = "https://codegod100--glean-reader-serve.modal.run" | ||
| 34 | 35 | ||
| 35 | VOLUME_PATH = "/data" | 36 | VOLUME_PATH = "/data" |
| 36 | SNAPSHOT_DIR = f"{VOLUME_PATH}/db" | 37 | SNAPSHOT_DIR = f"{VOLUME_PATH}/db" |
| @@ -45,7 +46,7 @@ SNAPSHOT_INTERVAL = 300 | |||
| 45 | 46 | ||
| 46 | image = ( | 47 | image = ( |
| 47 | modal.Image.debian_slim(python_version="3.12") | 48 | modal.Image.debian_slim(python_version="3.12") |
| 48 | - .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates") | 49 | + .apt_install("curl", "xz-utils", "gcc", "sqlite3", "libsqlite3-dev", "ca-certificates") |
| 49 | .run_commands( | 50 | .run_commands( |
| 50 | # choosenim is the supported installer and pins a known version; | 51 | # choosenim is the supported installer and pins a known version; |
| 51 | # Debian's Nim is far behind what this code needs. | 52 | # Debian's Nim is far behind what this code needs. |
| @@ -75,6 +76,44 @@ secret = modal.Secret.from_name("glean-reader") | |||
| 75 | app = modal.App(APP_NAME) | 76 | app = modal.App(APP_NAME) |
| 76 | 77 | ||
| 77 | 78 | ||
| 79 | +@app.function(image=image, secrets=[secret], timeout=300) | ||
| 80 | +def import_opml(opml: str) -> str: | ||
| 81 | + """Import a backup through the live API without revealing GLEAN_TOKEN. | ||
| 82 | + | ||
| 83 | + Run with ``modal run deploy/modal_app.py::import_opml --opml "$(<backup.opml)"``. | ||
| 84 | + Keeping this as a Modal function means only workspace members can invoke | ||
| 85 | + it, while the browser-facing reader remains protected by its shared token. | ||
| 86 | + """ | ||
| 87 | + from urllib.parse import urlencode | ||
| 88 | + from urllib.request import Request, urlopen | ||
| 89 | + | ||
| 90 | + token = os.environ["GLEAN_TOKEN"] | ||
| 91 | + request = Request( | ||
| 92 | + f"{PUBLIC_URL}/opml", | ||
| 93 | + data=urlencode({"opml": opml}).encode(), | ||
| 94 | + headers={"X-Glean-Token": token}, | ||
| 95 | + method="POST", | ||
| 96 | + ) | ||
| 97 | + with urlopen(request, timeout=240) as response: | ||
| 98 | + return response.read().decode() | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +@app.function(image=image, secrets=[secret], timeout=60) | ||
| 102 | +def inspect_feeds() -> str: | ||
| 103 | + """Return live feed and unread-article data for a recovery check.""" | ||
| 104 | + from urllib.request import Request, urlopen | ||
| 105 | + | ||
| 106 | + headers = {"X-Glean-Token": os.environ["GLEAN_TOKEN"]} | ||
| 107 | + results = {} | ||
| 108 | + for path in ("/feeds", "/articles?status=unread&limit=5"): | ||
| 109 | + with urlopen(Request(f"{PUBLIC_URL}{path}", headers=headers), timeout=50) as response: | ||
| 110 | + results[path] = response.read().decode() | ||
| 111 | + import json | ||
| 112 | + body = json.dumps(results) | ||
| 113 | + print(body, flush=True) | ||
| 114 | + return body | ||
| 115 | + | ||
| 116 | + | ||
| 78 | def _snapshot_once() -> None: | 117 | def _snapshot_once() -> None: |
| 79 | """Copy each live database to the Volume, via a local staging file. | 118 | """Copy each live database to the Volume, via a local staging file. |
| 80 | 119 | ||
added
nandi-glean-subscriptions.opml +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<opml version="2.0"> | |
| 3 | + <head><title>Glean subscriptions — nandi.uk</title></head> | |
| 4 | + <body> | |
| 5 | + <outline text="(iterate think thoughts)" title="(iterate think thoughts)" type="rss" xmlUrl="https://yogthos.net/feed.xml"/> | |
| 6 | + <outline text="This Week in GNOME" title="This Week in GNOME" type="rss" xmlUrl="https://thisweek.gnome.org/index.xml"/> | |
| 7 | + <outline text="The Tangled Blog" title="The Tangled Blog" type="rss" xmlUrl="https://blog.tangled.org/feed.xml"/> | |
| 8 | + <outline text="Daring Fireball" title="Daring Fireball" type="rss" xmlUrl="https://daringfireball.net/feeds/main"/> | |
| 9 | + <outline text="404 Media" title="404 Media" type="rss" xmlUrl="https://www.404media.co/rss/"/> | |
| 10 | + <outline text="Phoronix" title="Phoronix" type="rss" xmlUrl="https://www.phoronix.com/rss.php"/> | |
| 11 | + <outline text="The Phoenix Architecture" title="The Phoenix Architecture" type="rss" xmlUrl="https://aicoding.leaflet.pub/rss"/> | |
| 12 | + <outline text="articles" title="articles"> | |
| 13 | + <outline text="Hacker News" title="Hacker News" type="rss" xmlUrl="https://hackernews.com/rss"/> | |
| 14 | + <outline text="HEY" title="HEY" type="rss" xmlUrl="https://www.hey.com/feed/new.atom"/> | |
| 15 | + <outline text="Articles by Aaron Parecki" title="Articles by Aaron Parecki" type="rss" xmlUrl="https://aaronparecki.com/feed.xml"/> | |
| 16 | + <outline text="The XMPP Blog on XMPP" title="The XMPP Blog on XMPP" type="rss" xmlUrl="https://mov.im/feed/pubsub.movim.eu/XMPP"/> | |
| 17 | + <outline text="Gleam weekly" title="Gleam weekly" type="rss" xmlUrl="https://gleamweekly.com/atom.xml"/> | |
| 18 | + <outline text="freeq blog" title="freeq blog" type="rss" xmlUrl="https://freeq.at/blog/feed.xml"/> | |
| 19 | + <outline text="Lobsters" title="Lobsters" type="rss" xmlUrl="https://lobste.rs/rss"/> | |
| 20 | + <outline text="TechCrunch" title="TechCrunch" type="rss" xmlUrl="https://techcrunch.com/feed/"/> | |
| 21 | + <outline text="Matrix.org" title="Matrix.org" type="rss" xmlUrl="https://matrix.org/atom.xml"/> | |
| 22 | + </outline> | |
| 23 | + <outline text="tangled" title="tangled"> | |
| 24 | + <outline text="@mitchellh.com's timeline" title="@mitchellh.com's timeline" type="rss" xmlUrl="https://tangled.org/mitchellh.com/feed.atom"/> | |
| 25 | + <outline text="@expede.wtf's timeline" title="@expede.wtf's timeline" type="rss" xmlUrl="https://tangled.org/expede.wtf/feed.atom"/> | |
| 26 | + <outline text="activity feed for @aly.codes/tg" title="activity feed for @aly.codes/tg" type="rss" xmlUrl="https://tangled.org/aly.codes/tg/feed.atom"/> | |
| 27 | + </outline> | |
| 28 | + </body> | |
| 29 | +</opml> | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | ||
| 2 | +<opml version="2.0"> | ||
| 3 | + <head><title>Glean subscriptions — nandi.uk</title></head> | ||
| 4 | + <body> | ||
| 5 | + <outline text="(iterate think thoughts)" title="(iterate think thoughts)" type="rss" xmlUrl="https://yogthos.net/feed.xml"/> | ||
| 6 | + <outline text="This Week in GNOME" title="This Week in GNOME" type="rss" xmlUrl="https://thisweek.gnome.org/index.xml"/> | ||
| 7 | + <outline text="The Tangled Blog" title="The Tangled Blog" type="rss" xmlUrl="https://blog.tangled.org/feed.xml"/> | ||
| 8 | + <outline text="Daring Fireball" title="Daring Fireball" type="rss" xmlUrl="https://daringfireball.net/feeds/main"/> | ||
| 9 | + <outline text="404 Media" title="404 Media" type="rss" xmlUrl="https://www.404media.co/rss/"/> | ||
| 10 | + <outline text="Phoronix" title="Phoronix" type="rss" xmlUrl="https://www.phoronix.com/rss.php"/> | ||
| 11 | + <outline text="The Phoenix Architecture" title="The Phoenix Architecture" type="rss" xmlUrl="https://aicoding.leaflet.pub/rss"/> | ||
| 12 | + <outline text="articles" title="articles"> | ||
| 13 | + <outline text="Hacker News" title="Hacker News" type="rss" xmlUrl="https://hackernews.com/rss"/> | ||
| 14 | + <outline text="HEY" title="HEY" type="rss" xmlUrl="https://www.hey.com/feed/new.atom"/> | ||
| 15 | + <outline text="Articles by Aaron Parecki" title="Articles by Aaron Parecki" type="rss" xmlUrl="https://aaronparecki.com/feed.xml"/> | ||
| 16 | + <outline text="The XMPP Blog on XMPP" title="The XMPP Blog on XMPP" type="rss" xmlUrl="https://mov.im/feed/pubsub.movim.eu/XMPP"/> | ||
| 17 | + <outline text="Gleam weekly" title="Gleam weekly" type="rss" xmlUrl="https://gleamweekly.com/atom.xml"/> | ||
| 18 | + <outline text="freeq blog" title="freeq blog" type="rss" xmlUrl="https://freeq.at/blog/feed.xml"/> | ||
| 19 | + <outline text="Lobsters" title="Lobsters" type="rss" xmlUrl="https://lobste.rs/rss"/> | ||
| 20 | + <outline text="TechCrunch" title="TechCrunch" type="rss" xmlUrl="https://techcrunch.com/feed/"/> | ||
| 21 | + <outline text="Matrix.org" title="Matrix.org" type="rss" xmlUrl="https://matrix.org/atom.xml"/> | ||
| 22 | + </outline> | ||
| 23 | + <outline text="tangled" title="tangled"> | ||
| 24 | + <outline text="@mitchellh.com's timeline" title="@mitchellh.com's timeline" type="rss" xmlUrl="https://tangled.org/mitchellh.com/feed.atom"/> | ||
| 25 | + <outline text="@expede.wtf's timeline" title="@expede.wtf's timeline" type="rss" xmlUrl="https://tangled.org/expede.wtf/feed.atom"/> | ||
| 26 | + <outline text="activity feed for @aly.codes/tg" title="activity feed for @aly.codes/tg" type="rss" xmlUrl="https://tangled.org/aly.codes/tg/feed.atom"/> | ||
| 27 | + </outline> | ||
| 28 | + </body> | ||
| 29 | +</opml> | ||
modified
spike/nim/glean.nim +17 -19 | @@ -13,8 +13,8 @@ | ||
| 13 | 13 | ## as someone else's feed list. Unset, there is no gate at all and the local |
| 14 | 14 | ## case stays exactly as simple as it was. |
| 15 | 15 | ## |
| 16 | -## GLEAN_WEB points at a directory of static files (a Flutter web build) to | |
| 17 | -## serve instead of the built-in page. | |
| 16 | +## GLEAN_WEB must point at the Flutter web build. The reader refuses to start | |
| 17 | +## without it so deployments cannot silently serve a different client. | |
| 18 | 18 | ## |
| 19 | 19 | ## The pieces underneath -- fetching, parsing, storage, search, full-text |
| 20 | 20 | ## extraction -- are the same modules the larger port built and tested. |
| @@ -25,10 +25,6 @@ import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, | ||
| 25 | 25 | opml, scraper, sqlite] |
| 26 | 26 | |
| 27 | 27 | const |
| 28 | - ## Baked in at compile time rather than read from disk, so the reader stays | |
| 29 | - ## one file to copy around. | |
| 30 | - IndexHtml = staticRead("ui.html") | |
| 31 | - | |
| 32 | 28 | ## Everything is stored per-user underneath because the schema came from a |
| 33 | 29 | ## multi-user server. One constant user keeps that plumbing satisfied |
| 34 | 30 | ## without inventing accounts. |
| @@ -38,7 +34,7 @@ const | ||
| 38 | 34 | type Reader = ref object |
| 39 | 35 | db: GleanDb |
| 40 | 36 | token: string ## empty disables the gate entirely |
| 41 | - webRoot: string ## empty serves the built-in page | |
| 37 | + webRoot: string ## required Flutter web bundle | |
| 42 | 38 | |
| 43 | 39 | proc cookieValue(header, name: string): string = |
| 44 | 40 | for part in header.split(';'): |
| @@ -107,16 +103,18 @@ proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} = | ||
| 107 | 103 | let full = absolutePath(r.webRoot / rel) |
| 108 | 104 | let root = absolutePath(r.webRoot) |
| 109 | 105 | if not full.startsWith(root) or not fileExists(full): return false |
| 110 | - await req.respond(Http200, readFile(full), | |
| 111 | - newHttpHeaders({"Content-Type": contentTypeFor(full)})) | |
| 106 | + var headers = newHttpHeaders({"Content-Type": contentTypeFor(full)}) | |
| 107 | + # Flutter's bootstrap and main bundle keep stable filenames. Revalidate | |
| 108 | + # them so a Modal deploy cannot leave browsers running yesterday's client. | |
| 109 | + let ext = full.splitFile.ext.toLowerAscii | |
| 110 | + if ext in [".html", ".js", ".mjs"]: | |
| 111 | + headers["Cache-Control"] = "no-cache" | |
| 112 | + await req.respond(Http200, readFile(full), headers) | |
| 112 | 113 | true |
| 113 | 114 | |
| 114 | 115 | proc serveApp(r: Reader, req: Request) {.async.} = |
| 115 | - ## The client: a Flutter web build when one is configured, otherwise the | |
| 116 | - ## page baked into the binary. | |
| 117 | - if await serveStatic(r, req, "index.html"): return | |
| 118 | - await req.respond(Http200, IndexHtml, | |
| 119 | - newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | |
| 116 | + if not await serveStatic(r, req, "index.html"): | |
| 117 | + await fail(req, Http500, "Flutter web bundle is unavailable") | |
| 120 | 118 | |
| 121 | 119 | proc toJson(a: Article): JsonNode = |
| 122 | 120 | %*{ |
| @@ -384,11 +382,11 @@ proc main() {.async.} = | ||
| 384 | 382 | db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) |
| 385 | 383 | |
| 386 | 384 | let token = getEnv("GLEAN_TOKEN") |
| 387 | - var webRoot = getEnv("GLEAN_WEB") | |
| 388 | - if webRoot.len > 0 and not dirExists(webRoot): | |
| 389 | - # Failing loudly beats silently serving the fallback page and leaving | |
| 390 | - # someone wondering why their client never updated. | |
| 391 | - quit(&"GLEAN_WEB points at {webRoot}, which is not a directory") | |
| 385 | + let webRoot = getEnv("GLEAN_WEB") | |
| 386 | + if webRoot.len == 0: | |
| 387 | + quit("GLEAN_WEB is required and must point at a Flutter web build") | |
| 388 | + if not dirExists(webRoot) or not fileExists(webRoot / "index.html"): | |
| 389 | + quit(&"GLEAN_WEB points at {webRoot}, which has no index.html") | |
| 392 | 390 | |
| 393 | 391 | let reader = Reader(db: db, token: token, webRoot: webRoot) |
| 394 | 392 | |
| @@ -13,8 +13,8 @@ | |||
| 13 | ## as someone else's feed list. Unset, there is no gate at all and the local | 13 | ## as someone else's feed list. Unset, there is no gate at all and the local |
| 14 | ## case stays exactly as simple as it was. | 14 | ## case stays exactly as simple as it was. |
| 15 | ## | 15 | ## |
| 16 | -## GLEAN_WEB points at a directory of static files (a Flutter web build) to | 16 | +## GLEAN_WEB must point at the Flutter web build. The reader refuses to start |
| 17 | -## serve instead of the built-in page. | 17 | +## without it so deployments cannot silently serve a different client. |
| 18 | ## | 18 | ## |
| 19 | ## The pieces underneath -- fetching, parsing, storage, search, full-text | 19 | ## The pieces underneath -- fetching, parsing, storage, search, full-text |
| 20 | ## extraction -- are the same modules the larger port built and tested. | 20 | ## extraction -- are the same modules the larger port built and tested. |
| @@ -25,10 +25,6 @@ import reader/[articlestore, feedfetcher, feedparser, feedstore, gleandb, | |||
| 25 | opml, scraper, sqlite] | 25 | opml, scraper, sqlite] |
| 26 | 26 | ||
| 27 | const | 27 | const |
| 28 | - ## Baked in at compile time rather than read from disk, so the reader stays | ||
| 29 | - ## one file to copy around. | ||
| 30 | - IndexHtml = staticRead("ui.html") | ||
| 31 | - | ||
| 32 | ## Everything is stored per-user underneath because the schema came from a | 28 | ## Everything is stored per-user underneath because the schema came from a |
| 33 | ## multi-user server. One constant user keeps that plumbing satisfied | 29 | ## multi-user server. One constant user keeps that plumbing satisfied |
| 34 | ## without inventing accounts. | 30 | ## without inventing accounts. |
| @@ -38,7 +34,7 @@ const | |||
| 38 | type Reader = ref object | 34 | type Reader = ref object |
| 39 | db: GleanDb | 35 | db: GleanDb |
| 40 | token: string ## empty disables the gate entirely | 36 | token: string ## empty disables the gate entirely |
| 41 | - webRoot: string ## empty serves the built-in page | 37 | + webRoot: string ## required Flutter web bundle |
| 42 | 38 | ||
| 43 | proc cookieValue(header, name: string): string = | 39 | proc cookieValue(header, name: string): string = |
| 44 | for part in header.split(';'): | 40 | for part in header.split(';'): |
| @@ -107,16 +103,18 @@ proc serveStatic(r: Reader, req: Request, rel: string): Future[bool] {.async.} = | |||
| 107 | let full = absolutePath(r.webRoot / rel) | 103 | let full = absolutePath(r.webRoot / rel) |
| 108 | let root = absolutePath(r.webRoot) | 104 | let root = absolutePath(r.webRoot) |
| 109 | if not full.startsWith(root) or not fileExists(full): return false | 105 | if not full.startsWith(root) or not fileExists(full): return false |
| 110 | - await req.respond(Http200, readFile(full), | 106 | + var headers = newHttpHeaders({"Content-Type": contentTypeFor(full)}) |
| 111 | - newHttpHeaders({"Content-Type": contentTypeFor(full)})) | 107 | + # Flutter's bootstrap and main bundle keep stable filenames. Revalidate |
| 108 | + # them so a Modal deploy cannot leave browsers running yesterday's client. | ||
| 109 | + let ext = full.splitFile.ext.toLowerAscii | ||
| 110 | + if ext in [".html", ".js", ".mjs"]: | ||
| 111 | + headers["Cache-Control"] = "no-cache" | ||
| 112 | + await req.respond(Http200, readFile(full), headers) | ||
| 112 | true | 113 | true |
| 113 | 114 | ||
| 114 | proc serveApp(r: Reader, req: Request) {.async.} = | 115 | proc serveApp(r: Reader, req: Request) {.async.} = |
| 115 | - ## The client: a Flutter web build when one is configured, otherwise the | 116 | + if not await serveStatic(r, req, "index.html"): |
| 116 | - ## page baked into the binary. | 117 | + await fail(req, Http500, "Flutter web bundle is unavailable") |
| 117 | - if await serveStatic(r, req, "index.html"): return | ||
| 118 | - await req.respond(Http200, IndexHtml, | ||
| 119 | - newHttpHeaders({"Content-Type": "text/html; charset=utf-8"})) | ||
| 120 | 118 | ||
| 121 | proc toJson(a: Article): JsonNode = | 119 | proc toJson(a: Article): JsonNode = |
| 122 | %*{ | 120 | %*{ |
| @@ -384,11 +382,11 @@ proc main() {.async.} = | |||
| 384 | db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) | 382 | db.db.run("INSERT OR IGNORE INTO users (did) VALUES (?)", p(LocalUser)) |
| 385 | 383 | ||
| 386 | let token = getEnv("GLEAN_TOKEN") | 384 | let token = getEnv("GLEAN_TOKEN") |
| 387 | - var webRoot = getEnv("GLEAN_WEB") | 385 | + let webRoot = getEnv("GLEAN_WEB") |
| 388 | - if webRoot.len > 0 and not dirExists(webRoot): | 386 | + if webRoot.len == 0: |
| 389 | - # Failing loudly beats silently serving the fallback page and leaving | 387 | + quit("GLEAN_WEB is required and must point at a Flutter web build") |
| 390 | - # someone wondering why their client never updated. | 388 | + if not dirExists(webRoot) or not fileExists(webRoot / "index.html"): |
| 391 | - quit(&"GLEAN_WEB points at {webRoot}, which is not a directory") | 389 | + quit(&"GLEAN_WEB points at {webRoot}, which has no index.html") |
| 392 | 390 | ||
| 393 | let reader = Reader(db: db, token: token, webRoot: webRoot) | 391 | let reader = Reader(db: db, token: token, webRoot: webRoot) |
| 394 | 392 | ||
deleted
spike/nim/ui.html +0 -299 | deleted file mode 100644 | ||
| @@ -1,299 +0,0 @@ | ||
| 1 | -<!doctype html> | |
| 2 | -<meta charset="utf-8"> | |
| 3 | -<meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 4 | -<title>glean</title> | |
| 5 | -<style> | |
| 6 | - :root { | |
| 7 | - --bg: #fafaf7; --fg: #1a1a1a; --muted: #6b6b6b; --line: #e0e0da; | |
| 8 | - --surface: #fff; --accent: #00754a; --danger: #c82014; | |
| 9 | - } | |
| 10 | - @media (prefers-color-scheme: dark) { | |
| 11 | - :root { | |
| 12 | - --bg: #16161a; --fg: #e8e8e3; --muted: #9a9a9a; --line: #2c2c32; | |
| 13 | - --surface: #1d1d22; --accent: #4ade80; --danger: #ff6b5e; | |
| 14 | - } | |
| 15 | - } | |
| 16 | - * { box-sizing: border-box; } | |
| 17 | - body { | |
| 18 | - margin: 0; background: var(--bg); color: var(--fg); | |
| 19 | - font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; | |
| 20 | - display: grid; grid-template-columns: 280px 1fr; height: 100vh; | |
| 21 | - } | |
| 22 | - /* The sidebar scrolls independently of the article, so a long read never | |
| 23 | - loses the feed list. */ | |
| 24 | - aside { | |
| 25 | - border-right: 1px solid var(--line); overflow-y: auto; padding: 14px; | |
| 26 | - background: var(--surface); | |
| 27 | - } | |
| 28 | - main { overflow-y: auto; padding: 20px 28px; } | |
| 29 | - h1 { font-size: 18px; margin: 0 0 14px; letter-spacing: -.01em; } | |
| 30 | - h1 span { color: var(--muted); font-weight: 400; font-size: 13px; } | |
| 31 | - button, input, select { | |
| 32 | - font: inherit; color: inherit; background: var(--bg); | |
| 33 | - border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; | |
| 34 | - } | |
| 35 | - button { cursor: pointer; } | |
| 36 | - button:hover { border-color: var(--accent); } | |
| 37 | - button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } | |
| 38 | - .row { display: flex; gap: 6px; margin-bottom: 10px; } | |
| 39 | - .row input { flex: 1; min-width: 0; } | |
| 40 | - .feed { | |
| 41 | - display: flex; align-items: center; gap: 8px; padding: 6px 8px; | |
| 42 | - border-radius: 6px; cursor: pointer; font-size: 14px; | |
| 43 | - } | |
| 44 | - .feed:hover { background: var(--bg); } | |
| 45 | - .feed.on { background: var(--bg); font-weight: 600; } | |
| 46 | - .feed .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 47 | - .count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } | |
| 48 | - .feed .x { color: var(--muted); opacity: 0; } | |
| 49 | - .feed:hover .x { opacity: 1; } | |
| 50 | - .feed .x:hover { color: var(--danger); } | |
| 51 | - .item { | |
| 52 | - padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; | |
| 53 | - } | |
| 54 | - .item.read .title { color: var(--muted); font-weight: 400; } | |
| 55 | - .title { font-weight: 600; margin-bottom: 3px; } | |
| 56 | - .meta { color: var(--muted); font-size: 12.5px; } | |
| 57 | - .summary { color: var(--muted); font-size: 13.5px; margin-top: 4px; | |
| 58 | - display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; | |
| 59 | - overflow: hidden; } | |
| 60 | - article { max-width: 42em; } | |
| 61 | - article h2 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; } | |
| 62 | - .body { margin-top: 18px; } | |
| 63 | - .body img { max-width: 100%; height: auto; } | |
| 64 | - .body pre { overflow-x: auto; background: var(--surface); padding: 10px; | |
| 65 | - border-radius: 6px; } | |
| 66 | - .body a { color: var(--accent); } | |
| 67 | - .body blockquote { border-left: 3px solid var(--line); margin-left: 0; | |
| 68 | - padding-left: 14px; color: var(--muted); } | |
| 69 | - .empty { color: var(--muted); padding: 30px 0; } | |
| 70 | - .err { color: var(--danger); font-size: 13px; } | |
| 71 | - .bar { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; | |
| 72 | - flex-wrap: wrap; } | |
| 73 | - .bar input[type=search] { flex: 1; min-width: 140px; } | |
| 74 | - @media (max-width: 720px) { | |
| 75 | - body { grid-template-columns: 1fr; } | |
| 76 | - aside { display: none; } | |
| 77 | - aside.show { display: block; } | |
| 78 | - } | |
| 79 | -</style> | |
| 80 | - | |
| 81 | -<aside> | |
| 82 | - <h1>glean <span id="total"></span></h1> | |
| 83 | - <div class="row"> | |
| 84 | - <input id="url" placeholder="feed or site URL" autocomplete="off"> | |
| 85 | - <button id="add" class="primary">+</button> | |
| 86 | - </div> | |
| 87 | - <div class="row"> | |
| 88 | - <button id="refresh">Refresh</button> | |
| 89 | - <button id="allread">Mark read</button> | |
| 90 | - </div> | |
| 91 | - <div id="err" class="err"></div> | |
| 92 | - <div id="feeds"></div> | |
| 93 | -</aside> | |
| 94 | - | |
| 95 | -<main> | |
| 96 | - <div class="bar"> | |
| 97 | - <select id="status"> | |
| 98 | - <option value="unread">Unread</option> | |
| 99 | - <option value="all">All</option> | |
| 100 | - <option value="read">Read</option> | |
| 101 | - </select> | |
| 102 | - <input id="q" type="search" placeholder="Search…"> | |
| 103 | - <button id="back" hidden>← Back</button> | |
| 104 | - </div> | |
| 105 | - <div id="view"></div> | |
| 106 | -</main> | |
| 107 | - | |
| 108 | -<script> | |
| 109 | -const $ = s => document.querySelector(s); | |
| 110 | -const state = { feed: "", status: "unread", q: "", article: null }; | |
| 111 | - | |
| 112 | -const api = async (path, opts) => { | |
| 113 | - const res = await fetch(path, opts); | |
| 114 | - const body = await res.json().catch(() => ({ error: "bad response" })); | |
| 115 | - if (!res.ok || body.error) throw new Error(body.error || `HTTP ${res.status}`); | |
| 116 | - return body; | |
| 117 | -}; | |
| 118 | -const post = (path, params) => | |
| 119 | - api(path, { | |
| 120 | - method: "POST", | |
| 121 | - headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| 122 | - body: new URLSearchParams(params || {}), | |
| 123 | - }); | |
| 124 | - | |
| 125 | -const say = msg => { $("#err").textContent = msg || ""; }; | |
| 126 | -const when = iso => { | |
| 127 | - if (!iso) return ""; | |
| 128 | - const d = new Date(iso), mins = (Date.now() - d) / 60000; | |
| 129 | - if (mins < 60) return `${Math.max(1, Math.round(mins))}m`; | |
| 130 | - if (mins < 1440) return `${Math.round(mins / 60)}h`; | |
| 131 | - if (mins < 43200) return `${Math.round(mins / 1440)}d`; | |
| 132 | - return d.toLocaleDateString(); | |
| 133 | -}; | |
| 134 | - | |
| 135 | -async function loadFeeds() { | |
| 136 | - const feeds = await api("/feeds"); | |
| 137 | - const { count } = await api("/unread"); | |
| 138 | - $("#total").textContent = count ? `${count} unread` : "all read"; | |
| 139 | - | |
| 140 | - const el = $("#feeds"); | |
| 141 | - el.innerHTML = ""; | |
| 142 | - el.append(feedRow({ feed_url: "", title: "All feeds", unread: count })); | |
| 143 | - for (const f of feeds) el.append(feedRow(f)); | |
| 144 | -} | |
| 145 | - | |
| 146 | -function feedRow(f) { | |
| 147 | - const div = document.createElement("div"); | |
| 148 | - div.className = "feed" + (state.feed === f.feed_url ? " on" : ""); | |
| 149 | - div.innerHTML = | |
| 150 | - `<span class="name"></span><span class="count"></span>` + | |
| 151 | - (f.feed_url ? `<span class="x" title="Unsubscribe">×</span>` : ""); | |
| 152 | - // textContent, not innerHTML: a feed title is whatever a stranger's server | |
| 153 | - // put in <title>. | |
| 154 | - div.querySelector(".name").textContent = f.title || f.feed_url; | |
| 155 | - div.querySelector(".count").textContent = f.unread || ""; | |
| 156 | - div.onclick = e => { | |
| 157 | - if (e.target.classList.contains("x")) return; | |
| 158 | - state.feed = f.feed_url; state.article = null; render(); | |
| 159 | - }; | |
| 160 | - const x = div.querySelector(".x"); | |
| 161 | - if (x) x.onclick = async e => { | |
| 162 | - e.stopPropagation(); | |
| 163 | - if (!confirm(`Unsubscribe from ${f.title || f.feed_url}?`)) return; | |
| 164 | - await api(`/feeds?url=${encodeURIComponent(f.feed_url)}`, { method: "DELETE" }); | |
| 165 | - if (state.feed === f.feed_url) state.feed = ""; | |
| 166 | - render(); | |
| 167 | - }; | |
| 168 | - return div; | |
| 169 | -} | |
| 170 | - | |
| 171 | -async function loadArticles() { | |
| 172 | - const p = new URLSearchParams({ limit: "100" }); | |
| 173 | - if (state.q) p.set("q", state.q); | |
| 174 | - else { | |
| 175 | - p.set("status", state.status); | |
| 176 | - if (state.feed) p.set("feed", state.feed); | |
| 177 | - } | |
| 178 | - return api(`/articles?${p}`); | |
| 179 | -} | |
| 180 | - | |
| 181 | -function listView(items) { | |
| 182 | - const view = $("#view"); | |
| 183 | - view.innerHTML = ""; | |
| 184 | - if (!items.length) { | |
| 185 | - view.innerHTML = `<div class="empty">Nothing here.</div>`; | |
| 186 | - return; | |
| 187 | - } | |
| 188 | - for (const a of items) { | |
| 189 | - const div = document.createElement("div"); | |
| 190 | - div.className = "item" + (a.is_read ? " read" : ""); | |
| 191 | - div.innerHTML = `<div class="title"></div><div class="meta"></div>` + | |
| 192 | - (a.summary ? `<div class="summary"></div>` : ""); | |
| 193 | - div.querySelector(".title").textContent = a.title || "(untitled)"; | |
| 194 | - div.querySelector(".meta").textContent = | |
| 195 | - [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | |
| 196 | - if (a.summary) { | |
| 197 | - // Summaries are HTML in most feeds; show them as the text they read as. | |
| 198 | - const tmp = document.createElement("div"); | |
| 199 | - tmp.innerHTML = a.summary; | |
| 200 | - div.querySelector(".summary").textContent = tmp.textContent; | |
| 201 | - } | |
| 202 | - div.onclick = () => openArticle(a.id); | |
| 203 | - view.append(div); | |
| 204 | - } | |
| 205 | -} | |
| 206 | - | |
| 207 | -async function openArticle(id) { | |
| 208 | - const a = await api(`/articles/${id}`); | |
| 209 | - state.article = a; | |
| 210 | - if (!a.is_read) { await post("/read", { id }); loadFeeds(); } | |
| 211 | - | |
| 212 | - const view = $("#view"); | |
| 213 | - view.innerHTML = `<article> | |
| 214 | - <h2></h2><div class="meta"></div> | |
| 215 | - <div class="bar" style="margin-top:14px"> | |
| 216 | - <a id="orig" target="_blank" rel="noopener noreferrer"><button>Open original</button></a> | |
| 217 | - <button id="full">Fetch full text</button> | |
| 218 | - <button id="unread">Mark unread</button> | |
| 219 | - </div> | |
| 220 | - <div class="body"></div> | |
| 221 | - </article>`; | |
| 222 | - view.querySelector("h2").textContent = a.title || "(untitled)"; | |
| 223 | - view.querySelector(".meta").textContent = | |
| 224 | - [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | |
| 225 | - // The server sanitises every article body through the same whitelist the | |
| 226 | - // scraper uses, so this is the one place innerHTML is deliberate. | |
| 227 | - view.querySelector(".body").innerHTML = a.content || "<p><em>No content.</em></p>"; | |
| 228 | - $("#orig").href = a.url || "#"; | |
| 229 | - $("#back").hidden = false; | |
| 230 | - | |
| 231 | - $("#full").onclick = async e => { | |
| 232 | - e.target.disabled = true; e.target.textContent = "Fetching…"; | |
| 233 | - try { | |
| 234 | - const r = await post("/fetch-content", { id }); | |
| 235 | - view.querySelector(".body").innerHTML = r.content; | |
| 236 | - e.target.textContent = "Fetched"; | |
| 237 | - } catch (err) { | |
| 238 | - e.target.textContent = "Fetch full text"; | |
| 239 | - e.target.disabled = false; | |
| 240 | - say(err.message); | |
| 241 | - } | |
| 242 | - }; | |
| 243 | - $("#unread").onclick = async () => { | |
| 244 | - await post("/read", { id, undo: "1" }); | |
| 245 | - state.article = null; render(); | |
| 246 | - }; | |
| 247 | -} | |
| 248 | - | |
| 249 | -async function render() { | |
| 250 | - say(""); | |
| 251 | - try { | |
| 252 | - await loadFeeds(); | |
| 253 | - if (state.article) return; | |
| 254 | - $("#back").hidden = true; | |
| 255 | - listView(await loadArticles()); | |
| 256 | - } catch (e) { say(e.message); } | |
| 257 | -} | |
| 258 | - | |
| 259 | -$("#add").onclick = async () => { | |
| 260 | - const url = $("#url").value.trim(); | |
| 261 | - if (!url) return; | |
| 262 | - $("#add").disabled = true; | |
| 263 | - try { | |
| 264 | - const r = await post("/feeds", { url }); | |
| 265 | - if (r.error) say(r.error); else $("#url").value = ""; | |
| 266 | - render(); | |
| 267 | - } catch (e) { say(e.message); } | |
| 268 | - finally { $("#add").disabled = false; } | |
| 269 | -}; | |
| 270 | -$("#url").onkeydown = e => { if (e.key === "Enter") $("#add").click(); }; | |
| 271 | - | |
| 272 | -$("#refresh").onclick = async e => { | |
| 273 | - e.target.disabled = true; e.target.textContent = "Refreshing…"; | |
| 274 | - try { | |
| 275 | - const r = await post("/refresh"); | |
| 276 | - if (r.errors?.length) say(`${r.errors.length} feed(s) failed`); | |
| 277 | - state.article = null; render(); | |
| 278 | - } catch (err) { say(err.message); } | |
| 279 | - finally { e.target.disabled = false; e.target.textContent = "Refresh"; } | |
| 280 | -}; | |
| 281 | - | |
| 282 | -$("#allread").onclick = async () => { | |
| 283 | - if (!confirm(state.feed ? "Mark this feed read?" : "Mark everything read?")) return; | |
| 284 | - await post("/read-all", state.feed ? { feed: state.feed } : {}); | |
| 285 | - state.article = null; render(); | |
| 286 | -}; | |
| 287 | - | |
| 288 | -$("#status").onchange = e => { state.status = e.target.value; state.article = null; render(); }; | |
| 289 | -$("#back").onclick = () => { state.article = null; render(); }; | |
| 290 | - | |
| 291 | -let timer; | |
| 292 | -$("#q").oninput = e => { | |
| 293 | - clearTimeout(timer); | |
| 294 | - // Debounced: every keystroke is an FTS5 query otherwise. | |
| 295 | - timer = setTimeout(() => { state.q = e.target.value.trim(); state.article = null; render(); }, 250); | |
| 296 | -}; | |
| 297 | - | |
| 298 | -render(); | |
| 299 | -</script> | |
| deleted file mode 100644 | |||
| @@ -1,299 +0,0 @@ | |||
| 1 | -<!doctype html> | ||
| 2 | -<meta charset="utf-8"> | ||
| 3 | -<meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 4 | -<title>glean</title> | ||
| 5 | -<style> | ||
| 6 | - :root { | ||
| 7 | - --bg: #fafaf7; --fg: #1a1a1a; --muted: #6b6b6b; --line: #e0e0da; | ||
| 8 | - --surface: #fff; --accent: #00754a; --danger: #c82014; | ||
| 9 | - } | ||
| 10 | - @media (prefers-color-scheme: dark) { | ||
| 11 | - :root { | ||
| 12 | - --bg: #16161a; --fg: #e8e8e3; --muted: #9a9a9a; --line: #2c2c32; | ||
| 13 | - --surface: #1d1d22; --accent: #4ade80; --danger: #ff6b5e; | ||
| 14 | - } | ||
| 15 | - } | ||
| 16 | - * { box-sizing: border-box; } | ||
| 17 | - body { | ||
| 18 | - margin: 0; background: var(--bg); color: var(--fg); | ||
| 19 | - font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; | ||
| 20 | - display: grid; grid-template-columns: 280px 1fr; height: 100vh; | ||
| 21 | - } | ||
| 22 | - /* The sidebar scrolls independently of the article, so a long read never | ||
| 23 | - loses the feed list. */ | ||
| 24 | - aside { | ||
| 25 | - border-right: 1px solid var(--line); overflow-y: auto; padding: 14px; | ||
| 26 | - background: var(--surface); | ||
| 27 | - } | ||
| 28 | - main { overflow-y: auto; padding: 20px 28px; } | ||
| 29 | - h1 { font-size: 18px; margin: 0 0 14px; letter-spacing: -.01em; } | ||
| 30 | - h1 span { color: var(--muted); font-weight: 400; font-size: 13px; } | ||
| 31 | - button, input, select { | ||
| 32 | - font: inherit; color: inherit; background: var(--bg); | ||
| 33 | - border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; | ||
| 34 | - } | ||
| 35 | - button { cursor: pointer; } | ||
| 36 | - button:hover { border-color: var(--accent); } | ||
| 37 | - button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } | ||
| 38 | - .row { display: flex; gap: 6px; margin-bottom: 10px; } | ||
| 39 | - .row input { flex: 1; min-width: 0; } | ||
| 40 | - .feed { | ||
| 41 | - display: flex; align-items: center; gap: 8px; padding: 6px 8px; | ||
| 42 | - border-radius: 6px; cursor: pointer; font-size: 14px; | ||
| 43 | - } | ||
| 44 | - .feed:hover { background: var(--bg); } | ||
| 45 | - .feed.on { background: var(--bg); font-weight: 600; } | ||
| 46 | - .feed .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 47 | - .count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } | ||
| 48 | - .feed .x { color: var(--muted); opacity: 0; } | ||
| 49 | - .feed:hover .x { opacity: 1; } | ||
| 50 | - .feed .x:hover { color: var(--danger); } | ||
| 51 | - .item { | ||
| 52 | - padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; | ||
| 53 | - } | ||
| 54 | - .item.read .title { color: var(--muted); font-weight: 400; } | ||
| 55 | - .title { font-weight: 600; margin-bottom: 3px; } | ||
| 56 | - .meta { color: var(--muted); font-size: 12.5px; } | ||
| 57 | - .summary { color: var(--muted); font-size: 13.5px; margin-top: 4px; | ||
| 58 | - display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; | ||
| 59 | - overflow: hidden; } | ||
| 60 | - article { max-width: 42em; } | ||
| 61 | - article h2 { font-size: 24px; line-height: 1.25; margin: 0 0 6px; } | ||
| 62 | - .body { margin-top: 18px; } | ||
| 63 | - .body img { max-width: 100%; height: auto; } | ||
| 64 | - .body pre { overflow-x: auto; background: var(--surface); padding: 10px; | ||
| 65 | - border-radius: 6px; } | ||
| 66 | - .body a { color: var(--accent); } | ||
| 67 | - .body blockquote { border-left: 3px solid var(--line); margin-left: 0; | ||
| 68 | - padding-left: 14px; color: var(--muted); } | ||
| 69 | - .empty { color: var(--muted); padding: 30px 0; } | ||
| 70 | - .err { color: var(--danger); font-size: 13px; } | ||
| 71 | - .bar { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; | ||
| 72 | - flex-wrap: wrap; } | ||
| 73 | - .bar input[type=search] { flex: 1; min-width: 140px; } | ||
| 74 | - @media (max-width: 720px) { | ||
| 75 | - body { grid-template-columns: 1fr; } | ||
| 76 | - aside { display: none; } | ||
| 77 | - aside.show { display: block; } | ||
| 78 | - } | ||
| 79 | -</style> | ||
| 80 | - | ||
| 81 | -<aside> | ||
| 82 | - <h1>glean <span id="total"></span></h1> | ||
| 83 | - <div class="row"> | ||
| 84 | - <input id="url" placeholder="feed or site URL" autocomplete="off"> | ||
| 85 | - <button id="add" class="primary">+</button> | ||
| 86 | - </div> | ||
| 87 | - <div class="row"> | ||
| 88 | - <button id="refresh">Refresh</button> | ||
| 89 | - <button id="allread">Mark read</button> | ||
| 90 | - </div> | ||
| 91 | - <div id="err" class="err"></div> | ||
| 92 | - <div id="feeds"></div> | ||
| 93 | -</aside> | ||
| 94 | - | ||
| 95 | -<main> | ||
| 96 | - <div class="bar"> | ||
| 97 | - <select id="status"> | ||
| 98 | - <option value="unread">Unread</option> | ||
| 99 | - <option value="all">All</option> | ||
| 100 | - <option value="read">Read</option> | ||
| 101 | - </select> | ||
| 102 | - <input id="q" type="search" placeholder="Search…"> | ||
| 103 | - <button id="back" hidden>← Back</button> | ||
| 104 | - </div> | ||
| 105 | - <div id="view"></div> | ||
| 106 | -</main> | ||
| 107 | - | ||
| 108 | -<script> | ||
| 109 | -const $ = s => document.querySelector(s); | ||
| 110 | -const state = { feed: "", status: "unread", q: "", article: null }; | ||
| 111 | - | ||
| 112 | -const api = async (path, opts) => { | ||
| 113 | - const res = await fetch(path, opts); | ||
| 114 | - const body = await res.json().catch(() => ({ error: "bad response" })); | ||
| 115 | - if (!res.ok || body.error) throw new Error(body.error || `HTTP ${res.status}`); | ||
| 116 | - return body; | ||
| 117 | -}; | ||
| 118 | -const post = (path, params) => | ||
| 119 | - api(path, { | ||
| 120 | - method: "POST", | ||
| 121 | - headers: { "Content-Type": "application/x-www-form-urlencoded" }, | ||
| 122 | - body: new URLSearchParams(params || {}), | ||
| 123 | - }); | ||
| 124 | - | ||
| 125 | -const say = msg => { $("#err").textContent = msg || ""; }; | ||
| 126 | -const when = iso => { | ||
| 127 | - if (!iso) return ""; | ||
| 128 | - const d = new Date(iso), mins = (Date.now() - d) / 60000; | ||
| 129 | - if (mins < 60) return `${Math.max(1, Math.round(mins))}m`; | ||
| 130 | - if (mins < 1440) return `${Math.round(mins / 60)}h`; | ||
| 131 | - if (mins < 43200) return `${Math.round(mins / 1440)}d`; | ||
| 132 | - return d.toLocaleDateString(); | ||
| 133 | -}; | ||
| 134 | - | ||
| 135 | -async function loadFeeds() { | ||
| 136 | - const feeds = await api("/feeds"); | ||
| 137 | - const { count } = await api("/unread"); | ||
| 138 | - $("#total").textContent = count ? `${count} unread` : "all read"; | ||
| 139 | - | ||
| 140 | - const el = $("#feeds"); | ||
| 141 | - el.innerHTML = ""; | ||
| 142 | - el.append(feedRow({ feed_url: "", title: "All feeds", unread: count })); | ||
| 143 | - for (const f of feeds) el.append(feedRow(f)); | ||
| 144 | -} | ||
| 145 | - | ||
| 146 | -function feedRow(f) { | ||
| 147 | - const div = document.createElement("div"); | ||
| 148 | - div.className = "feed" + (state.feed === f.feed_url ? " on" : ""); | ||
| 149 | - div.innerHTML = | ||
| 150 | - `<span class="name"></span><span class="count"></span>` + | ||
| 151 | - (f.feed_url ? `<span class="x" title="Unsubscribe">×</span>` : ""); | ||
| 152 | - // textContent, not innerHTML: a feed title is whatever a stranger's server | ||
| 153 | - // put in <title>. | ||
| 154 | - div.querySelector(".name").textContent = f.title || f.feed_url; | ||
| 155 | - div.querySelector(".count").textContent = f.unread || ""; | ||
| 156 | - div.onclick = e => { | ||
| 157 | - if (e.target.classList.contains("x")) return; | ||
| 158 | - state.feed = f.feed_url; state.article = null; render(); | ||
| 159 | - }; | ||
| 160 | - const x = div.querySelector(".x"); | ||
| 161 | - if (x) x.onclick = async e => { | ||
| 162 | - e.stopPropagation(); | ||
| 163 | - if (!confirm(`Unsubscribe from ${f.title || f.feed_url}?`)) return; | ||
| 164 | - await api(`/feeds?url=${encodeURIComponent(f.feed_url)}`, { method: "DELETE" }); | ||
| 165 | - if (state.feed === f.feed_url) state.feed = ""; | ||
| 166 | - render(); | ||
| 167 | - }; | ||
| 168 | - return div; | ||
| 169 | -} | ||
| 170 | - | ||
| 171 | -async function loadArticles() { | ||
| 172 | - const p = new URLSearchParams({ limit: "100" }); | ||
| 173 | - if (state.q) p.set("q", state.q); | ||
| 174 | - else { | ||
| 175 | - p.set("status", state.status); | ||
| 176 | - if (state.feed) p.set("feed", state.feed); | ||
| 177 | - } | ||
| 178 | - return api(`/articles?${p}`); | ||
| 179 | -} | ||
| 180 | - | ||
| 181 | -function listView(items) { | ||
| 182 | - const view = $("#view"); | ||
| 183 | - view.innerHTML = ""; | ||
| 184 | - if (!items.length) { | ||
| 185 | - view.innerHTML = `<div class="empty">Nothing here.</div>`; | ||
| 186 | - return; | ||
| 187 | - } | ||
| 188 | - for (const a of items) { | ||
| 189 | - const div = document.createElement("div"); | ||
| 190 | - div.className = "item" + (a.is_read ? " read" : ""); | ||
| 191 | - div.innerHTML = `<div class="title"></div><div class="meta"></div>` + | ||
| 192 | - (a.summary ? `<div class="summary"></div>` : ""); | ||
| 193 | - div.querySelector(".title").textContent = a.title || "(untitled)"; | ||
| 194 | - div.querySelector(".meta").textContent = | ||
| 195 | - [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | ||
| 196 | - if (a.summary) { | ||
| 197 | - // Summaries are HTML in most feeds; show them as the text they read as. | ||
| 198 | - const tmp = document.createElement("div"); | ||
| 199 | - tmp.innerHTML = a.summary; | ||
| 200 | - div.querySelector(".summary").textContent = tmp.textContent; | ||
| 201 | - } | ||
| 202 | - div.onclick = () => openArticle(a.id); | ||
| 203 | - view.append(div); | ||
| 204 | - } | ||
| 205 | -} | ||
| 206 | - | ||
| 207 | -async function openArticle(id) { | ||
| 208 | - const a = await api(`/articles/${id}`); | ||
| 209 | - state.article = a; | ||
| 210 | - if (!a.is_read) { await post("/read", { id }); loadFeeds(); } | ||
| 211 | - | ||
| 212 | - const view = $("#view"); | ||
| 213 | - view.innerHTML = `<article> | ||
| 214 | - <h2></h2><div class="meta"></div> | ||
| 215 | - <div class="bar" style="margin-top:14px"> | ||
| 216 | - <a id="orig" target="_blank" rel="noopener noreferrer"><button>Open original</button></a> | ||
| 217 | - <button id="full">Fetch full text</button> | ||
| 218 | - <button id="unread">Mark unread</button> | ||
| 219 | - </div> | ||
| 220 | - <div class="body"></div> | ||
| 221 | - </article>`; | ||
| 222 | - view.querySelector("h2").textContent = a.title || "(untitled)"; | ||
| 223 | - view.querySelector(".meta").textContent = | ||
| 224 | - [a.feed_title, when(a.published), a.author].filter(Boolean).join(" · "); | ||
| 225 | - // The server sanitises every article body through the same whitelist the | ||
| 226 | - // scraper uses, so this is the one place innerHTML is deliberate. | ||
| 227 | - view.querySelector(".body").innerHTML = a.content || "<p><em>No content.</em></p>"; | ||
| 228 | - $("#orig").href = a.url || "#"; | ||
| 229 | - $("#back").hidden = false; | ||
| 230 | - | ||
| 231 | - $("#full").onclick = async e => { | ||
| 232 | - e.target.disabled = true; e.target.textContent = "Fetching…"; | ||
| 233 | - try { | ||
| 234 | - const r = await post("/fetch-content", { id }); | ||
| 235 | - view.querySelector(".body").innerHTML = r.content; | ||
| 236 | - e.target.textContent = "Fetched"; | ||
| 237 | - } catch (err) { | ||
| 238 | - e.target.textContent = "Fetch full text"; | ||
| 239 | - e.target.disabled = false; | ||
| 240 | - say(err.message); | ||
| 241 | - } | ||
| 242 | - }; | ||
| 243 | - $("#unread").onclick = async () => { | ||
| 244 | - await post("/read", { id, undo: "1" }); | ||
| 245 | - state.article = null; render(); | ||
| 246 | - }; | ||
| 247 | -} | ||
| 248 | - | ||
| 249 | -async function render() { | ||
| 250 | - say(""); | ||
| 251 | - try { | ||
| 252 | - await loadFeeds(); | ||
| 253 | - if (state.article) return; | ||
| 254 | - $("#back").hidden = true; | ||
| 255 | - listView(await loadArticles()); | ||
| 256 | - } catch (e) { say(e.message); } | ||
| 257 | -} | ||
| 258 | - | ||
| 259 | -$("#add").onclick = async () => { | ||
| 260 | - const url = $("#url").value.trim(); | ||
| 261 | - if (!url) return; | ||
| 262 | - $("#add").disabled = true; | ||
| 263 | - try { | ||
| 264 | - const r = await post("/feeds", { url }); | ||
| 265 | - if (r.error) say(r.error); else $("#url").value = ""; | ||
| 266 | - render(); | ||
| 267 | - } catch (e) { say(e.message); } | ||
| 268 | - finally { $("#add").disabled = false; } | ||
| 269 | -}; | ||
| 270 | -$("#url").onkeydown = e => { if (e.key === "Enter") $("#add").click(); }; | ||
| 271 | - | ||
| 272 | -$("#refresh").onclick = async e => { | ||
| 273 | - e.target.disabled = true; e.target.textContent = "Refreshing…"; | ||
| 274 | - try { | ||
| 275 | - const r = await post("/refresh"); | ||
| 276 | - if (r.errors?.length) say(`${r.errors.length} feed(s) failed`); | ||
| 277 | - state.article = null; render(); | ||
| 278 | - } catch (err) { say(err.message); } | ||
| 279 | - finally { e.target.disabled = false; e.target.textContent = "Refresh"; } | ||
| 280 | -}; | ||
| 281 | - | ||
| 282 | -$("#allread").onclick = async () => { | ||
| 283 | - if (!confirm(state.feed ? "Mark this feed read?" : "Mark everything read?")) return; | ||
| 284 | - await post("/read-all", state.feed ? { feed: state.feed } : {}); | ||
| 285 | - state.article = null; render(); | ||
| 286 | -}; | ||
| 287 | - | ||
| 288 | -$("#status").onchange = e => { state.status = e.target.value; state.article = null; render(); }; | ||
| 289 | -$("#back").onclick = () => { state.article = null; render(); }; | ||
| 290 | - | ||
| 291 | -let timer; | ||
| 292 | -$("#q").oninput = e => { | ||
| 293 | - clearTimeout(timer); | ||
| 294 | - // Debounced: every keystroke is an FTS5 query otherwise. | ||
| 295 | - timer = setTimeout(() => { state.q = e.target.value.trim(); state.article = null; render(); }, 250); | ||
| 296 | -}; | ||
| 297 | - | ||
| 298 | -render(); | ||
| 299 | -</script> | ||