🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View File

@@ -0,0 +1,353 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_highlight/themes/monokai-sublime.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/discovery/discovery_models/site_file.dart';
import 'package:island/creators/publication_site.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/sites/site_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:photo_view/photo_view.dart';
import 'package:island/core/config.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,
this.onNavigateDirectory,
});
Future<void> _downloadFile(BuildContext context, WidgetRef ref) async {
try {
final apiClient = ref.read(apiClientProvider);
// Get downloads directory
Directory? directory;
if (Platform.isAndroid) {
directory = await getExternalStorageDirectory();
if (directory != null) {
directory = Directory('${directory.path}/Download');
}
} else {
directory = await getDownloadsDirectory();
}
if (directory == null) {
throw Exception('Unable to access downloads directory');
}
// Create directory if it doesn't exist
await directory.create(recursive: true);
// Generate file path
final fileName = file.relativePath.split('/').last;
final filePath = '${directory.path}/$fileName';
// Use Dio's download method to directly stream from server to file
await apiClient.download(
'/zone/sites/${site.id}/files/content/${file.relativePath}',
filePath,
);
showSnackBar('Downloaded to $filePath');
} catch (e) {
showErrorAlert(e);
}
}
Future<void> _showImageViewer(BuildContext context, WidgetRef ref) async {
final serverUrl = ref.read(serverUrlProvider);
final token = await getToken(ref.read(tokenProvider));
final imageUrl =
'$serverUrl/zone/sites/${site.id}/files/content/${file.relativePath}';
if (context.mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(
title: Text(file.relativePath),
backgroundColor: Colors.transparent,
elevation: 0,
),
extendBodyBehindAppBar: true,
backgroundColor: Colors.black,
body: PhotoView(
imageProvider: CachedNetworkImageProvider(
imageUrl,
headers: token != null
? {'Authorization': 'AtField $token'}
: null,
),
heroAttributes: PhotoViewHeroAttributes(tag: file.relativePath),
),
),
),
);
}
}
Future<void> _openFile(BuildContext context, WidgetRef ref) async {
final ext = file.relativePath.split('.').last.toLowerCase();
final isImage = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'ico',
'svg',
].contains(ext);
if (isImage) {
await _showImageViewer(context, ref);
return;
}
// Check for large files (> 1MB)
if (file.size > 1024 * 1024) {
final confirmed = await showConfirmAlert(
'This file is large (${(file.size / 1024 / 1024).toStringAsFixed(2)} MB). Opening it might cause performance issues. Do you want to continue?',
'Large File',
);
if (confirmed != true) return;
}
if (context.mounted) await _showEditSheet(context, ref);
}
Future<void> _showEditSheet(BuildContext context, WidgetRef ref) async {
try {
final fileContent = await ref.read(
siteFileContentProvider(
siteId: site.id,
relativePath: file.relativePath,
).future,
);
if (context.mounted) {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: false,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height,
),
barrierColor: Theme.of(context).colorScheme.surfaceContainerLow,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
builder: (BuildContext context) {
return FileEditorSheet(
file: file,
site: site,
initialContent: fileContent.content,
);
},
);
}
} catch (e) {
showErrorAlert(e);
}
}
@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<String>(
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('Open'),
],
),
),
],
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':
await _downloadFile(context, ref);
break;
case 'edit':
await _openFile(context, ref);
break;
case 'delete':
final confirmed = await showDialog<bool>(
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);
showSnackBar('File deleted successfully');
} catch (e) {
showErrorAlert(e);
}
}
break;
}
},
),
onTap: () {
if (file.isDirectory) {
onNavigateDirectory?.call(file.relativePath);
} else {
_openFile(context, ref);
}
},
),
);
}
}
class FileEditorSheet extends HookConsumerWidget {
final SnSiteFileEntry file;
final SnPublicationSite site;
final String initialContent;
const FileEditorSheet({
super.key,
required this.file,
required this.site,
required this.initialContent,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final codeController = useMemoized(
() => CodeController(
text: initialContent,
language: null, // Let the editor auto-detect or use plain text
),
);
final isSaving = useState(false);
final saveFile = useCallback(() async {
if (codeController.text.trim().isEmpty) {
showSnackBar('contentCantEmpty'.tr());
return;
}
isSaving.value = true;
try {
await ref
.read(
siteFilesNotifierProvider((siteId: site.id, path: null)).notifier,
)
.updateFileContent(file.relativePath, codeController.text);
if (context.mounted) {
showSnackBar('File saved successfully');
Navigator.of(context).pop();
}
} catch (e) {
showErrorAlert(e);
} finally {
isSaving.value = false;
}
}, [codeController, ref, site.id, file.relativePath, context, isSaving]);
return SheetScaffold(
heightFactor: 1,
titleText: 'Edit ${file.relativePath}',
actions: [
FilledButton(
onPressed: isSaving.value ? null : saveFile,
child: Text(isSaving.value ? 'Saving...' : 'Save'),
),
],
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: CodeTheme(
data: CodeThemeData(styles: monokaiSublimeTheme),
child: CodeField(
controller: codeController,
minLines: 20,
maxLines: null,
),
),
),
);
}
}

