Site basis

This commit is contained in:
2025-11-20 21:29:08 +08:00
parent faf3a677d4
commit fafa460fe8
13 changed files with 1465 additions and 4 deletions

View File

@@ -6,7 +6,7 @@ part of 'chat.dart';
// RiverpodGenerator
// **************************************************************************
String _$chatroomsJoinedHash() => r'3bb6389af07e81007680484d04bf5fe6f6c10571';
String _$chatroomsJoinedHash() => r'9523efecd1869e7dd26adfc8ec87be48db19ee1c';
/// See also [chatroomsJoined].
@ProviderFor(chatroomsJoined)

View File

@@ -403,6 +403,21 @@ class CreatorHubScreen extends HookConsumerWidget {
);
},
),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
minTileHeight: 48,
title: Text('publicationSites').tr(),
trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.web),
onTap: () {
context.pushNamed(
'creatorSites',
pathParameters: {'name': currentPublisher.value!.name},
);
},
),
ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -585,7 +600,7 @@ class CreatorHubScreen extends HookConsumerWidget {
).padding(horizontal: 12),
buildNavigationWidget(true),
],
)
).padding(vertical: 24)
: Column(
spacing: 12,
children: [

View File

@@ -0,0 +1,252 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/sites/site_list.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class SiteForm extends HookConsumerWidget {
final String pubName;
final String? siteId;
const SiteForm({super.key, required this.pubName, this.siteId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final slugController = useTextEditingController();
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final isLoading = useState(false);
final saveSite = useCallback(() async {
if (!formKey.currentState!.validate()) return;
isLoading.value = true;
try {
final client = ref.read(apiClientProvider);
final url = '/zone/sites/$pubName';
final payload = <String, dynamic>{
'slug': slugController.text,
'name': nameController.text,
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
};
if (siteId != null) {
await client.patch('$url/$siteId', data: payload);
} else {
await client.post(url, data: payload);
}
// Refresh the site list
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('Publication site saved successfully');
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, siteId, context]);
final deleteSite = useCallback(() async {
final confirmed = await showConfirmAlert(
'Are you sure you want to delete this publication site? This action cannot be undone.',
'Delete Publication Site',
);
if (confirmed != true) return;
isLoading.value = true;
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/${siteId!}');
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('Publication site deleted successfully');
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, siteId, context]);
// Handle loading and error states for editing
final isFetchLoading = useState(siteId != null);
final site = useState<SnPublicationSite?>(null);
final errorMessage = useState<String?>(null);
useEffect(() {
if (siteId == null) return;
Future<void> fetchSite() async {
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/zone/sites/$siteId');
final fetchedSite = SnPublicationSite.fromJson(response.data);
site.value = fetchedSite;
// Initialize form fields if they're empty and we have a site
if (nameController.text.isEmpty) {
slugController.text = fetchedSite.slug;
nameController.text = fetchedSite.name;
descriptionController.text = fetchedSite.description ?? '';
}
} catch (e) {
errorMessage.value = e.toString();
} finally {
isFetchLoading.value = false;
}
}
fetchSite();
return null;
}, [siteId]);
if (siteId != null && isFetchLoading.value) {
return const SheetScaffold(
titleText: 'Edit Publication Site',
child: Center(child: CircularProgressIndicator()),
);
}
if (siteId != null && errorMessage.value != null) {
return SheetScaffold(
titleText: 'Edit Publication Site',
child: ResponseErrorWidget(
error: errorMessage.value!,
onRetry: () {
isFetchLoading.value = true;
errorMessage.value = null;
// Refetch
useEffect(() {
Future<void> fetchSite() async {
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/zone/sites/$siteId');
final fetchedSite = SnPublicationSite.fromJson(response.data);
site.value = fetchedSite;
slugController.text = fetchedSite.slug;
nameController.text = fetchedSite.name;
descriptionController.text = fetchedSite.description ?? '';
} catch (e) {
errorMessage.value = e.toString();
} finally {
isFetchLoading.value = false;
}
}
fetchSite();
return null;
}, ['$siteId-${DateTime.now().millisecondsSinceEpoch}']);
},
),
);
}
final formFields = Column(
children: [
TextFormField(
controller: slugController,
decoration: const InputDecoration(
labelText: 'Slug',
hintText: 'my-site',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a slug';
}
final slugRegex = RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$');
if (!slugRegex.hasMatch(value)) {
return 'Slug can only contain lowercase letters, numbers, and dashes';
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Site Name',
hintText: 'My Publication Site',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a site name';
}
return null;
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
maxLines: 3,
),
],
).padding(all: 20);
final saveButton = TextButton.icon(
onPressed: isLoading.value ? null : saveSite,
icon: const Icon(Symbols.save),
label: Text('saveChanges').tr(),
).padding(horizontal: 20, vertical: 12);
return SheetScaffold(
titleText:
siteId == null ? 'New Publication Site' : 'Edit Publication Site',
child: SingleChildScrollView(
child: Column(
children: [
Form(key: formKey, child: formFields),
Row(
children: [
if (siteId != null) ...[
TextButton.icon(
onPressed: isLoading.value ? null : deleteSite,
icon: const Icon(Symbols.delete_forever),
label: const Text('Delete Publication Site'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
).alignment(Alignment.centerRight),
const SizedBox(height: 16),
],
const Spacer(),
saveButton,
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,230 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/sites/site_edit.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart';
part 'site_list.g.dart';
@riverpod
class SiteListNotifier extends _$SiteListNotifier
with CursorPagingNotifierMixin<SnPublicationSite> {
static const int _pageSize = 20;
@override
Future<CursorPagingData<SnPublicationSite>> build(String? pubName) {
// immediately load first page
return fetch(cursor: null);
}
@override
Future<CursorPagingData<SnPublicationSite>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
// read the current family argument passed to provider
final queryParams = {'offset': offset, 'take': _pageSize};
final response = await client.get(
'/zone/sites/$pubName',
queryParameters: queryParams,
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final items = data.map((json) => SnPublicationSite.fromJson(json)).toList();
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
return CursorPagingData(
items: items,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
class CreatorSiteListScreen extends HookConsumerWidget {
const CreatorSiteListScreen({super.key, required this.pubName});
final String pubName;
Future<void> _createSite(BuildContext context) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SiteForm(pubName: pubName),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('Publication Sites')),
floatingActionButton: FloatingActionButton(
onPressed: () => _createSite(context),
child: Icon(Icons.add),
),
body: ExtendedRefreshIndicator(
onRefresh: () => ref.refresh(siteListNotifierProvider(pubName).future),
child: CustomScrollView(
slivers: [
PagingHelperSliverView(
provider: siteListNotifierProvider(pubName),
futureRefreshable: siteListNotifierProvider(pubName).future,
notifierRefreshable: siteListNotifierProvider(pubName).notifier,
contentBuilder:
(data, widgetCount, endItemView) => SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == widgetCount - 1) {
return endItemView;
}
final site = data.items[index];
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: _CreatorSiteItem(site: site, pubName: pubName),
).center();
},
),
),
],
),
),
);
}
}
class _CreatorSiteItem extends HookConsumerWidget {
final String pubName;
const _CreatorSiteItem({required this.site, required this.pubName});
final SnPublicationSite site;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
clipBehavior: Clip.antiAlias,
child: ListTile(
title: Text(site.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (site.description != null && site.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
site.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Slug: ${site.slug} · Pages: ${site.pages.length}',
style: theme.textTheme.bodySmall,
),
),
],
),
trailing: PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) =>
SiteForm(pubName: pubName, siteId: site.id),
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('Delete Site'),
content: Text(
'Are you sure you want to delete this site?',
),
actions: [
TextButton(
onPressed:
() => Navigator.of(context).pop(false),
child: Text('Cancel'),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
child: Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/${site.id}');
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Site deleted successfully'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete site')),
);
}
}
}
},
),
],
),
onTap: () {
// Open site details or pages
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Site details coming soon')));
},
),
);
}
}

