Add share token manage panel

This commit is contained in:
2024-08-28 10:07:12 +00:00
committed by GitHub
parent 32a42df473
commit 254f380831
11 changed files with 608 additions and 2 deletions

View File

@@ -252,6 +252,18 @@ abstract class _EHApi {
{@Part(name: "expired") int? expired,
@Part(name: "type") String type = "gallery",
@CancelRequest() CancelToken? cancel});
@PATCH('/shared_token')
@MultiPart()
Future<ApiResult<SharedTokenWithUrl>> updateShareGallery(
@Part(name: "token") String token,
{@Part(name: "expired") int? expired,
@Part(name: "type") String type = "gallery",
@CancelRequest() CancelToken? cancel});
@GET('/shared_token/list')
Future<ApiResult<List<SharedTokenWithUrl>>> listShareGalleries(
{@Query("gid") int? gid,
@Query("type") String type = "gallery",
@CancelRequest() CancelToken? cancel});
@GET('/tag/{id}')
// ignore: unused_element

View File

@@ -1056,6 +1056,102 @@ class __EHApi implements _EHApi {
return _value;
}
@override
Future<ApiResult<SharedTokenWithUrl>> updateShareGallery(
String token, {
int? expired,
String type = "gallery",
CancelToken? cancel,
}) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final _data = FormData();
_data.fields.add(MapEntry(
'token',
token,
));
if (expired != null) {
_data.fields.add(MapEntry(
'expired',
expired.toString(),
));
}
_data.fields.add(MapEntry(
'type',
type,
));
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<ApiResult<SharedTokenWithUrl>>(Options(
method: 'PATCH',
headers: _headers,
extra: _extra,
contentType: 'multipart/form-data',
)
.compose(
_dio.options,
'/shared_token',
queryParameters: queryParameters,
data: _data,
cancelToken: cancel,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
))));
final _value = ApiResult<SharedTokenWithUrl>.fromJson(
_result.data!,
(json) => SharedTokenWithUrl.fromJson(json as Map<String, dynamic>),
);
return _value;
}
@override
Future<ApiResult<List<SharedTokenWithUrl>>> listShareGalleries({
int? gid,
String type = "gallery",
CancelToken? cancel,
}) async {
final _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{
r'gid': gid,
r'type': type,
};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
const Map<String, dynamic>? _data = null;
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<ApiResult<List<SharedTokenWithUrl>>>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/shared_token/list',
queryParameters: queryParameters,
data: _data,
cancelToken: cancel,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
))));
final _value = ApiResult<List<SharedTokenWithUrl>>.fromJson(
_result.data!,
(json) => json is List<dynamic>
? json
.map<SharedTokenWithUrl>(
(i) => SharedTokenWithUrl.fromJson(i as Map<String, dynamic>))
.toList()
: List.empty(),
);
return _value;
}
@override
Future<ApiResult<Tags>> _getTags(
String id, {

View File

@@ -33,6 +33,8 @@ class AuthInfo {
_user?.permissions.has(UserPermission.deleteGallery);
bool? get canManageTasks =>
_user?.permissions.has(UserPermission.manageTasks);
bool? get canShareGallery =>
_user?.permissions.has(UserPermission.shareGallery);
void clear() {
_user = null;

View File

@@ -0,0 +1,419 @@
import 'dart:ui';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import '../api/token.dart';
import '../globals.dart';
import '../main.dart';
import '../platform/media_query.dart';
import '../utils.dart';
import '../utils/clipboard.dart';
final _log = Logger("GallerySharePage");
Future<void> _change(
String token, DateTime? expired, AppLocalizations i18n) async {
try {
final t = (await api.updateShareGallery(token,
expired: expired?.millisecondsSinceEpoch))
.unwrap();
listener.tryEmit("gallery_share_token_changed", t);
} catch (e, stack) {
String errmsg = "${i18n.failedChangeExpireTime}$e";
if (e is (int, String)) {
_log.warning("Failed to change expire time: $e");
} else {
_log.severe("Failed to change expire time: $e\n$stack");
}
final snack = SnackBar(content: Text(errmsg));
rootScaffoldMessengerKey.currentState?.showSnackBar(snack);
}
}
Future<void> _add(int gid, DateTime? expired, AppLocalizations i18n) async {
try {
final t =
(await api.shareGallery(gid, expired: expired?.millisecondsSinceEpoch))
.unwrap();
listener.tryEmit("gallery_share_token_added", t);
} catch (e, stack) {
String errmsg = "${i18n.failedShareGallery}$e";
if (e is (int, String)) {
_log.warning("Failed to share gallery: $e");
} else {
_log.severe("Failed to share gallery: $e\n$stack");
}
final snack = SnackBar(content: Text(errmsg));
rootScaffoldMessengerKey.currentState?.showSnackBar(snack);
}
}
class _ChangeDialog extends StatefulWidget {
const _ChangeDialog(this.gid, {this.token});
final int gid;
final String? token;
@override
State<_ChangeDialog> createState() => _ChangeDialogState();
}
enum _ExpireDuration {
day,
week,
month,
never,
custom;
DateTime? expiredTime() {
switch (this) {
case _ExpireDuration.day:
return DateTime.now().add(const Duration(days: 1));
case _ExpireDuration.week:
return DateTime.now().add(const Duration(days: 7));
case _ExpireDuration.month:
return DateTime.now().add(const Duration(days: 30));
default:
return null;
}
}
String localText(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
switch (this) {
case _ExpireDuration.custom:
return i18n.custom;
case _ExpireDuration.day:
return i18n.oneDayAfter;
case _ExpireDuration.week:
return i18n.oneWeekAfter;
case _ExpireDuration.month:
return i18n.oneMonthAfter;
case _ExpireDuration.never:
return i18n.never;
}
}
}
class _ChangeDialogState extends State<_ChangeDialog> {
final _formKey = GlobalKey<FormState>();
DateTime _expired = DateTime.now();
_ExpireDuration _dur = _ExpireDuration.never;
@override
Widget build(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
return AlertDialog(
title:
Text(widget.token != null ? i18n.editExpireTime : i18n.shareGallery),
content: Form(
key: _formKey,
child: Column(mainAxisSize: MainAxisSize.min, children: [
DropdownButtonFormField<_ExpireDuration>(
items: _ExpireDuration.values
.map((e) => DropdownMenuItem(
value: e, child: Text(e.localText(context))))
.toList(),
value: _dur,
onChanged: (dur) {
if (dur != null) {
setState(() {
_dur = dur;
});
}
if (dur == _ExpireDuration.custom) {
DatePicker.showDateTimePicker(context, onConfirm: (e) {
setState(() {
_expired = e;
});
},
locale: MainApp.of(context).lang.toLocaleType(),
minTime: DateTime.now());
}
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: i18n.expireTime,
)),
]),
),
actions: [
TextButton(
onPressed: _dur != _ExpireDuration.custom ||
_dur == _ExpireDuration.custom &&
_expired.isAfter(DateTime.now())
? () {
final expired = _dur != _ExpireDuration.custom
? _dur.expiredTime()
: _expired;
if (widget.token != null) {
_change(widget.token!, expired, i18n);
} else {
_add(widget.gid, expired, i18n);
}
context.pop();
}
: null,
child: Text(widget.token != null ? i18n.edit : i18n.share)),
TextButton(
onPressed: () {
context.pop();
},
child: Text(i18n.cancel)),
],
);
}
}
class GallerySharePage extends StatefulWidget {
const GallerySharePage(this.gid, {super.key});
final int gid;
static const routeName = "/dialog/gallery/share/:gid";
@override
State<GallerySharePage> createState() => _GallerySharePage();
}
class _GallerySharePage extends State<GallerySharePage> {
List<SharedTokenWithUrl>? _lists;
CancelToken? _cancel;
bool _isLoading = false;
Object? _error;
final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
GlobalKey<RefreshIndicatorState>();
Future<void> _fetchList() async {
_cancel = CancelToken();
_isLoading = true;
try {
_lists = (await api.listShareGalleries(gid: widget.gid, cancel: _cancel))
.unwrap();
if (!_cancel!.isCancelled) {
setState(() {
_isLoading = false;
});
}
} catch (e) {
if (!_cancel!.isCancelled) {
_log.severe("Failed to load gallery shared list ${widget.gid}:", e);
setState(() {
_error = e;
_isLoading = false;
});
}
}
}
void onTokenChanged(dynamic arg) {
if (_lists == null) return;
final t = arg as SharedTokenWithUrl;
final ind = _lists!.indexWhere((a) => a.token.id == t.token.id);
if (ind != -1) {
setState(() {
_lists![ind] = t;
});
}
}
void onTokenAdded(dynamic arg) {
if (_lists == null) return;
final t = arg as SharedTokenWithUrl;
final g = t.token.info as GallerySharedTokenInfo;
if (g.gid == widget.gid) {
setState(() {
_lists!.add(t);
});
}
}
@override
void initState() {
listener.on("gallery_share_token_changed", onTokenChanged);
listener.on("gallery_share_token_added", onTokenAdded);
super.initState();
}
@override
void dispose() {
_cancel?.cancel();
listener.removeEventListener("gallery_share_token_changed", onTokenChanged);
listener.removeEventListener("gallery_share_token_added", onTokenAdded);
super.dispose();
}
Widget _buildView(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
final p = Theme.of(context).colorScheme.primary;
final s = TextStyle(color: p);
return CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(
child: Stack(
alignment: Alignment.center,
children: [
Text(
i18n.shareGallery,
style: Theme.of(context).textTheme.headlineSmall,
),
Align(
alignment: Alignment.centerRight,
child: IconButton(
onPressed: () => context.canPop()
? context.pop()
: context.go("/gallery/${widget.gid}"),
icon: const Icon(Icons.close),
)),
],
)),
SliverToBoxAdapter(
child: Row(children: [
Expanded(
child: Text(i18n.token, textAlign: TextAlign.center, style: s)),
Expanded(
child: Text(i18n.expireTime,
textAlign: TextAlign.center, style: s)),
SizedBox(
width: 120,
child:
Text(i18n.action, textAlign: TextAlign.center, style: s)),
])),
SliverList.builder(
itemBuilder: (context, index) {
final item = _lists![index];
return Row(children: [
Expanded(
child: SelectableText(item.token.token,
textAlign: TextAlign.center)),
Expanded(
child: SelectableText(
item.token.expired == null
? i18n.never
: DateFormat.yMd(MainApp.of(context)
.lang
.toLocale()
.toString())
.add_jms()
.format(item.token.expired!.toLocal()),
textAlign: TextAlign.center),
),
SizedBox(
width: 120,
child: Row(children: [
IconButton.filled(
onPressed: () {
copyTextToClipboard(item.url);
},
icon: const Icon(Icons.link)),
IconButton.filled(
onPressed: () => showDialog(
context: context,
builder: (context) => _ChangeDialog(widget.gid,
token: item.token.token)),
icon: const Icon(Icons.edit)),
IconButton.filled(
onPressed: () => showDialog(
context: context,
builder: (context) {
return AlertDialog(title: Text(i18n.delete));
}),
icon: const Icon(Icons.delete)),
]),
),
]);
},
itemCount: _lists!.length),
]);
}
Widget _buildRefreshIcon(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
return IconButton(
onPressed: () {
_refreshIndicatorKey.currentState?.show();
},
tooltip: i18n.refresh,
icon: const Icon(Icons.refresh));
}
Widget _buildAddIcon(BuildContext context) {
final i18n = AppLocalizations.of(context)!;
return IconButton(
onPressed: () => showDialog(
context: context, builder: (context) => _ChangeDialog(widget.gid)),
tooltip: i18n.create,
icon: const Icon(Icons.add));
}
Widget _buildIconList(BuildContext context) {
return Row(children: [
isDesktop || (kIsWeb && pointerIsMouse)
? _buildRefreshIcon(context)
: Container(),
_buildAddIcon(context),
]);
}
@override
Widget build(BuildContext context) {
tryInitApi(context);
final isLoading = _lists == null && _error == null;
final i18n = AppLocalizations.of(context)!;
final size = MediaQuery.of(context).size;
final maxWidth = size.width;
if (isLoading && !_isLoading) _fetchList();
return Container(
padding: maxWidth < 400
? const EdgeInsets.symmetric(vertical: 20, horizontal: 10)
: const EdgeInsets.all(20),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(10)),
width: maxWidth < 810 ? null : 800,
child: isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("Error: $_error"),
ElevatedButton.icon(
onPressed: () {
_fetchList();
setState(() {
_error = null;
});
},
icon: const Icon(Icons.refresh),
label: Text(i18n.retry)),
],
))
: Stack(
children: <Widget>[
RefreshIndicator(
key: _refreshIndicatorKey,
onRefresh: () async {
return await _fetchList();
},
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
},
),
child: _buildView(context))),
Positioned(
bottom: size.height / 10,
right: size.width / 10,
child: _buildIconList(context))
],
),
);
}
}

