💄 Optimize publication site screen
This commit is contained in:
@@ -17,6 +17,7 @@ import 'package:island/services/responsive.dart';
|
|||||||
import 'package:island/services/time.dart';
|
import 'package:island/services/time.dart';
|
||||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
part 'site_detail.g.dart';
|
part 'site_detail.g.dart';
|
||||||
|
|
||||||
@@ -83,7 +84,8 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
PagesSection(site: site, pubName: pubName),
|
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,
|
monospace: true,
|
||||||
),
|
),
|
||||||
const Gap(8),
|
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(
|
InfoRow(
|
||||||
label: 'Mode',
|
label: 'Mode',
|
||||||
value:
|
value:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/publication_site.dart';
|
import 'package:island/models/publication_site.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
@@ -81,6 +82,7 @@ class CreatorSiteListScreen extends HookConsumerWidget {
|
|||||||
onRefresh: () => ref.refresh(siteListNotifierProvider(pubName).future),
|
onRefresh: () => ref.refresh(siteListNotifierProvider(pubName).future),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
|
const SliverGap(8),
|
||||||
PagingHelperSliverView(
|
PagingHelperSliverView(
|
||||||
provider: siteListNotifierProvider(pubName),
|
provider: siteListNotifierProvider(pubName),
|
||||||
futureRefreshable: siteListNotifierProvider(pubName).future,
|
futureRefreshable: siteListNotifierProvider(pubName).future,
|
||||||
@@ -115,110 +117,10 @@ class _CreatorSiteItem extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: ListTile(
|
child: InkWell(
|
||||||
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, 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<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: () {
|
onTap: () {
|
||||||
// Navigate to site detail screen
|
// Navigate to site detail screen
|
||||||
context.pushNamed(
|
context.pushNamed(
|
||||||
@@ -226,6 +128,126 @@ class _CreatorSiteItem extends HookConsumerWidget {
|
|||||||
pathParameters: {'name': pubName, 'siteSlug': site.slug},
|
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<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,
|
||||||
|
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<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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class InfoRow extends StatelessWidget {
|
|||||||
final String value;
|
final String value;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final bool monospace;
|
final bool monospace;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const InfoRow({
|
const InfoRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -14,10 +15,22 @@ class InfoRow extends StatelessWidget {
|
|||||||
required this.value,
|
required this.value,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
this.monospace = false,
|
this.monospace = false,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -33,17 +46,7 @@ class InfoRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Gap(12),
|
const Gap(12),
|
||||||
Expanded(
|
Expanded(flex: 3, child: valueWidget),
|
||||||
flex: 3,
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style:
|
|
||||||
monospace
|
|
||||||
? GoogleFonts.robotoMono(fontSize: 14)
|
|
||||||
: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,237 +203,207 @@ class PageForm extends HookConsumerWidget {
|
|||||||
|
|
||||||
return SheetScaffold(
|
return SheetScaffold(
|
||||||
titleText: page == null ? 'Create Page' : 'Edit Page',
|
titleText: page == null ? 'Create Page' : 'Edit Page',
|
||||||
child: Builder(
|
child: SingleChildScrollView(
|
||||||
builder:
|
child: Column(
|
||||||
(context) => SingleChildScrollView(
|
children: [
|
||||||
|
Form(
|
||||||
|
key: formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Form(
|
// Page type selector
|
||||||
key: formKey,
|
DropdownButtonFormField<int>(
|
||||||
child: Column(
|
value: pageType.value,
|
||||||
children: [
|
decoration: const InputDecoration(
|
||||||
// Page type selector
|
labelText: 'Page Type',
|
||||||
DropdownButtonFormField<int>(
|
border: OutlineInputBorder(
|
||||||
value: pageType.value,
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
decoration: const InputDecoration(
|
),
|
||||||
labelText: 'Page Type',
|
),
|
||||||
border: OutlineInputBorder(
|
items: const [
|
||||||
borderRadius: BorderRadius.all(
|
DropdownMenuItem(
|
||||||
Radius.circular(12),
|
value: 0,
|
||||||
),
|
child: Row(
|
||||||
),
|
children: [
|
||||||
),
|
Icon(Symbols.code, size: 20),
|
||||||
items: const [
|
Gap(8),
|
||||||
DropdownMenuItem(
|
Text('HTML Page'),
|
||||||
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;
|
DropdownMenuItem(
|
||||||
}
|
value: 1,
|
||||||
},
|
child: Row(
|
||||||
validator: (value) {
|
children: [
|
||||||
if (value == null) {
|
Icon(Symbols.link, size: 20),
|
||||||
return 'Please select a page type';
|
Gap(8),
|
||||||
}
|
Text('Redirect Page'),
|
||||||
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:
|
|
||||||
'<h1>Hello World</h1><p>This is my page content...</p>',
|
|
||||||
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),
|
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:
|
||||||
|
'<h1>Hello World</h1><p>This is my page content...</p>',
|
||||||
|
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),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user