Editing article thumbnail

This commit is contained in:
2026-01-16 01:30:46 +08:00
parent d1ee2e5160
commit 3a57f4265b
5 changed files with 174 additions and 186 deletions

View File

@@ -1592,5 +1592,7 @@
"tasksCount": { "tasksCount": {
"one": "{} task", "one": "{} task",
"other": "{} tasks" "other": "{} tasks"
} },
"setAsThumbnail": "Set as thumbnail",
"unsetAsThumbnail": "Unset as thumbnail"
} }

View File

@@ -13,12 +13,11 @@ import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.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/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/compose_form_fields.dart'; import 'package:island/widgets/post/compose_form_fields.dart';
import 'package:island/widgets/post/compose_shared.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_settings_sheet.dart';
import 'package:island/widgets/post/compose_toolbar.dart'; import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/widgets/post/publishers_modal.dart';
@@ -88,9 +87,6 @@ class ArticleComposeScreen extends HookConsumerWidget {
}, [state]); }, [state]);
final showPreview = useState(false); final showPreview = useState(false);
final isAttachmentsExpanded = useState(
true,
); // New state for attachments section
// Initialize publisher once when data is available // Initialize publisher once when data is available
useEffect(() { useEffect(() {
@@ -274,111 +270,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
), ),
// Attachments preview // Attachments preview
ValueListenableBuilder<List<UniversalFile>>( ArticleComposeAttachments(state: state),
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<Map<int, double?>>(
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),
],
),
);
},
),
], ],
), ),
), ),

View File

