diff --git a/lib/screens/creators/sites/site_detail.dart b/lib/screens/creators/sites/site_detail.dart index 6ad5105b..80048e55 100644 --- a/lib/screens/creators/sites/site_detail.dart +++ b/lib/screens/creators/sites/site_detail.dart @@ -17,6 +17,7 @@ import 'package:island/services/responsive.dart'; import 'package:island/services/time.dart'; import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:url_launcher/url_launcher_string.dart'; part 'site_detail.g.dart'; @@ -83,7 +84,8 @@ class PublicationSiteDetailScreen extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ PagesSection(site: site, pubName: pubName), - FileManagementSection(site: site, pubName: pubName), + if (site.mode == 1) // Self-Managed only + FileManagementSection(site: site, pubName: pubName), ], ), ), @@ -118,6 +120,18 @@ class PublicationSiteDetailScreen extends HookConsumerWidget { monospace: true, ), const Gap(8), + InfoRow( + label: 'Domain', + value: '${site.slug}.solian.page', + icon: Symbols.globe, + monospace: true, + onTap: () { + final url = + 'https://${site.slug}.solian.page'; + launchUrlString(url); + }, + ), + const Gap(8), InfoRow( label: 'Mode', value: diff --git a/lib/screens/creators/sites/site_list.dart b/lib/screens/creators/sites/site_list.dart index 672745f1..b1331edd 100644 --- a/lib/screens/creators/sites/site_list.dart +++ b/lib/screens/creators/sites/site_list.dart @@ -2,6 +2,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: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'; @@ -81,6 +82,7 @@ class CreatorSiteListScreen extends HookConsumerWidget { onRefresh: () => ref.refresh(siteListNotifierProvider(pubName).future), child: CustomScrollView( slivers: [ + const SliverGap(8), PagingHelperSliverView( provider: siteListNotifierProvider(pubName), futureRefreshable: siteListNotifierProvider(pubName).future, @@ -115,110 +117,10 @@ class _CreatorSiteItem extends HookConsumerWidget { @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( - 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, siteSlug: site.slug), - ); - }, - ), - 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( - 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')), - ); - } - } - } - }, - ), - ], - ), + child: InkWell( onTap: () { // Navigate to site detail screen context.pushNamed( @@ -226,6 +128,126 @@ class _CreatorSiteItem extends HookConsumerWidget { pathParameters: {'name': pubName, 'siteSlug': site.slug}, ); }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + spacing: 2, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.globe, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + const Gap(6), + Text(site.name).bold(), + ], + ), + if (site.description != null && + site.description!.isNotEmpty) + Text( + site.description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Divider(height: 8), + Text( + '${site.slug}.solian.page', + style: GoogleFonts.robotoMono(fontSize: 11), + ).opacity(0.8), + ], + ), + ), + PopupMenuButton( + 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, + siteSlug: site.slug, + ), + ); + }, + ), + 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( + 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'), + ), + ); + } + } + } + }, + ), + ], + ), + ], + ), + ), ), ); } diff --git a/lib/widgets/sites/info_row.dart b/lib/widgets/sites/info_row.dart index 3ca198e3..d2097bdf 100644 --- a/lib/widgets/sites/info_row.dart +++ b/lib/widgets/sites/info_row.dart @@ -7,6 +7,7 @@ class InfoRow extends StatelessWidget { final String value; final IconData icon; final bool monospace; + final VoidCallback? onTap; const InfoRow({ super.key, @@ -14,10 +15,22 @@ class InfoRow extends StatelessWidget { required this.value, required this.icon, this.monospace = false, + this.onTap, }); @override Widget build(BuildContext context) { + Widget valueWidget = Text( + value, + style: + monospace + ? GoogleFonts.robotoMono(fontSize: 14) + : Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.end, + ); + + if (onTap != null) valueWidget = InkWell(onTap: onTap, child: valueWidget); + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -33,17 +46,7 @@ class InfoRow extends StatelessWidget { ), ), const Gap(12), - Expanded( - flex: 3, - child: Text( - value, - style: - monospace - ? GoogleFonts.robotoMono(fontSize: 14) - : Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.end, - ), - ), + Expanded(flex: 3, child: valueWidget), ], ); } diff --git a/lib/widgets/sites/page_form.dart b/lib/widgets/sites/page_form.dart index 349fdba6..aabff7b1 100644 --- a/lib/widgets/sites/page_form.dart +++ b/lib/widgets/sites/page_form.dart @@ -203,237 +203,207 @@ class PageForm extends HookConsumerWidget { return SheetScaffold( titleText: page == null ? 'Create Page' : 'Edit Page', - child: Builder( - builder: - (context) => SingleChildScrollView( + child: SingleChildScrollView( + child: Column( + children: [ + Form( + key: formKey, 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'), - ], - ), - ), + // 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'), ], - 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'), + ), + ), + DropdownMenuItem( + value: 1, + child: Row( + children: [ + Icon(Symbols.link, size: 20), + Gap(8), + Text('Redirect Page'), + ], + ), ), ], - ).padding(horizontal: 20, vertical: 12), + 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)), + ), + ), + 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), + Row( + children: [ + if (page != null) ...[ + TextButton.icon( + onPressed: deletePage, + icon: const Icon(Symbols.delete_forever), + label: const Text('Delete Page'), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + ).alignment(Alignment.centerRight), + const Spacer(), + ] else + const Spacer(), + TextButton.icon( + onPressed: savePage, + icon: const Icon(Symbols.save), + label: const Text('Save Page'), + ), + ], + ).padding(horizontal: 20, vertical: 16), + ], ], ), ), + ], + ), ), ); }