Compare commits

...

8 Commits

Author SHA1 Message Date
shenlong-tanwen
0decde1cc3 add clear data action 2026-02-24 23:47:34 +05:30
shenlong
1daa9c058d Update mobile/lib/pages/common/splash_screen.page.dart
Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-02-24 00:00:13 +05:30
shenlong-tanwen
49d819830a feat: splash screen error page 2026-02-23 19:55:12 +05:30
Thomas
caebe5166a chore(mobile): remove redundant assignment (#26404)
The view controller is already assigned during page build. Reassigning
it for every drag doesn't really make any sense.
2026-02-23 12:48:25 +00:00
Michel Heusschen
1bd28c3e78 fix(web): prevent state_unsafe_mutation error on people page (#26438) 2026-02-23 13:24:51 +01:00
Matthew Momjian
31a55aaa73 fix(web): storage template example (#26424) 2026-02-23 10:34:56 +00:00
Thomas
8b2e1509ff chore(mobile): simplify pop logic (#26410)
We have all the information we need to decide on whether we should pop
or not at the end of a drag. There's no need to track that separately,
and update the value constantly.
2026-02-23 14:49:15 +05:30
Lauritz Tieste
d0cb97f994 feat(mobile): Add slug support for shared links (#26441)
* feat(mobile): add slug support for shared links

* fix(mobile): ensure slug retains existing value when unchanged
2026-02-23 14:31:42 +05:30
11 changed files with 364 additions and 49 deletions

View File

@@ -1883,7 +1883,10 @@
"reset_pin_code_success": "Successfully reset PIN code",
"reset_pin_code_with_password": "You can always reset your PIN code with your password",
"reset_sqlite": "Reset SQLite Database",
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
"reset_sqlite_clear_app_data": "Clear Data",
"reset_sqlite_confirmation": "Are you sure you want to clear the app data? This will remove all settings and sign you out.",
"reset_sqlite_confirmation_note": "Note: You will need to restart the app after clearing.",
"reset_sqlite_done": "App data has been cleared. Please restart Immich and log in again.",
"reset_sqlite_success": "Successfully reset the SQLite database",
"reset_to_default": "Reset to default",
"resolution": "Resolution",
@@ -1911,6 +1914,8 @@
"saved_settings": "Saved settings",
"say_something": "Say something",
"scaffold_body_error_occurred": "Error occurred",
"scaffold_body_error_unrecoverable": "An unrecoverable error has occurred. Please share the error and stack trace on Discord or GitHub so we can help. If advised, you can clear the app data below.",
"scan": "Scan",
"scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan",

View File

@@ -20,6 +20,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
@@ -49,30 +50,34 @@ import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
void main() async {
ImmichWidgetsBinding();
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
try {
ImmichWidgetsBinding();
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
await EasyLocalization.ensureInitialized();
final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp(
ProviderScope(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const MainWidget(),
),
);
runApp(
ProviderScope(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const MainWidget(),
),
);
} catch (error, stack) {
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
}
}
Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
await initializeDateFormatting();
if (Platform.isAndroid) {

View File

@@ -14,6 +14,7 @@ class SharedLink {
final String key;
final bool showMetadata;
final SharedLinkSource type;
final String? slug;
const SharedLink({
required this.id,
@@ -27,6 +28,7 @@ class SharedLink {
required this.key,
required this.showMetadata,
required this.type,
required this.slug,
});
SharedLink copyWith({
@@ -41,6 +43,7 @@ class SharedLink {
String? key,
bool? showMetadata,
SharedLinkSource? type,
String? slug,
}) {
return SharedLink(
id: id ?? this.id,
@@ -54,6 +57,7 @@ class SharedLink {
key: key ?? this.key,
showMetadata: showMetadata ?? this.showMetadata,
type: type ?? this.type,
slug: slug ?? this.slug,
);
}
@@ -66,6 +70,7 @@ class SharedLink {
expiresAt = dto.expiresAt,
key = dto.key,
showMetadata = dto.showMetadata,
slug = dto.slug,
type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM
? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
@@ -78,7 +83,7 @@ class SharedLink {
@override
String toString() =>
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)';
@override
bool operator ==(Object other) =>
@@ -94,7 +99,8 @@ class SharedLink {
other.expiresAt == expiresAt &&
other.key == key &&
other.showMetadata == showMetadata &&
other.type == type;
other.type == type &&
other.slug == slug;
@override
int get hashCode =>
@@ -108,5 +114,6 @@ class SharedLink {
expiresAt.hashCode ^
key.hashCode ^
showMetadata.hashCode ^
type.hashCode;
type.hashCode ^
slug.hashCode;
}

View File

@@ -1,10 +1,17 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -13,7 +20,254 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:logging/logging.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl, LaunchMode;
class BootstrapErrorWidget extends StatelessWidget {
final String error;
final String stack;
const BootstrapErrorWidget({super.key, required this.error, required this.stack});
@override
Widget build(BuildContext _) {
final immichTheme = defaultColorPreset.themeOfPreset;
return EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
useFallbackTranslations: true,
fallbackLocale: locales.values.first,
assetLoader: const CodegenLoader(),
child: Builder(
builder: (lCtx) => MaterialApp(
title: 'Immich',
debugShowCheckedModeBanner: true,
localizationsDelegates: lCtx.localizationDelegates,
supportedLocales: lCtx.supportedLocales,
locale: lCtx.locale,
themeMode: ThemeMode.system,
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: lCtx.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: lCtx.locale),
home: Builder(
builder: (ctx) => Scaffold(
body: Column(
children: [
const SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ImmichLogo(size: 48), SizedBox(width: 12), ImmichTitleText(fontSize: 24)],
),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _ErrorCard(error: error, stack: stack),
),
),
const Divider(height: 1),
const SafeArea(
top: false,
child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()),
),
],
),
),
),
),
),
);
}
}
class _BottomPanel extends StatefulWidget {
const _BottomPanel();
@override
State<_BottomPanel> createState() => _BottomPanelState();
}
class _BottomPanelState extends State<_BottomPanel> {
bool _cleared = false;
Future<void> _clearDatabase() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: Text(context.t.reset_sqlite_clear_app_data),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t.reset_sqlite_confirmation),
const SizedBox(height: 12),
Text(
context.t.reset_sqlite_confirmation_note,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(false), child: Text(context.t.cancel)),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(true),
child: Text(context.t.confirm, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final db = Drift();
try {
await db.reset();
} finally {
await db.close();
}
if (mounted) {
setState(() => _cleared = true);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Text(
_cleared ? context.t.reset_sqlite_done : context.t.scaffold_body_error_unrecoverable,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ActionLink(
icon: Icons.chat_bubble_outline,
label: context.t.discord,
onTap: () => launchUrl(Uri.parse('https://discord.immich.app/'), mode: LaunchMode.externalApplication),
),
_ActionLink(
icon: Icons.bug_report_outlined,
label: context.t.profile_drawer_github,
onTap: () => launchUrl(
Uri.parse('https://github.com/immich-app/immich/issues'),
mode: LaunchMode.externalApplication,
),
),
if (!_cleared)
_ActionLink(
icon: Icons.delete_outline,
label: context.t.reset_sqlite_clear_app_data,
onTap: _clearDatabase,
),
],
),
],
);
}
}
class _ActionLink extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ActionLink({required this.icon, required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 24),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12)),
],
),
),
);
}
}
class _ErrorCard extends StatelessWidget {
final String error;
final String stack;
const _ErrorCard({required this.error, required this.stack});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ColoredBox(
color: scheme.error,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
context.t.scaffold_body_error_occurred,
style: textTheme.titleSmall?.copyWith(color: scheme.onError),
),
),
IconButton(
tooltip: context.t.copy_error,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(Icons.copy_outlined, size: 16, color: scheme.onError),
onPressed: () => Clipboard.setData(ClipboardData(text: '$error\n\n$stack')),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(error, style: textTheme.bodyMedium),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t.stacktrace, style: textTheme.labelMedium),
const SizedBox(height: 4),
SelectableText(stack, style: textTheme.bodySmall?.copyWith(fontFamily: 'GoogleSansCode')),
],
),
),
],
),
);
}
}
@RoutePage()
class SplashScreenPage extends StatefulHookConsumerWidget {

View File

@@ -29,6 +29,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
final descriptionController = useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final passwordController = useTextEditingController(text: existingLink?.password ?? "");
final slugController = useTextEditingController(text: existingLink?.slug ?? "");
final slugFocusNode = useFocusNode();
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
@@ -108,6 +110,26 @@ class SharedLinkEditPage extends HookConsumerWidget {
);
}
Widget buildSlugField() {
return TextField(
controller: slugController,
enabled: newShareLink.value.isEmpty,
focusNode: slugFocusNode,
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: 'custom_url'.tr(),
labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'custom_url'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))),
),
onTapOutside: (_) => slugFocusNode.unfocus(),
);
}
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
@@ -261,6 +283,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
allowUpload: allowUpload.value,
description: descriptionController.text.isEmpty ? null : descriptionController.text,
password: passwordController.text.isEmpty ? null : passwordController.text,
slug: slugController.text.isEmpty ? null : slugController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
@@ -274,7 +297,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
}
if (newLink != null && serverUrl != null) {
newShareLink.value = "${serverUrl}share/${newLink.key}";
final hasSlug = newLink.slug?.isNotEmpty == true;
final urlPath = hasSlug ? newLink.slug : newLink.key;
final basePath = hasSlug ? 's' : 'share';
newShareLink.value = "$serverUrl$basePath/$urlPath";
copyLinkToClipboard();
} else if (newLink == null) {
ImmichToast.show(
@@ -292,6 +318,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? meta;
String? desc;
String? password;
String? slug;
DateTime? expiry;
bool? changeExpiry;
@@ -315,6 +342,12 @@ class SharedLinkEditPage extends HookConsumerWidget {
password = passwordController.text;
}
if (slugController.text != (existingLink!.slug ?? "")) {
slug = slugController.text.isEmpty ? null : slugController.text;
} else {
slug = existingLink!.slug;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
changeExpiry = true;
@@ -329,6 +362,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
allowUpload: upload,
description: desc,
password: password,
slug: slug,
expiresAt: expiry,
changeExpiry: changeExpiry,
);
@@ -349,6 +383,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()),
Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()),
Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()),
Padding(padding: const EdgeInsets.all(padding), child: buildSlugField()),
Padding(
padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding),
child: buildShowMetaButton(),

