diff --git a/sojorn_app/lib/routes/app_routes.dart b/sojorn_app/lib/routes/app_routes.dart index 479373c..f700a32 100644 --- a/sojorn_app/lib/routes/app_routes.dart +++ b/sojorn_app/lib/routes/app_routes.dart @@ -8,6 +8,7 @@ import '../screens/admin/admin_dashboard_screen.dart'; import '../screens/admin/admin_scaffold.dart'; import '../screens/admin/admin_user_base_screen.dart'; import '../screens/admin/moderation_queue_screen.dart'; +import '../screens/admin/admin_content_tools_screen.dart'; import '../screens/beacon/beacon_screen.dart'; import '../screens/home/feed_personal_screen.dart'; import '../screens/home/home_shell.dart'; @@ -150,6 +151,10 @@ class AppRoutes { path: '/admin/users', builder: (_, __) => const AdminUserBaseScreen(), ), + GoRoute( + path: '/admin/content-tools', + builder: (_, __) => const AdminContentToolsScreen(), + ), ], ), ], @@ -158,6 +163,7 @@ class AppRoutes { static int _adminIndexForPath(String path) { if (path.startsWith('/admin/moderation')) return 1; if (path.startsWith('/admin/users')) return 2; + if (path.startsWith('/admin/content-tools')) return 3; return 0; } diff --git a/sojorn_app/lib/screens/admin/admin_content_tools_screen.dart b/sojorn_app/lib/screens/admin/admin_content_tools_screen.dart new file mode 100644 index 0000000..3f07a00 --- /dev/null +++ b/sojorn_app/lib/screens/admin/admin_content_tools_screen.dart @@ -0,0 +1,704 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../services/api_service.dart'; + +class AdminContentToolsScreen extends StatefulWidget { + const AdminContentToolsScreen({super.key}); + + @override + State createState() => _AdminContentToolsScreenState(); +} + +class _AdminContentToolsScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Content Tools'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(icon: Icon(Icons.person_add), text: 'Create User'), + Tab(icon: Icon(Icons.upload_file), text: 'Import Content'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ + _CreateUserTab(), + _ImportContentTab(), + ], + ), + ); + } +} + +// ────────────────────────────────────────────── +// Tab 1: Create User +// ────────────────────────────────────────────── + +class _CreateUserTab extends StatefulWidget { + const _CreateUserTab(); + + @override + State<_CreateUserTab> createState() => _CreateUserTabState(); +} + +class _CreateUserTabState extends State<_CreateUserTab> { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _handleCtrl = TextEditingController(); + final _displayNameCtrl = TextEditingController(); + final _bioCtrl = TextEditingController(); + String _role = 'user'; + bool _verified = false; + bool _official = false; + bool _loading = false; + String? _result; + bool _resultIsError = false; + + @override + void dispose() { + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + _handleCtrl.dispose(); + _displayNameCtrl.dispose(); + _bioCtrl.dispose(); + super.dispose(); + } + + Future _createUser() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _loading = true; + _result = null; + }); + + try { + final resp = await ApiService.instance.callGoApi( + '/admin/users/create', + method: 'POST', + body: { + 'email': _emailCtrl.text.trim(), + 'password': _passwordCtrl.text, + 'handle': _handleCtrl.text.trim().toLowerCase(), + 'display_name': _displayNameCtrl.text.trim(), + 'bio': _bioCtrl.text.trim(), + 'role': _role, + 'verified': _verified, + 'official': _official, + 'skip_email': true, + }, + ); + setState(() { + _result = 'User created: ${resp['handle']} (${resp['user_id']})'; + _resultIsError = false; + }); + _emailCtrl.clear(); + _passwordCtrl.clear(); + _handleCtrl.clear(); + _displayNameCtrl.clear(); + _bioCtrl.clear(); + } catch (e) { + setState(() { + _result = e.toString(); + _resultIsError = true; + }); + } finally { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Create New User', style: theme.textTheme.headlineSmall), + const SizedBox(height: 8), + Text( + 'Admin-created accounts are immediately active (no email verification required).', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 24), + + // Email + Password row + Row( + children: [ + Expanded( + child: TextFormField( + controller: _emailCtrl, + decoration: const InputDecoration( + labelText: 'Email *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.email), + ), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + if (!v.contains('@')) return 'Invalid email'; + return null; + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _passwordCtrl, + decoration: const InputDecoration( + labelText: 'Password *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + obscureText: true, + validator: (v) { + if (v == null || v.length < 8) return 'Min 8 chars'; + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Handle + Display Name row + Row( + children: [ + Expanded( + child: TextFormField( + controller: _handleCtrl, + decoration: const InputDecoration( + labelText: 'Handle *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.alternate_email), + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _displayNameCtrl, + decoration: const InputDecoration( + labelText: 'Display Name *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.badge), + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Bio + TextFormField( + controller: _bioCtrl, + decoration: const InputDecoration( + labelText: 'Bio', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.info_outline), + ), + maxLines: 2, + ), + const SizedBox(height: 16), + + // Role + flags + Row( + children: [ + SizedBox( + width: 200, + child: DropdownButtonFormField( + value: _role, + decoration: const InputDecoration( + labelText: 'Role', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'user', child: Text('User')), + DropdownMenuItem(value: 'admin', child: Text('Admin')), + DropdownMenuItem( + value: 'moderator', child: Text('Moderator')), + ], + onChanged: (v) => setState(() => _role = v ?? 'user'), + ), + ), + const SizedBox(width: 24), + FilterChip( + label: const Text('Verified'), + selected: _verified, + onSelected: (v) => setState(() => _verified = v), + ), + const SizedBox(width: 12), + FilterChip( + label: const Text('Official'), + selected: _official, + onSelected: (v) => setState(() => _official = v), + ), + ], + ), + const SizedBox(height: 24), + + // Submit + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton.icon( + onPressed: _loading ? null : _createUser, + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.person_add), + label: Text(_loading ? 'Creating...' : 'Create User'), + ), + ), + + // Result + if (_result != null) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _resultIsError + ? Colors.red.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _resultIsError ? Colors.red : Colors.green, + width: 0.5, + ), + ), + child: SelectableText( + _result!, + style: TextStyle( + color: _resultIsError ? Colors.red.shade300 : Colors.green.shade300, + fontSize: 13, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +// ────────────────────────────────────────────── +// Tab 2: Import Content +// ────────────────────────────────────────────── + +class _ImportContentTab extends StatefulWidget { + const _ImportContentTab(); + + @override + State<_ImportContentTab> createState() => _ImportContentTabState(); +} + +class _ImportContentTabState extends State<_ImportContentTab> { + final _authorIdCtrl = TextEditingController(); + final _inputCtrl = TextEditingController(); + final _bodyCtrl = TextEditingController(); + String _contentType = 'post'; + String _inputMode = 'links'; // links or csv + bool _isNsfw = false; + String _visibility = 'public'; + bool _loading = false; + Map? _result; + + @override + void dispose() { + _authorIdCtrl.dispose(); + _inputCtrl.dispose(); + _bodyCtrl.dispose(); + super.dispose(); + } + + List> _parseItems() { + final raw = _inputCtrl.text.trim(); + if (raw.isEmpty) return []; + + if (_inputMode == 'links') { + // Plain text: one URL per line + return raw + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .map((url) => { + 'body': _bodyCtrl.text.trim(), + 'media_url': url, + 'is_nsfw': _isNsfw, + 'visibility': _visibility, + 'tags': [], + }) + .toList(); + } else { + // CSV format: body,media_url,thumbnail_url,tags(semicolon-sep),is_nsfw,visibility + final lines = raw.split('\n').where((l) => l.trim().isNotEmpty).toList(); + // Skip header if present + final startIdx = + lines.isNotEmpty && lines[0].toLowerCase().contains('body') ? 1 : 0; + + return lines.skip(startIdx).map((line) { + final cols = _parseCsvLine(line); + return { + 'body': cols.isNotEmpty ? cols[0] : '', + 'media_url': cols.length > 1 ? cols[1] : '', + 'thumbnail_url': cols.length > 2 ? cols[2] : '', + 'tags': cols.length > 3 + ? cols[3].split(';').where((t) => t.isNotEmpty).toList() + : [], + 'is_nsfw': + cols.length > 4 ? (cols[4].toLowerCase() == 'true') : _isNsfw, + 'visibility': cols.length > 5 ? cols[5] : _visibility, + }; + }).toList(); + } + } + + List _parseCsvLine(String line) { + // Simple CSV parser respecting quoted fields + final result = []; + bool inQuotes = false; + final current = StringBuffer(); + + for (int i = 0; i < line.length; i++) { + final ch = line[i]; + if (ch == '"') { + inQuotes = !inQuotes; + } else if (ch == ',' && !inQuotes) { + result.add(current.toString().trim()); + current.clear(); + } else { + current.write(ch); + } + } + result.add(current.toString().trim()); + return result; + } + + Future _importContent() async { + final authorId = _authorIdCtrl.text.trim(); + if (authorId.isEmpty) { + setState(() => _result = {'error': 'Author ID is required'}); + return; + } + + final items = _parseItems(); + if (items.isEmpty) { + setState(() => _result = {'error': 'No items to import'}); + return; + } + + setState(() { + _loading = true; + _result = null; + }); + + try { + final resp = await ApiService.instance.callGoApi( + '/admin/content/import', + method: 'POST', + body: { + 'author_id': authorId, + 'content_type': _contentType, + 'items': items, + }, + ); + setState(() => _result = resp); + } catch (e) { + setState(() => _result = {'error': e.toString()}); + } finally { + setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Import Content', style: theme.textTheme.headlineSmall), + const SizedBox(height: 8), + Text( + 'Import posts, quips, or beacons from direct R2 links or CSV data.', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 24), + + // Author ID + Content Type + Row( + children: [ + Expanded( + flex: 2, + child: TextFormField( + controller: _authorIdCtrl, + decoration: const InputDecoration( + labelText: 'Author User ID *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + hintText: 'UUID of the user who owns these posts', + ), + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 160, + child: DropdownButtonFormField( + value: _contentType, + decoration: const InputDecoration( + labelText: 'Type', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'post', child: Text('Post')), + DropdownMenuItem(value: 'quip', child: Text('Quip')), + DropdownMenuItem(value: 'beacon', child: Text('Beacon')), + ], + onChanged: (v) => + setState(() => _contentType = v ?? 'post'), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Input mode toggle + Row( + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'links', + icon: Icon(Icons.link), + label: Text('Plain Links'), + ), + ButtonSegment( + value: 'csv', + icon: Icon(Icons.table_chart), + label: Text('CSV'), + ), + ], + selected: {_inputMode}, + onSelectionChanged: (v) => + setState(() => _inputMode = v.first), + ), + const Spacer(), + FilterChip( + label: const Text('NSFW'), + selected: _isNsfw, + onSelected: (v) => setState(() => _isNsfw = v), + ), + const SizedBox(width: 12), + SizedBox( + width: 140, + child: DropdownButtonFormField( + value: _visibility, + decoration: const InputDecoration( + labelText: 'Visibility', + border: OutlineInputBorder(), + isDense: true, + ), + items: const [ + DropdownMenuItem( + value: 'public', child: Text('Public')), + DropdownMenuItem( + value: 'followers', child: Text('Followers')), + DropdownMenuItem( + value: 'private', child: Text('Private')), + ], + onChanged: (v) => + setState(() => _visibility = v ?? 'public'), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Body (for links mode - shared body for all items) + if (_inputMode == 'links') ...[ + TextFormField( + controller: _bodyCtrl, + decoration: const InputDecoration( + labelText: 'Post Body (shared for all items)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.text_fields), + hintText: 'Optional caption for all imported items', + ), + maxLines: 2, + ), + const SizedBox(height: 16), + ], + + // Main input + TextFormField( + controller: _inputCtrl, + decoration: InputDecoration( + labelText: _inputMode == 'links' + ? 'Media URLs (one per line)' + : 'CSV Data', + border: const OutlineInputBorder(), + hintText: _inputMode == 'links' + ? 'https://media.sojorn.net/uploads/image1.jpg\nhttps://media.sojorn.net/uploads/video1.mp4' + : 'body,media_url,thumbnail_url,tags,is_nsfw,visibility\nHello world,https://...,,,false,public', + hintMaxLines: 3, + alignLabelWithHint: true, + ), + maxLines: 12, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + ), + const SizedBox(height: 8), + + // Preview count + Builder(builder: (context) { + final items = _parseItems(); + return Text( + '${items.length} item(s) detected', + style: theme.textTheme.bodySmall?.copyWith( + color: items.isEmpty ? Colors.orange : Colors.green, + ), + ); + }), + const SizedBox(height: 16), + + // Import button + SizedBox( + width: double.infinity, + height: 48, + child: FilledButton.icon( + onPressed: _loading ? null : _importContent, + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.cloud_upload), + label: Text(_loading ? 'Importing...' : 'Import Content'), + ), + ), + + // Result + if (_result != null) ...[ + const SizedBox(height: 16), + _buildResultCard(), + ], + ], + ), + ); + } + + Widget _buildResultCard() { + final isError = _result!.containsKey('error') && _result!['success'] == null; + final bgColor = isError + ? Colors.red.withValues(alpha: 0.15) + : Colors.green.withValues(alpha: 0.15); + final borderColor = isError ? Colors.red : Colors.green; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isError) + SelectableText( + _result!['error'].toString(), + style: TextStyle(color: Colors.red.shade300, fontSize: 13), + ) + else ...[ + Text( + _result!['message'] ?? 'Done', + style: TextStyle( + color: Colors.green.shade300, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Text( + 'Success: ${_result!['success']} | Failures: ${_result!['failures']}', + style: const TextStyle(fontSize: 13), + ), + if (_result!['errors'] != null && + (_result!['errors'] as List).isNotEmpty) ...[ + const SizedBox(height: 8), + const Text('Errors:', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + const SizedBox(height: 4), + ...(_result!['errors'] as List).map((e) => Text( + '• $e', + style: TextStyle( + fontSize: 11, color: Colors.red.shade300), + )), + ], + if (_result!['created'] != null && + (_result!['created'] as List).isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Text('Post IDs: ', + style: + TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + IconButton( + icon: const Icon(Icons.copy, size: 16), + tooltip: 'Copy all IDs', + onPressed: () { + Clipboard.setData(ClipboardData( + text: (_result!['created'] as List).join('\n'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('IDs copied')), + ); + }, + ), + ], + ), + ...(_result!['created'] as List).take(10).map((id) => + SelectableText(id.toString(), + style: const TextStyle( + fontSize: 11, fontFamily: 'monospace'))), + if ((_result!['created'] as List).length > 10) + Text( + '... and ${(_result!['created'] as List).length - 10} more', + style: const TextStyle(fontSize: 11), + ), + ], + ], + ], + ), + ); + } +} diff --git a/sojorn_app/lib/screens/admin/admin_scaffold.dart b/sojorn_app/lib/screens/admin/admin_scaffold.dart index 050ae87..f45ccf1 100644 --- a/sojorn_app/lib/screens/admin/admin_scaffold.dart +++ b/sojorn_app/lib/screens/admin/admin_scaffold.dart @@ -15,6 +15,7 @@ class AdminScaffold extends StatelessWidget { '/admin', '/admin/moderation', '/admin/users', + '/admin/content-tools', ]; static ThemeData _adminTheme() { @@ -133,6 +134,10 @@ class AdminScaffold extends StatelessWidget { icon: Icon(Icons.people_alt_outlined), label: Text('User Base'), ), + NavigationRailDestination( + icon: Icon(Icons.build_circle_outlined), + label: Text('Content Tools'), + ), ], ), const VerticalDivider(width: 1),