diff --git a/lib/screens/creators/sites/site_detail.dart b/lib/screens/creators/sites/site_detail.dart index 80048e55..6b27bc2b 100644 --- a/lib/screens/creators/sites/site_detail.dart +++ b/lib/screens/creators/sites/site_detail.dart @@ -13,6 +13,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:island/widgets/sites/info_row.dart'; import 'package:island/widgets/sites/pages_section.dart'; import 'package:island/widgets/sites/file_management_section.dart'; +import 'package:island/widgets/sites/file_management_action_section.dart'; import 'package:island/services/responsive.dart'; import 'package:island/services/time.dart'; import 'package:island/widgets/extended_refresh_indicator.dart'; @@ -94,76 +95,86 @@ class PublicationSiteDetailScreen extends HookConsumerWidget { flex: 2, child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), - child: 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, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: 'Domain', + value: '${site.slug}.solian.page', + icon: Symbols.globe, + monospace: true, + onTap: () { + final url = + 'https://${site.slug}.solian.page'; + launchUrlString(url); + }, + ), + 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, + ), + ], ), - 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: 'Domain', - value: '${site.slug}.solian.page', - icon: Symbols.globe, - monospace: true, - onTap: () { - final url = - 'https://${site.slug}.solian.page'; - launchUrlString(url); - }, - ), - 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, - ), - ], + ), ), - ), + const Gap(8), + if (site.mode == 1) // Self-Managed only + FileManagementActionSection( + site: site, + pubName: pubName, + ), + ], ), ), ), diff --git a/lib/widgets/sites/file_management_action_section.dart b/lib/widgets/sites/file_management_action_section.dart new file mode 100644 index 00000000..143e3b3b --- /dev/null +++ b/lib/widgets/sites/file_management_action_section.dart @@ -0,0 +1,160 @@ +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:dio/dio.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/pods/site_files.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class FileManagementActionSection extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + + const FileManagementActionSection({ + super.key, + required this.site, + required this.pubName, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return Card( + child: Column( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'File Actions', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ).padding(horizontal: 16, top: 16), + Column( + children: [ + ListTile( + leading: Icon( + Symbols.delete_forever, + color: theme.colorScheme.error, + ), + title: const Text('Purge Files'), + subtitle: const Text( + 'Remove all uploaded files from the site', + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + onTap: () => _purgeFiles(context, ref), + ), + const Gap(8), + ListTile( + leading: Icon( + Symbols.upload, + color: theme.colorScheme.primary, + ), + title: const Text('Deploy Site'), + subtitle: const Text( + 'Upload and deploy a new version from ZIP archive', + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + onTap: () => _deploySite(context, ref), + ), + ], + ).padding(vertical: 8), + ], + ), + ], + ), + ); + } + + Future _purgeFiles(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Confirm Purge'), + content: const Text( + 'This will permanently delete all files uploaded to this site. This action cannot be undone. Are you sure you want to continue?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: const Text('Purge All Files'), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + final apiClient = ref.read(apiClientProvider); + await apiClient.delete('/zone/sites/${site.id}/files/purge'); + if (context.mounted) { + showSnackBar('All files purged successfully'); + // Refresh the file management section + ref.invalidate(siteFilesProvider(siteId: site.id)); + } + } catch (e) { + if (context.mounted) { + showSnackBar('Failed to purge files: $e'); + } + } + } + + Future _deploySite(BuildContext context, WidgetRef ref) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['zip'], + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) { + return; // User canceled + } + + final file = File(result.files.first.path!); + + try { + final apiClient = ref.read(apiClientProvider); + + // Create multipart form data + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile( + file.path, + filename: result.files.first.name, + contentType: MediaType('application', 'zip'), + ), + }); + + await apiClient.post( + '/zone/sites/${site.id}/files/deploy', + data: formData, + ); + + if (context.mounted) { + showSnackBar('Site deployed successfully'); + // Refresh the file management section + ref.invalidate(siteFilesProvider(siteId: site.id)); + } + } catch (e) { + if (context.mounted) { + showSnackBar('Failed to deploy site: $e'); + } + } + } +} diff --git a/lib/widgets/sites/site_detail_content.dart b/lib/widgets/sites/site_detail_content.dart index cfef93bc..3fd6219d 100644 --- a/lib/widgets/sites/site_detail_content.dart +++ b/lib/widgets/sites/site_detail_content.dart @@ -3,6 +3,7 @@ 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/file_management_action_section.dart'; import 'package:island/widgets/sites/info_row.dart'; import 'package:island/widgets/sites/pages_section.dart'; import 'package:island/services/time.dart'; @@ -90,6 +91,9 @@ class SiteDetailContent extends HookConsumerWidget { ), ), ), + const Gap(8), + if (site.mode == 1) // Self-Managed only + FileManagementActionSection(site: site, pubName: pubName), // Pages Section PagesSection(site: site, pubName: pubName), FileManagementSection(site: site, pubName: pubName),