View File

@@ -61,7 +61,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
DragStartDetails? _dragStart;
_DragIntent _dragIntent = _DragIntent.none;
Drag? _drag;
bool _shouldPopOnDrag = false;
@override
void initState() {
@@ -120,7 +119,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _beginDrag(DragStartDetails details) {
_dragStart = details;
_shouldPopOnDrag = false;
_lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0;
if (_viewController != null) {
@@ -163,6 +161,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _endDrag(DragEndDetails details) {
if (_dragStart == null) return;
final start = _dragStart;
_dragStart = null;
final intent = _dragIntent;
@@ -178,7 +177,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_drag?.end(details);
_drag = null;
case _DragIntent.dismiss:
if (_shouldPopOnDrag) {
const popThreshold = 75.0;
if (details.localPosition.dy - start!.localPosition.dy > popThreshold) {
context.maybePop();
return;
}
@@ -197,7 +197,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
PhotoViewControllerBase controller,
PhotoViewScaleStateController scaleStateController,
) {
_viewController = controller;
if (!_showingDetails && _isZoomed) return;
_beginDrag(details);
}
@@ -211,12 +210,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _handleDragDown(BuildContext context, Offset delta) {
const dragRatio = 0.2;
const popThreshold = 75.0;
_shouldPopOnDrag = delta.dy > popThreshold;
final distance = delta.dy.abs();
final maxScaleDistance = context.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale;

View File

@@ -37,6 +37,7 @@ class SharedLinkService {
required bool allowUpload,
String? description,
String? password,
String? slug,
String? albumId,
List<String>? assetIds,
DateTime? expiresAt,
@@ -54,6 +55,7 @@ class SharedLinkService {
expiresAt: expiresAt,
description: description,
password: password,
slug: slug,
);
} else if (assetIds != null) {
dto = SharedLinkCreateDto(
@@ -64,6 +66,7 @@ class SharedLinkService {
expiresAt: expiresAt,
description: description,
password: password,
slug: slug,
assetIds: assetIds,
);
}
@@ -88,6 +91,7 @@ class SharedLinkService {
bool? changeExpiry = false,
String? description,
String? password,
String? slug,
DateTime? expiresAt,
}) async {
try {
@@ -100,6 +104,7 @@ class SharedLinkService {
expiresAt: expiresAt,
description: description,
password: password,
slug: slug,
changeExpiryTime: changeExpiry,
),
);

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -5,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -87,25 +89,27 @@ class SyncStatusAndActions extends HookConsumerWidget {
context: context,
builder: (context) {
return AlertDialog(
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
title: Text(context.t.reset_sqlite),
content: Text(context.t.reset_sqlite_confirmation),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel)),
TextButton(
onPressed: () async {
await ref.read(driftProvider).reset();
context.pop();
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(context.t.reset_sqlite_success),
content: Text(context.t.reset_sqlite_done),
actions: [TextButton(onPressed: () => ctx.pop(), child: Text(context.t.ok))],
),
),
);
},
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
child: Text(context.t.confirm, style: TextStyle(color: context.colorScheme.error)),
),
],
);

View File

@@ -78,7 +78,10 @@ class SharedLinkItem extends ConsumerWidget {
return;
}
Clipboard.setData(ClipboardData(text: "${serverUrl}share/${sharedLink.key}")).then((_) {
final hasSlug = sharedLink.slug?.isNotEmpty == true;
final urlPath = hasSlug ? sharedLink.slug : sharedLink.key;
final basePath = hasSlug ? 's' : 'share';
Clipboard.setData(ClipboardData(text: "$serverUrl$basePath/$urlPath")).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(

View File

@@ -1,3 +1,5 @@
import { on } from 'svelte/events';
interface Options {
onFocusOut?: (event: FocusEvent) => void;
}
@@ -19,11 +21,11 @@ export function focusOutside(node: HTMLElement, options: Options = {}) {
}
};
node.addEventListener('focusout', handleFocusOut);
const off = on(node, 'focusout', handleFocusOut);
return {
destroy() {
node.removeEventListener('focusout', handleFocusOut);
off();
},
};
}

View File

@@ -23,7 +23,7 @@
<CardHeader>
<Text class="mb-1">{$t('admin.storage_template_date_time_description')}</Text>
<Text color="primary"
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-03T20:03:05.250' } })}</Text
>{$t('admin.storage_template_date_time_sample', { values: { date: '2022-02-15T20:03:05.250+00:00' } })}</Text
>
</CardHeader>
<CardBody>