View File

@@ -0,0 +1,139 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
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/creators/publication_site.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/sites/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(
'fileActions'.tr(),
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: Text('purgeFiles'.tr()),
subtitle: Text('purgeFilesDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () => _purgeFiles(context, ref),
),
const Gap(8),
ListTile(
leading: Icon(
Symbols.upload,
color: theme.colorScheme.primary,
),
title: Text('deploySite'.tr()),
subtitle: Text('deploySiteDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () => _deploySite(context, ref),
),
],
).padding(vertical: 8),
],
),
],
),
);
}
Future<void> _purgeFiles(BuildContext context, WidgetRef ref) async {
final confirmed = await showConfirmAlert(
'purgeFilesConfirm'.tr(),
'confirmPurge'.tr(),
isDanger: true,
);
if (confirmed != true) return;
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.delete('/zone/sites/${site.id}/files/purge');
if (context.mounted) {
showSnackBar('allFilesPurgedSuccess'.tr());
// Refresh the file management section
ref.invalidate(siteFilesProvider(siteId: site.id));
}
} catch (e) {
if (context.mounted) {
showSnackBar('failedToPurgeFiles'.tr(args: [e.toString()]));
}
}
}
Future<void> _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('siteDeployedSuccess'.tr());
// Refresh the file management section
ref.invalidate(siteFilesProvider(siteId: site.id));
}
} catch (e) {
if (context.mounted) {
showSnackBar('failedToDeploySite'.tr(args: [e.toString()]));
}
}
}
}

View File

@@ -0,0 +1,308 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.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/creators/publication_site.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/sites/site_files.dart';
import 'package:island/sites/sites_widgets/file_item.dart';
import 'package:island/sites/sites_widgets/file_upload_dialog.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p;
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 currentPath = useState<String?>(null);
final filesAsync = ref.watch(
siteFilesProvider(siteId: site.id, path: currentPath.value),
);
final theme = Theme.of(context);
return Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.folder, size: 20),
const Gap(8),
Text(
'fileManagement'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
PopupMenuButton<String>(
icon: const Icon(Symbols.upload),
onSelected: (String choice) async {
if (!kIsWeb) {
await Permission.storage.request();
}
List<File> files = [];
List<Map<String, dynamic>>? 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) {
showSnackBar('noFilesFoundInFolder'.tr());
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) => [
PopupMenuItem<String>(
value: 'files',
child: Row(
children: [
Icon(Symbols.file_copy),
Gap(12),
Text('siteFiles'.tr()),
],
),
),
PopupMenuItem<String>(
value: 'folder',
child: Row(
children: [
Icon(Symbols.folder),
Gap(12),
Text('siteFolder'.tr()),
],
),
),
],
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,
),
),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InkWell(
onTap: () => currentPath.value = null,
child: Text('siteRoot'.tr()),
),
...() {
final parts = currentPath.value!
.split('/')
.where((part) => part.isNotEmpty)
.toList();
final widgets = <Widget>[];
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;
}(),
],
),
),
],
),
),
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(
'noFilesUploadedYet'.tr(),
style: theme.textTheme.bodyLarge,
),
const Gap(8),
Text(
'uploadFirstFile'.tr(),
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('failedToLoadFiles'.tr()),
const Gap(8),
ElevatedButton(
onPressed: () => ref.invalidate(
siteFilesProvider(
siteId: site.id,
path: currentPath.value,
),
),
child: Text('retry'.tr()),
),
],
),
),
),
],
),
),
],
),
);
}
Future<List<Map<String, dynamic>>> _getFilesRecursive(String dirPath) async {
final List<Map<String, dynamic>> results = [];
try {
await for (final entity in Directory(dirPath).list(recursive: true)) {
if (entity is File) {
String relativePath = p.relative(entity.path, from: dirPath);
// Normalize to forward slashes for consistency (e.g. for API uploads)
relativePath = relativePath.replaceAll(r'\', '/');
if (relativePath.isEmpty) continue;
results.add({
'file': File(entity.path),
'relativePath': relativePath,
});
}
}
} catch (e) {
// Handle error if needed
}
return results;
}
}

