From 7b85533184610326c5295913c167f0907af07817 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 21 Nov 2025 00:05:36 +0800 Subject: [PATCH] :sparkles: Pages management in site detail --- lib/models/site_file.dart | 25 + lib/models/site_file.freezed.dart | 539 ++++++++ lib/models/site_file.g.dart | 29 + lib/pods/site_files.dart | 142 +++ lib/pods/site_files.g.dart | 304 +++++ lib/pods/site_pages.dart | 116 ++ lib/pods/site_pages.g.dart | 280 +++++ lib/screens/creators/sites/site_detail.dart | 1236 ++++++++++++++++++- lib/widgets/content/sheet.dart | 5 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 11 files changed, 2665 insertions(+), 14 deletions(-) create mode 100644 lib/models/site_file.dart create mode 100644 lib/models/site_file.freezed.dart create mode 100644 lib/models/site_file.g.dart create mode 100644 lib/pods/site_files.dart create mode 100644 lib/pods/site_files.g.dart create mode 100644 lib/pods/site_pages.dart create mode 100644 lib/pods/site_pages.g.dart diff --git a/lib/models/site_file.dart b/lib/models/site_file.dart new file mode 100644 index 00000000..b9681bbd --- /dev/null +++ b/lib/models/site_file.dart @@ -0,0 +1,25 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'site_file.freezed.dart'; +part 'site_file.g.dart'; + +@freezed +sealed class SnSiteFileEntry with _$SnSiteFileEntry { + const factory SnSiteFileEntry({ + required bool isDirectory, + required String relativePath, + required int size, // Size in bytes (0 for directories) + required DateTime modified, // ISO 8601 timestamp + }) = _SnSiteFileEntry; + + factory SnSiteFileEntry.fromJson(Map json) => + _$SnSiteFileEntryFromJson(json); +} + +@freezed +sealed class SnFileContent with _$SnFileContent { + const factory SnFileContent({required String content}) = _SnFileContent; + + factory SnFileContent.fromJson(Map json) => + _$SnFileContentFromJson(json); +} diff --git a/lib/models/site_file.freezed.dart b/lib/models/site_file.freezed.dart new file mode 100644 index 00000000..73794489 --- /dev/null +++ b/lib/models/site_file.freezed.dart @@ -0,0 +1,539 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'site_file.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SnSiteFileEntry { + + bool get isDirectory; String get relativePath; int get size;// Size in bytes (0 for directories) + DateTime get modified; +/// Create a copy of SnSiteFileEntry +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnSiteFileEntryCopyWith get copyWith => _$SnSiteFileEntryCopyWithImpl(this as SnSiteFileEntry, _$identity); + + /// Serializes this SnSiteFileEntry to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnSiteFileEntry&&(identical(other.isDirectory, isDirectory) || other.isDirectory == isDirectory)&&(identical(other.relativePath, relativePath) || other.relativePath == relativePath)&&(identical(other.size, size) || other.size == size)&&(identical(other.modified, modified) || other.modified == modified)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,isDirectory,relativePath,size,modified); + +@override +String toString() { + return 'SnSiteFileEntry(isDirectory: $isDirectory, relativePath: $relativePath, size: $size, modified: $modified)'; +} + + +} + +/// @nodoc +abstract mixin class $SnSiteFileEntryCopyWith<$Res> { + factory $SnSiteFileEntryCopyWith(SnSiteFileEntry value, $Res Function(SnSiteFileEntry) _then) = _$SnSiteFileEntryCopyWithImpl; +@useResult +$Res call({ + bool isDirectory, String relativePath, int size, DateTime modified +}); + + + + +} +/// @nodoc +class _$SnSiteFileEntryCopyWithImpl<$Res> + implements $SnSiteFileEntryCopyWith<$Res> { + _$SnSiteFileEntryCopyWithImpl(this._self, this._then); + + final SnSiteFileEntry _self; + final $Res Function(SnSiteFileEntry) _then; + +/// Create a copy of SnSiteFileEntry +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? isDirectory = null,Object? relativePath = null,Object? size = null,Object? modified = null,}) { + return _then(_self.copyWith( +isDirectory: null == isDirectory ? _self.isDirectory : isDirectory // ignore: cast_nullable_to_non_nullable +as bool,relativePath: null == relativePath ? _self.relativePath : relativePath // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,modified: null == modified ? _self.modified : modified // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SnSiteFileEntry]. +extension SnSiteFileEntryPatterns on SnSiteFileEntry { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SnSiteFileEntry value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnSiteFileEntry() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SnSiteFileEntry value) $default,){ +final _that = this; +switch (_that) { +case _SnSiteFileEntry(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SnSiteFileEntry value)? $default,){ +final _that = this; +switch (_that) { +case _SnSiteFileEntry() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isDirectory, String relativePath, int size, DateTime modified)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnSiteFileEntry() when $default != null: +return $default(_that.isDirectory,_that.relativePath,_that.size,_that.modified);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool isDirectory, String relativePath, int size, DateTime modified) $default,) {final _that = this; +switch (_that) { +case _SnSiteFileEntry(): +return $default(_that.isDirectory,_that.relativePath,_that.size,_that.modified);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isDirectory, String relativePath, int size, DateTime modified)? $default,) {final _that = this; +switch (_that) { +case _SnSiteFileEntry() when $default != null: +return $default(_that.isDirectory,_that.relativePath,_that.size,_that.modified);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnSiteFileEntry implements SnSiteFileEntry { + const _SnSiteFileEntry({required this.isDirectory, required this.relativePath, required this.size, required this.modified}); + factory _SnSiteFileEntry.fromJson(Map json) => _$SnSiteFileEntryFromJson(json); + +@override final bool isDirectory; +@override final String relativePath; +@override final int size; +// Size in bytes (0 for directories) +@override final DateTime modified; + +/// Create a copy of SnSiteFileEntry +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnSiteFileEntryCopyWith<_SnSiteFileEntry> get copyWith => __$SnSiteFileEntryCopyWithImpl<_SnSiteFileEntry>(this, _$identity); + +@override +Map toJson() { + return _$SnSiteFileEntryToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnSiteFileEntry&&(identical(other.isDirectory, isDirectory) || other.isDirectory == isDirectory)&&(identical(other.relativePath, relativePath) || other.relativePath == relativePath)&&(identical(other.size, size) || other.size == size)&&(identical(other.modified, modified) || other.modified == modified)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,isDirectory,relativePath,size,modified); + +@override +String toString() { + return 'SnSiteFileEntry(isDirectory: $isDirectory, relativePath: $relativePath, size: $size, modified: $modified)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnSiteFileEntryCopyWith<$Res> implements $SnSiteFileEntryCopyWith<$Res> { + factory _$SnSiteFileEntryCopyWith(_SnSiteFileEntry value, $Res Function(_SnSiteFileEntry) _then) = __$SnSiteFileEntryCopyWithImpl; +@override @useResult +$Res call({ + bool isDirectory, String relativePath, int size, DateTime modified +}); + + + + +} +/// @nodoc +class __$SnSiteFileEntryCopyWithImpl<$Res> + implements _$SnSiteFileEntryCopyWith<$Res> { + __$SnSiteFileEntryCopyWithImpl(this._self, this._then); + + final _SnSiteFileEntry _self; + final $Res Function(_SnSiteFileEntry) _then; + +/// Create a copy of SnSiteFileEntry +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? isDirectory = null,Object? relativePath = null,Object? size = null,Object? modified = null,}) { + return _then(_SnSiteFileEntry( +isDirectory: null == isDirectory ? _self.isDirectory : isDirectory // ignore: cast_nullable_to_non_nullable +as bool,relativePath: null == relativePath ? _self.relativePath : relativePath // ignore: cast_nullable_to_non_nullable +as String,size: null == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as int,modified: null == modified ? _self.modified : modified // ignore: cast_nullable_to_non_nullable +as DateTime, + )); +} + + +} + + +/// @nodoc +mixin _$SnFileContent { + + String get content; +/// Create a copy of SnFileContent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnFileContentCopyWith get copyWith => _$SnFileContentCopyWithImpl(this as SnFileContent, _$identity); + + /// Serializes this SnFileContent to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFileContent&&(identical(other.content, content) || other.content == content)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,content); + +@override +String toString() { + return 'SnFileContent(content: $content)'; +} + + +} + +/// @nodoc +abstract mixin class $SnFileContentCopyWith<$Res> { + factory $SnFileContentCopyWith(SnFileContent value, $Res Function(SnFileContent) _then) = _$SnFileContentCopyWithImpl; +@useResult +$Res call({ + String content +}); + + + + +} +/// @nodoc +class _$SnFileContentCopyWithImpl<$Res> + implements $SnFileContentCopyWith<$Res> { + _$SnFileContentCopyWithImpl(this._self, this._then); + + final SnFileContent _self; + final $Res Function(SnFileContent) _then; + +/// Create a copy of SnFileContent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? content = null,}) { + return _then(_self.copyWith( +content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SnFileContent]. +extension SnFileContentPatterns on SnFileContent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SnFileContent value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SnFileContent() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SnFileContent value) $default,){ +final _that = this; +switch (_that) { +case _SnFileContent(): +return $default(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SnFileContent value)? $default,){ +final _that = this; +switch (_that) { +case _SnFileContent() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String content)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SnFileContent() when $default != null: +return $default(_that.content);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String content) $default,) {final _that = this; +switch (_that) { +case _SnFileContent(): +return $default(_that.content);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String content)? $default,) {final _that = this; +switch (_that) { +case _SnFileContent() when $default != null: +return $default(_that.content);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SnFileContent implements SnFileContent { + const _SnFileContent({required this.content}); + factory _SnFileContent.fromJson(Map json) => _$SnFileContentFromJson(json); + +@override final String content; + +/// Create a copy of SnFileContent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnFileContentCopyWith<_SnFileContent> get copyWith => __$SnFileContentCopyWithImpl<_SnFileContent>(this, _$identity); + +@override +Map toJson() { + return _$SnFileContentToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFileContent&&(identical(other.content, content) || other.content == content)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,content); + +@override +String toString() { + return 'SnFileContent(content: $content)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnFileContentCopyWith<$Res> implements $SnFileContentCopyWith<$Res> { + factory _$SnFileContentCopyWith(_SnFileContent value, $Res Function(_SnFileContent) _then) = __$SnFileContentCopyWithImpl; +@override @useResult +$Res call({ + String content +}); + + + + +} +/// @nodoc +class __$SnFileContentCopyWithImpl<$Res> + implements _$SnFileContentCopyWith<$Res> { + __$SnFileContentCopyWithImpl(this._self, this._then); + + final _SnFileContent _self; + final $Res Function(_SnFileContent) _then; + +/// Create a copy of SnFileContent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? content = null,}) { + return _then(_SnFileContent( +content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/models/site_file.g.dart b/lib/models/site_file.g.dart new file mode 100644 index 00000000..beebc083 --- /dev/null +++ b/lib/models/site_file.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'site_file.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SnSiteFileEntry _$SnSiteFileEntryFromJson(Map json) => + _SnSiteFileEntry( + isDirectory: json['is_directory'] as bool, + relativePath: json['relative_path'] as String, + size: (json['size'] as num).toInt(), + modified: DateTime.parse(json['modified'] as String), + ); + +Map _$SnSiteFileEntryToJson(_SnSiteFileEntry instance) => + { + 'is_directory': instance.isDirectory, + 'relative_path': instance.relativePath, + 'size': instance.size, + 'modified': instance.modified.toIso8601String(), + }; + +_SnFileContent _$SnFileContentFromJson(Map json) => + _SnFileContent(content: json['content'] as String); + +Map _$SnFileContentToJson(_SnFileContent instance) => + {'content': instance.content}; diff --git a/lib/pods/site_files.dart b/lib/pods/site_files.dart new file mode 100644 index 00000000..8d047dc3 --- /dev/null +++ b/lib/pods/site_files.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:island/models/site_file.dart'; +import 'package:island/pods/network.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'site_files.g.dart'; + +@riverpod +Future> siteFiles( + Ref ref, { + required String siteId, + String? path, +}) async { + final apiClient = ref.watch(apiClientProvider); + final queryParams = path != null ? {'path': path} : null; + final resp = await apiClient.get( + '/zone/sites/$siteId/files', + queryParameters: queryParams, + ); + final data = resp.data as List; + return data.map((json) => SnSiteFileEntry.fromJson(json)).toList(); +} + +@riverpod +Future siteFileContent( + Ref ref, { + required String siteId, + required String relativePath, +}) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get( + '/zone/sites/$siteId/files/content/$relativePath', + ); + return SnFileContent.fromJson(resp.data); +} + +class SiteFilesNotifier + extends + AutoDisposeFamilyAsyncNotifier< + List, + ({String siteId, String? path}) + > { + @override + Future> build( + ({String siteId, String? path}) arg, + ) async { + return fetchFiles(); + } + + Future> fetchFiles() async { + try { + final apiClient = ref.read(apiClientProvider); + final queryParams = arg.path != null ? {'path': arg.path} : null; + final resp = await apiClient.get( + '/zone/sites/${arg.siteId}/files', + queryParameters: queryParams, + ); + final data = resp.data as List; + return data.map((json) => SnSiteFileEntry.fromJson(json)).toList(); + } catch (e) { + rethrow; + } + } + + Future uploadFile(File file, String filePath) async { + state = const AsyncValue.loading(); + try { + final apiClient = ref.read(apiClientProvider); + + // Create multipart form data + final formData = FormData.fromMap({ + 'filePath': filePath, + 'file': await MultipartFile.fromFile( + file.path, + filename: file.path.split('/').last, + contentType: MediaType('application', 'octet-stream'), + ), + }); + + await apiClient.post( + '/zone/sites/${arg.siteId}/files/upload', + data: formData, + ); + + // Refresh the files list + ref.invalidateSelf(); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } + + Future updateFileContent(String relativePath, String newContent) async { + state = const AsyncValue.loading(); + try { + final apiClient = ref.read(apiClientProvider); + await apiClient.put( + '/zone/sites/${arg.siteId}/files/edit/$relativePath', + data: {'new_content': newContent}, + ); + + // Refresh the files list + ref.invalidateSelf(); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } + + Future deleteFile(String relativePath) async { + state = const AsyncValue.loading(); + try { + final apiClient = ref.read(apiClientProvider); + await apiClient.delete( + '/zone/sites/${arg.siteId}/files/delete/$relativePath', + ); + + // Refresh the files list + ref.invalidateSelf(); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } + + Future createDirectory(String directoryPath) async { + // For directories, we upload a dummy file first then delete it or create through upload + // Actually, according to API docs, directories are created when uploading files to them + // So we'll just invalidate to refresh the list + ref.invalidateSelf(); + } +} + +final siteFilesNotifierProvider = AsyncNotifierProvider.autoDispose.family< + SiteFilesNotifier, + List, + ({String siteId, String? path}) +>(SiteFilesNotifier.new); diff --git a/lib/pods/site_files.g.dart b/lib/pods/site_files.g.dart new file mode 100644 index 00000000..c2933ef1 --- /dev/null +++ b/lib/pods/site_files.g.dart @@ -0,0 +1,304 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'site_files.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$siteFilesHash() => r'd4029e6c160edcd454eb39ef1c19427b7f95a8d8'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [siteFiles]. +@ProviderFor(siteFiles) +const siteFilesProvider = SiteFilesFamily(); + +/// See also [siteFiles]. +class SiteFilesFamily extends Family>> { + /// See also [siteFiles]. + const SiteFilesFamily(); + + /// See also [siteFiles]. + SiteFilesProvider call({required String siteId, String? path}) { + return SiteFilesProvider(siteId: siteId, path: path); + } + + @override + SiteFilesProvider getProviderOverride(covariant SiteFilesProvider provider) { + return call(siteId: provider.siteId, path: provider.path); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'siteFilesProvider'; +} + +/// See also [siteFiles]. +class SiteFilesProvider + extends AutoDisposeFutureProvider> { + /// See also [siteFiles]. + SiteFilesProvider({required String siteId, String? path}) + : this._internal( + (ref) => siteFiles(ref as SiteFilesRef, siteId: siteId, path: path), + from: siteFilesProvider, + name: r'siteFilesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$siteFilesHash, + dependencies: SiteFilesFamily._dependencies, + allTransitiveDependencies: SiteFilesFamily._allTransitiveDependencies, + siteId: siteId, + path: path, + ); + + SiteFilesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.siteId, + required this.path, + }) : super.internal(); + + final String siteId; + final String? path; + + @override + Override overrideWith( + FutureOr> Function(SiteFilesRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SiteFilesProvider._internal( + (ref) => create(ref as SiteFilesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + siteId: siteId, + path: path, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _SiteFilesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SiteFilesProvider && + other.siteId == siteId && + other.path == path; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, siteId.hashCode); + hash = _SystemHash.combine(hash, path.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SiteFilesRef on AutoDisposeFutureProviderRef> { + /// The parameter `siteId` of this provider. + String get siteId; + + /// The parameter `path` of this provider. + String? get path; +} + +class _SiteFilesProviderElement + extends AutoDisposeFutureProviderElement> + with SiteFilesRef { + _SiteFilesProviderElement(super.provider); + + @override + String get siteId => (origin as SiteFilesProvider).siteId; + @override + String? get path => (origin as SiteFilesProvider).path; +} + +String _$siteFileContentHash() => r'bb820f0fe5bdca55efb08beee97aa38d09be04a7'; + +/// See also [siteFileContent]. +@ProviderFor(siteFileContent) +const siteFileContentProvider = SiteFileContentFamily(); + +/// See also [siteFileContent]. +class SiteFileContentFamily extends Family> { + /// See also [siteFileContent]. + const SiteFileContentFamily(); + + /// See also [siteFileContent]. + SiteFileContentProvider call({ + required String siteId, + required String relativePath, + }) { + return SiteFileContentProvider(siteId: siteId, relativePath: relativePath); + } + + @override + SiteFileContentProvider getProviderOverride( + covariant SiteFileContentProvider provider, + ) { + return call(siteId: provider.siteId, relativePath: provider.relativePath); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'siteFileContentProvider'; +} + +/// See also [siteFileContent]. +class SiteFileContentProvider extends AutoDisposeFutureProvider { + /// See also [siteFileContent]. + SiteFileContentProvider({ + required String siteId, + required String relativePath, + }) : this._internal( + (ref) => siteFileContent( + ref as SiteFileContentRef, + siteId: siteId, + relativePath: relativePath, + ), + from: siteFileContentProvider, + name: r'siteFileContentProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$siteFileContentHash, + dependencies: SiteFileContentFamily._dependencies, + allTransitiveDependencies: + SiteFileContentFamily._allTransitiveDependencies, + siteId: siteId, + relativePath: relativePath, + ); + + SiteFileContentProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.siteId, + required this.relativePath, + }) : super.internal(); + + final String siteId; + final String relativePath; + + @override + Override overrideWith( + FutureOr Function(SiteFileContentRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SiteFileContentProvider._internal( + (ref) => create(ref as SiteFileContentRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + siteId: siteId, + relativePath: relativePath, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _SiteFileContentProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SiteFileContentProvider && + other.siteId == siteId && + other.relativePath == relativePath; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, siteId.hashCode); + hash = _SystemHash.combine(hash, relativePath.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SiteFileContentRef on AutoDisposeFutureProviderRef { + /// The parameter `siteId` of this provider. + String get siteId; + + /// The parameter `relativePath` of this provider. + String get relativePath; +} + +class _SiteFileContentProviderElement + extends AutoDisposeFutureProviderElement + with SiteFileContentRef { + _SiteFileContentProviderElement(super.provider); + + @override + String get siteId => (origin as SiteFileContentProvider).siteId; + @override + String get relativePath => (origin as SiteFileContentProvider).relativePath; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/pods/site_pages.dart b/lib/pods/site_pages.dart new file mode 100644 index 00000000..5884c32a --- /dev/null +++ b/lib/pods/site_pages.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:island/models/publication_site.dart'; +import 'package:island/pods/network.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'site_pages.g.dart'; + +@riverpod +Future> sitePages( + Ref ref, + String pubName, + String siteSlug, +) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get('/zone/sites/$pubName/$siteSlug/pages'); + final data = resp.data as List; + return data.map((json) => SnPublicationPage.fromJson(json)).toList(); +} + +@riverpod +Future sitePage(Ref ref, String pageId) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get('/zone/sites/pages/$pageId'); + return SnPublicationPage.fromJson(resp.data); +} + +class SitePagesNotifier + extends + AutoDisposeFamilyAsyncNotifier< + List, + ({String pubName, String siteSlug}) + > { + @override + Future> build( + ({String pubName, String siteSlug}) arg, + ) async { + return fetchPages(); + } + + Future> fetchPages() async { + try { + final apiClient = ref.read(apiClientProvider); + final resp = await apiClient.get( + '/zone/sites/${arg.pubName}/${arg.siteSlug}/pages', + ); + final data = resp.data as List; + return data.map((json) => SnPublicationPage.fromJson(json)).toList(); + } catch (e) { + rethrow; + } + } + + Future createPage(Map pageData) async { + state = const AsyncValue.loading(); + try { + final apiClient = ref.read(apiClientProvider); + final resp = await apiClient.post( + '/zone/sites/${arg.pubName}/${arg.siteSlug}/pages', + data: pageData, + ); + final newPage = SnPublicationPage.fromJson(resp.data); + + // Refresh the pages list + ref.invalidateSelf(); + + return newPage; + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } + + Future updatePage( + String pageId, + Map pageData, + ) async { + state = const AsyncValue.loading(); + try { + final apiClient = ref.read(apiClientProvider); + final resp = await apiClient.patch( + '/zone/sites/pages/$pageId', + data: pageData, + ); + final updatedPage = SnPublicationPage.fromJson(resp.data); + + // Refresh the pages list + ref.invalidateSelf(); + + return updatedPage; + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } + + Future deletePage(String pageId) async { + state = const AsyncValue.loading(); + try { + final apiClient = ref.read(apiClientProvider); + await apiClient.delete('/zone/sites/pages/$pageId'); + + // Refresh the pages list + ref.invalidateSelf(); + } catch (error, stackTrace) { + state = AsyncValue.error(error, stackTrace); + rethrow; + } + } +} + +final sitePagesNotifierProvider = AsyncNotifierProvider.autoDispose.family< + SitePagesNotifier, + List, + ({String pubName, String siteSlug}) +>(SitePagesNotifier.new); diff --git a/lib/pods/site_pages.g.dart b/lib/pods/site_pages.g.dart new file mode 100644 index 00000000..3d681f6b --- /dev/null +++ b/lib/pods/site_pages.g.dart @@ -0,0 +1,280 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'site_pages.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sitePagesHash() => r'5e084e9694ad665e9b238c6a747c6c6e99c5eb03'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [sitePages]. +@ProviderFor(sitePages) +const sitePagesProvider = SitePagesFamily(); + +/// See also [sitePages]. +class SitePagesFamily extends Family>> { + /// See also [sitePages]. + const SitePagesFamily(); + + /// See also [sitePages]. + SitePagesProvider call(String pubName, String siteSlug) { + return SitePagesProvider(pubName, siteSlug); + } + + @override + SitePagesProvider getProviderOverride(covariant SitePagesProvider provider) { + return call(provider.pubName, provider.siteSlug); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'sitePagesProvider'; +} + +/// See also [sitePages]. +class SitePagesProvider + extends AutoDisposeFutureProvider> { + /// See also [sitePages]. + SitePagesProvider(String pubName, String siteSlug) + : this._internal( + (ref) => sitePages(ref as SitePagesRef, pubName, siteSlug), + from: sitePagesProvider, + name: r'sitePagesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$sitePagesHash, + dependencies: SitePagesFamily._dependencies, + allTransitiveDependencies: SitePagesFamily._allTransitiveDependencies, + pubName: pubName, + siteSlug: siteSlug, + ); + + SitePagesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.pubName, + required this.siteSlug, + }) : super.internal(); + + final String pubName; + final String siteSlug; + + @override + Override overrideWith( + FutureOr> Function(SitePagesRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SitePagesProvider._internal( + (ref) => create(ref as SitePagesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + pubName: pubName, + siteSlug: siteSlug, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _SitePagesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SitePagesProvider && + other.pubName == pubName && + other.siteSlug == siteSlug; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, pubName.hashCode); + hash = _SystemHash.combine(hash, siteSlug.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SitePagesRef on AutoDisposeFutureProviderRef> { + /// The parameter `pubName` of this provider. + String get pubName; + + /// The parameter `siteSlug` of this provider. + String get siteSlug; +} + +class _SitePagesProviderElement + extends AutoDisposeFutureProviderElement> + with SitePagesRef { + _SitePagesProviderElement(super.provider); + + @override + String get pubName => (origin as SitePagesProvider).pubName; + @override + String get siteSlug => (origin as SitePagesProvider).siteSlug; +} + +String _$sitePageHash() => r'542f70c5b103fe34d7cf7eb0821d52f017022efc'; + +/// See also [sitePage]. +@ProviderFor(sitePage) +const sitePageProvider = SitePageFamily(); + +/// See also [sitePage]. +class SitePageFamily extends Family> { + /// See also [sitePage]. + const SitePageFamily(); + + /// See also [sitePage]. + SitePageProvider call(String pageId) { + return SitePageProvider(pageId); + } + + @override + SitePageProvider getProviderOverride(covariant SitePageProvider provider) { + return call(provider.pageId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'sitePageProvider'; +} + +/// See also [sitePage]. +class SitePageProvider extends AutoDisposeFutureProvider { + /// See also [sitePage]. + SitePageProvider(String pageId) + : this._internal( + (ref) => sitePage(ref as SitePageRef, pageId), + from: sitePageProvider, + name: r'sitePageProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$sitePageHash, + dependencies: SitePageFamily._dependencies, + allTransitiveDependencies: SitePageFamily._allTransitiveDependencies, + pageId: pageId, + ); + + SitePageProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.pageId, + }) : super.internal(); + + final String pageId; + + @override + Override overrideWith( + FutureOr Function(SitePageRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SitePageProvider._internal( + (ref) => create(ref as SitePageRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + pageId: pageId, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _SitePageProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SitePageProvider && other.pageId == pageId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, pageId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SitePageRef on AutoDisposeFutureProviderRef { + /// The parameter `pageId` of this provider. + String get pageId; +} + +class _SitePageProviderElement + extends AutoDisposeFutureProviderElement + with SitePageRef { + _SitePageProviderElement(super.provider); + + @override + String get pageId => (origin as SitePageProvider).pageId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/creators/sites/site_detail.dart b/lib/screens/creators/sites/site_detail.dart index 0dbc5693..2932ed4d 100644 --- a/lib/screens/creators/sites/site_detail.dart +++ b/lib/screens/creators/sites/site_detail.dart @@ -1,13 +1,21 @@ 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/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'; @@ -82,14 +90,1221 @@ class PublicationSiteDetailScreen extends HookConsumerWidget { ), loading: () => const Center(child: CircularProgressIndicator()), ), - floatingActionButton: FloatingActionButton( - onPressed: () { - // TODO: Add page creation + floatingActionButton: siteAsync.maybeWhen( + data: + (site) => FloatingActionButton( + onPressed: () { + // Create new page + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => PageForm(site: site, pubName: pubName), + ).then((_) { + // Refresh pages after creation + ref.invalidate(sitePagesProvider(pubName, site.slug)); + }); + }, + child: const Icon(Symbols.add), + ), + orElse: () => null, + ), + ); + } +} + +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 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 filePath, + 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, + ); + + await siteFilesNotifier.uploadFile(file, filePath); + + 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 { + 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]; + // For now, upload to root. In a real implementation, you'd get this from a form field + final uploadPath = '/${file.path.split('/').last}'; + await uploadFile(uploadPath, 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, + ], + ); + + return SheetScaffold( + titleText: 'Upload Files', + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ), + ), + ], + ), + ], + ], + ), + ), + ); + }), + 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( - const SnackBar(content: Text('Add page feature coming soon')), + 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 ?? '/'}')), ); }, - child: const Icon(Symbols.add), + ), + ); + } +} + +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, + physics: const NeverScrollableScrollPhysics(), + 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( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + 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}')), + ); + } + }, ), ); } @@ -105,7 +1320,7 @@ class _SiteDetailContent extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - return RefreshIndicator( + return ExtendedRefreshIndicator( onRefresh: () async => ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)), @@ -156,12 +1371,6 @@ class _SiteDetailContent extends HookConsumerWidget { ), ], const Gap(8), - _InfoRow( - label: 'Pages', - value: '${site.pages.length}', - icon: Symbols.article, - ), - const Gap(8), _InfoRow( label: 'Created', value: site.createdAt.formatSystem(), @@ -177,6 +1386,9 @@ class _SiteDetailContent extends HookConsumerWidget { ), ), ), + // Pages Section + _PagesSection(site: site, pubName: pubName), + _FileManagementSection(site: site, pubName: pubName), ], ), ), diff --git a/lib/widgets/content/sheet.dart b/lib/widgets/content/sheet.dart index 22d915ae..5dec5672 100644 --- a/lib/widgets/content/sheet.dart +++ b/lib/widgets/content/sheet.dart @@ -51,7 +51,10 @@ class SheetScaffold extends StatelessWidget { const Spacer(), ...actions, IconButton( - icon: const Icon(Symbols.close), + icon: Icon( + Symbols.close, + color: Theme.of(context).colorScheme.onSurface, + ), onPressed: () => onClose != null diff --git a/pubspec.lock b/pubspec.lock index 8e0f0af1..5266ae93 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1302,7 +1302,7 @@ packages: source: hosted version: "3.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/pubspec.yaml b/pubspec.yaml index fe5809de..5ad8802a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -169,6 +169,7 @@ dependencies: convert: ^3.1.2 desktop_drop: ^0.7.0 flutter_animate: ^4.5.2 + http_parser: ^4.1.2 dev_dependencies: flutter_test: