mirror of
https://github.com/immich-app/immich.git
synced 2026-02-01 17:54:44 -08:00
Compare commits
4 Commits
refactor/p
...
fix/databa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452943f342 | ||
|
|
87f389afb4 | ||
|
|
ab2036f289 | ||
|
|
9208512648 |
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
opentofu = "1.10.7"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -18,8 +18,8 @@ node = "24.13.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.28.0"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
java = "21.0.2"
|
||||
opentofu = "1.10.7"
|
||||
java = "25.0.1"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
3
mobile/.fvmrc
Normal file
3
mobile/.fvmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"flutter": "3.35.7"
|
||||
}
|
||||
5
mobile/.gitignore
vendored
5
mobile/.gitignore
vendored
@@ -55,5 +55,8 @@ default.isar
|
||||
default.isar.lock
|
||||
libisar.so
|
||||
|
||||
# FVM Version
|
||||
.fvm/
|
||||
|
||||
# Translation file
|
||||
lib/generated/
|
||||
lib/generated/
|
||||
4
mobile/.vscode/settings.json
vendored
4
mobile/.vscode/settings.json
vendored
@@ -2,9 +2,7 @@
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.7",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [
|
||||
120
|
||||
]
|
||||
"editor.rulers": [120]
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/.fvm": true
|
||||
|
||||
@@ -4,12 +4,10 @@ The Immich mobile app is a Flutter-based solution leveraging the Isar Database f
|
||||
|
||||
## Setup
|
||||
|
||||
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
|
||||
2. Change to the immich directory and trust the mise config with `mise trust`.
|
||||
3. Install tools with mise: `mise install`.
|
||||
4. Run `flutter pub get` to install the dependencies.
|
||||
5. Run `make translation` to generate the translation file.
|
||||
6. Run `flutter run` to start the app.
|
||||
1. Setup Flutter toolchain using FVM.
|
||||
2. Run `flutter pub get` to install the dependencies.
|
||||
3. Run `make translation` to generate the translation file.
|
||||
4. Run `fvm flutter run` to start the app.
|
||||
|
||||
## Translation
|
||||
|
||||
@@ -31,7 +29,7 @@ dcm analyze lib
|
||||
```
|
||||
|
||||
[DCM](https://dcm.dev/) is a vendor tool that needs to be downloaded manually to run locally.
|
||||
Immich was provided an open source license.
|
||||
Immich was provided an open source license.
|
||||
To use it, it is important that you do not have an active free tier license (can be verified with `dcm license`).
|
||||
If you have write-access to the Immich repository directly, running dcm in your clone should just work.
|
||||
If you are working on a clone of a fork, you need to connect to the main Immich repository as remote first:
|
||||
|
||||
@@ -20,7 +20,7 @@ enum VersionStatus {
|
||||
|
||||
class ServerInfo {
|
||||
final ServerVersion serverVersion;
|
||||
final ServerVersion? latestVersion;
|
||||
final ServerVersion latestVersion;
|
||||
final ServerFeatures serverFeatures;
|
||||
final ServerConfig serverConfig;
|
||||
final ServerDiskInfo serverDiskInfo;
|
||||
|
||||
@@ -15,7 +15,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
: super(
|
||||
const ServerInfo(
|
||||
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
|
||||
latestVersion: null,
|
||||
latestVersion: ServerVersion(major: 0, minor: 0, patch: 0),
|
||||
serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true),
|
||||
serverConfig: ServerConfig(
|
||||
trashDays: 30,
|
||||
@@ -43,7 +43,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
try {
|
||||
final serverVersion = await _serverInfoService.getServerVersion();
|
||||
|
||||
// using isClientOutOfDate since that will show to users regardless of if they are an admin
|
||||
// using isClientOutOfDate since that will show to users reguardless of if they are an admin
|
||||
if (serverVersion == null) {
|
||||
state = state.copyWith(versionStatus: VersionStatus.error);
|
||||
return;
|
||||
@@ -76,7 +76,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
state = state.copyWith(versionStatus: VersionStatus.upToDate);
|
||||
}
|
||||
|
||||
handleReleaseInfo(ServerVersion serverVersion, ServerVersion? latestVersion) {
|
||||
handleReleaseInfo(ServerVersion serverVersion, ServerVersion latestVersion) {
|
||||
// Update local server version
|
||||
_checkServerVersionMismatch(serverVersion, latestVersion: latestVersion);
|
||||
}
|
||||
|
||||
@@ -170,52 +170,50 @@ class AppBarServerInfo extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (serverInfoState.latestVersion != null) ...[
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 5.0),
|
||||
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
|
||||
),
|
||||
Text(
|
||||
"latest_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 5.0),
|
||||
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
|
||||
),
|
||||
Text(
|
||||
"latest_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.latestVersion!.major > 0
|
||||
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.latestVersion.major > 0
|
||||
? "${serverInfoState.latestVersion.major}.${serverInfoState.latestVersion.minor}.${serverInfoState.latestVersion.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -414,7 +414,6 @@ class LoginForm extends HookConsumerWidget {
|
||||
keyboardAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.url,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
autoCorrect: false,
|
||||
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,7 +12,6 @@ class ImmichTextInput extends StatefulWidget {
|
||||
final List<String>? autofillHints;
|
||||
final Widget? suffixIcon;
|
||||
final bool obscureText;
|
||||
final bool autoCorrect;
|
||||
|
||||
const ImmichTextInput({
|
||||
super.key,
|
||||
@@ -27,7 +26,6 @@ class ImmichTextInput extends StatefulWidget {
|
||||
this.autofillHints,
|
||||
this.suffixIcon,
|
||||
this.obscureText = false,
|
||||
this.autoCorrect = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -81,7 +79,6 @@ class _ImmichTextInputState extends State<ImmichTextInput> {
|
||||
validator: _validateInput,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.keyboardAction,
|
||||
autocorrect: widget.autoCorrect,
|
||||
autofillHints: widget.autofillHints,
|
||||
onTap: () => setState(() => _error = null),
|
||||
onTapOutside: (_) => _focusNode.unfocus(),
|
||||
|
||||
@@ -130,7 +130,7 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onWebsocketConnection', () => {
|
||||
describe('onWebsocketConnectionEvent', () => {
|
||||
it('should send on_server_version client event', async () => {
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
@@ -143,12 +143,5 @@ describe(VersionService.name, () => {
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not send a release notification when the version check is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } });
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,12 +105,6 @@ export class VersionService extends BaseService {
|
||||
@OnEvent({ name: 'WebsocketConnect' })
|
||||
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
|
||||
this.websocketRepository.clientSend('on_server_version', userId, serverVersion);
|
||||
|
||||
const { newVersionCheck } = await this.getConfig({ withCache: true });
|
||||
if (!newVersionCheck.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
|
||||
if (metadata) {
|
||||
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata));
|
||||
|
||||
@@ -59,6 +59,7 @@ export async function buildPostgresLaunchArguments(
|
||||
): Promise<{
|
||||
bin: string;
|
||||
args: string[];
|
||||
databaseUsername: string;
|
||||
databasePassword: string;
|
||||
databaseVersion: string;
|
||||
databaseMajorVersion?: number;
|
||||
@@ -73,6 +74,7 @@ export async function buildPostgresLaunchArguments(
|
||||
const databaseMajorVersion = databaseSemver?.major;
|
||||
|
||||
const args: string[] = [];
|
||||
let databaseUsername;
|
||||
|
||||
if (isUrlConnection) {
|
||||
if (bin !== 'pg_dump') {
|
||||
@@ -85,18 +87,20 @@ export async function buildPostgresLaunchArguments(
|
||||
// remove known bad parameters
|
||||
parsedUrl.searchParams.delete('uselibpqcompat');
|
||||
|
||||
if (options.username) {
|
||||
parsedUrl.username = options.username;
|
||||
}
|
||||
|
||||
databaseUsername = parsedUrl.username;
|
||||
url = parsedUrl.toString();
|
||||
}
|
||||
|
||||
// assume typical values if we can't parse URL or not present
|
||||
databaseUsername ??= 'postgres';
|
||||
|
||||
args.push(url);
|
||||
} else {
|
||||
databaseUsername = databaseConfig.username;
|
||||
|
||||
args.push(
|
||||
'--username',
|
||||
options.username ?? databaseConfig.username,
|
||||
databaseConfig.username,
|
||||
'--host',
|
||||
databaseConfig.host,
|
||||
'--port',
|
||||
@@ -151,6 +155,7 @@ export async function buildPostgresLaunchArguments(
|
||||
return {
|
||||
bin: `/usr/lib/postgresql/${databaseMajorVersion}/bin/${bin}`,
|
||||
args,
|
||||
databaseUsername,
|
||||
databasePassword: isUrlConnection ? new URL(databaseConfig.url).password : databaseConfig.password,
|
||||
databaseVersion,
|
||||
databaseMajorVersion,
|
||||
@@ -207,44 +212,35 @@ const SQL_DROP_CONNECTIONS = `
|
||||
AND pid <> pg_backend_pid();
|
||||
`;
|
||||
|
||||
const SQL_RESET_SCHEMA = `
|
||||
const SQL_RESET_SCHEMA = (username: string) => `
|
||||
-- re-create the default schema
|
||||
DROP SCHEMA public CASCADE;
|
||||
CREATE SCHEMA public;
|
||||
|
||||
-- restore access to schema
|
||||
GRANT ALL ON SCHEMA public TO postgres;
|
||||
GRANT ALL ON SCHEMA public TO "${username}";
|
||||
GRANT ALL ON SCHEMA public TO public;
|
||||
`;
|
||||
|
||||
async function* sql(inputStream: Readable, isPgClusterDump: boolean) {
|
||||
async function* sql(inputStream: Readable, databaseUsername: string, isPgClusterDump: boolean) {
|
||||
yield SQL_DROP_CONNECTIONS;
|
||||
yield isPgClusterDump
|
||||
? String.raw`
|
||||
? // it is likely the dump contains SQL to try to drop the currently active
|
||||
// database to ensure we have a fresh slate; if the `postgres` database exists
|
||||
// then prefer to switch before continuing otherwise this will just silently fail
|
||||
String.raw`
|
||||
\c postgres
|
||||
`
|
||||
: SQL_RESET_SCHEMA;
|
||||
: SQL_RESET_SCHEMA(databaseUsername);
|
||||
|
||||
for await (const chunk of inputStream) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
async function* sqlRollback(inputStream: Readable, isPgClusterDump: boolean) {
|
||||
async function* sqlRollback(inputStream: Readable, databaseUsername: string) {
|
||||
yield SQL_DROP_CONNECTIONS;
|
||||
|
||||
if (isPgClusterDump) {
|
||||
yield String.raw`
|
||||
-- try to create database
|
||||
-- may fail but script will continue running
|
||||
CREATE DATABASE immich;
|
||||
|
||||
-- switch to database / newly created database
|
||||
\c immich
|
||||
`;
|
||||
}
|
||||
|
||||
yield SQL_RESET_SCHEMA;
|
||||
yield SQL_RESET_SCHEMA(databaseUsername);
|
||||
|
||||
for await (const chunk of inputStream) {
|
||||
yield chunk;
|
||||
@@ -273,12 +269,11 @@ export async function restoreDatabaseBackup(
|
||||
isPgClusterDump = true;
|
||||
}
|
||||
|
||||
const { bin, args, databasePassword, databaseMajorVersion } = await buildPostgresLaunchArguments(
|
||||
const { bin, args, databaseUsername, databasePassword, databaseMajorVersion } = await buildPostgresLaunchArguments(
|
||||
{ logger, database: databaseRepository, ...pgRepos },
|
||||
'psql',
|
||||
{
|
||||
singleTransaction: !isPgClusterDump,
|
||||
username: isPgClusterDump ? 'postgres' : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -301,7 +296,7 @@ export async function restoreDatabaseBackup(
|
||||
inputStream = storage.createPlainReadStream(backupFilePath);
|
||||
}
|
||||
|
||||
const sqlStream = Readable.from(sql(inputStream, isPgClusterDump));
|
||||
const sqlStream = Readable.from(sql(inputStream, databaseUsername, isPgClusterDump));
|
||||
const psql = processRepository.spawnDuplexStream(bin, args, {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
@@ -332,7 +327,7 @@ export async function restoreDatabaseBackup(
|
||||
fileStream.pipe(gunzip);
|
||||
inputStream = gunzip;
|
||||
|
||||
const sqlStream = Readable.from(sqlRollback(inputStream, isPgClusterDump));
|
||||
const sqlStream = Readable.from(sqlRollback(inputStream, databaseUsername));
|
||||
const psql = processRepository.spawnDuplexStream(bin, args, {
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
|
||||
@@ -22,3 +22,5 @@
|
||||
return assetViewerManager.on(events);
|
||||
});
|
||||
</script>
|
||||
|
||||
const event = name.slice(2) as keyof Events;
|
||||
|
||||
@@ -194,7 +194,9 @@
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
console.log(asset);
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
console.log(refreshedAsset);
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => editManager.resetAllChanges()}
|
||||
disabled={!editManager.canReset}
|
||||
disabled={!editManager.hasChanges}
|
||||
class="self-start"
|
||||
shape="round"
|
||||
size="small"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { activateProduct, getActivationKey } from '$lib/utils/license-utils';
|
||||
import { Button, Heading, LoadingSpinner } from '@immich/ui';
|
||||
@@ -26,7 +26,7 @@
|
||||
await activateProduct(productKey, activationKey);
|
||||
|
||||
onActivate();
|
||||
authManager.isPurchased = true;
|
||||
purchaseStore.setPurchaseStatus(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('purchase_failed_activation'));
|
||||
} finally {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { OpenQueryParam } from '$lib/constants';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import PurchaseModal from '$lib/modals/PurchaseModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAccountAge } from '$lib/utils/auth';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
let showBuyButton = $state(getButtonVisibility());
|
||||
|
||||
const { isPurchased } = purchaseStore;
|
||||
|
||||
const openPurchaseModal = async () => {
|
||||
await modalManager.show(PurchaseModal);
|
||||
showMessage = false;
|
||||
@@ -70,7 +72,7 @@
|
||||
</script>
|
||||
|
||||
<div class="license-status ps-4 text-sm">
|
||||
{#if authManager.isPurchased && $preferences.purchase.showSupportBadge}
|
||||
{#if $isPurchased && $preferences.purchase.showSupportBadge}
|
||||
<button
|
||||
onclick={() => goto(Route.userSettings({ isOpen: OpenQueryParam.PURCHASE_SETTINGS }))}
|
||||
class="w-full mt-2"
|
||||
@@ -78,7 +80,7 @@
|
||||
>
|
||||
<SupporterBadge size="small" effect="always" />
|
||||
</button>
|
||||
{:else if !authManager.isPurchased && showBuyButton && getAccountAge() > 14}
|
||||
{:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openPurchaseModal}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import PurchaseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { setSupportBadgeVisibility } from '$lib/utils/purchase-utils';
|
||||
@@ -22,6 +22,7 @@
|
||||
import { mdiKey } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
const { isPurchased } = purchaseStore;
|
||||
|
||||
let isServerProduct = $state(false);
|
||||
let serverPurchaseInfo: LicenseResponseDto | null = $state(null);
|
||||
@@ -52,7 +53,7 @@
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!authManager.isPurchased) {
|
||||
if (!$isPurchased) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,7 +73,7 @@
|
||||
}
|
||||
|
||||
await deleteIndividualProductKey();
|
||||
authManager.isPurchased = false;
|
||||
purchaseStore.setPurchaseStatus(false);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_remove_product_key'));
|
||||
}
|
||||
@@ -91,21 +92,21 @@
|
||||
}
|
||||
|
||||
await deleteServerProductKey();
|
||||
authManager.isPurchased = false;
|
||||
purchaseStore.setPurchaseStatus(false);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_remove_product_key'));
|
||||
}
|
||||
};
|
||||
|
||||
const onProductActivated = async () => {
|
||||
authManager.isPurchased = true;
|
||||
purchaseStore.setPurchaseStatus(true);
|
||||
await checkPurchaseInfo();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
{#if authManager.isPurchased}
|
||||
{#if $isPurchased}
|
||||
<!-- BADGE TOGGLE -->
|
||||
<div class="mb-4">
|
||||
<SettingSwitch
|
||||
|
||||
@@ -3,31 +3,12 @@ import { page } from '$app/state';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { isSharedLinkRoute } from '$lib/utils/navigation';
|
||||
import { getAboutInfo, logout, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { logout } from '@immich/sdk';
|
||||
|
||||
class AuthManager {
|
||||
isPurchased = $state(false);
|
||||
isSharedLink = $derived(isSharedLinkRoute(page.route?.id));
|
||||
params = $derived(this.isSharedLink ? { key: page.params.key, slug: page.params.slug } : {});
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AuthUserLoaded: (user) => this.onAuthUserLoaded(user),
|
||||
});
|
||||
}
|
||||
|
||||
private async onAuthUserLoaded(user: UserAdminResponseDto) {
|
||||
if (user.license?.activatedAt) {
|
||||
authManager.isPurchased = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const serverInfo = await getAboutInfo().catch(() => undefined);
|
||||
if (serverInfo?.licensed) {
|
||||
authManager.isPurchased = true;
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
let redirectUri;
|
||||
|
||||
@@ -49,7 +30,6 @@ class AuthManager {
|
||||
globalThis.location.href = redirectUri;
|
||||
}
|
||||
} finally {
|
||||
this.isPurchased = false;
|
||||
eventManager.emit('AuthLogout');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface EditToolManager {
|
||||
onDeactivate: () => void;
|
||||
resetAllChanges: () => Promise<void>;
|
||||
hasChanges: boolean;
|
||||
canReset: boolean;
|
||||
edits: EditAction[];
|
||||
}
|
||||
|
||||
@@ -42,22 +41,19 @@ export class EditManager {
|
||||
|
||||
currentAsset = $state<AssetResponseDto | null>(null);
|
||||
selectedTool = $state<EditTool | null>(null);
|
||||
hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges));
|
||||
|
||||
// used to disable multiple confirm dialogs and mouse events while one is open
|
||||
isShowingConfirmDialog = $state(false);
|
||||
isApplyingEdits = $state(false);
|
||||
hasAppliedEdits = $state(false);
|
||||
|
||||
hasUnsavedChanges = $derived(this.tools.some((t) => t.manager.hasChanges) && !this.hasAppliedEdits);
|
||||
canReset = $derived(this.tools.some((t) => t.manager.canReset));
|
||||
|
||||
async closeConfirm(): Promise<boolean> {
|
||||
// Prevent multiple dialogs (usually happens with rapid escape key presses)
|
||||
if (this.isShowingConfirmDialog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.hasUnsavedChanges) {
|
||||
if (!this.hasChanges || this.hasAppliedEdits) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ type RegionConvertParams = {
|
||||
};
|
||||
|
||||
class TransformManager implements EditToolManager {
|
||||
canReset: boolean = $derived.by(() => this.checkEdits());
|
||||
hasChanges: boolean = $state(false);
|
||||
hasChanges: boolean = $derived.by(() => this.checkEdits());
|
||||
|
||||
darkenLevel = $state(0.65);
|
||||
isInteracting = $state(false);
|
||||
@@ -57,7 +56,7 @@ class TransformManager implements EditToolManager {
|
||||
cropAspectRatio = $state('free');
|
||||
originalImageSize = $state<ImageDimensions>({ width: 1000, height: 1000 });
|
||||
region = $state({ x: 0, y: 0, width: 100, height: 100 });
|
||||
previewImageSize = $derived({
|
||||
preveiwImgSize = $derived({
|
||||
width: this.cropImageSize.width * this.cropImageScale,
|
||||
height: this.cropImageSize.height * this.cropImageScale,
|
||||
});
|
||||
@@ -74,7 +73,6 @@ class TransformManager implements EditToolManager {
|
||||
edits = $derived.by(() => this.getEdits());
|
||||
|
||||
setAspectRatio(aspectRatio: string) {
|
||||
this.hasChanges = true;
|
||||
this.cropAspectRatio = aspectRatio;
|
||||
|
||||
if (!this.imgElement || !this.cropAreaEl) {
|
||||
@@ -90,8 +88,8 @@ class TransformManager implements EditToolManager {
|
||||
|
||||
checkEdits() {
|
||||
return (
|
||||
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.previewImageSize.height - this.region.height) > 2 ||
|
||||
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2 ||
|
||||
this.mirrorHorizontal ||
|
||||
this.mirrorVertical ||
|
||||
this.normalizedRotation !== 0
|
||||
@@ -100,8 +98,8 @@ class TransformManager implements EditToolManager {
|
||||
|
||||
checkCropEdits() {
|
||||
return (
|
||||
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.previewImageSize.height - this.region.height) > 2
|
||||
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,12 +232,9 @@ class TransformManager implements EditToolManager {
|
||||
this.originalImageSize = { width: 1000, height: 1000 };
|
||||
this.cropImageScale = 1;
|
||||
this.cropAspectRatio = 'free';
|
||||
this.hasChanges = false;
|
||||
}
|
||||
|
||||
mirror(axis: 'horizontal' | 'vertical') {
|
||||
this.hasChanges = true;
|
||||
|
||||
if (this.imageRotation % 180 !== 0) {
|
||||
axis = axis === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
}
|
||||
@@ -252,8 +247,6 @@ class TransformManager implements EditToolManager {
|
||||
}
|
||||
|
||||
async rotate(angle: number) {
|
||||
this.hasChanges = true;
|
||||
|
||||
this.imageRotation += angle;
|
||||
await tick();
|
||||
this.onImageLoad();
|
||||
@@ -767,7 +760,6 @@ class TransformManager implements EditToolManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasChanges = true;
|
||||
const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width));
|
||||
const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height));
|
||||
|
||||
@@ -789,7 +781,6 @@ class TransformManager implements EditToolManager {
|
||||
}
|
||||
this.fadeOverlay(false);
|
||||
|
||||
this.hasChanges = true;
|
||||
const { x, y, width, height } = crop;
|
||||
const minSize = 50;
|
||||
let newRegion = { ...crop };
|
||||
|
||||
16
web/src/lib/stores/purchase.store.ts
Normal file
16
web/src/lib/stores/purchase.store.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createPurchaseStore() {
|
||||
const isPurcharsed = writable(false);
|
||||
|
||||
function setPurchaseStatus(status: boolean) {
|
||||
isPurcharsed.set(status);
|
||||
}
|
||||
|
||||
return {
|
||||
isPurchased: readonly(isPurcharsed),
|
||||
setPurchaseStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export const purchaseStore = createPurchaseStore();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
@@ -12,6 +13,7 @@ export const preferences = writable<UserPreferencesResponseDto>();
|
||||
export const resetSavedUser = () => {
|
||||
user.set(undefined as unknown as UserAdminResponseDto);
|
||||
preferences.set(undefined as unknown as UserPreferencesResponseDto);
|
||||
purchaseStore.setPurchaseStatus(false);
|
||||
};
|
||||
|
||||
eventManager.on({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
||||
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -17,12 +18,19 @@ export const loadUser = async () => {
|
||||
try {
|
||||
let user = get(user$);
|
||||
let preferences = get(preferences$);
|
||||
let serverInfo;
|
||||
|
||||
if ((!user || !preferences) && hasAuthCookie()) {
|
||||
[user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]);
|
||||
[user, preferences, serverInfo] = await Promise.all([getMyUser(), getMyPreferences(), getAboutInfo()]);
|
||||
user$.set(user);
|
||||
preferences$.set(preferences);
|
||||
|
||||
eventManager.emit('AuthUserLoaded', user);
|
||||
|
||||
// Check for license status
|
||||
if (serverInfo.licensed || user.license?.activatedAt) {
|
||||
purchaseStore.setPurchaseStatus(true);
|
||||
}
|
||||
}
|
||||
return user;
|
||||
} catch {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import LicenseActivationSuccess from '$lib/components/shared-components/purchasing/purchase-activation-success.svelte';
|
||||
import LicenseContent from '$lib/components/shared-components/purchasing/purchase-content.svelte';
|
||||
import SupporterBadge from '$lib/components/shared-components/side-bar/supporter-badge.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { Alert, Container, Stack } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -17,16 +17,17 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
let showLicenseActivated = $state(false);
|
||||
const { isPurchased } = purchaseStore;
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
<UserPageLayout title={$t('buy')}>
|
||||
<Container size="medium" center>
|
||||
<Stack gap={4} class="mt-4">
|
||||
{#if data.isActivated === false}
|
||||
<Alert icon={mdiAlertCircleOutline} color="danger" title={$t('purchase_failed_activation')} />
|
||||
{/if}
|
||||
|
||||
{#if authManager.isPurchased}
|
||||
{#if $isPurchased}
|
||||
<SupporterBadge logoSize="lg" centered />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { activateProduct, getActivationKey } from '$lib/utils/license-utils';
|
||||
@@ -21,7 +21,7 @@ export const load = (async ({ url }) => {
|
||||
const response = await activateProduct(licenseKey, activationKey);
|
||||
if (response.activatedAt !== '') {
|
||||
isActivated = true;
|
||||
authManager.isPurchased = true;
|
||||
purchaseStore.setPurchaseStatus(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user