From c03ba3bc3a5c513bf143a089d3fddcc268904c23 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 21 Nov 2025 00:34:39 +0800 Subject: [PATCH] :recycle: Breakdown of the site detail page --- lib/screens/creators/sites/site_detail.dart | 1508 +---------------- lib/widgets/sites/file_item.dart | 156 ++ .../sites/file_management_section.dart | 149 ++ lib/widgets/sites/file_upload_dialog.dart | 315 ++++ lib/widgets/sites/info_row.dart | 50 + lib/widgets/sites/page_form.dart | 440 +++++ lib/widgets/sites/page_item.dart | 135 ++ lib/widgets/sites/pages_section.dart | 122 ++ lib/widgets/sites/site_action_menu.dart | 107 ++ lib/widgets/sites/site_detail_content.dart | 101 ++ 10 files changed, 1580 insertions(+), 1503 deletions(-) create mode 100644 lib/widgets/sites/file_item.dart create mode 100644 lib/widgets/sites/file_management_section.dart create mode 100644 lib/widgets/sites/file_upload_dialog.dart create mode 100644 lib/widgets/sites/info_row.dart create mode 100644 lib/widgets/sites/page_form.dart create mode 100644 lib/widgets/sites/page_item.dart create mode 100644 lib/widgets/sites/pages_section.dart create mode 100644 lib/widgets/sites/site_action_menu.dart create mode 100644 lib/widgets/sites/site_detail_content.dart diff --git a/lib/screens/creators/sites/site_detail.dart b/lib/screens/creators/sites/site_detail.dart index d57ac82d..c1b84437 100644 --- a/lib/screens/creators/sites/site_detail.dart +++ b/lib/screens/creators/sites/site_detail.dart @@ -1,24 +1,15 @@ -import 'package:easy_localization/easy_localization.dart'; -import 'dart:io'; -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/publication_site.dart'; -import 'package:island/models/site_file.dart'; import 'package:island/pods/network.dart'; -import 'package:island/pods/site_files.dart'; import 'package:island/pods/site_pages.dart'; -import 'package:island/screens/creators/sites/site_edit.dart'; -import 'package:island/services/time.dart'; +import 'package:island/widgets/sites/page_form.dart'; +import 'package:island/widgets/sites/site_action_menu.dart'; +import 'package:island/widgets/sites/site_detail_content.dart'; import 'package:island/widgets/app_scaffold.dart'; -import 'package:island/widgets/content/sheet.dart'; -import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:styled_widget/styled_widget.dart'; part 'site_detail.g.dart'; @@ -58,14 +49,14 @@ class PublicationSiteDetailScreen extends HookConsumerWidget { ), actions: [ siteAsync.maybeWhen( - data: (site) => _SiteActionMenu(site: site, pubName: pubName), + data: (site) => SiteActionMenu(site: site, pubName: pubName), orElse: () => const SizedBox.shrink(), ), const Gap(8), ], ), body: siteAsync.when( - data: (site) => _SiteDetailContent(site: site, pubName: pubName), + data: (site) => SiteDetailContent(site: site, pubName: pubName), error: (error, stack) => Center( child: Column( @@ -111,1492 +102,3 @@ class PublicationSiteDetailScreen extends HookConsumerWidget { ); } } - -class FileUploadDialog extends HookConsumerWidget { - final List selectedFiles; - final SnPublicationSite site; - final VoidCallback onUploadComplete; - - const FileUploadDialog({ - super.key, - required this.selectedFiles, - required this.site, - required this.onUploadComplete, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final formKey = useMemoized(() => GlobalKey()); - final pathController = useTextEditingController(text: '/'); - final isUploading = useState(false); - final progressStates = useState>>( - selectedFiles - .map( - (file) => { - 'fileName': file.path.split('/').last, - 'progress': 0.0, - 'status': - 'pending', // 'pending', 'uploading', 'completed', 'error' - 'error': null, - }, - ) - .toList(), - ); - - final uploadFile = useCallback(( - String basePath, - File file, - int index, - ) async { - try { - progressStates.value[index]['status'] = 'uploading'; - progressStates.value = [...progressStates.value]; - - final siteFilesNotifier = ref.read( - siteFilesNotifierProvider((siteId: site.id, path: null)).notifier, - ); - - final fileName = file.path.split('/').last; - final uploadPath = - basePath.endsWith('/') - ? '$basePath$fileName' - : '$basePath/$fileName'; - - await siteFilesNotifier.uploadFile(file, uploadPath); - - progressStates.value[index]['status'] = 'completed'; - progressStates.value[index]['progress'] = 1.0; - progressStates.value = [...progressStates.value]; - } catch (e) { - progressStates.value[index]['status'] = 'error'; - progressStates.value[index]['error'] = e.toString(); - progressStates.value = [...progressStates.value]; - } - }, [ref, site.id, progressStates]); - - final uploadAllFiles = useCallback( - () async { - if (!formKey.currentState!.validate()) return; - - isUploading.value = true; - - // Reset all progress states - for (int i = 0; i < progressStates.value.length; i++) { - progressStates.value[i]['status'] = 'pending'; - progressStates.value[i]['progress'] = 0.0; - progressStates.value[i]['error'] = null; - } - progressStates.value = [...progressStates.value]; - - // Upload files sequentially (could be made parallel if needed) - for (int i = 0; i < selectedFiles.length; i++) { - final file = selectedFiles[i]; - await uploadFile(pathController.text, file, i); - } - - isUploading.value = false; - onUploadComplete(); - - // Close dialog if all uploads completed successfully - if (progressStates.value.every( - (state) => state['status'] == 'completed', - )) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All files uploaded successfully')), - ); - Navigator.of(context).pop(); - } - } - }, - [ - uploadFile, - isUploading, - progressStates, - selectedFiles, - onUploadComplete, - context, - formKey, - pathController, - ], - ); - - return SheetScaffold( - titleText: 'Upload Files', - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Upload path field - TextFormField( - controller: pathController, - decoration: const InputDecoration( - labelText: 'Upload Path', - hintText: '/ (root) or /assets/images/', - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter an upload path'; - } - if (!value.startsWith('/') && value != '/') { - return 'Path must start with /'; - } - if (value.contains(' ')) { - return 'Path cannot contain spaces'; - } - if (value.contains('//')) { - return 'Path cannot have consecutive slashes'; - } - return null; - }, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - const Gap(20), - Text( - 'Ready to upload ${selectedFiles.length} file${selectedFiles.length == 1 ? '' : 's'}:', - style: Theme.of(context).textTheme.titleMedium, - ), - const Gap(16), - ...selectedFiles.map((file) { - final index = selectedFiles.indexOf(file); - final progressState = progressStates.value[index]; - final fileName = file.path.split('/').last; - final fileSize = file.lengthSync(); - final fileSizeText = - fileSize < 1024 * 1024 - ? '${(fileSize / 1024).toStringAsFixed(1)} KB' - : '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB'; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Symbols.description, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - const Gap(8), - Expanded( - child: Text( - fileName, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - ), - ), - Text( - fileSizeText, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: - Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - if (progressState['status'] == 'uploading') ...[ - const Gap(8), - LinearProgressIndicator( - value: progressState['progress'], - backgroundColor: - Theme.of(context).colorScheme.surfaceVariant, - ), - const Gap(4), - Text( - 'Uploading... ${(progressState['progress'] * 100).toStringAsFixed(0)}%', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ] else if (progressState['status'] == 'completed') ...[ - const Gap(8), - Row( - children: [ - Icon( - Symbols.check_circle, - color: Colors.green, - size: 16, - ), - const Gap(4), - Text( - 'Completed', - style: TextStyle( - color: Colors.green, - fontSize: 12, - ), - ), - ], - ), - ] else if (progressState['status'] == 'error') ...[ - const Gap(8), - Row( - children: [ - Icon(Symbols.error, color: Colors.red, size: 16), - const Gap(4), - Expanded( - child: Text( - progressState['error'] ?? 'Upload failed', - style: TextStyle( - color: Colors.red, - fontSize: 12, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - // Show the final upload path when not uploading - if (!isUploading.value && - progressState['status'] != 'uploading') ...[ - const Gap(8), - Text( - 'Will upload to: ${pathController.text.endsWith('/') ? pathController.text : '${pathController.text}/'}$fileName', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith( - color: - Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - ); - }), - const Gap(24), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: - isUploading.value - ? null - : () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ), - const Gap(12), - Expanded( - child: FilledButton( - onPressed: isUploading.value ? null : uploadAllFiles, - child: Text( - isUploading.value - ? 'Uploading...' - : 'Upload ${selectedFiles.length} File${selectedFiles.length == 1 ? '' : 's'}', - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class PageForm extends HookConsumerWidget { - final SnPublicationSite site; - final String pubName; - final SnPublicationPage? page; // null for create, non-null for edit - - const PageForm({ - super.key, - required this.site, - required this.pubName, - this.page, - }); - - int _getPageType(SnPublicationPage? page) { - if (page == null) return 0; // Default to HTML - // Check config structure to determine type - return page.config?.containsKey('target') == true ? 1 : 0; - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final formKey = useMemoized(() => GlobalKey()); - final pathController = useTextEditingController(text: page?.path ?? '/'); - - // Determine initial type and create appropriate controllers - final initialType = _getPageType(page); - final pageType = useState(initialType); - - final htmlController = useTextEditingController( - text: - pageType.value == 0 - ? (page?.config?['html'] ?? page?.config?['content'] ?? '') - : '', - ); - final titleController = useTextEditingController( - text: pageType.value == 0 ? (page?.config?['title'] ?? '') : '', - ); - final targetController = useTextEditingController( - text: pageType.value == 1 ? (page?.config?['target'] ?? '') : '', - ); - - final isLoading = useState(false); - - // Update controllers when page type changes - useEffect(() { - pageType.addListener(() { - if (pageType.value == 0) { - // HTML mode - htmlController.text = - page?.config?['html'] ?? page?.config?['content'] ?? ''; - titleController.text = page?.config?['title'] ?? ''; - targetController.clear(); - } else { - // Redirect mode - htmlController.clear(); - titleController.clear(); - targetController.text = page?.config?['target'] ?? ''; - } - }); - return null; - }, [pageType]); - - // Initialize form fields when page data is loaded - useEffect(() { - if (page?.path != null && pathController.text == '/') { - pathController.text = page!.path!; - if (pageType.value == 0) { - htmlController.text = - page!.config?['html'] ?? page!.config?['content'] ?? ''; - titleController.text = page!.config?['title'] ?? ''; - } else { - targetController.text = page!.config?['target'] ?? ''; - } - } - return null; - }, [page]); - - final savePage = useCallback(() async { - if (!formKey.currentState!.validate()) return; - - isLoading.value = true; - - try { - final pagesNotifier = ref.read( - sitePagesNotifierProvider(( - pubName: pubName, - siteSlug: site.slug, - )).notifier, - ); - - late final Map pageData; - - if (pageType.value == 0) { - // HTML page - pageData = { - 'type': 0, - 'path': pathController.text, - 'config': { - 'title': titleController.text, - 'html': htmlController.text, - }, - }; - } else { - // Redirect page - pageData = { - 'type': 1, - 'path': pathController.text, - 'config': {'target': targetController.text}, - }; - } - - if (page == null) { - // Create new page - await pagesNotifier.createPage(pageData); - } else { - // Update existing page - await pagesNotifier.updatePage(page!.id, pageData); - } - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - page == null - ? 'Page created successfully' - : 'Page updated successfully', - ), - ), - ); - Navigator.pop(context); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save page: ${e.toString()}')), - ); - } - } finally { - isLoading.value = false; - } - }, [pageType, pubName, site.slug, page]); - - final deletePage = useCallback(() async { - if (page == null) return; // Shouldn't happen for editing - - final confirmed = await showDialog( - context: context, - builder: - (context) => AlertDialog( - title: const Text('Delete Page'), - content: const Text('Are you sure you want to delete this page?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Delete'), - ), - ], - ), - ); - if (confirmed != true) return; - - isLoading.value = true; - - try { - final pagesNotifier = ref.read( - sitePagesNotifierProvider(( - pubName: pubName, - siteSlug: site.slug, - )).notifier, - ); - - await pagesNotifier.deletePage(page!.id); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Page deleted successfully')), - ); - Navigator.pop(context); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to delete page')), - ); - } - } finally { - isLoading.value = false; - } - }, [pubName, site.slug, page, context]); - - return SheetScaffold( - titleText: page == null ? 'Create Page' : 'Edit Page', - child: Builder( - builder: - (context) => SingleChildScrollView( - child: Column( - children: [ - Form( - key: formKey, - child: Column( - children: [ - // Page type selector - DropdownButtonFormField( - value: pageType.value, - decoration: const InputDecoration( - labelText: 'Page Type', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(12), - ), - ), - ), - items: const [ - DropdownMenuItem( - value: 0, - child: Row( - children: [ - Icon(Symbols.code, size: 20), - Gap(8), - Text('HTML Page'), - ], - ), - ), - DropdownMenuItem( - value: 1, - child: Row( - children: [ - Icon(Symbols.link, size: 20), - Gap(8), - Text('Redirect Page'), - ], - ), - ), - ], - onChanged: (value) { - if (value != null) { - pageType.value = value; - } - }, - validator: (value) { - if (value == null) { - return 'Please select a page type'; - } - return null; - }, - ).padding(all: 20), - // Conditional form fields based on page type - if (pageType.value == 0) ...[ - // HTML Page fields - TextFormField( - controller: pathController, - decoration: const InputDecoration( - labelText: 'Page Path', - hintText: '/about, /contact, etc.', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(12), - ), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a page path'; - } - if (!RegExp( - r'^[a-zA-Z0-9\-/_]+$', - ).hasMatch(value)) { - return 'Page path can only contain letters, numbers, hyphens, underscores, and slashes'; - } - if (!value.startsWith('/')) { - return 'Page path must start with /'; - } - if (value.contains('//')) { - return 'Page path cannot have consecutive slashes'; - } - return null; - }, - onTapOutside: - (_) => - FocusManager.instance.primaryFocus - ?.unfocus(), - ).padding(horizontal: 20), - const SizedBox(height: 16), - TextFormField( - controller: titleController, - decoration: const InputDecoration( - labelText: 'Page Title', - hintText: 'About Us, Contact, etc.', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(12), - ), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a page title'; - } - return null; - }, - onTapOutside: - (_) => - FocusManager.instance.primaryFocus - ?.unfocus(), - ).padding(horizontal: 20), - const SizedBox(height: 16), - TextFormField( - controller: htmlController, - decoration: const InputDecoration( - labelText: 'Page Content (HTML)', - hintText: - '

Hello World

This is my page content...

', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(12), - ), - ), - alignLabelWithHint: true, - ), - maxLines: 10, - onTapOutside: - (_) => - FocusManager.instance.primaryFocus - ?.unfocus(), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter HTML content for the page'; - } - return null; - }, - ).padding(horizontal: 20), - ] else ...[ - // Redirect Page fields - TextFormField( - controller: pathController, - decoration: const InputDecoration( - labelText: 'Page Path', - hintText: '/old-page, /redirect, etc.', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(12), - ), - ), - prefixText: '/', - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a page path'; - } - if (!RegExp( - r'^[a-zA-Z0-9\-/_]+$', - ).hasMatch(value)) { - return 'Page path can only contain letters, numbers, hyphens, underscores, and slashes'; - } - if (!value.startsWith('/')) { - return 'Page path must start with /'; - } - if (value.contains('//')) { - return 'Page path cannot have consecutive slashes'; - } - return null; - }, - onTapOutside: - (_) => - FocusManager.instance.primaryFocus - ?.unfocus(), - ).padding(horizontal: 20), - const SizedBox(height: 16), - TextFormField( - controller: targetController, - decoration: const InputDecoration( - labelText: 'Redirect Target', - hintText: '/new-page, https://example.com, etc.', - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(12), - ), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a redirect target'; - } - if (!value.startsWith('/') && - !value.startsWith('http://') && - !value.startsWith('https://')) { - return 'Target must be a relative path (/) or absolute URL (http/https)'; - } - return null; - }, - onTapOutside: - (_) => - FocusManager.instance.primaryFocus - ?.unfocus(), - ).padding(horizontal: 20), - ], - ], - ).padding(vertical: 20), - ), - Row( - children: [ - if (page != null) ...[ - TextButton.icon( - onPressed: deletePage, - icon: const Icon(Symbols.delete_forever), - label: const Text('Delete Page'), - style: TextButton.styleFrom( - foregroundColor: Colors.red, - ), - ).alignment(Alignment.centerRight), - const Spacer(), - ] else - const Spacer(), - TextButton.icon( - onPressed: savePage, - icon: const Icon(Symbols.save), - label: const Text('Save Page'), - ), - ], - ).padding(horizontal: 20, vertical: 12), - ], - ), - ), - ), - ); - } -} - -class _PagesSection extends HookConsumerWidget { - final SnPublicationSite site; - final String pubName; - - const _PagesSection({required this.site, required this.pubName}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final pagesAsync = ref.watch(sitePagesProvider(pubName, site.slug)); - final theme = Theme.of(context); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Symbols.article, size: 20), - const Gap(8), - Text( - 'Pages', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - onPressed: () { - // Open page creation dialog - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: - (context) => PageForm(site: site, pubName: pubName), - ).then((_) { - // Refresh pages after creation - ref.invalidate(sitePagesProvider(pubName, site.slug)); - }); - }, - icon: const Icon(Symbols.add), - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - ), - ], - ), - const Gap(16), - pagesAsync.when( - data: (pages) { - if (pages.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - children: [ - Icon( - Symbols.article, - size: 48, - color: theme.colorScheme.outline, - ), - const Gap(16), - Text( - 'No pages yet', - style: theme.textTheme.bodyLarge, - ), - const Gap(8), - Text( - 'Create your first page to get started', - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - ); - } - - return ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: pages.length, - itemBuilder: (context, index) { - final page = pages[index]; - return _PageItem(page: page, site: site, pubName: pubName); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, stack) => Center( - child: Column( - children: [ - Text('Failed to load pages'), - const Gap(8), - ElevatedButton( - onPressed: - () => ref.invalidate( - sitePagesProvider(pubName, site.slug), - ), - child: const Text('Retry'), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -class _PageItem extends HookConsumerWidget { - final SnPublicationPage page; - final SnPublicationSite site; - final String pubName; - - const _PageItem({ - required this.page, - required this.site, - required this.pubName, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - elevation: 0, - child: ListTile( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - leading: Icon(Symbols.article, color: theme.colorScheme.primary), - title: Text(page.path ?? '/'), - subtitle: Text(page.config?['title'] ?? 'Untitled'), - trailing: PopupMenuButton( - itemBuilder: - (context) => [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Symbols.edit), - const Gap(16), - Text('edit'.tr()), - ], - ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Symbols.delete, color: Colors.red), - const Gap(16), - Text('delete'.tr()).textColor(Colors.red), - ], - ), - ), - ], - onSelected: (value) async { - switch (value) { - case 'edit': - // Open page edit dialog - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: - (context) => - PageForm(site: site, pubName: pubName, page: page), - ).then((_) { - // Refresh pages after editing - ref.invalidate(sitePagesProvider(pubName, site.slug)); - }); - break; - case 'delete': - final confirmed = await showDialog( - context: context, - builder: - (context) => AlertDialog( - title: const Text('Delete Page'), - content: const Text( - 'Are you sure you want to delete this page?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Delete'), - ), - ], - ), - ); - - if (confirmed == true) { - try { - await ref - .read( - sitePagesNotifierProvider(( - pubName: pubName, - siteSlug: site.slug, - )).notifier, - ) - .deletePage(page.id); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Page deleted successfully'), - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to delete page')), - ); - } - } - } - break; - } - }, - ), - onTap: () { - // TODO: Open page preview or edit - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Opening page: ${page.path ?? '/'}')), - ); - }, - ), - ); - } -} - -class _FileManagementSection extends HookConsumerWidget { - final SnPublicationSite site; - final String pubName; - - const _FileManagementSection({required this.site, required this.pubName}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final filesAsync = ref.watch(siteFilesProvider(siteId: site.id)); - final theme = Theme.of(context); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Symbols.folder, size: 20), - const Gap(8), - Text( - 'File Management', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - IconButton( - onPressed: () async { - // Open file upload dialog - final selectedFiles = await FilePicker.platform.pickFiles( - allowMultiple: true, - type: FileType.any, - ); - - if (selectedFiles == null || selectedFiles.files.isEmpty) { - return; // User canceled - } - - if (!context.mounted) return; - - // Show upload dialog for path specification - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: - (context) => FileUploadDialog( - selectedFiles: - selectedFiles.files - .map((f) => File(f.path!)) - .toList(), - site: site, - onUploadComplete: () { - // Refresh file list - ref.invalidate( - siteFilesProvider(siteId: site.id), - ); - }, - ), - ); - }, - icon: const Icon(Symbols.upload), - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - ), - ], - ), - const Gap(16), - filesAsync.when( - data: (files) { - if (files.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - children: [ - Icon( - Symbols.folder, - size: 48, - color: theme.colorScheme.outline, - ), - const Gap(16), - Text( - 'No files uploaded yet', - style: theme.textTheme.bodyLarge, - ), - const Gap(8), - Text( - 'Upload your first file to get started', - style: theme.textTheme.bodySmall, - ), - ], - ), - ), - ); - } - - return ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: files.length, - itemBuilder: (context, index) { - final file = files[index]; - return _FileItem(file: file, site: site); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, stack) => Center( - child: Column( - children: [ - Text('Failed to load files'), - const Gap(8), - ElevatedButton( - onPressed: - () => ref.invalidate( - siteFilesProvider(siteId: site.id), - ), - child: const Text('Retry'), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -class _FileItem extends HookConsumerWidget { - final SnSiteFileEntry file; - final SnPublicationSite site; - - const _FileItem({required this.file, required this.site}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - - return Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - elevation: 0, - child: ListTile( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - leading: Icon( - file.isDirectory ? Symbols.folder : Symbols.description, - color: theme.colorScheme.primary, - ), - title: Text(file.relativePath), - subtitle: Text( - file.isDirectory - ? 'Directory' - : '${(file.size / 1024).toStringAsFixed(1)} KB', - ), - trailing: PopupMenuButton( - itemBuilder: - (context) => [ - PopupMenuItem( - value: 'download', - child: Row( - children: [ - const Icon(Symbols.download), - const Gap(16), - Text('Download'), - ], - ), - ), - if (!file.isDirectory) ...[ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Symbols.edit), - const Gap(16), - Text('Edit Content'), - ], - ), - ), - ], - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Symbols.delete, color: Colors.red), - const Gap(16), - Text('Delete').textColor(Colors.red), - ], - ), - ), - ], - onSelected: (value) async { - switch (value) { - case 'download': - // TODO: Implement file download - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Downloading ${file.relativePath}')), - ); - break; - case 'edit': - // TODO: Implement file editing - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Editing ${file.relativePath}')), - ); - break; - case 'delete': - final confirmed = await showDialog( - context: context, - builder: - (context) => AlertDialog( - title: const Text('Delete File'), - content: Text( - 'Are you sure you want to delete "${file.relativePath}"?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Delete'), - ), - ], - ), - ); - - if (confirmed == true) { - try { - await ref - .read( - siteFilesNotifierProvider(( - siteId: site.id, - path: null, - )).notifier, - ) - .deleteFile(file.relativePath); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('File deleted successfully'), - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to delete file')), - ); - } - } - } - break; - } - }, - ), - onTap: () { - if (file.isDirectory) { - // TODO: Navigate into directory - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Opening directory: ${file.relativePath}'), - ), - ); - } else { - // TODO: Open file preview/editor - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Opening file: ${file.relativePath}')), - ); - } - }, - ), - ); - } -} - -class _SiteDetailContent extends HookConsumerWidget { - final SnPublicationSite site; - final String pubName; - - const _SiteDetailContent({required this.site, required this.pubName}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - - return ExtendedRefreshIndicator( - onRefresh: - () async => - ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)), - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Site Info Card - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Site Information', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Gap(16), - _InfoRow( - label: 'Name', - value: site.name, - icon: Symbols.title, - ), - const Gap(8), - _InfoRow( - label: 'Slug', - value: site.slug, - icon: Symbols.tag, - monospace: true, - ), - const Gap(8), - _InfoRow( - label: 'Mode', - value: site.mode == 0 ? 'Fully Managed' : 'Self-Managed', - icon: Symbols.settings, - ), - if (site.description != null && - site.description!.isNotEmpty) ...[ - const Gap(8), - _InfoRow( - label: 'Description', - value: site.description!, - icon: Symbols.description, - ), - ], - const Gap(8), - _InfoRow( - label: 'Created', - value: site.createdAt.formatSystem(), - icon: Symbols.calendar_add_on, - ), - const Gap(8), - _InfoRow( - label: 'Updated', - value: site.updatedAt.formatSystem(), - icon: Symbols.update, - ), - ], - ), - ), - ), - // Pages Section - _PagesSection(site: site, pubName: pubName), - _FileManagementSection(site: site, pubName: pubName), - ], - ), - ), - ); - } -} - -class _InfoRow extends StatelessWidget { - final String label; - final String value; - final IconData icon; - final bool monospace; - - const _InfoRow({ - required this.label, - required this.value, - required this.icon, - this.monospace = false, - }); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), - const Gap(12), - Expanded( - flex: 2, - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - const Gap(12), - Expanded( - flex: 3, - child: Text( - value, - style: - monospace - ? GoogleFonts.robotoMono(fontSize: 14) - : Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.end, - ), - ), - ], - ); - } -} - -class _SiteActionMenu extends HookConsumerWidget { - final SnPublicationSite site; - final String pubName; - - const _SiteActionMenu({required this.site, required this.pubName}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return PopupMenuButton( - itemBuilder: - (context) => [ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - Icon( - Symbols.edit, - color: Theme.of(context).colorScheme.onSurface, - ), - const Gap(16), - Text('edit'.tr()), - ], - ), - ), - const PopupMenuDivider(), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Symbols.delete, color: Colors.red), - const Gap(16), - Text('delete'.tr()).textColor(Colors.red), - ], - ), - ), - ], - onSelected: (value) async { - switch (value) { - case 'edit': - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: - (context) => SiteForm(pubName: pubName, siteSlug: site.slug), - ).then((_) { - // Refresh site data after potential edit - ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)); - }); - break; - case 'delete': - final confirmed = await showDialog( - context: context, - builder: - (context) => AlertDialog( - title: const Text('Delete Site'), - content: const Text( - 'Are you sure you want to delete this publication site? This action cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Delete'), - ), - ], - ), - ); - - if (confirmed == true) { - try { - final client = ref.read(apiClientProvider); - await client.delete('/zone/sites/${site.id}'); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Site deleted successfully')), - ); - // Navigate back to list - Navigator.of(context).pop(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to delete site')), - ); - } - } - } - break; - } - }, - ); - } -} diff --git a/lib/widgets/sites/file_item.dart b/lib/widgets/sites/file_item.dart new file mode 100644 index 00000000..f89f2dad --- /dev/null +++ b/lib/widgets/sites/file_item.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/site_file.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/site_files.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class FileItem extends HookConsumerWidget { + final SnSiteFileEntry file; + final SnPublicationSite site; + + const FileItem({super.key, required this.file, required this.site}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + elevation: 0, + child: ListTile( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + leading: Icon( + file.isDirectory ? Symbols.folder : Symbols.description, + color: theme.colorScheme.primary, + ), + title: Text(file.relativePath), + subtitle: Text( + file.isDirectory + ? 'Directory' + : '${(file.size / 1024).toStringAsFixed(1)} KB', + ), + trailing: PopupMenuButton( + itemBuilder: + (context) => [ + PopupMenuItem( + value: 'download', + child: Row( + children: [ + const Icon(Symbols.download), + const Gap(16), + Text('Download'), + ], + ), + ), + if (!file.isDirectory) ...[ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Symbols.edit), + const Gap(16), + Text('Edit Content'), + ], + ), + ), + ], + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Symbols.delete, color: Colors.red), + const Gap(16), + Text('Delete').textColor(Colors.red), + ], + ), + ), + ], + onSelected: (value) async { + switch (value) { + case 'download': + // TODO: Implement file download + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Downloading ${file.relativePath}')), + ); + break; + case 'edit': + // TODO: Implement file editing + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Editing ${file.relativePath}')), + ); + break; + case 'delete': + final confirmed = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Delete File'), + content: Text( + 'Are you sure you want to delete "${file.relativePath}"?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await ref + .read( + siteFilesNotifierProvider(( + siteId: site.id, + path: null, + )).notifier, + ) + .deleteFile(file.relativePath); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('File deleted successfully'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to delete file')), + ); + } + } + } + break; + } + }, + ), + onTap: () { + if (file.isDirectory) { + // TODO: Navigate into directory + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Opening directory: ${file.relativePath}'), + ), + ); + } else { + // TODO: Open file preview/editor + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Opening file: ${file.relativePath}')), + ); + } + }, + ), + ); + } +} diff --git a/lib/widgets/sites/file_management_section.dart b/lib/widgets/sites/file_management_section.dart new file mode 100644 index 00000000..85bf38ec --- /dev/null +++ b/lib/widgets/sites/file_management_section.dart @@ -0,0 +1,149 @@ +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/site_files.dart'; +import 'package:island/widgets/sites/file_upload_dialog.dart'; +import 'package:island/widgets/sites/file_item.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class FileManagementSection extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + + const FileManagementSection({ + super.key, + required this.site, + required this.pubName, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final filesAsync = ref.watch(siteFilesProvider(siteId: site.id)); + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Symbols.folder, size: 20), + const Gap(8), + Text( + 'File Management', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + onPressed: () async { + // Open file upload dialog + final selectedFiles = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: FileType.any, + ); + + if (selectedFiles == null || selectedFiles.files.isEmpty) { + return; // User canceled + } + + if (!context.mounted) return; + + // Show upload dialog for path specification + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => FileUploadDialog( + selectedFiles: + selectedFiles.files + .map((f) => File(f.path!)) + .toList(), + site: site, + onUploadComplete: () { + // Refresh file list + ref.invalidate( + siteFilesProvider(siteId: site.id), + ); + }, + ), + ); + }, + icon: const Icon(Symbols.upload), + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + ], + ), + const Gap(16), + filesAsync.when( + data: (files) { + if (files.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + Symbols.folder, + size: 48, + color: theme.colorScheme.outline, + ), + const Gap(16), + Text( + 'No files uploaded yet', + style: theme.textTheme.bodyLarge, + ), + const Gap(8), + Text( + 'Upload your first file to get started', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) { + final file = files[index]; + return FileItem(file: file, site: site); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => Center( + child: Column( + children: [ + Text('Failed to load files'), + const Gap(8), + ElevatedButton( + onPressed: + () => ref.invalidate( + siteFilesProvider(siteId: site.id), + ), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/sites/file_upload_dialog.dart b/lib/widgets/sites/file_upload_dialog.dart new file mode 100644 index 00000000..c35f8f19 --- /dev/null +++ b/lib/widgets/sites/file_upload_dialog.dart @@ -0,0 +1,315 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/site_files.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class FileUploadDialog extends HookConsumerWidget { + final List selectedFiles; + final SnPublicationSite site; + final VoidCallback onUploadComplete; + + const FileUploadDialog({ + super.key, + required this.selectedFiles, + required this.site, + required this.onUploadComplete, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + final pathController = useTextEditingController(text: '/'); + final isUploading = useState(false); + final progressStates = useState>>( + selectedFiles + .map( + (file) => { + 'fileName': file.path.split('/').last, + 'progress': 0.0, + 'status': + 'pending', // 'pending', 'uploading', 'completed', 'error' + 'error': null, + }, + ) + .toList(), + ); + + final uploadFile = useCallback(( + String basePath, + File file, + int index, + ) async { + try { + progressStates.value[index]['status'] = 'uploading'; + progressStates.value = [...progressStates.value]; + + final siteFilesNotifier = ref.read( + siteFilesNotifierProvider((siteId: site.id, path: null)).notifier, + ); + + final fileName = file.path.split('/').last; + final uploadPath = + basePath.endsWith('/') + ? '$basePath$fileName' + : '$basePath/$fileName'; + + await siteFilesNotifier.uploadFile(file, uploadPath); + + progressStates.value[index]['status'] = 'completed'; + progressStates.value[index]['progress'] = 1.0; + progressStates.value = [...progressStates.value]; + } catch (e) { + progressStates.value[index]['status'] = 'error'; + progressStates.value[index]['error'] = e.toString(); + progressStates.value = [...progressStates.value]; + } + }, [ref, site.id, progressStates]); + + final uploadAllFiles = useCallback( + () async { + if (!formKey.currentState!.validate()) return; + + isUploading.value = true; + + // Reset all progress states + for (int i = 0; i < progressStates.value.length; i++) { + progressStates.value[i]['status'] = 'pending'; + progressStates.value[i]['progress'] = 0.0; + progressStates.value[i]['error'] = null; + } + progressStates.value = [...progressStates.value]; + + // Upload files sequentially (could be made parallel if needed) + for (int i = 0; i < selectedFiles.length; i++) { + final file = selectedFiles[i]; + await uploadFile(pathController.text, file, i); + } + + isUploading.value = false; + onUploadComplete(); + + // Close dialog if all uploads completed successfully + if (progressStates.value.every( + (state) => state['status'] == 'completed', + )) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('All files uploaded successfully')), + ); + Navigator.of(context).pop(); + } + } + }, + [ + uploadFile, + isUploading, + progressStates, + selectedFiles, + onUploadComplete, + context, + formKey, + pathController, + ], + ); + + return SheetScaffold( + titleText: 'Upload Files', + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Upload path field + TextFormField( + controller: pathController, + decoration: const InputDecoration( + labelText: 'Upload Path', + hintText: '/ (root) or /assets/images/', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an upload path'; + } + if (!value.startsWith('/') && value != '/') { + return 'Path must start with /'; + } + if (value.contains(' ')) { + return 'Path cannot contain spaces'; + } + if (value.contains('//')) { + return 'Path cannot have consecutive slashes'; + } + return null; + }, + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(20), + Text( + 'Ready to upload ${selectedFiles.length} file${selectedFiles.length == 1 ? '' : 's'}:', + style: Theme.of(context).textTheme.titleMedium, + ), + const Gap(16), + ...selectedFiles.map((file) { + final index = selectedFiles.indexOf(file); + final progressState = progressStates.value[index]; + final fileName = file.path.split('/').last; + final fileSize = file.lengthSync(); + final fileSizeText = + fileSize < 1024 * 1024 + ? '${(fileSize / 1024).toStringAsFixed(1)} KB' + : '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB'; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.description, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const Gap(8), + Expanded( + child: Text( + fileName, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + fileSizeText, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + if (progressState['status'] == 'uploading') ...[ + const Gap(8), + LinearProgressIndicator( + value: progressState['progress'], + backgroundColor: + Theme.of(context).colorScheme.surfaceVariant, + ), + const Gap(4), + Text( + 'Uploading... ${(progressState['progress'] * 100).toStringAsFixed(0)}%', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ] else if (progressState['status'] == 'completed') ...[ + const Gap(8), + Row( + children: [ + Icon( + Symbols.check_circle, + color: Colors.green, + size: 16, + ), + const Gap(4), + Text( + 'Completed', + style: TextStyle( + color: Colors.green, + fontSize: 12, + ), + ), + ], + ), + ] else if (progressState['status'] == 'error') ...[ + const Gap(8), + Row( + children: [ + Icon(Symbols.error, color: Colors.red, size: 16), + const Gap(4), + Expanded( + child: Text( + progressState['error'] ?? 'Upload failed', + style: TextStyle( + color: Colors.red, + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + // Show the final upload path when not uploading + if (!isUploading.value && + progressState['status'] != 'uploading') ...[ + const Gap(8), + Text( + 'Will upload to: ${pathController.text.endsWith('/') ? pathController.text : '${pathController.text}/'}$fileName', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith( + color: + Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ); + }), + const Gap(24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: + isUploading.value + ? null + : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ), + const Gap(12), + Expanded( + child: FilledButton( + onPressed: isUploading.value ? null : uploadAllFiles, + child: Text( + isUploading.value + ? 'Uploading...' + : 'Upload ${selectedFiles.length} File${selectedFiles.length == 1 ? '' : 's'}', + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sites/info_row.dart b/lib/widgets/sites/info_row.dart new file mode 100644 index 00000000..3ca198e3 --- /dev/null +++ b/lib/widgets/sites/info_row.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class InfoRow extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final bool monospace; + + const InfoRow({ + super.key, + required this.label, + required this.value, + required this.icon, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const Gap(12), + Expanded( + flex: 2, + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + const Gap(12), + Expanded( + flex: 3, + child: Text( + value, + style: + monospace + ? GoogleFonts.robotoMono(fontSize: 14) + : Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/sites/page_form.dart b/lib/widgets/sites/page_form.dart new file mode 100644 index 00000000..349fdba6 --- /dev/null +++ b/lib/widgets/sites/page_form.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/site_pages.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class PageForm extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + final SnPublicationPage? page; // null for create, non-null for edit + + const PageForm({ + super.key, + required this.site, + required this.pubName, + this.page, + }); + + int _getPageType(SnPublicationPage? page) { + if (page == null) return 0; // Default to HTML + // Check config structure to determine type + return page.config?.containsKey('target') == true ? 1 : 0; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + final pathController = useTextEditingController(text: page?.path ?? '/'); + + // Determine initial type and create appropriate controllers + final initialType = _getPageType(page); + final pageType = useState(initialType); + + final htmlController = useTextEditingController( + text: + pageType.value == 0 + ? (page?.config?['html'] ?? page?.config?['content'] ?? '') + : '', + ); + final titleController = useTextEditingController( + text: pageType.value == 0 ? (page?.config?['title'] ?? '') : '', + ); + final targetController = useTextEditingController( + text: pageType.value == 1 ? (page?.config?['target'] ?? '') : '', + ); + + final isLoading = useState(false); + + // Update controllers when page type changes + useEffect(() { + pageType.addListener(() { + if (pageType.value == 0) { + // HTML mode + htmlController.text = + page?.config?['html'] ?? page?.config?['content'] ?? ''; + titleController.text = page?.config?['title'] ?? ''; + targetController.clear(); + } else { + // Redirect mode + htmlController.clear(); + titleController.clear(); + targetController.text = page?.config?['target'] ?? ''; + } + }); + return null; + }, [pageType]); + + // Initialize form fields when page data is loaded + useEffect(() { + if (page?.path != null && pathController.text == '/') { + pathController.text = page!.path!; + if (pageType.value == 0) { + htmlController.text = + page!.config?['html'] ?? page!.config?['content'] ?? ''; + titleController.text = page!.config?['title'] ?? ''; + } else { + targetController.text = page!.config?['target'] ?? ''; + } + } + return null; + }, [page]); + + final savePage = useCallback(() async { + if (!formKey.currentState!.validate()) return; + + isLoading.value = true; + + try { + final pagesNotifier = ref.read( + sitePagesNotifierProvider(( + pubName: pubName, + siteSlug: site.slug, + )).notifier, + ); + + late final Map pageData; + + if (pageType.value == 0) { + // HTML page + pageData = { + 'type': 0, + 'path': pathController.text, + 'config': { + 'title': titleController.text, + 'html': htmlController.text, + }, + }; + } else { + // Redirect page + pageData = { + 'type': 1, + 'path': pathController.text, + 'config': {'target': targetController.text}, + }; + } + + if (page == null) { + // Create new page + await pagesNotifier.createPage(pageData); + } else { + // Update existing page + await pagesNotifier.updatePage(page!.id, pageData); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + page == null + ? 'Page created successfully' + : 'Page updated successfully', + ), + ), + ); + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save page: ${e.toString()}')), + ); + } + } finally { + isLoading.value = false; + } + }, [pageType, pubName, site.slug, page]); + + final deletePage = useCallback(() async { + if (page == null) return; // Shouldn't happen for editing + + final confirmed = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Delete Page'), + content: const Text('Are you sure you want to delete this page?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + + isLoading.value = true; + + try { + final pagesNotifier = ref.read( + sitePagesNotifierProvider(( + pubName: pubName, + siteSlug: site.slug, + )).notifier, + ); + + await pagesNotifier.deletePage(page!.id); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Page deleted successfully')), + ); + Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to delete page')), + ); + } + } finally { + isLoading.value = false; + } + }, [pubName, site.slug, page, context]); + + return SheetScaffold( + titleText: page == null ? 'Create Page' : 'Edit Page', + child: Builder( + builder: + (context) => SingleChildScrollView( + child: Column( + children: [ + Form( + key: formKey, + child: Column( + children: [ + // Page type selector + DropdownButtonFormField( + value: pageType.value, + decoration: const InputDecoration( + labelText: 'Page Type', + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + ), + items: const [ + DropdownMenuItem( + value: 0, + child: Row( + children: [ + Icon(Symbols.code, size: 20), + Gap(8), + Text('HTML Page'), + ], + ), + ), + DropdownMenuItem( + value: 1, + child: Row( + children: [ + Icon(Symbols.link, size: 20), + Gap(8), + Text('Redirect Page'), + ], + ), + ), + ], + onChanged: (value) { + if (value != null) { + pageType.value = value; + } + }, + validator: (value) { + if (value == null) { + return 'Please select a page type'; + } + return null; + }, + ).padding(all: 20), + // Conditional form fields based on page type + if (pageType.value == 0) ...[ + // HTML Page fields + TextFormField( + controller: pathController, + decoration: const InputDecoration( + labelText: 'Page Path', + hintText: '/about, /contact, etc.', + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a page path'; + } + if (!RegExp( + r'^[a-zA-Z0-9\-/_]+$', + ).hasMatch(value)) { + return 'Page path can only contain letters, numbers, hyphens, underscores, and slashes'; + } + if (!value.startsWith('/')) { + return 'Page path must start with /'; + } + if (value.contains('//')) { + return 'Page path cannot have consecutive slashes'; + } + return null; + }, + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ).padding(horizontal: 20), + const SizedBox(height: 16), + TextFormField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Page Title', + hintText: 'About Us, Contact, etc.', + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a page title'; + } + return null; + }, + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ).padding(horizontal: 20), + const SizedBox(height: 16), + TextFormField( + controller: htmlController, + decoration: const InputDecoration( + labelText: 'Page Content (HTML)', + hintText: + '

Hello World

This is my page content...

', + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + alignLabelWithHint: true, + ), + maxLines: 10, + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter HTML content for the page'; + } + return null; + }, + ).padding(horizontal: 20), + ] else ...[ + // Redirect Page fields + TextFormField( + controller: pathController, + decoration: const InputDecoration( + labelText: 'Page Path', + hintText: '/old-page, /redirect, etc.', + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + prefixText: '/', + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a page path'; + } + if (!RegExp( + r'^[a-zA-Z0-9\-/_]+$', + ).hasMatch(value)) { + return 'Page path can only contain letters, numbers, hyphens, underscores, and slashes'; + } + if (!value.startsWith('/')) { + return 'Page path must start with /'; + } + if (value.contains('//')) { + return 'Page path cannot have consecutive slashes'; + } + return null; + }, + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ).padding(horizontal: 20), + const SizedBox(height: 16), + TextFormField( + controller: targetController, + decoration: const InputDecoration( + labelText: 'Redirect Target', + hintText: '/new-page, https://example.com, etc.', + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a redirect target'; + } + if (!value.startsWith('/') && + !value.startsWith('http://') && + !value.startsWith('https://')) { + return 'Target must be a relative path (/) or absolute URL (http/https)'; + } + return null; + }, + onTapOutside: + (_) => + FocusManager.instance.primaryFocus + ?.unfocus(), + ).padding(horizontal: 20), + ], + ], + ).padding(vertical: 20), + ), + Row( + children: [ + if (page != null) ...[ + TextButton.icon( + onPressed: deletePage, + icon: const Icon(Symbols.delete_forever), + label: const Text('Delete Page'), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + ).alignment(Alignment.centerRight), + const Spacer(), + ] else + const Spacer(), + TextButton.icon( + onPressed: savePage, + icon: const Icon(Symbols.save), + label: const Text('Save Page'), + ), + ], + ).padding(horizontal: 20, vertical: 12), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/sites/page_item.dart b/lib/widgets/sites/page_item.dart new file mode 100644 index 00000000..75cc39fe --- /dev/null +++ b/lib/widgets/sites/page_item.dart @@ -0,0 +1,135 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/site_pages.dart'; +import 'package:island/widgets/sites/page_form.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class PageItem extends HookConsumerWidget { + final SnPublicationPage page; + final SnPublicationSite site; + final String pubName; + + const PageItem({ + required this.page, + required this.site, + required this.pubName, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + elevation: 0, + child: ListTile( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + leading: Icon(Symbols.article, color: theme.colorScheme.primary), + title: Text(page.path ?? '/'), + subtitle: Text(page.config?['title'] ?? 'Untitled'), + trailing: PopupMenuButton( + itemBuilder: + (context) => [ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Symbols.edit), + const Gap(16), + Text('edit'.tr()), + ], + ), + ), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Symbols.delete, color: Colors.red), + const Gap(16), + Text('delete'.tr()).textColor(Colors.red), + ], + ), + ), + ], + onSelected: (value) async { + switch (value) { + case 'edit': + // Open page edit dialog + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => + PageForm(site: site, pubName: pubName, page: page), + ).then((_) { + // Refresh pages after editing + ref.invalidate(sitePagesProvider(pubName, site.slug)); + }); + break; + case 'delete': + final confirmed = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Delete Page'), + content: const Text( + 'Are you sure you want to delete this page?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await ref + .read( + sitePagesNotifierProvider(( + pubName: pubName, + siteSlug: site.slug, + )).notifier, + ) + .deletePage(page.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Page deleted successfully'), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to delete page')), + ); + } + } + } + break; + } + }, + ), + onTap: () { + // TODO: Open page preview or edit + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Opening page: ${page.path ?? '/'}')), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/sites/pages_section.dart b/lib/widgets/sites/pages_section.dart new file mode 100644 index 00000000..4163173e --- /dev/null +++ b/lib/widgets/sites/pages_section.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/site_pages.dart'; +import 'package:island/widgets/sites/page_form.dart'; +import 'package:island/widgets/sites/page_item.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class PagesSection extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + + const PagesSection({super.key, required this.site, required this.pubName}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pagesAsync = ref.watch(sitePagesProvider(pubName, site.slug)); + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Symbols.article, size: 20), + const Gap(8), + Text( + 'Pages', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + onPressed: () { + // Open page creation dialog + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => PageForm(site: site, pubName: pubName), + ).then((_) { + // Refresh pages after creation + ref.invalidate(sitePagesProvider(pubName, site.slug)); + }); + }, + icon: const Icon(Symbols.add), + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + ], + ), + const Gap(16), + pagesAsync.when( + data: (pages) { + if (pages.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + Symbols.article, + size: 48, + color: theme.colorScheme.outline, + ), + const Gap(16), + Text( + 'No pages yet', + style: theme.textTheme.bodyLarge, + ), + const Gap(8), + Text( + 'Create your first page to get started', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: pages.length, + itemBuilder: (context, index) { + final page = pages[index]; + return PageItem(page: page, site: site, pubName: pubName); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => Center( + child: Column( + children: [ + Text('Failed to load pages'), + const Gap(8), + ElevatedButton( + onPressed: + () => ref.invalidate( + sitePagesProvider(pubName, site.slug), + ), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/sites/site_action_menu.dart b/lib/widgets/sites/site_action_menu.dart new file mode 100644 index 00000000..9fea1e5f --- /dev/null +++ b/lib/widgets/sites/site_action_menu.dart @@ -0,0 +1,107 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/screens/creators/sites/site_detail.dart'; +import 'package:island/screens/creators/sites/site_edit.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class SiteActionMenu extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + + const SiteActionMenu({super.key, required this.site, required this.pubName}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PopupMenuButton( + itemBuilder: + (context) => [ + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon( + Symbols.edit, + color: Theme.of(context).colorScheme.onSurface, + ), + const Gap(16), + Text('edit'.tr()), + ], + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Symbols.delete, color: Colors.red), + const Gap(16), + Text('delete'.tr()).textColor(Colors.red), + ], + ), + ), + ], + onSelected: (value) async { + switch (value) { + case 'edit': + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => SiteForm(pubName: pubName, siteSlug: site.slug), + ).then((_) { + // Refresh site data after potential edit + ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)); + }); + break; + case 'delete': + final confirmed = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Delete Site'), + content: const Text( + 'Are you sure you want to delete this publication site? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + final client = ref.read(apiClientProvider); + await client.delete('/zone/sites/${site.id}'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Site deleted successfully')), + ); + // Navigate back to list + Navigator.of(context).pop(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to delete site')), + ); + } + } + } + break; + } + }, + ); + } +} diff --git a/lib/widgets/sites/site_detail_content.dart b/lib/widgets/sites/site_detail_content.dart new file mode 100644 index 00000000..cfef93bc --- /dev/null +++ b/lib/widgets/sites/site_detail_content.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/widgets/sites/file_management_section.dart'; +import 'package:island/widgets/sites/info_row.dart'; +import 'package:island/widgets/sites/pages_section.dart'; +import 'package:island/services/time.dart'; +import 'package:island/widgets/extended_refresh_indicator.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:island/screens/creators/sites/site_detail.dart'; + +class SiteDetailContent extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + + const SiteDetailContent({ + super.key, + required this.site, + required this.pubName, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return ExtendedRefreshIndicator( + onRefresh: + () async => + ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Site Info Card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Site Information', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(16), + InfoRow( + label: 'Name', + value: site.name, + icon: Symbols.title, + ), + const Gap(8), + InfoRow( + label: 'Slug', + value: site.slug, + icon: Symbols.tag, + monospace: true, + ), + const Gap(8), + InfoRow( + label: 'Mode', + value: site.mode == 0 ? 'Fully Managed' : 'Self-Managed', + icon: Symbols.settings, + ), + if (site.description != null && + site.description!.isNotEmpty) ...[ + const Gap(8), + InfoRow( + label: 'Description', + value: site.description!, + icon: Symbols.description, + ), + ], + const Gap(8), + InfoRow( + label: 'Created', + value: site.createdAt.formatSystem(), + icon: Symbols.calendar_add_on, + ), + const Gap(8), + InfoRow( + label: 'Updated', + value: site.updatedAt.formatSystem(), + icon: Symbols.update, + ), + ], + ), + ), + ), + // Pages Section + PagesSection(site: site, pubName: pubName), + FileManagementSection(site: site, pubName: pubName), + ], + ), + ), + ); + } +}