From 580663dcdac25d8c809c70e04aecb2fb1b367c88 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 12 Dec 2025 00:11:52 +0800 Subject: [PATCH] :sparkles: Post page form --- lib/pods/site_pages.dart | 14 +- lib/widgets/sites/page_form.dart | 452 +++++++++++++++++++++++-------- lib/widgets/sites/page_item.dart | 88 +++--- 3 files changed, 381 insertions(+), 173 deletions(-) diff --git a/lib/pods/site_pages.dart b/lib/pods/site_pages.dart index 129d141d..57649c7c 100644 --- a/lib/pods/site_pages.dart +++ b/lib/pods/site_pages.dart @@ -95,9 +95,6 @@ class SitePagesNotifier extends AsyncNotifier> { try { final apiClient = ref.read(apiClientProvider); await apiClient.delete('/zone/sites/pages/$pageId'); - - // Refresh the pages list - ref.invalidate(sitePagesProvider(arg.pubName, arg.siteSlug)); } catch (error, stackTrace) { state = AsyncValue.error(error, stackTrace); rethrow; @@ -105,8 +102,9 @@ class SitePagesNotifier extends AsyncNotifier> { } } -final sitePagesNotifierProvider = AsyncNotifierProvider.autoDispose.family< - SitePagesNotifier, - List, - ({String pubName, String siteSlug}) ->(SitePagesNotifier.new); +final sitePagesNotifierProvider = AsyncNotifierProvider.autoDispose + .family< + SitePagesNotifier, + List, + ({String pubName, String siteSlug}) + >(SitePagesNotifier.new); diff --git a/lib/widgets/sites/page_form.dart b/lib/widgets/sites/page_form.dart index c8db18bc..c0037ef0 100644 --- a/lib/widgets/sites/page_form.dart +++ b/lib/widgets/sites/page_form.dart @@ -24,6 +24,10 @@ class PageForm extends HookConsumerWidget { int _getPageType(SnPublicationPage? page) { if (page == null) return 0; // Default to HTML // Check config structure to determine type + if (page.config?.containsKey('filter') == true || + page.config?.containsKey('layout') == true) { + return 2; // Post Page + } return page.config?.containsKey('target') == true ? 1 : 0; } @@ -48,6 +52,42 @@ class PageForm extends HookConsumerWidget { text: pageType.value == 1 ? (page?.config?['target'] ?? '') : '', ); + // Post Page Controllers + final filterPubNameController = useTextEditingController( + text: pageType.value == 2 + ? (page?.config?['filter']?['pub_name'] ?? '') + : '', + ); + final filterOrderByController = useTextEditingController( + text: pageType.value == 2 + ? (page?.config?['filter']?['order_by'] ?? '') + : '', + ); + final filterOrderDesc = useState( + pageType.value == 2 + ? (page?.config?['filter']?['order_desc'] ?? true) + : true, + ); + final filterTypes = useState>( + (page?.config?['filter']?['types'] as List?)?.cast() ?? [0], + ); + + final layoutTitleController = useTextEditingController( + text: pageType.value == 2 + ? (page?.config?['layout']?['title'] ?? '') + : '', + ); + final layoutDescriptionController = useTextEditingController( + text: pageType.value == 2 + ? (page?.config?['layout']?['description'] ?? '') + : '', + ); + final layoutShowPub = useState( + pageType.value == 2 + ? (page?.config?['layout']?['show_pub'] ?? true) + : true, + ); + final isLoading = useState(false); // Update controllers when page type changes @@ -59,11 +99,36 @@ class PageForm extends HookConsumerWidget { page?.config?['html'] ?? page?.config?['content'] ?? ''; titleController.text = page?.config?['title'] ?? ''; targetController.clear(); - } else { + filterPubNameController.clear(); + filterOrderByController.clear(); + layoutTitleController.clear(); + layoutDescriptionController.clear(); + } else if (pageType.value == 1) { // Redirect mode htmlController.clear(); titleController.clear(); targetController.text = page?.config?['target'] ?? ''; + filterPubNameController.clear(); + filterOrderByController.clear(); + layoutTitleController.clear(); + layoutDescriptionController.clear(); + } else if (pageType.value == 2) { + // Post Page mode + htmlController.clear(); + titleController.clear(); + targetController.clear(); + filterPubNameController.text = + page?.config?['filter']?['pub_name'] ?? ''; + filterOrderByController.text = + page?.config?['filter']?['order_by'] ?? ''; + filterOrderDesc.value = + page?.config?['filter']?['order_desc'] ?? true; + filterTypes.value = + (page?.config?['filter']?['types'] as List?)?.cast() ?? [0]; + layoutTitleController.text = page?.config?['layout']?['title'] ?? ''; + layoutDescriptionController.text = + page?.config?['layout']?['description'] ?? ''; + layoutShowPub.value = page?.config?['layout']?['show_pub'] ?? true; } }); return null; @@ -77,69 +142,116 @@ class PageForm extends HookConsumerWidget { htmlController.text = page!.config?['html'] ?? page!.config?['content'] ?? ''; titleController.text = page!.config?['title'] ?? ''; - } else { + } else if (pageType.value == 1) { targetController.text = page!.config?['target'] ?? ''; + } else if (pageType.value == 2) { + filterPubNameController.text = + page!.config?['filter']?['pub_name'] ?? ''; + filterOrderByController.text = + page!.config?['filter']?['order_by'] ?? ''; + filterOrderDesc.value = + page!.config?['filter']?['order_desc'] ?? true; + filterTypes.value = + (page!.config?['filter']?['types'] as List?)?.cast() ?? [0]; + layoutTitleController.text = page!.config?['layout']?['title'] ?? ''; + layoutDescriptionController.text = + page!.config?['layout']?['description'] ?? ''; + layoutShowPub.value = page!.config?['layout']?['show_pub'] ?? true; } } return null; }, [page]); - final savePage = useCallback(() async { - if (!formKey.currentState!.validate()) return; + final savePage = useCallback( + () async { + if (!formKey.currentState!.validate()) return; - isLoading.value = true; + 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) { - showSnackBar( - page == null - ? 'Page created successfully' - : 'Page updated successfully', + try { + final pagesNotifier = ref.read( + sitePagesNotifierProvider(( + pubName: pubName, + siteSlug: site.slug, + )).notifier, ); - Navigator.pop(context); + + late final Map pageData; + + if (pageType.value == 0) { + // HTML page + pageData = { + 'type': 0, + 'path': pathController.text, + 'config': { + 'title': titleController.text, + 'html': htmlController.text, + }, + }; + } else if (pageType.value == 1) { + // Redirect page + pageData = { + 'type': 1, + 'path': pathController.text, + 'config': {'target': targetController.text}, + }; + } else { + // Post Page + pageData = { + 'type': 2, + 'path': pathController.text, + 'config': { + 'filter': { + if (filterPubNameController.text.isNotEmpty) + 'pub_name': filterPubNameController.text, + if (filterOrderByController.text.isNotEmpty) + 'order_by': filterOrderByController.text, + 'order_desc': filterOrderDesc.value, + 'types': filterTypes.value, + }, + 'layout': { + if (layoutTitleController.text.isNotEmpty) + 'title': layoutTitleController.text, + if (layoutDescriptionController.text.isNotEmpty) + 'description': layoutDescriptionController.text, + 'show_pub': layoutShowPub.value, + }, + }, + }; + } + + if (page == null) { + // Create new page + await pagesNotifier.createPage(pageData); + } else { + // Update existing page + await pagesNotifier.updatePage(page!.id, pageData); + } + + if (context.mounted) { + showSnackBar( + page == null + ? 'Page created successfully' + : 'Page updated successfully', + ); + Navigator.pop(context); + } + } catch (e) { + showErrorAlert(e); + } finally { + isLoading.value = false; } - } catch (e) { - showErrorAlert(e); - } finally { - isLoading.value = false; - } - }, [pageType, pubName, site.slug, page]); + }, + [ + pageType, + pubName, + site.slug, + page, + filterOrderDesc.value, + filterTypes.value, + layoutShowPub.value, + ], + ); final deletePage = useCallback(() async { if (page == null) return; // Shouldn't happen for editing @@ -225,6 +337,16 @@ class PageForm extends HookConsumerWidget { ], ), ), + DropdownMenuItem( + value: 2, + child: Row( + children: [ + Icon(Symbols.article, size: 20), + Gap(8), + Text('Post Page'), + ], + ), + ), ], onChanged: (value) { if (value != null) { @@ -238,37 +360,40 @@ class PageForm extends HookConsumerWidget { return null; }, ).padding(all: 20), + + // Common "Path" field for all types + TextFormField( + controller: pathController, + decoration: const InputDecoration( + labelText: 'Page Path', + hintText: '/about, /posts, etc.', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a page path'; + } + if (!RegExp(r'^[a-zA-Z0-9\-/_]+$').hasMatch(value)) { + return 'Page path can only contain letters, numbers, hyphens, underscores, and slashes'; + } + if (!value.startsWith('/')) { + return 'Page path must start with /'; + } + if (value.contains('//')) { + return 'Page path cannot have consecutive slashes'; + } + return null; + }, + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 20), + const SizedBox(height: 16), + // Conditional form fields based on page type if (pageType.value == 0) ...[ // HTML Page fields - TextFormField( - controller: 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( @@ -309,36 +434,8 @@ class PageForm extends HookConsumerWidget { return null; }, ).padding(horizontal: 20), - ] else ...[ + ] else if (pageType.value == 1) ...[ // 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)), - ), - ), - 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( @@ -353,7 +450,9 @@ class PageForm extends HookConsumerWidget { return 'Please enter a redirect target'; } if (!value.startsWith('/') && + // ignore: use_string_starts_with_pattern !value.startsWith('http://') && + // ignore: use_string_starts_with_pattern !value.startsWith('https://')) { return 'Target must be a relative path (/) or absolute URL (http/https)'; } @@ -362,7 +461,138 @@ class PageForm extends HookConsumerWidget { onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ).padding(horizontal: 20), + ] else if (pageType.value == 2) ...[ + // Post Page fields + const Text( + 'Filter Settings', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).alignment(Alignment.centerLeft).padding(horizontal: 24), + const Gap(8), + TextFormField( + controller: filterPubNameController, + decoration: const InputDecoration( + labelText: 'Publication Name (Optional)', + hintText: 'Filter by publication name', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 20), + const Gap(16), + TextFormField( + controller: filterOrderByController, + decoration: const InputDecoration( + labelText: 'Order By (Optional)', + hintText: 'e.g. published_at', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 20), + const Gap(8), + SwitchListTile( + value: filterOrderDesc.value, + onChanged: (value) => filterOrderDesc.value = value, + title: const Text('Order Descending'), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + ), + const Gap(8), + const Text( + 'Content Types', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ).alignment(Alignment.centerLeft).padding(horizontal: 24), + const Gap(4), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.start, + runAlignment: WrapAlignment.start, + children: [ + FilterChip( + label: const Text('Regular Post'), + selected: filterTypes.value.contains(0), + onSelected: (selected) { + final types = [...filterTypes.value]; + if (selected) { + types.add(0); + } else { + types.remove(0); + } + filterTypes.value = types; + }, + ), + FilterChip( + label: const Text('Article'), + selected: filterTypes.value.contains(1), + onSelected: (selected) { + final types = [...filterTypes.value]; + if (selected) { + types.add(1); + } else { + types.remove(1); + } + filterTypes.value = types; + }, + ), + ], + ).padding(horizontal: 20), + const Gap(24), + const Text( + 'Layout Settings', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).alignment(Alignment.centerLeft).padding(horizontal: 24), + const Gap(8), + TextFormField( + controller: layoutTitleController, + decoration: const InputDecoration( + labelText: 'Title (Optional)', + hintText: 'Page Title', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 20), + const Gap(16), + TextFormField( + controller: layoutDescriptionController, + decoration: const InputDecoration( + labelText: 'Description (Optional)', + hintText: 'Page Description', + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ).padding(horizontal: 20), + const Gap(8), + SwitchListTile( + value: layoutShowPub.value, + onChanged: (value) => layoutShowPub.value = value, + title: const Text('Show Publication Info'), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + ), ], + Row( children: [ if (page != null) ...[ diff --git a/lib/widgets/sites/page_item.dart b/lib/widgets/sites/page_item.dart index 902b30a0..7ff4d33b 100644 --- a/lib/widgets/sites/page_item.dart +++ b/lib/widgets/sites/page_item.dart @@ -37,29 +37,28 @@ class PageItem extends HookConsumerWidget { 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), - ], - ), - ), - ], + 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': @@ -67,46 +66,27 @@ class PageItem extends HookConsumerWidget { showModalBottomSheet( context: context, isScrollControlled: true, - builder: - (context) => - PageForm(site: site, pubName: pubName, page: page), + 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'), - ), - ], - ), + final confirmed = await showConfirmAlert( + 'Are you sure you want to delete this page?', + 'Delete the Page', ); - if (confirmed == true) { + if (confirmed) { try { - await ref - .read( - sitePagesNotifierProvider(( - pubName: pubName, - siteSlug: site.slug, - )).notifier, - ) - .deletePage(page.id); + final provider = sitePagesNotifierProvider(( + pubName: pubName, + siteSlug: site.slug, + )); + await ref.read(provider.notifier).deletePage(page.id); + ref.invalidate(provider); showSnackBar('Page deleted successfully'); } catch (e) { showErrorAlert(e);