diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 61ba31c..1f40f6e 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -954,5 +954,9 @@ "attachmentEditorUploadHint": "This attachment is uploaded.", "attachmentRating": "Rating", "fieldAttachmentRating": "Content Rating", - "fieldAttachmentQuality": "Quality Rating" + "fieldAttachmentQuality": "Quality Rating", + "attachmentReferenceLink": "Use external attachment", + "fieldAttachmentReferenceLink": "Reference Link", + "attachmentReferenceLinkDescription": "It will be used as the source file of the attachment. The link needs to allow cross-origin access.", + "fieldAttachmentMimetype": "Mimetype" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 11acc9c..582477f 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -951,5 +951,9 @@ "attachmentEditorUploadHint": "该附件已上传。", "attachmentRating": "评级", "fieldAttachmentRating": "内容分级", - "fieldAttachmentQuality": "质量评分" + "fieldAttachmentQuality": "质量评分", + "attachmentReferenceLink": "引用外部附件", + "fieldAttachmentReferenceLink": "引用连接", + "attachmentReferenceLinkDescription": "作为附件的源文件。需要链接允许跨域访问。", + "fieldAttachmentMimetype": "文件类型" } diff --git a/lib/providers/sn_attachment.dart b/lib/providers/sn_attachment.dart index b3c864e..42186e4 100644 --- a/lib/providers/sn_attachment.dart +++ b/lib/providers/sn_attachment.dart @@ -120,6 +120,25 @@ class SnAttachmentProvider { 'webp': 'image/webp', }; + Future createWithReferenceLink( + String url, + String pool, + Map? metadata, { + String? mimetype, + }) async { + final resp = await _sn.client.post( + '/cgi/uc/attachments/referenced', + data: { + 'url': url, + 'pool': pool, + 'metadata': metadata, + if (mimetype != null) 'mimetype': mimetype, + }, + ); + + return SnAttachment.fromJson(resp.data); + } + Future directUploadOne( Uint8List data, String filename, diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index d7894c2..0ca0417 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; @@ -16,7 +15,6 @@ import 'package:responsive_framework/responsive_framework.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/providers/config.dart'; -import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_realm.dart'; import 'package:surface/types/attachment.dart'; @@ -25,8 +23,7 @@ import 'package:surface/types/realm.dart'; import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/attachment/attachment_input.dart'; import 'package:surface/widgets/attachment/attachment_item.dart'; -import 'package:surface/widgets/attachment/pending_attachment_alt.dart'; -import 'package:surface/widgets/attachment/pending_attachment_boost.dart'; +import 'package:surface/widgets/attachment/pending_attachment_actions.dart'; import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/navigation/app_scaffold.dart'; @@ -1130,77 +1127,6 @@ class _PostVideoEditor extends StatelessWidget { controller.setVideoAttachment(video); } - void _setAlt(BuildContext context) async { - if (controller.videoAttachment == null) return; - - final result = await showDialog( - context: context, - builder: (context) => PendingAttachmentAltDialog( - media: PostWriteMedia(controller.videoAttachment)), - ); - if (result == null) return; - - controller.setVideoAttachment(result); - } - - Future _createBoost(BuildContext context) async { - if (controller.videoAttachment == null) return; - - final result = await showDialog( - context: context, - builder: (context) => PendingAttachmentBoostDialog( - media: PostWriteMedia(controller.videoAttachment)), - ); - if (result == null) return; - - final newAttach = controller.videoAttachment!.copyWith( - boosts: [...controller.videoAttachment!.boosts, result], - ); - - controller.setVideoAttachment(newAttach); - } - - void _setThumbnail(BuildContext context) async { - if (controller.videoAttachment == null) return; - - final thumbnail = await showDialog( - context: context, - builder: (context) => AttachmentInputDialog( - title: 'attachmentSetThumbnail'.tr(), - pool: 'interactive', - analyzeNow: true, - ), - ); - if (thumbnail == null) return; - if (!context.mounted) return; - - try { - final attach = context.read(); - final newAttach = await attach.updateOne( - controller.videoAttachment!, - thumbnailId: thumbnail.id, - ); - controller.setVideoAttachment(newAttach); - } catch (err) { - if (!context.mounted) return; - context.showErrorDialog(err); - } - } - - Future _deleteAttachment(BuildContext context) async { - if (controller.videoAttachment == null) return; - - try { - final sn = context.read(); - await sn.client - .delete('/cgi/uc/attachments/${controller.videoAttachment!.id}'); - controller.setVideoAttachment(null); - } catch (err) { - if (!context.mounted) return; - context.showErrorDialog(err); - } - } - @override Widget build(BuildContext context) { return Container( @@ -1274,80 +1200,49 @@ class _PostVideoEditor extends StatelessWidget { borderRadius: BorderRadius.circular(16), border: Border.all(color: Theme.of(context).dividerColor), ), - child: ContextMenuRegion( - contextMenu: ContextMenu( - entries: [ - MenuItem( - label: 'attachmentSetAlt'.tr(), - icon: Symbols.description, - onSelected: () { - _setAlt(context); - }, - ), - MenuItem( - label: 'attachmentBoost'.tr(), - icon: Symbols.bolt, - onSelected: () { - _createBoost(context); - }, - ), - MenuItem( - label: 'attachmentSetThumbnail'.tr(), - icon: Symbols.image, - onSelected: () { - _setThumbnail(context); - }, - ), - MenuItem( - label: 'attachmentCopyRandomId'.tr(), - icon: Symbols.content_copy, - onSelected: () { - Clipboard.setData(ClipboardData( - text: controller.videoAttachment!.rid)); - }, - ), - MenuItem( - label: 'delete'.tr(), - icon: Symbols.delete, - onSelected: () => _deleteAttachment(context), - ), - MenuItem( - label: 'unlink'.tr(), - icon: Symbols.link_off, - onSelected: () { - controller.setVideoAttachment(null); - }, - ), - ], - ), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: controller.videoAttachment == null - ? () => _selectVideo(context) - : null, - child: AspectRatio( - aspectRatio: 16 / 9, - child: controller.videoAttachment == null - ? Center( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.add), - const Gap(4), - Text('postVideoUpload'.tr()), - ], - ), - ) - : ClipRRect( - borderRadius: BorderRadius.circular(16), - child: AttachmentItem( - data: controller.videoAttachment!, - heroTag: const Uuid().v4(), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: controller.videoAttachment == null + ? () => _selectVideo(context) + : () { + showModalBottomSheet( + context: context, + builder: (context) => + PendingAttachmentActionSheet( + media: PostWriteMedia( + controller.videoAttachment!, ), ), - ), + ).then((value) async { + if (value is PostWriteMedia) { + controller.setVideoAttachment(value.attachment); + } else if (value == false) { + controller.setVideoAttachment(null); + } + }); + }, + child: AspectRatio( + aspectRatio: 16 / 9, + child: controller.videoAttachment == null + ? Center( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add), + const Gap(4), + Text('postVideoUpload'.tr()), + ], + ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(16), + child: AttachmentItem( + data: controller.videoAttachment!, + heroTag: const Uuid().v4(), + ), + ), ), ), ), diff --git a/lib/widgets/attachment/attachment_input.dart b/lib/widgets/attachment/attachment_input.dart index 7f4bb45..535e3fa 100644 --- a/lib/widgets/attachment/attachment_input.dart +++ b/lib/widgets/attachment/attachment_input.dart @@ -12,6 +12,9 @@ import 'package:surface/widgets/dialog.dart'; class AttachmentInputDialog extends StatefulWidget { final String? title; final bool? analyzeNow; + final bool canPickMedia; + final bool canReferenceLink; + final bool canRandomId; final SnMediaType? mediaType; final String pool; @@ -21,6 +24,9 @@ class AttachmentInputDialog extends StatefulWidget { required this.pool, this.analyzeNow = false, this.mediaType = SnMediaType.image, + this.canPickMedia = true, + this.canReferenceLink = true, + this.canRandomId = true, }); @override @@ -29,6 +35,8 @@ class AttachmentInputDialog extends StatefulWidget { class _AttachmentInputDialogState extends State { final _randomIdController = TextEditingController(); + final _referenceLinkController = TextEditingController(); + final _referenceMimetypeController = TextEditingController(); XFile? _file; double? _progress; @@ -61,6 +69,22 @@ class _AttachmentInputDialogState extends State { if (!mounted) return; context.showErrorDialog(err); } + } else if (_referenceLinkController.text.isNotEmpty) { + try { + final attachment = await attach.createWithReferenceLink( + _referenceLinkController.text, + widget.pool, + null, + mimetype: _referenceMimetypeController.text.isNotEmpty + ? _referenceMimetypeController.text + : null, + ); + if (!mounted) return; + Navigator.pop(context, attachment); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } } else if (_file != null) { try { final place = await attach.chunkedUploadInitialize( @@ -90,44 +114,98 @@ class _AttachmentInputDialogState extends State { return AlertDialog( title: Text(widget.title ?? 'attachmentInputDialog'.tr()), content: Column( + spacing: 16, crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('attachmentInputUseRandomId').tr().fontSize(14), - const Gap(8), - TextField( - controller: _randomIdController, - decoration: InputDecoration( - labelText: 'fieldAttachmentRandomId'.tr(), - border: const UnderlineInputBorder(), - isDense: true, - ), - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), - ), - const Gap(24), - Text('attachmentInputNew').tr().fontSize(14), - Card( - child: Column( + if (_file == null && + _referenceLinkController.text.isEmpty && + widget.canRandomId) + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + Text('attachmentInputUseRandomId').tr().fontSize(14), + const Gap(8), + TextField( + controller: _randomIdController, + decoration: InputDecoration( + labelText: 'fieldAttachmentRandomId'.tr(), + border: const OutlineInputBorder(), + isDense: true, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + ], + ), + if (_file == null && + _referenceLinkController.text.isEmpty && + widget.canReferenceLink) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('attachmentReferenceLink').tr().fontSize(14), + const Gap(8), + TextField( + controller: _referenceLinkController, + decoration: InputDecoration( + labelText: 'fieldAttachmentReferenceLink'.tr(), + helperText: 'attachmentReferenceLinkDescription'.tr(), + helperMaxLines: 3, + border: const OutlineInputBorder(), + isDense: true, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + const Gap(8), + TextField( + controller: _referenceLinkController, + decoration: InputDecoration( + labelText: 'fieldAttachmentMimetype'.tr(), + helperText: 'class/type', + border: const OutlineInputBorder(), + isDense: true, + ), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ), + ], + ), + if (_referenceLinkController.text.isEmpty && + _randomIdController.text.isEmpty && + widget.canPickMedia) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('attachmentInputNew').tr().fontSize(14), + Card( + margin: EdgeInsets.only(top: 8), + child: Column( + children: [ + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: const Icon(Symbols.add_photo_alternate), + trailing: const Icon(Symbols.chevron_right), + title: Text('addAttachmentFromAlbum').tr(), + subtitle: _file == null + ? Text('unset').tr() + : Text('waitingForUpload').tr(), + onTap: () { + _pickMedia(); + }, + ), + ], ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: const Icon(Symbols.add_photo_alternate), - trailing: const Icon(Symbols.chevron_right), - title: Text('addAttachmentFromAlbum').tr(), - subtitle: _file == null - ? Text('unset').tr() - : Text('waitingForUpload').tr(), - onTap: () { - _pickMedia(); - }, ), ], ), - ), if (_isBusy) LinearProgressIndicator( value: _progress, diff --git a/lib/widgets/attachment/pending_attachment_actions.dart b/lib/widgets/attachment/pending_attachment_actions.dart index ef4b31a..72e6693 100644 --- a/lib/widgets/attachment/pending_attachment_actions.dart +++ b/lib/widgets/attachment/pending_attachment_actions.dart @@ -27,7 +27,12 @@ import 'package:surface/widgets/loading_indicator.dart'; class PendingAttachmentActionSheet extends StatefulWidget { final PostWriteMedia media; - const PendingAttachmentActionSheet({super.key, required this.media}); + final bool canInsertLink; + const PendingAttachmentActionSheet({ + super.key, + required this.media, + this.canInsertLink = true, + }); @override State createState() => @@ -270,15 +275,16 @@ class _PendingAttachmentActionSheetState Navigator.pop(context); }, ), - ListTile( - minTileHeight: 48, - leading: const Icon(Symbols.add_link), - contentPadding: EdgeInsets.symmetric(horizontal: 24), - title: Text('attachmentInsertLink').tr(), - onTap: () { - Navigator.pop(context, 'link'); - }, - ), + if (widget.canInsertLink) + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.add_link), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('attachmentInsertLink').tr(), + onTap: () { + Navigator.pop(context, 'link'); + }, + ), ListTile( minTileHeight: 48, leading: const Icon(Symbols.bolt),