@@ -93,6 +93,8 @@ class AttachmentPreview extends HookConsumerWidget {
final Function(UniversalFile)? onUpdate; final Function(UniversalFile)? onUpdate;
final Function? onRequestUpload; final Function? onRequestUpload;
final bool isCompact; final bool isCompact;
final String? thumbnailId;
final Function(String?)? onSetThumbnail;
const AttachmentPreview({ const AttachmentPreview({
super.key, super.key,
@@ -105,6 +107,8 @@ class AttachmentPreview extends HookConsumerWidget {
this.onUpdate, this.onUpdate,
this.onInsert, this.onInsert,
this.isCompact = false, this.isCompact = false,
this.thumbnailId,
this.onSetThumbnail,
}); });
// GlobalKey for selector // 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); 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, child: contentWidget,

View File

@@ -1,3 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
@@ -110,84 +111,101 @@ class ArticleComposeAttachments extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ValueListenableBuilder<List<UniversalFile>>( return ValueListenableBuilder<String?>(
valueListenable: state.attachments, valueListenable: state.thumbnailId,
builder: (context, attachments, _) { builder: (context, thumbnailId, _) {
if (attachments.isEmpty) return const SizedBox.shrink(); return ValueListenableBuilder<List<UniversalFile>>(
return Theme( valueListenable: state.attachments,
data: Theme.of(context).copyWith(dividerColor: Colors.transparent), builder: (context, attachments, _) {
child: ExpansionTile( if (attachments.isEmpty) return const SizedBox.shrink();
initiallyExpanded: true, return Theme(
title: Column( data: Theme.of(
crossAxisAlignment: CrossAxisAlignment.start, context,
children: [ ).copyWith(dividerColor: Colors.transparent),
Text('attachments'), child: ExpansionTile(
Text( initiallyExpanded: true,
'articleAttachmentHint', title: Column(
style: Theme.of(context).textTheme.bodySmall?.copyWith( crossAxisAlignment: CrossAxisAlignment.start,
color: Theme.of(context).colorScheme.onSurfaceVariant, children: [
), Text('attachments').tr(),
Text(
'articleAttachmentHint'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
), ),
], children: [
), ValueListenableBuilder<Map<int, double?>>(
children: [ valueListenable: state.attachmentProgress,
ValueListenableBuilder<Map<int, double?>>( builder: (context, progressMap, _) {
valueListenable: state.attachmentProgress, return Wrap(
builder: (context, progressMap, _) { runSpacing: 8,
return Wrap( spacing: 8,
runSpacing: 8, children: [
spacing: 8, for (var idx = 0; idx < attachments.length; idx++)
children: [ SizedBox(
for (var idx = 0; idx < attachments.length; idx++) width: 180,
SizedBox( height: 180,
width: 180, child: AttachmentPreview(
height: 180, isCompact: true,
child: AttachmentPreview( item: attachments[idx],
isCompact: true, progress: progressMap[idx],
item: attachments[idx], isUploading: progressMap.containsKey(idx),
progress: progressMap[idx], thumbnailId: thumbnailId,
isUploading: progressMap.containsKey(idx), onSetThumbnail: (id) =>
onRequestUpload: () async { ComposeLogic.setThumbnail(state, id),
final config = onRequestUpload: () async {
await showModalBottomSheet< final config =
AttachmentUploadConfig await showModalBottomSheet<
>( AttachmentUploadConfig
context: context, >(
isScrollControlled: true, context: context,
builder: (context) => isScrollControlled: true,
AttachmentUploaderSheet( builder: (context) =>
ref: ref, AttachmentUploaderSheet(
state: state, ref: ref,
index: idx, state: state,
), index: idx,
); ),
if (config != null) { );
await ComposeLogic.uploadAttachment( if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onUpdate: (value) =>
ComposeLogic.updateAttachment(
state,
value,
idx,
),
onDelete: () => ComposeLogic.deleteAttachment(
ref, ref,
state, state,
idx, idx,
poolId: config.poolId, ),
); onInsert: () => ComposeLogic.insertAttachment(
} ref,
}, state,
onUpdate: (value) => ComposeLogic.updateAttachment( idx,
state, ),
value, ),
idx,
), ),
onDelete: () => ],
ComposeLogic.deleteAttachment(ref, state, idx), );
onInsert: () => },
ComposeLogic.insertAttachment(ref, state, idx), ),
), const SizedBox(height: 16),
), ],
],
);
},
), ),
const SizedBox(height: 16), );
], },
),
); );
}, },
); );

View File

@@ -49,6 +49,8 @@ class ComposeState {
final ValueNotifier<String?> pollId; final ValueNotifier<String?> pollId;
// Linked fund id for this compose session (nullable) // Linked fund id for this compose session (nullable)
final ValueNotifier<String?> fundId; final ValueNotifier<String?> fundId;
// Thumbnail id for article type post (nullable)
final ValueNotifier<String?> thumbnailId;
Timer? _autoSaveTimer; Timer? _autoSaveTimer;
ComposeState({ ComposeState({
@@ -69,8 +71,10 @@ class ComposeState {
this.postType = 0, this.postType = 0,
String? pollId, String? pollId,
String? fundId, String? fundId,
String? thumbnailId,
}) : pollId = ValueNotifier<String?>(pollId), }) : pollId = ValueNotifier<String?>(pollId),
fundId = ValueNotifier<String?>(fundId); fundId = ValueNotifier<String?>(fundId),
thumbnailId = ValueNotifier<String?>(thumbnailId);
void startAutoSave(WidgetRef ref) { void startAutoSave(WidgetRef ref) {
_autoSaveTimer?.cancel(); _autoSaveTimer?.cancel();
@@ -121,6 +125,9 @@ class ComposeLogic {
} catch (_) {} } catch (_) {}
} }
// Extract thumbnail ID from meta
final thumbnailId = originalPost?.meta?['thumbnail'] as String?;
return ComposeState( return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>( attachments: ValueNotifier<List<UniversalFile>>(
originalPost?.attachments originalPost?.attachments
@@ -156,11 +163,13 @@ class ComposeLogic {
postType: postType, postType: postType,
pollId: pollId, pollId: pollId,
fundId: fundId, fundId: fundId,
thumbnailId: thumbnailId,
); );
} }
static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) { static ComposeState createStateFromDraft(SnPost draft, {int postType = 0}) {
final tags = draft.tags.map((tag) => tag.slug).toList(); final tags = draft.tags.map((tag) => tag.slug).toList();
final thumbnailId = draft.meta?['thumbnail'] as String?;
return ComposeState( return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>( attachments: ValueNotifier<List<UniversalFile>>(
@@ -183,6 +192,7 @@ class ComposeLogic {
pollId: null, pollId: null,
// initialize without fund by default // initialize without fund by default
fundId: null, fundId: null,
thumbnailId: thumbnailId,
); );
} }
@@ -230,7 +240,9 @@ class ComposeLogic {
visibility: state.visibility.value, visibility: state.visibility.value,
content: state.contentController.text, content: state.contentController.text,
type: state.postType, type: state.postType,
meta: null, meta: state.postType == 1 && state.thumbnailId.value != null
? {'thumbnail': state.thumbnailId.value}
: null,
viewsUnique: 0, viewsUnique: 0,
viewsTotal: 0, viewsTotal: 0,
upvotes: 0, upvotes: 0,
@@ -302,7 +314,9 @@ class ComposeLogic {
visibility: state.visibility.value, visibility: state.visibility.value,
content: state.contentController.text, content: state.contentController.text,
type: state.postType, type: state.postType,
meta: null, meta: state.postType == 1 && state.thumbnailId.value != null
? {'thumbnail': state.thumbnailId.value}
: null,
viewsUnique: 0, viewsUnique: 0,
viewsTotal: 0, viewsTotal: 0,
upvotes: 0, upvotes: 0,
@@ -612,6 +626,10 @@ class ComposeLogic {
state.embedView.value = null; state.embedView.value = null;
} }
static void setThumbnail(ComposeState state, String? thumbnailId) {
state.thumbnailId.value = thumbnailId;
}
static Future<void> pickPoll( static Future<void> pickPoll(
WidgetRef ref, WidgetRef ref,
ComposeState state, ComposeState state,
@@ -720,6 +738,8 @@ class ComposeLogic {
if (state.realm.value != null) 'realm_id': state.realm.value?.id, if (state.realm.value != null) 'realm_id': state.realm.value?.id,
if (state.pollId.value != null) 'poll_id': state.pollId.value, if (state.pollId.value != null) 'poll_id': state.pollId.value,
if (state.fundId.value != null) 'fund_id': state.fundId.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) if (state.embedView.value != null)
'embed_view': state.embedView.value!.toJson(), 'embed_view': state.embedView.value!.toJson(),
}; };
@@ -872,5 +892,6 @@ class ComposeLogic {
state.embedView.dispose(); state.embedView.dispose();
state.pollId.dispose(); state.pollId.dispose();
state.fundId.dispose(); state.fundId.dispose();
state.thumbnailId.dispose();
} }
} }