View File

@@ -7,6 +7,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'
show ApplicationSwitcherDescription, SystemChrome;
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart'
show LocaleType;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
@@ -171,6 +173,7 @@ enum MoreVertSettings {
taskManager,
markAsAd,
markAsNonAd,
shareGallery,
}
void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) {
@@ -196,6 +199,12 @@ void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) {
case MoreVertSettings.markAsNonAd:
GalleryPage.maybeOf(context)?.markAsAd(false);
break;
case MoreVertSettings.shareGallery:
final gid = GalleryPage.maybeOf(context)?.gid;
if (gid != null) {
context.push("/dialog/gallery/share/$gid");
}
break;
default:
break;
}
@@ -223,6 +232,10 @@ List<PopupMenuEntry<MoreVertSettings>> buildMoreVertSettings(
list.add(PopupMenuItem(
value: MoreVertSettings.taskManager, child: Text(i18n.taskManager)));
}
if (path == "/gallery/:gid" && auth.canShareGallery == true) {
list.add(PopupMenuItem(
value: MoreVertSettings.shareGallery, child: Text(i18n.shareGallery)));
}
var showNsfw = prefs.getBool("showNsfw") ?? false;
list.add(PopupMenuItem(
child: StatefulBuilder(
@@ -363,6 +376,16 @@ enum Lang {
return PlatformDispatcher.instance.locale;
}
}
LocaleType toLocaleType() {
final l = toLocale();
switch (l.languageCode) {
case "zh":
return LocaleType.zh;
default:
return LocaleType.en;
}
}
}
enum ThumbnailSize {

View File

@@ -315,5 +315,17 @@
}
},
"enableServerTiming": "Enable server time tracking",
"shareGallery": "Share gallery"
"shareGallery": "Share gallery",
"expireTime": "Expire time",
"token": "Token",
"action": "Action",
"never": "Never",
"oneDayAfter": "1 day after",
"oneWeekAfter": "1 week after",
"oneMonthAfter": "1 month after",
"custom": "Custom",
"editExpireTime": "Edit expire time",
"share": "Share",
"failedChangeExpireTime": "Failed to change expired time: ",
"failedShareGallery": "Failed to share gallery: "
}