View File

@@ -0,0 +1,183 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'site_list.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$siteListNotifierHash() => r'5cd2d75f13b6e7d4910dc66a24bbd6508fc4175d';
/// 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));
}
}
abstract class _$SiteListNotifier
extends
BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPublicationSite>> {
late final String? pubName;
FutureOr<CursorPagingData<SnPublicationSite>> build(String? pubName);
}
/// See also [SiteListNotifier].
@ProviderFor(SiteListNotifier)
const siteListNotifierProvider = SiteListNotifierFamily();
/// See also [SiteListNotifier].
class SiteListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnPublicationSite>>> {
/// See also [SiteListNotifier].
const SiteListNotifierFamily();
/// See also [SiteListNotifier].
SiteListNotifierProvider call(String? pubName) {
return SiteListNotifierProvider(pubName);
}
@override
SiteListNotifierProvider getProviderOverride(
covariant SiteListNotifierProvider provider,
) {
return call(provider.pubName);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'siteListNotifierProvider';
}
/// See also [SiteListNotifier].
class SiteListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
SiteListNotifier,
CursorPagingData<SnPublicationSite>
> {
/// See also [SiteListNotifier].
SiteListNotifierProvider(String? pubName)
: this._internal(
() => SiteListNotifier()..pubName = pubName,
from: siteListNotifierProvider,
name: r'siteListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$siteListNotifierHash,
dependencies: SiteListNotifierFamily._dependencies,
allTransitiveDependencies:
SiteListNotifierFamily._allTransitiveDependencies,
pubName: pubName,
);
SiteListNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.pubName,
}) : super.internal();
final String? pubName;
@override
FutureOr<CursorPagingData<SnPublicationSite>> runNotifierBuild(
covariant SiteListNotifier notifier,
) {
return notifier.build(pubName);
}
@override
Override overrideWith(SiteListNotifier Function() create) {
return ProviderOverride(
origin: this,
override: SiteListNotifierProvider._internal(
() => create()..pubName = pubName,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
pubName: pubName,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
SiteListNotifier,
CursorPagingData<SnPublicationSite>
>
createElement() {
return _SiteListNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SiteListNotifierProvider && other.pubName == pubName;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, pubName.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SiteListNotifierRef
on
AutoDisposeAsyncNotifierProviderRef<
CursorPagingData<SnPublicationSite>
> {
/// The parameter `pubName` of this provider.
String? get pubName;
}
class _SiteListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
SiteListNotifier,
CursorPagingData<SnPublicationSite>
>
with SiteListNotifierRef {
_SiteListNotifierProviderElement(super.provider);
@override
String? get pubName => (origin as SiteListNotifierProvider).pubName;
}
// 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