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": {
"one": "{} task",
"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/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<List<UniversalFile>>(
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),
],
),
);
},
),
ArticleComposeAttachments(state: state),
],
),
),

View File

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

View File

@@ -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<List<UniversalFile>>(
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<String?>(
valueListenable: state.thumbnailId,
builder: (context, thumbnailId, _) {
return ValueListenableBuilder<List<UniversalFile>>(
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<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(
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),
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),
],
),
);
},
);
},
);

View File

@@ -49,6 +49,8 @@ class ComposeState {
final ValueNotifier<String?> pollId;
// Linked fund id for this compose session (nullable)
final ValueNotifier<String?> fundId;
// Thumbnail id for article type post (nullable)
final ValueNotifier<String?> thumbnailId;
Timer? _autoSaveTimer;
ComposeState({
@@ -69,8 +71,10 @@ class ComposeState {
this.postType = 0,
String? pollId,
String? fundId,
String? thumbnailId,
}) : pollId = ValueNotifier<String?>(pollId),
fundId = ValueNotifier<String?>(fundId);
fundId = ValueNotifier<String?>(fundId),
thumbnailId = ValueNotifier<String?>(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<List<UniversalFile>>(
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<List<UniversalFile>>(
@@ -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<void> 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();
}
}