diff --git a/lib/pods/sites.dart b/lib/pods/sites.dart index ac6ee675..c430c84f 100644 --- a/lib/pods/sites.dart +++ b/lib/pods/sites.dart @@ -45,7 +45,7 @@ class SiteNotifier final response = site.id.isEmpty ? await client.post(url, data: site.toJson()) - : await client.patch('${url}/${site.id}', data: site.toJson()); + : await client.patch('$url/${site.id}', data: site.toJson()); state = AsyncValue.data(SnPublicationSite.fromJson(response.data)); } catch (error, stackTrace) { diff --git a/lib/widgets/sites/file_item.dart b/lib/widgets/sites/file_item.dart index b0202a77..5e3e7606 100644 --- a/lib/widgets/sites/file_item.dart +++ b/lib/widgets/sites/file_item.dart @@ -11,8 +11,14 @@ import 'package:styled_widget/styled_widget.dart'; class FileItem extends HookConsumerWidget { final SnSiteFileEntry file; final SnPublicationSite site; + final void Function(String path)? onNavigateDirectory; - const FileItem({super.key, required this.file, required this.site}); + const FileItem({ + super.key, + required this.file, + required this.site, + this.onNavigateDirectory, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -128,12 +134,7 @@ class FileItem extends HookConsumerWidget { ), onTap: () { if (file.isDirectory) { - // TODO: Navigate into directory - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Opening directory: ${file.relativePath}'), - ), - ); + onNavigateDirectory?.call(file.relativePath); } else { // TODO: Open file preview/editor ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/widgets/sites/file_management_section.dart b/lib/widgets/sites/file_management_section.dart index 31e3c3fa..55b3f01e 100644 --- a/lib/widgets/sites/file_management_section.dart +++ b/lib/widgets/sites/file_management_section.dart @@ -1,10 +1,12 @@ 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:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/publication_site.dart'; import 'package:island/pods/site_files.dart'; +import 'package:island/widgets/alert.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'; @@ -21,177 +23,268 @@ class FileManagementSection extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final filesAsync = ref.watch(siteFilesProvider(siteId: site.id)); + final currentPath = useState(null); + final filesAsync = ref.watch( + siteFilesProvider(siteId: site.id, path: currentPath.value), + ); final theme = Theme.of(context); return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Symbols.folder, size: 20), - const Gap(8), - Text( - 'File Management', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - PopupMenuButton( - icon: const Icon(Symbols.upload), - onSelected: (String choice) async { - List files = []; - List>? results; - if (choice == 'files') { - final selectedFiles = await FilePicker.platform.pickFiles( - allowMultiple: true, - type: FileType.any, - ); - if (selectedFiles == null || - selectedFiles.files.isEmpty) { - return; // User canceled - } - files = - selectedFiles.files - .map((f) => File(f.path!)) - .toList(); - } else if (choice == 'folder') { - final dirPath = - await FilePicker.platform.getDirectoryPath(); - if (dirPath == null) return; - results = await _getFilesRecursive(dirPath); - files = results.map((m) => m['file'] as File).toList(); - if (files.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'No files found in the selected folder', - ), - ), - ); - return; - } - } - - if (!context.mounted) return; - - // Show upload dialog for path specification - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: - (context) => FileUploadDialog( - selectedFiles: files, - site: site, - relativePaths: - results - ?.map((m) => m['relativePath'] as String) - .toList(), - onUploadComplete: () { - // Refresh file list - ref.invalidate( - siteFilesProvider(siteId: site.id), + Row( + children: [ + Icon(Symbols.folder, size: 20), + const Gap(8), + Text( + 'File Management', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + PopupMenuButton( + icon: const Icon(Symbols.upload), + onSelected: (String choice) async { + List files = []; + List>? results; + if (choice == 'files') { + final selectedFiles = await FilePicker.platform + .pickFiles( + allowMultiple: true, + type: FileType.any, ); - }, - ), - ); - }, - itemBuilder: - (BuildContext context) => [ - const PopupMenuItem( - value: 'files', - child: Row( - children: [ - Icon(Symbols.file_copy), - Gap(12), - Text('Files'), - ], + if (selectedFiles == null || + selectedFiles.files.isEmpty) { + return; // User canceled + } + files = + selectedFiles.files + .map((f) => File(f.path!)) + .toList(); + } else if (choice == 'folder') { + final dirPath = + await FilePicker.platform.getDirectoryPath(); + if (dirPath == null) return; + results = await _getFilesRecursive(dirPath); + files = + results.map((m) => m['file'] as File).toList(); + if (files.isEmpty) { + showSnackBar( + 'No files found in the selected folder', + ); + return; + } + } + + if (!context.mounted) return; + + // Show upload dialog for path specification + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (context) => FileUploadDialog( + selectedFiles: files, + site: site, + relativePaths: + results + ?.map( + (m) => m['relativePath'] as String, + ) + .toList(), + onUploadComplete: () { + // Refresh file list + ref.invalidate( + siteFilesProvider( + siteId: site.id, + path: currentPath.value, + ), + ); + }, + ), + ); + }, + itemBuilder: + (BuildContext context) => [ + const PopupMenuItem( + value: 'files', + child: Row( + children: [ + Icon(Symbols.file_copy), + Gap(12), + Text('Files'), + ], + ), + ), + const PopupMenuItem( + value: 'folder', + child: Row( + children: [ + Icon(Symbols.folder), + Gap(12), + Text('Folder'), + ], + ), + ), + ], + style: ButtonStyle( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + ), + ), + ], + ), + const Gap(8), + if (currentPath.value != null && currentPath.value!.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Symbols.arrow_back), + onPressed: () { + final pathParts = + currentPath.value! + .split('/') + .where((part) => part.isNotEmpty) + .toList(); + if (pathParts.isEmpty) { + currentPath.value = null; + } else { + pathParts.removeLast(); + currentPath.value = + pathParts.isEmpty + ? null + : pathParts.join('/'); + } + }, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, ), ), - const PopupMenuItem( - value: 'folder', - child: Row( + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Icon(Symbols.folder), - Gap(12), - Text('Folder'), + InkWell( + onTap: () => currentPath.value = null, + child: const Text('Root'), + ), + ...() { + final parts = + currentPath.value! + .split('/') + .where((part) => part.isNotEmpty) + .toList(); + final widgets = []; + String currentBuilder = ''; + for (final part in parts) { + currentBuilder += + (currentBuilder.isEmpty ? '' : '/') + + part; + final pathToSet = currentBuilder; + widgets.addAll([ + const Text(' / '), + InkWell( + onTap: + () => currentPath.value = pathToSet, + child: Text(part), + ), + ]); + } + return widgets; + }(), ], ), ), ], - style: ButtonStyle( - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, ), ), + const Gap(8), + 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, + onNavigateDirectory: + (path) => currentPath.value = path, + ); + }, + ); + }, + 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, + path: currentPath.value, + ), + ), + child: const Text('Retry'), + ), + ], + ), + ), ), ], ), - 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'), - ), - ], - ), - ), - ), - ], - ), + ), + ], ), ); }