feat(front-hall): add link management with form and icon picker
- Create IconPicker widget with searchable grid of Material icons - Create QuickLinkForm with full CRUD support (create, edit, delete) - Update QuickLinkSettingsContent to use real form instead of placeholder - Update QuickLinksPanel to use shared getIconData function - Form includes name, URL, category, icon, type, and active toggle - Validation for required fields and URL format 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
9c9ec472ef
commit
327b4a1233
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/icon_picker.dart';
|
||||
|
||||
/// Form for creating and editing Quick Links.
|
||||
///
|
||||
/// Handles validation, submission, and state management for link data.
|
||||
class QuickLinkForm extends ConsumerStatefulWidget {
|
||||
const QuickLinkForm({
|
||||
super.key,
|
||||
required this.mode,
|
||||
this.quickLink,
|
||||
required this.onCancel,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
final EntityPageMode mode;
|
||||
final QuickLink? quickLink;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
ConsumerState<QuickLinkForm> createState() => _QuickLinkFormState();
|
||||
}
|
||||
|
||||
class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _urlController;
|
||||
late final TextEditingController _categoryController;
|
||||
late String _iconName;
|
||||
late QuickLinkType _type;
|
||||
late bool _isActive;
|
||||
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final link = widget.quickLink;
|
||||
_nameController = TextEditingController(text: link?.name ?? '');
|
||||
_urlController = TextEditingController(text: link?.url ?? '');
|
||||
_categoryController = TextEditingController(text: link?.category ?? '');
|
||||
_iconName = link?.iconName ?? 'link';
|
||||
_type = link?.type ?? QuickLinkType.iframe;
|
||||
_isActive = link?.isActive ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_urlController.dispose();
|
||||
_categoryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get isCreate => widget.mode == EntityPageMode.create;
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
try {
|
||||
final actions = ref.read(quickLinkActionsProvider.notifier);
|
||||
|
||||
// Generate ID for new links
|
||||
final id = widget.quickLink?.id ??
|
||||
_nameController.text.toLowerCase().replaceAll(RegExp(r'\s+'), '-');
|
||||
|
||||
final link = QuickLink(
|
||||
id: id,
|
||||
name: _nameController.text.trim(),
|
||||
url: _urlController.text.trim(),
|
||||
iconName: _iconName,
|
||||
category:
|
||||
_categoryController.text.trim().isEmpty ? null : _categoryController.text.trim(),
|
||||
type: _type,
|
||||
isActive: _isActive,
|
||||
sortOrder: widget.quickLink?.sortOrder ?? 0,
|
||||
);
|
||||
|
||||
if (isCreate) {
|
||||
await actions.create(link);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Link created successfully'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await actions.update(link);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Link updated successfully'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
widget.onSaved();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleDelete() async {
|
||||
if (widget.quickLink == null) return;
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Link'),
|
||||
content: Text('Are you sure you want to delete "${widget.quickLink!.name}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
await ref.read(quickLinkActionsProvider.notifier).delete(widget.quickLink!.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Link deleted'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
widget.onSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error deleting: $e'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
// Name field
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name',
|
||||
hintText: 'Enter link name',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Name is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// URL field
|
||||
TextFormField(
|
||||
controller: _urlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL',
|
||||
hintText: 'https://example.com',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'URL is required';
|
||||
}
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || !uri.hasScheme) {
|
||||
return 'Enter a valid URL with scheme (http/https)';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Category field
|
||||
TextFormField(
|
||||
controller: _categoryController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Category',
|
||||
hintText: 'e.g., Home, Infrastructure, Coding',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Icon picker
|
||||
IconPicker(
|
||||
selectedIcon: _iconName,
|
||||
onChanged: (icon) => setState(() => _iconName = icon),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Link type
|
||||
Text(
|
||||
'Link Type',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<QuickLinkType>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: QuickLinkType.iframe,
|
||||
icon: Icon(Icons.web),
|
||||
label: Text('Iframe'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: QuickLinkType.newTab,
|
||||
icon: Icon(Icons.open_in_new),
|
||||
label: Text('New Tab'),
|
||||
),
|
||||
],
|
||||
selected: {_type},
|
||||
onSelectionChanged: (selected) {
|
||||
setState(() => _type = selected.first);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_type == QuickLinkType.iframe
|
||||
? 'Opens embedded in the dashboard'
|
||||
: 'Opens in a new browser tab',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Active toggle
|
||||
SwitchListTile(
|
||||
title: const Text('Active'),
|
||||
subtitle: const Text('Show this link in the panel'),
|
||||
value: _isActive,
|
||||
onChanged: (value) => setState(() => _isActive = value),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
if (!isCreate) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isSubmitting ? null : _handleDelete,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.error,
|
||||
),
|
||||
icon: const Icon(Icons.delete),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
const Spacer(),
|
||||
] else
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _isSubmitting ? null : widget.onCancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _isSubmitting ? null : _handleSubmit,
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(isCreate ? 'Create' : 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,62 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/widgets/quick_link_form.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
|
||||
|
||||
/// Settings content for Quick Link management.
|
||||
///
|
||||
/// Shows either:
|
||||
/// - Link editor form (when a link is selected)
|
||||
/// - Create form (when isCreating state is set)
|
||||
/// - Edit form (when a link is selected)
|
||||
/// - Empty state with "Add New Link" prompt (when no link selected)
|
||||
///
|
||||
/// Full implementation in Phase 4.
|
||||
class QuickLinkSettingsContent extends ConsumerWidget {
|
||||
class QuickLinkSettingsContent extends ConsumerStatefulWidget {
|
||||
const QuickLinkSettingsContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<QuickLinkSettingsContent> createState() =>
|
||||
_QuickLinkSettingsContentState();
|
||||
}
|
||||
|
||||
class _QuickLinkSettingsContentState
|
||||
extends ConsumerState<QuickLinkSettingsContent> {
|
||||
bool _isCreating = false;
|
||||
|
||||
void _startCreating() {
|
||||
setState(() => _isCreating = true);
|
||||
// Clear any selected link when creating new
|
||||
ref.read(frontHallStateProvider.notifier).clearSelectedLink();
|
||||
}
|
||||
|
||||
void _stopCreating() {
|
||||
setState(() => _isCreating = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final frontHallState = ref.watch(frontHallStateProvider);
|
||||
final selectedLinkId = frontHallState.selectedLinkId;
|
||||
|
||||
// Create mode - show create form
|
||||
if (_isCreating) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: _stopCreating,
|
||||
),
|
||||
title: const Text('Create New Link'),
|
||||
),
|
||||
body: QuickLinkForm(
|
||||
mode: EntityPageMode.create,
|
||||
onCancel: _stopCreating,
|
||||
onSaved: _stopCreating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// No link selected - show empty state
|
||||
if (selectedLinkId == null) {
|
||||
return Center(
|
||||
@@ -42,7 +80,7 @@ class QuickLinkSettingsContent extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select a link from the panel to edit, or click "Add New Link" to create one.',
|
||||
'Select a link from the panel to edit, or create a new one.',
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.outline,
|
||||
@@ -50,15 +88,7 @@ class QuickLinkSettingsContent extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Create new link - will show form in Phase 4
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Link editor coming in Phase 4'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
onPressed: _startCreating,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Create New Link'),
|
||||
),
|
||||
@@ -68,7 +98,7 @@ class QuickLinkSettingsContent extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Link selected - show placeholder for editor (Phase 4)
|
||||
// Link selected - show edit form
|
||||
final linkAsync = ref.watch(quickLinkProvider(selectedLinkId));
|
||||
|
||||
return linkAsync.when(
|
||||
@@ -82,7 +112,8 @@ class QuickLinkSettingsContent extends ConsumerWidget {
|
||||
Text('Error loading link: $error'),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: () => ref.invalidate(quickLinkProvider(selectedLinkId)),
|
||||
onPressed: () =>
|
||||
ref.invalidate(quickLinkProvider(selectedLinkId)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
@@ -98,110 +129,18 @@ class QuickLinkSettingsContent extends ConsumerWidget {
|
||||
.clearSelectedLink(),
|
||||
),
|
||||
title: Text('Edit: ${link.name}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Save link - Phase 4
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Save functionality coming in Phase 4'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Placeholder form fields (Phase 4)
|
||||
_PlaceholderField(label: 'Name', value: link.name),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(label: 'URL', value: link.url),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(
|
||||
label: 'Category',
|
||||
value: link.category ?? 'None',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(
|
||||
label: 'Type',
|
||||
value: link.isIframe ? 'Iframe' : 'New Tab',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_PlaceholderField(label: 'Icon', value: link.iconName),
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Full link editor form will be implemented in Phase 4.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: QuickLinkForm(
|
||||
mode: EntityPageMode.edit,
|
||||
quickLink: link,
|
||||
onCancel: () => ref
|
||||
.read(frontHallStateProvider.notifier)
|
||||
.clearSelectedLink(),
|
||||
onSaved: () => ref
|
||||
.read(frontHallStateProvider.notifier)
|
||||
.clearSelectedLink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceholderField extends StatelessWidget {
|
||||
const _PlaceholderField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/front_hall_state_provider.dart';
|
||||
import 'package:tatlock_ui/features/front_hall/presentation/providers/quick_links_provider.dart';
|
||||
import 'package:tatlock_ui/shared/layouts/widgets/panel_header.dart';
|
||||
import 'package:tatlock_ui/shared/widgets/icon_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Left-side panel for quick link navigation in Front Hall.
|
||||
@@ -332,31 +333,6 @@ class _QuickLinkTile extends StatelessWidget {
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
IconData _getIconData(String iconName) {
|
||||
// Map common icon names to Material icons
|
||||
return switch (iconName.toLowerCase()) {
|
||||
'home' => Icons.home,
|
||||
'movie' => Icons.movie,
|
||||
'chat' => Icons.chat,
|
||||
'search' => Icons.search,
|
||||
'videogame_asset' => Icons.videogame_asset,
|
||||
'code' => Icons.code,
|
||||
'terminal' => Icons.terminal,
|
||||
'monitoring' => Icons.show_chart,
|
||||
'dns' => Icons.dns,
|
||||
'public' => Icons.public,
|
||||
'api' => Icons.api,
|
||||
'settings' => Icons.settings,
|
||||
'storage' => Icons.storage,
|
||||
'cloud' => Icons.cloud,
|
||||
'security' => Icons.security,
|
||||
'person' => Icons.person,
|
||||
'folder' => Icons.folder,
|
||||
'link' => Icons.link,
|
||||
_ => Icons.link,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -377,7 +353,7 @@ class _QuickLinkTile extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getIconData(link.iconName),
|
||||
getIconData(link.iconName),
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
|
||||
Reference in New Issue
Block a user