View File

@@ -0,0 +1,282 @@
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/creators/publication_site.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/sites/site_files.dart';
import 'package:material_symbols_icons/symbols.dart';
class FileUploadDialog extends HookConsumerWidget {
final List<File> selectedFiles;
final SnPublicationSite site;
final VoidCallback onUploadComplete;
final List<String>? relativePaths;
const FileUploadDialog({
super.key,
required this.selectedFiles,
required this.site,
required this.onUploadComplete,
this.relativePaths,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final pathController = useTextEditingController(text: '/');
final isUploading = useState(false);
final progressStates = useState<List<Map<String, dynamic>>>(
selectedFiles
.map(
(file) => {
'fileName':
relativePaths?[selectedFiles.indexOf(file)] ??
file.path.split('/').last,
'progress': 0.0,
'status':
'pending', // 'pending', 'uploading', 'completed', 'error'
'error': null,
},
)
.toList(),
);
// Calculate overall progress
final overallProgress = progressStates.value.isNotEmpty
? progressStates.value
.map((e) => e['progress'] as double)
.reduce((a, b) => a + b) /
progressStates.value.length
: 0.0;
final overallStatus = progressStates.value.isEmpty
? 'pending'
: progressStates.value.every((e) => e['status'] == 'completed')
? 'completed'
: progressStates.value.any((e) => e['status'] == 'error')
? 'error'
: progressStates.value.any((e) => e['status'] == 'uploading')
? 'uploading'
: 'pending';
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 = relativePaths?[index] ?? 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;
// Close dialog if all uploads completed successfully
if (progressStates.value.every(
(state) => state['status'] == 'completed',
)) {
if (context.mounted) {
showSnackBar('All files uploaded successfully');
onUploadComplete();
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(16),
Card(
child: Column(
children: [
// Overall progress
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${(overallProgress * 100).toStringAsFixed(0)}% completed',
style: Theme.of(context).textTheme.titleSmall,
),
const Gap(8),
LinearProgressIndicator(value: overallProgress),
const Gap(8),
Text(
_getOverallStatusText(overallStatus),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
// Divider
const Divider(height: 0),
// File list in expansion
ExpansionTile(
title: Text('${selectedFiles.length} files to upload'),
initiallyExpanded: selectedFiles.length <= 10,
children: selectedFiles.map((file) {
final index = selectedFiles.indexOf(file);
final progressState = progressStates.value[index];
final displayName = progressState['fileName'] as String;
return ListTile(
leading: _getStatusIcon(
progressState['status'] as String,
),
title: Text(displayName),
subtitle: Text(
'Size: ${(file.lengthSync() / 1024).toStringAsFixed(1)} KB',
),
dense: true,
);
}).toList(),
),
],
),
),
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'}',
),
),
),
],
),
],
),
),
),
);
}
Icon _getStatusIcon(String status) {
switch (status) {
case 'completed':
return const Icon(Symbols.check_circle, color: Colors.green);
case 'uploading':
return const Icon(Symbols.sync);
case 'error':
return const Icon(Symbols.error, color: Colors.red);
default:
return const Icon(Symbols.pending);
}
}
String _getOverallStatusText(String status) {
switch (status) {
case 'completed':
return 'All uploads completed';
case 'error':
return 'Some uploads failed';
case 'uploading':
return 'Uploading in progress';
default:
return 'Ready to upload';
}
}
}

