diff --git a/lib/route.dart b/lib/route.dart index 81213b96..adf06868 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -43,6 +43,7 @@ import 'package:island/screens/stickers/pack_detail.dart'; import 'package:island/screens/discovery/feeds/feed_marketplace.dart'; import 'package:island/screens/discovery/feeds/feed_detail.dart'; import 'package:island/screens/creators/poll/poll_list.dart'; +import 'package:island/screens/creators/sites/site_detail.dart'; import 'package:island/screens/creators/sites/site_list.dart'; import 'package:island/screens/creators/webfeed/webfeed_list.dart'; import 'package:island/screens/posts/compose.dart'; @@ -493,6 +494,20 @@ final routerProvider = Provider((ref) { final name = state.pathParameters['name']!; return CreatorSiteListScreen(pubName: name); }, + routes: [ + GoRoute( + name: 'creatorSiteDetail', + path: ':siteSlug', + builder: (context, state) { + final name = state.pathParameters['name']!; + final siteSlug = state.pathParameters['siteSlug']!; + return PublicationSiteDetailScreen( + siteSlug: siteSlug, + pubName: name, + ); + }, + ), + ], ), GoRoute( diff --git a/lib/screens/creators/sites/site_detail.dart b/lib/screens/creators/sites/site_detail.dart new file mode 100644 index 00000000..0dbc5693 --- /dev/null +++ b/lib/screens/creators/sites/site_detail.dart @@ -0,0 +1,328 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.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/pods/network.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:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; + +part 'site_detail.g.dart'; + +@riverpod +Future publicationSiteDetail( + Ref ref, + String pubName, + String siteSlug, +) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get('/zone/sites/$pubName/$siteSlug'); + return SnPublicationSite.fromJson(resp.data); +} + +class PublicationSiteDetailScreen extends HookConsumerWidget { + final String siteSlug; + final String pubName; + + const PublicationSiteDetailScreen({ + super.key, + required this.siteSlug, + required this.pubName, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final siteAsync = ref.watch( + publicationSiteDetailProvider(pubName, siteSlug), + ); + + return AppScaffold( + isNoBackground: false, + appBar: AppBar( + title: siteAsync.maybeWhen( + data: (site) => Text(site.name), + orElse: () => const Text('Site Details'), + ), + actions: [ + siteAsync.maybeWhen( + data: (site) => _SiteActionMenu(site: site, pubName: pubName), + orElse: () => const SizedBox.shrink(), + ), + const Gap(8), + ], + ), + body: siteAsync.when( + data: (site) => _SiteDetailContent(site: site, pubName: pubName), + error: + (error, stack) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Failed to load site', + style: Theme.of(context).textTheme.headlineSmall, + ), + const Gap(16), + Text(error.toString()), + const Gap(24), + ElevatedButton( + onPressed: + () => ref.invalidate( + publicationSiteDetailProvider(pubName, siteSlug), + ), + child: const Text('Retry'), + ), + ], + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + // TODO: Add page creation + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Add page feature coming soon')), + ); + }, + child: const Icon(Symbols.add), + ), + ); + } +} + +class _SiteDetailContent extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + + const _SiteDetailContent({required this.site, required this.pubName}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return RefreshIndicator( + onRefresh: + () async => + ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Site Info Card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Site Information', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Gap(16), + _InfoRow( + label: 'Name', + value: site.name, + icon: Symbols.title, + ), + const Gap(8), + _InfoRow( + label: 'Slug', + value: site.slug, + icon: Symbols.tag, + monospace: true, + ), + const Gap(8), + _InfoRow( + label: 'Mode', + value: site.mode == 0 ? 'Fully Managed' : 'Self-Managed', + icon: Symbols.settings, + ), + if (site.description != null && + site.description!.isNotEmpty) ...[ + const Gap(8), + _InfoRow( + label: 'Description', + value: site.description!, + icon: Symbols.description, + ), + ], + const Gap(8), + _InfoRow( + label: 'Pages', + value: '${site.pages.length}', + icon: Symbols.article, + ), + const Gap(8), + _InfoRow( + label: 'Created', + value: site.createdAt.formatSystem(), + icon: Symbols.calendar_add_on, + ), + const Gap(8), + _InfoRow( + label: 'Updated', + value: site.updatedAt.formatSystem(), + icon: Symbols.update, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final bool monospace; + + const _InfoRow({ + required this.label, + required this.value, + required this.icon, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), + const Gap(12), + Expanded( + flex: 2, + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + const Gap(12), + Expanded( + flex: 3, + child: Text( + value, + style: + monospace + ? GoogleFonts.robotoMono(fontSize: 14) + : Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} + +class _SiteActionMenu extends HookConsumerWidget { + final SnPublicationSite site; + final String pubName; + + const _SiteActionMenu({required this.site, required this.pubName}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PopupMenuButton( + 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 showDialog( + context: context, + builder: + (context) => AlertDialog( + title: const Text('Delete Site'), + content: const Text( + 'Are you sure you want to delete this publication site? This action cannot be undone.', + ), + 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 { + final client = ref.read(apiClientProvider); + await client.delete('/zone/sites/${site.id}'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Site deleted successfully')), + ); + // Navigate back to list + Navigator.of(context).pop(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to delete site')), + ); + } + } + } + break; + } + }, + ); + } +} diff --git a/lib/screens/creators/sites/site_detail.g.dart b/lib/screens/creators/sites/site_detail.g.dart new file mode 100644 index 00000000..9075ffc8 --- /dev/null +++ b/lib/screens/creators/sites/site_detail.g.dart @@ -0,0 +1,173 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'site_detail.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$publicationSiteDetailHash() => + r'e5d259ea39c4ba47e92d37e644fc3d84984927a9'; + +/// 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 [publicationSiteDetail]. +@ProviderFor(publicationSiteDetail) +const publicationSiteDetailProvider = PublicationSiteDetailFamily(); + +/// See also [publicationSiteDetail]. +class PublicationSiteDetailFamily + extends Family> { + /// See also [publicationSiteDetail]. + const PublicationSiteDetailFamily(); + + /// See also [publicationSiteDetail]. + PublicationSiteDetailProvider call(String pubName, String siteSlug) { + return PublicationSiteDetailProvider(pubName, siteSlug); + } + + @override + PublicationSiteDetailProvider getProviderOverride( + covariant PublicationSiteDetailProvider 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'publicationSiteDetailProvider'; +} + +/// See also [publicationSiteDetail]. +class PublicationSiteDetailProvider + extends AutoDisposeFutureProvider { + /// See also [publicationSiteDetail]. + PublicationSiteDetailProvider(String pubName, String siteSlug) + : this._internal( + (ref) => publicationSiteDetail( + ref as PublicationSiteDetailRef, + pubName, + siteSlug, + ), + from: publicationSiteDetailProvider, + name: r'publicationSiteDetailProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$publicationSiteDetailHash, + dependencies: PublicationSiteDetailFamily._dependencies, + allTransitiveDependencies: + PublicationSiteDetailFamily._allTransitiveDependencies, + pubName: pubName, + siteSlug: siteSlug, + ); + + PublicationSiteDetailProvider._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(PublicationSiteDetailRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: PublicationSiteDetailProvider._internal( + (ref) => create(ref as PublicationSiteDetailRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + pubName: pubName, + siteSlug: siteSlug, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _PublicationSiteDetailProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PublicationSiteDetailProvider && + 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 PublicationSiteDetailRef + on AutoDisposeFutureProviderRef { + /// The parameter `pubName` of this provider. + String get pubName; + + /// The parameter `siteSlug` of this provider. + String get siteSlug; +} + +class _PublicationSiteDetailProviderElement + extends AutoDisposeFutureProviderElement + with PublicationSiteDetailRef { + _PublicationSiteDetailProviderElement(super.provider); + + @override + String get pubName => (origin as PublicationSiteDetailProvider).pubName; + @override + String get siteSlug => (origin as PublicationSiteDetailProvider).siteSlug; +} + +// 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_edit.dart b/lib/screens/creators/sites/site_edit.dart index 5640b801..6678419a 100644 --- a/lib/screens/creators/sites/site_edit.dart +++ b/lib/screens/creators/sites/site_edit.dart @@ -2,8 +2,8 @@ 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_detail.dart'; import 'package:island/screens/creators/sites/site_list.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/sheet.dart'; @@ -13,9 +13,128 @@ import 'package:styled_widget/styled_widget.dart'; class SiteForm extends HookConsumerWidget { final String pubName; - final String? siteId; + final String? siteSlug; - const SiteForm({super.key, required this.pubName, this.siteId}); + const SiteForm({super.key, required this.pubName, this.siteSlug}); + + Widget _buildForm( + GlobalKey formKey, + TextEditingController slugController, + TextEditingController nameController, + TextEditingController descriptionController, + ValueNotifier modeController, + Function() saveSite, + Function() deleteSite, + String siteSlug, + ) { + 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, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: modeController.value, + decoration: const InputDecoration( + labelText: 'Mode', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + items: const [ + DropdownMenuItem(value: 0, child: Text('Fully Managed')), + DropdownMenuItem(value: 1, child: Text('Self-Managed')), + ], + onChanged: (value) { + if (value != null) { + modeController.value = value; + } + }, + ), + ], + ).padding(all: 20); + + return SheetScaffold( + titleText: 'Edit Publication Site', + child: Builder( + builder: + (context) => SingleChildScrollView( + child: Column( + children: [ + Form(key: formKey, child: formFields), + Row( + children: [ + TextButton.icon( + onPressed: deleteSite, + icon: const Icon(Symbols.delete_forever), + label: const Text('Delete Publication Site'), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + ).alignment(Alignment.centerRight), + const Spacer(), + TextButton.icon( + onPressed: saveSite, + icon: const Icon(Symbols.save), + label: Text('saveChanges').tr(), + ), + ], + ).padding(horizontal: 20, vertical: 12), + ], + ), + ), + ), + ); + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -42,8 +161,8 @@ class SiteForm extends HookConsumerWidget { 'description': descriptionController.text, }; - if (siteId != null) { - await client.patch('$url/$siteId', data: payload); + if (siteSlug != null) { + await client.patch('$url/$siteSlug', data: payload); } else { await client.post(url, data: payload); } @@ -60,9 +179,11 @@ class SiteForm extends HookConsumerWidget { } finally { isLoading.value = false; } - }, [pubName, siteId, context]); + }, [pubName, siteSlug, context]); final deleteSite = useCallback(() async { + if (siteSlug == null) return; // Shouldn't happen for editing + final confirmed = await showConfirmAlert( 'Are you sure you want to delete this publication site? This action cannot be undone.', 'Delete Publication Site', @@ -73,7 +194,7 @@ class SiteForm extends HookConsumerWidget { try { final client = ref.read(apiClientProvider); - await client.delete('/zone/sites/${siteId!}'); + await client.delete('/zone/sites/$pubName/$siteSlug'); ref.invalidate(siteListNotifierProvider(pubName)); @@ -86,82 +207,62 @@ class SiteForm extends HookConsumerWidget { } finally { isLoading.value = false; } - }, [pubName, siteId, context]); + }, [pubName, siteSlug, context]); - // Handle loading and error states for editing - final isFetchLoading = useState(siteId != null); - final site = useState(null); - final errorMessage = useState(null); + // Use Riverpod provider for loading and error states for editing + if (siteSlug != null) { + final editingSiteSlug = + siteSlug!; // Assert non-null since we checked above + final siteAsync = ref.watch( + publicationSiteDetailProvider(pubName, editingSiteSlug), + ); - useEffect(() { - if (siteId == null) return; - - Future 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 ?? ''; - modeController.value = fetchedSite.mode ?? 0; - } - } catch (e) { - errorMessage.value = e.toString(); - } finally { - isFetchLoading.value = false; + // Initialize form fields when site data is loaded + useEffect(() { + if (siteAsync.value != null && nameController.text.isEmpty) { + final site = siteAsync.value!; + slugController.text = site.slug; + nameController.text = site.name; + descriptionController.text = site.description ?? ''; + modeController.value = site.mode ?? 0; } - } + return null; + }, [siteAsync]); - fetchSite(); - return null; - }, [siteId]); - - if (siteId != null && isFetchLoading.value) { - return const SheetScaffold( - titleText: 'Edit Publication Site', - child: Center(child: CircularProgressIndicator()), + // Handle loading and error states for editing using AsyncValue + return siteAsync.when( + data: + (_) => _buildForm( + formKey, + slugController, + nameController, + descriptionController, + modeController, + saveSite, + deleteSite, + editingSiteSlug, + ), + loading: + () => const SheetScaffold( + titleText: 'Edit Publication Site', + child: Center(child: CircularProgressIndicator()), + ), + error: + (error, _) => SheetScaffold( + titleText: 'Edit Publication Site', + child: ResponseErrorWidget( + error: error.toString(), + onRetry: () { + ref.invalidate( + publicationSiteDetailProvider(pubName, editingSiteSlug), + ); + }, + ), + ), ); } - 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 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 ?? ''; - modeController.value = fetchedSite.mode ?? 0; - } catch (e) { - errorMessage.value = e.toString(); - } finally { - isFetchLoading.value = false; - } - } - - fetchSite(); - return null; - }, ['$siteId-${DateTime.now().millisecondsSinceEpoch}']); - }, - ), - ); - } + // For new sites, directly show the form final formFields = Column( children: [ @@ -247,14 +348,14 @@ class SiteForm extends HookConsumerWidget { return SheetScaffold( titleText: - siteId == null ? 'New Publication Site' : 'Edit Publication Site', + siteSlug == null ? 'New Publication Site' : 'Edit Publication Site', child: SingleChildScrollView( child: Column( children: [ Form(key: formKey, child: formFields), Row( children: [ - if (siteId != null) ...[ + if (siteSlug != null) ...[ TextButton.icon( onPressed: isLoading.value ? null : deleteSite, icon: const Icon(Symbols.delete_forever), diff --git a/lib/screens/creators/sites/site_list.dart b/lib/screens/creators/sites/site_list.dart index 37609055..672745f1 100644 --- a/lib/screens/creators/sites/site_list.dart +++ b/lib/screens/creators/sites/site_list.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/publication_site.dart'; import 'package:island/pods/network.dart'; @@ -159,7 +160,7 @@ class _CreatorSiteItem extends HookConsumerWidget { isScrollControlled: true, builder: (context) => - SiteForm(pubName: pubName, siteId: site.id), + SiteForm(pubName: pubName, siteSlug: site.slug), ); }, ), @@ -219,10 +220,11 @@ class _CreatorSiteItem extends HookConsumerWidget { ], ), onTap: () { - // Open site details or pages - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Site details coming soon'))); + // Navigate to site detail screen + context.pushNamed( + 'creatorSiteDetail', + pathParameters: {'name': pubName, 'siteSlug': site.slug}, + ); }, ), );