💄 Optimize publication site screen

This commit is contained in:
2025-11-22 14:39:03 +08:00
parent 4fb739b33b
commit 9b85b7573c
4 changed files with 345 additions and 336 deletions

View File

@@ -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:

View File

@@ -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<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')),
);
}
}
}
},
),
],
),
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<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'),
),
);
}
}
}
},
),
],
),
],
),
),
),
);
}

View File

@@ -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),
],
);
}

View File

@@ -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<int>(
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<int>(
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:
'<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'),
),
),
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:
'<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),
],
],
),
),
],
),
),
);
}