View File

@@ -0,0 +1,625 @@
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/creators/publication_site.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/sites/site_pages.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
if (page.config?.containsKey('filter') == true ||
page.config?.containsKey('layout') == true) {
return 2; // Post Page
}
return page.config?.containsKey('target') == true ? 1 : 0;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
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'] ?? '') : '',
);
// Post Page Controllers
final filterPubNameController = useTextEditingController(
text: pageType.value == 2
? (page?.config?['filter']?['pub_name'] ?? '')
: '',
);
final filterOrderByController = useTextEditingController(
text: pageType.value == 2
? (page?.config?['filter']?['order_by'] ?? '')
: '',
);
final filterOrderDesc = useState(
pageType.value == 2
? (page?.config?['filter']?['order_desc'] ?? true)
: true,
);
final filterTypes = useState<List<int>>(
(page?.config?['filter']?['types'] as List?)?.cast<int>() ?? [0],
);
final layoutTitleController = useTextEditingController(
text: pageType.value == 2
? (page?.config?['layout']?['title'] ?? '')
: '',
);
final layoutDescriptionController = useTextEditingController(
text: pageType.value == 2
? (page?.config?['layout']?['description'] ?? '')
: '',
);
final layoutShowPub = useState(
pageType.value == 2
? (page?.config?['layout']?['show_pub'] ?? true)
: true,
);
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();
filterPubNameController.clear();
filterOrderByController.clear();
layoutTitleController.clear();
layoutDescriptionController.clear();
} else if (pageType.value == 1) {
// Redirect mode
htmlController.clear();
titleController.clear();
targetController.text = page?.config?['target'] ?? '';
filterPubNameController.clear();
filterOrderByController.clear();
layoutTitleController.clear();
layoutDescriptionController.clear();
} else if (pageType.value == 2) {
// Post Page mode
htmlController.clear();
titleController.clear();
targetController.clear();
filterPubNameController.text =
page?.config?['filter']?['pub_name'] ?? '';
filterOrderByController.text =
page?.config?['filter']?['order_by'] ?? '';
filterOrderDesc.value =
page?.config?['filter']?['order_desc'] ?? true;
filterTypes.value =
(page?.config?['filter']?['types'] as List?)?.cast<int>() ?? [0];
layoutTitleController.text = page?.config?['layout']?['title'] ?? '';
layoutDescriptionController.text =
page?.config?['layout']?['description'] ?? '';
layoutShowPub.value = page?.config?['layout']?['show_pub'] ?? true;
}
});
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 if (pageType.value == 1) {
targetController.text = page!.config?['target'] ?? '';
} else if (pageType.value == 2) {
filterPubNameController.text =
page!.config?['filter']?['pub_name'] ?? '';
filterOrderByController.text =
page!.config?['filter']?['order_by'] ?? '';
filterOrderDesc.value =
page!.config?['filter']?['order_desc'] ?? true;
filterTypes.value =
(page!.config?['filter']?['types'] as List?)?.cast<int>() ?? [0];
layoutTitleController.text = page!.config?['layout']?['title'] ?? '';
layoutDescriptionController.text =
page!.config?['layout']?['description'] ?? '';
layoutShowPub.value = page!.config?['layout']?['show_pub'] ?? true;
}
}
return null;
}, [page]);
final savePage = useCallback(
() async {
if (!formKey.currentState!.validate()) return;
isLoading.value = true;
try {
final provider = sitePagesNotifierProvider((
pubName: pubName,
siteSlug: site.slug,
));
final pagesNotifier = ref.read(provider.notifier);
late final Map<String, dynamic> pageData;
if (pageType.value == 0) {
// HTML page
pageData = {
'type': 0,
'path': pathController.text,
'config': {
'title': titleController.text,
'html': htmlController.text,
},
};
} else if (pageType.value == 1) {
// Redirect page
pageData = {
'type': 1,
'path': pathController.text,
'config': {'target': targetController.text},
};
} else {
// Post Page
pageData = {
'type': 2,
'path': pathController.text,
'config': {
'filter': {
if (filterPubNameController.text.isNotEmpty)
'pub_name': filterPubNameController.text,
if (filterOrderByController.text.isNotEmpty)
'order_by': filterOrderByController.text,
'order_desc': filterOrderDesc.value,
'types': filterTypes.value,
},
'layout': {
if (layoutTitleController.text.isNotEmpty)
'title': layoutTitleController.text,
if (layoutDescriptionController.text.isNotEmpty)
'description': layoutDescriptionController.text,
'show_pub': layoutShowPub.value,
},
},
};
}
if (page == null) {
// Create new page
await pagesNotifier.createPage(pageData);
} else {
// Update existing page
await pagesNotifier.updatePage(page!.id, pageData);
}
ref.invalidate(provider);
if (context.mounted) {
showSnackBar(
page == null
? 'Page created successfully'
: 'Page updated successfully',
);
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
},
[
pageType,
pubName,
site.slug,
page,
filterOrderDesc.value,
filterTypes.value,
layoutShowPub.value,
],
);
final deletePage = useCallback(() async {
if (page == null) return; // Shouldn't happen for editing
final confirmed = await showDialog<bool>(
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) {
showSnackBar('Page deleted successfully');
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, site.slug, page, context]);
return SheetScaffold(
titleText: page == null ? 'Create Page' : 'Edit Page',
child: SingleChildScrollView(
child: Column(
children: [
Form(
key: formKey,
child: Column(
children: [
// Page type selector
DropdownButtonFormField<int>(
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'),
],
),
),
DropdownMenuItem(
value: 2,
child: Row(
children: [
Icon(Symbols.article, size: 20),
Gap(8),
Text('Post 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),
// Common "Path" field for all types
TextFormField(
controller: pathController,
decoration: const InputDecoration(
labelText: 'Page Path',
hintText: '/about, /posts, 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),
// Conditional form fields based on page type
if (pageType.value == 0) ...[
// HTML Page fields
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:
'<h1>Hello World</h1><p>This is my page content...</p>',
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 if (pageType.value == 1) ...[
// Redirect Page fields
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('/') &&
// ignore: use_string_starts_with_pattern
!value.startsWith('http://') &&
// ignore: use_string_starts_with_pattern
!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),
] else if (pageType.value == 2) ...[
// Post Page fields
const Text(
'Filter Settings',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).alignment(Alignment.centerLeft).padding(horizontal: 24),
const Gap(8),
TextFormField(
controller: filterPubNameController,
decoration: const InputDecoration(
labelText: 'Publication Name (Optional)',
hintText: 'Filter by publication name',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
const Gap(16),
TextFormField(
controller: filterOrderByController,
decoration: const InputDecoration(
labelText: 'Order By (Optional)',
hintText: 'e.g. published_at',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
const Gap(8),
SwitchListTile(
value: filterOrderDesc.value,
onChanged: (value) => filterOrderDesc.value = value,
title: const Text('Order Descending'),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
),
const Gap(8),
const Text(
'Content Types',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
).alignment(Alignment.centerLeft).padding(horizontal: 24),
const Gap(4),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
children: [
FilterChip(
label: const Text('Regular Post'),
selected: filterTypes.value.contains(0),
onSelected: (selected) {
final types = [...filterTypes.value];
if (selected) {
types.add(0);
} else {
types.remove(0);
}
filterTypes.value = types;
},
),
FilterChip(
label: const Text('Article'),
selected: filterTypes.value.contains(1),
onSelected: (selected) {
final types = [...filterTypes.value];
if (selected) {
types.add(1);
} else {
types.remove(1);
}
filterTypes.value = types;
},
),
],
).padding(horizontal: 20),
const Gap(24),
const Text(
'Layout Settings',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).alignment(Alignment.centerLeft).padding(horizontal: 24),
const Gap(8),
TextFormField(
controller: layoutTitleController,
decoration: const InputDecoration(
labelText: 'Title (Optional)',
hintText: 'Page Title',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
const Gap(16),
TextFormField(
controller: layoutDescriptionController,
decoration: const InputDecoration(
labelText: 'Description (Optional)',
hintText: 'Page Description',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
const Gap(8),
SwitchListTile(
value: layoutShowPub.value,
onChanged: (value) => layoutShowPub.value = value,
title: const Text('Show Publication Info'),
contentPadding: const EdgeInsets.symmetric(
horizontal: 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: 16),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,105 @@
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/creators/publication_site.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/sites/site_pages.dart';
import 'package:island/sites/sites_widgets/page_form.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PageItem extends HookConsumerWidget {
final SnPublicationPage page;
final SnPublicationSite site;
final String pubName;
const PageItem({
super.key,
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<String>(
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 showConfirmAlert(
'Are you sure you want to delete this page?',
'Delete the Page',
);
if (confirmed) {
try {
final provider = sitePagesNotifierProvider((
pubName: pubName,
siteSlug: site.slug,
));
await ref.read(provider.notifier).deletePage(page.id);
ref.invalidate(provider);
showSnackBar('Page deleted successfully');
} catch (e) {
showErrorAlert(e);
}
}
break;
}
},
),
onTap: () {
launchUrlString('https://${site.slug}.solian.page${page.path ?? ''}');
},
),
);
}
}

View File

@@ -0,0 +1,120 @@
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/creators/publication_site.dart';
import 'package:island/sites/site_pages.dart';
import 'package:island/sites/sites_widgets/page_form.dart';
import 'package:island/sites/sites_widgets/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(
'sitePages'.tr(),
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(
'noPagesYet'.tr(),
style: theme.textTheme.bodyLarge,
),
const Gap(8),
Text(
'createFirstPage'.tr(),
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('failedToLoadPages'.tr()),
const Gap(8),
ElevatedButton(
onPressed: () =>
ref.invalidate(sitePagesProvider(pubName, site.slug)),
child: Text('retry'.tr()),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
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/creators/creators/sites/site_detail.dart';
import 'package:island/creators/creators/sites/site_edit.dart';
import 'package:island/creators/publication_site.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.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<String>(
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 showConfirmAlert(
'publicationSiteDeleteConfirm'.tr(),
'deleteSite'.tr(),
isDanger: true,
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/$pubName/${site.slug}');
if (context.mounted) {
showSnackBar('siteDeletedSuccess'.tr());
Navigator.of(context).pop();
}
} catch (e) {
showErrorAlert(e);
}
}
break;
}
},
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/creators/creators/sites/site_detail.dart';
import 'package:island/creators/publication_site.dart';
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
import 'package:island/sites/sites_widgets/file_management_action_section.dart';
import 'package:island/sites/sites_widgets/file_management_section.dart';
import 'package:island/sites/sites_widgets/pages_section.dart';
import 'package:island/sites/sites_widgets/site_info_card.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) {
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
SiteInfoCard(site: site),
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),
],
),
),
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/creators/publication_site.dart';
import 'package:island/core/services/time.dart';
import 'package:island/shared/widgets/info_row.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SiteInfoCard extends StatelessWidget {
final SnPublicationSite site;
const SiteInfoCard({super.key, required this.site});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'siteInformation'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Gap(16),
InfoRow(label: 'name'.tr(), value: site.name, icon: Symbols.title),
const Gap(8),
InfoRow(
label: 'slug'.tr(),
value: site.slug,
icon: Symbols.tag,
monospace: true,
),
const Gap(8),
InfoRow(
label: 'siteDomain'.tr(),
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: 'siteMode'.tr(),
value: site.mode == 0
? 'siteModeFullyManaged'.tr()
: 'siteModeSelfManaged'.tr(),
icon: Symbols.settings,
),
if (site.description != null && site.description!.isNotEmpty) ...[
const Gap(8),
InfoRow(
label: 'description'.tr(),
value: site.description!,
icon: Symbols.description,
),
],
const Gap(8),
InfoRow(
label: 'siteCreated'.tr(),
value: site.createdAt.formatSystem(),
icon: Symbols.calendar_add_on,
),
const Gap(8),
InfoRow(
label: 'siteUpdated'.tr(),
value: site.updatedAt.formatSystem(),
icon: Symbols.update,
),
],
),
),
);
}
}