View File

@@ -315,5 +315,17 @@
}
},
"enableServerTiming": "测量服务器所用时间",
"shareGallery": "分享画廊"
"shareGallery": "分享画廊",
"expireTime": "过期时间",
"token": "令牌",
"action": "操作",
"never": "从不",
"oneDayAfter": "1 天后",
"oneWeekAfter": "1 周后",
"oneMonthAfter": "1 月后",
"custom": "自定义",
"editExpireTime": "修改过期时间",
"share": "分享",
"failedChangeExpireTime": "修改过期时间失败:",
"failedShareGallery": "分享画廊失败:"
}

View File

@@ -10,6 +10,7 @@ import 'dialog/dialog_page.dart';
import 'dialog/download_zip_page.dart';
import 'dialog/edit_user_page.dart';
import 'dialog/gallery_details_page.dart';
import 'dialog/gallery_share_page.dart';
import 'dialog/new_download_task_page.dart';
import 'dialog/new_export_zip_task_page.dart';
import 'dialog/new_import_task_page.dart';
@@ -307,6 +308,25 @@ final _router = GoRouter(
return NewImportTaskPage(gid: gid, token: token);
});
}),
GoRoute(
path: GallerySharePage.routeName,
pageBuilder: (context, state) {
final gid = int.parse(state.pathParameters["gid"]!);
return DialogPage(
key: state.pageKey,
builder: (context) {
return GallerySharePage(gid);
});
},
redirect: (context, state) {
try {
int.parse(state.pathParameters["gid"]!);
return null;
} catch (e) {
_routerLog.warning("Failed to parse gid:", e);
return "/";
}
}),
],
observers: [
_NavigatorObserver(),

View File

@@ -50,6 +50,7 @@ class _GalleryPage extends State<GalleryPage>
final List<String> _selected = [];
bool? get isAllNsfw => _data?.isAllNsfw;
bool get isSelectMode => _isSelectMode;
int get gid => widget._gid;
Future<void> markGalleryAsNsfw(bool isNsfw) async {
try {
_markAsNsfwCancel = CancelToken();

View File

@@ -300,6 +300,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_datetime_picker_plus:
dependency: "direct main"
description:
name: flutter_datetime_picker_plus
sha256: "7d82da02c4e070bb28a9107de119ad195e2319b45c786fecc13482a9ffcc51da"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter_hooks:
dependency: "direct main"
description:

View File

@@ -16,6 +16,7 @@ dependencies:
file: ^7.0.0
flutter:
sdk: flutter
flutter_datetime_picker_plus: ^2.2.0
flutter_hooks: ^0.20.0
flutter_localizations:
sdk: flutter