From 3a57f4265b6648a4155f8101d3718ebc5b523212 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 16 Jan 2026 01:30:46 +0800 Subject: [PATCH] :sparkles: Editing article thumbnail --- assets/i18n/en-US.json | 4 +- lib/screens/posts/compose_article.dart | 112 +------------- lib/widgets/content/attachment_preview.dart | 55 +++++++ lib/widgets/post/compose_attachments.dart | 162 +++++++++++--------- lib/widgets/post/compose_shared.dart | 27 +++- 5 files changed, 174 insertions(+), 186 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index f8a98207..954c9cc5 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1592,5 +1592,7 @@ "tasksCount": { "one": "{} task", "other": "{} tasks" - } + }, + "setAsThumbnail": "Set as thumbnail", + "unsetAsThumbnail": "Unset as thumbnail" } diff --git a/lib/screens/posts/compose_article.dart b/lib/screens/posts/compose_article.dart index 06b533cc..fa1e6f6a 100644 --- a/lib/screens/posts/compose_article.dart +++ b/lib/screens/posts/compose_article.dart @@ -13,12 +13,11 @@ import 'package:island/screens/posts/post_detail.dart'; import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; -import 'package:island/widgets/attachment_uploader.dart'; -import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/post/compose_form_fields.dart'; import 'package:island/widgets/post/compose_shared.dart'; +import 'package:island/widgets/post/compose_attachments.dart'; import 'package:island/widgets/post/compose_settings_sheet.dart'; import 'package:island/widgets/post/compose_toolbar.dart'; import 'package:island/widgets/post/publishers_modal.dart'; @@ -88,9 +87,6 @@ class ArticleComposeScreen extends HookConsumerWidget { }, [state]); final showPreview = useState(false); - final isAttachmentsExpanded = useState( - true, - ); // New state for attachments section // Initialize publisher once when data is available useEffect(() { @@ -274,111 +270,7 @@ class ArticleComposeScreen extends HookConsumerWidget { ), // Attachments preview - ValueListenableBuilder>( - valueListenable: state.attachments, - builder: (context, attachments, _) { - if (attachments.isEmpty) return const SizedBox.shrink(); - return Theme( - data: Theme.of( - context, - ).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - initiallyExpanded: isAttachmentsExpanded.value, - onExpansionChanged: (expanded) { - isAttachmentsExpanded.value = expanded; - }, - collapsedBackgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainer, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('attachments').tr(), - Text( - 'articleAttachmentHint'.tr(), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - children: [ - ValueListenableBuilder>( - valueListenable: state.attachmentProgress, - builder: (context, progressMap, _) { - return Wrap( - runSpacing: 8, - spacing: 8, - children: [ - for ( - var idx = 0; - idx < attachments.length; - idx++ - ) - SizedBox( - width: 180, - height: 180, - child: AttachmentPreview( - isCompact: true, - item: attachments[idx], - progress: progressMap[idx], - isUploading: progressMap.containsKey(idx), - onRequestUpload: () async { - final config = - await showModalBottomSheet< - AttachmentUploadConfig - >( - context: context, - isScrollControlled: true, - builder: (context) => - AttachmentUploaderSheet( - ref: ref, - state: state, - index: idx, - ), - ); - if (config != null) { - await ComposeLogic.uploadAttachment( - ref, - state, - idx, - poolId: config.poolId, - ); - } - }, - onUpdate: (value) => - ComposeLogic.updateAttachment( - state, - value, - idx, - ), - onDelete: () => - ComposeLogic.deleteAttachment( - ref, - state, - idx, - ), - onInsert: () => - ComposeLogic.insertAttachment( - ref, - state, - idx, - ), - ), - ), - ], - ); - }, - ), - Gap(16), - ], - ), - ); - }, - ), + ArticleComposeAttachments(state: state), ], ), ), diff --git a/lib/widgets/content/attachment_preview.dart b/lib/widgets/content/attachment_preview.dart index aafabef8..ce635d3e 100644 --- a/lib/widgets/content/attachment_preview.dart +++ b/lib/widgets/content/attachment_preview.dart @@ -93,6 +93,8 @@ class AttachmentPreview extends HookConsumerWidget { final Function(UniversalFile)? onUpdate; final Function? onRequestUpload; final bool isCompact; + final String? thumbnailId; + final Function(String?)? onSetThumbnail; const AttachmentPreview({ super.key, @@ -105,6 +107,8 @@ class AttachmentPreview extends HookConsumerWidget { this.onUpdate, this.onInsert, this.isCompact = false, + this.thumbnailId, + this.onSetThumbnail, }); // GlobalKey for selector @@ -453,6 +457,39 @@ class AttachmentPreview extends HookConsumerWidget { ), ), ), + if (thumbnailId != null && + item.isOnCloud && + (item.data as SnCloudFile).id == thumbnailId) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + if (thumbnailId != null && + item.isOnCloud && + (item.data as SnCloudFile).id == thumbnailId) + Positioned( + top: 8, + right: 8, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Icon( + Symbols.image, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), ], ); @@ -641,6 +678,24 @@ class AttachmentPreview extends HookConsumerWidget { await _showSensitiveDialog(context, ref); }, ), + if (item.isOnCloud && + item.type == UniversalFileType.image && + onSetThumbnail != null) + MenuAction( + title: thumbnailId == (item.data as SnCloudFile).id + ? 'unsetAsThumbnail'.tr() + : 'setAsThumbnail'.tr(), + image: MenuImage.icon(Symbols.image), + callback: () { + final isCurrentlyThumbnail = + thumbnailId == (item.data as SnCloudFile).id; + if (isCurrentlyThumbnail) { + onSetThumbnail?.call(null); + } else { + onSetThumbnail?.call((item.data as SnCloudFile).id); + } + }, + ), ], ), child: contentWidget, diff --git a/lib/widgets/post/compose_attachments.dart b/lib/widgets/post/compose_attachments.dart index 57f22e9f..7fcd6ca2 100644 --- a/lib/widgets/post/compose_attachments.dart +++ b/lib/widgets/post/compose_attachments.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; @@ -110,84 +111,101 @@ class ArticleComposeAttachments extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return ValueListenableBuilder>( - valueListenable: state.attachments, - builder: (context, attachments, _) { - if (attachments.isEmpty) return const SizedBox.shrink(); - return Theme( - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - initiallyExpanded: true, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('attachments'), - Text( - 'articleAttachmentHint', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + return ValueListenableBuilder( + valueListenable: state.thumbnailId, + builder: (context, thumbnailId, _) { + return ValueListenableBuilder>( + valueListenable: state.attachments, + builder: (context, attachments, _) { + if (attachments.isEmpty) return const SizedBox.shrink(); + return Theme( + data: Theme.of( + context, + ).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + initiallyExpanded: true, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('attachments').tr(), + Text( + 'articleAttachmentHint'.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), - ], - ), - children: [ - ValueListenableBuilder>( - valueListenable: state.attachmentProgress, - builder: (context, progressMap, _) { - return Wrap( - runSpacing: 8, - spacing: 8, - children: [ - for (var idx = 0; idx < attachments.length; idx++) - SizedBox( - width: 180, - height: 180, - child: AttachmentPreview( - isCompact: true, - item: attachments[idx], - progress: progressMap[idx], - isUploading: progressMap.containsKey(idx), - onRequestUpload: () async { - final config = - await showModalBottomSheet< - AttachmentUploadConfig - >( - context: context, - isScrollControlled: true, - builder: (context) => - AttachmentUploaderSheet( - ref: ref, - state: state, - index: idx, - ), - ); - if (config != null) { - await ComposeLogic.uploadAttachment( + children: [ + ValueListenableBuilder>( + valueListenable: state.attachmentProgress, + builder: (context, progressMap, _) { + return Wrap( + runSpacing: 8, + spacing: 8, + children: [ + for (var idx = 0; idx < attachments.length; idx++) + SizedBox( + width: 180, + height: 180, + child: AttachmentPreview( + isCompact: true, + item: attachments[idx], + progress: progressMap[idx], + isUploading: progressMap.containsKey(idx), + thumbnailId: thumbnailId, + onSetThumbnail: (id) => + ComposeLogic.setThumbnail(state, id), + onRequestUpload: () async { + final config = + await showModalBottomSheet< + AttachmentUploadConfig + >( + context: context, + isScrollControlled: true, + builder: (context) => + AttachmentUploaderSheet( + ref: ref, + state: state, + index: idx, + ), + ); + if (config != null) { + await ComposeLogic.uploadAttachment( + ref, + state, + idx, + poolId: config.poolId, + ); + } + }, + onUpdate: (value) => + ComposeLogic.updateAttachment( + state, + value, + idx, + ), + onDelete: () => ComposeLogic.deleteAttachment( ref, state, idx, - poolId: config.poolId, - ); - } - }, - onUpdate: (value) => ComposeLogic.updateAttachment( - state, - value, - idx, + ), + onInsert: () => ComposeLogic.insertAttachment( + ref, + state, + idx, + ), + ), ), - onDelete: () => - ComposeLogic.deleteAttachment(ref, state, idx), - onInsert: () => - ComposeLogic.insertAttachment(ref, state, idx), - ), - ), - ], - ); - }, + ], + ); + }, + ), + const SizedBox(height: 16), + ], ), - const SizedBox(height: 16), - ], - ), + ); + }, ); }, ); diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart index 5f90efc7..04e6b35b 100644 --- a/lib/widgets/post/compose_shared.dart +++ b/lib/widgets/post/compose_shared.dart @@ -49,6 +49,8 @@ class ComposeState { final ValueNotifier pollId; // Linked fund id for this compose session (nullable) final ValueNotifier fundId; + // Thumbnail id for article type post (nullable) + final ValueNotifier thumbnailId; Timer? _autoSaveTimer; ComposeState({ @@ -69,8 +71,10 @@ class ComposeState { this.postType = 0, String? pollId, String? fundId, + String? thumbnailId, }) : pollId = ValueNotifier(pollId), - fundId = ValueNotifier(fundId); + fundId = ValueNotifier(fundId), + thumbnailId = ValueNotifier(thumbnailId); void startAutoSave(WidgetRef ref) { _autoSaveTimer?.cancel(); @@ -121,6 +125,9 @@ class ComposeLogic { } catch (_) {} } + // Extract thumbnail ID from meta + final thumbnailId = originalPost?.meta?['thumbnail'] as String?; + return ComposeState( attachments: ValueNotifier>( originalPost?.attachments @@ -156,11 +163,13 @@ class ComposeLogic { postType: postType, pollId: pollId, fundId: fundId, + thumbnailId: thumbnailId, ); } static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) { final tags = draft.tags.map((tag) => tag.slug).toList(); + final thumbnailId = draft.meta?['thumbnail'] as String?; return ComposeState( attachments: ValueNotifier>( @@ -183,6 +192,7 @@ class ComposeLogic { pollId: null, // initialize without fund by default fundId: null, + thumbnailId: thumbnailId, ); } @@ -230,7 +240,9 @@ class ComposeLogic { visibility: state.visibility.value, content: state.contentController.text, type: state.postType, - meta: null, + meta: state.postType == 1 && state.thumbnailId.value != null + ? {'thumbnail': state.thumbnailId.value} + : null, viewsUnique: 0, viewsTotal: 0, upvotes: 0, @@ -302,7 +314,9 @@ class ComposeLogic { visibility: state.visibility.value, content: state.contentController.text, type: state.postType, - meta: null, + meta: state.postType == 1 && state.thumbnailId.value != null + ? {'thumbnail': state.thumbnailId.value} + : null, viewsUnique: 0, viewsTotal: 0, upvotes: 0, @@ -612,6 +626,10 @@ class ComposeLogic { state.embedView.value = null; } + static void setThumbnail(ComposeState state, String? thumbnailId) { + state.thumbnailId.value = thumbnailId; + } + static Future pickPoll( WidgetRef ref, ComposeState state, @@ -720,6 +738,8 @@ class ComposeLogic { if (state.realm.value != null) 'realm_id': state.realm.value?.id, if (state.pollId.value != null) 'poll_id': state.pollId.value, if (state.fundId.value != null) 'fund_id': state.fundId.value, + if (state.postType == 1 && state.thumbnailId.value != null) + 'thumbnail_id': state.thumbnailId.value, if (state.embedView.value != null) 'embed_view': state.embedView.value!.toJson(), }; @@ -872,5 +892,6 @@ class ComposeLogic { state.embedView.dispose(); state.pollId.dispose(); state.fundId.dispose(); + state.thumbnailId.dispose(); } }