Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
2aab5c28a1 refactor: purchase store 2026-01-30 16:41:11 -05:00
11 changed files with 57 additions and 119 deletions

View File

@@ -44,7 +44,6 @@ import { getDimensions } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { isFacialRecognitionEnabled } from 'src/utils/misc';
import { Point, transformPoints } from 'src/utils/transform';
@Injectable()
export class PersonService extends BaseService {
@@ -635,50 +634,15 @@ export class PersonService extends BaseService {
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);
const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true });
if (!asset) {
throw new NotFoundException('Asset not found');
}
const edits = asset.edits || [];
let p1: Point = { x: dto.x, y: dto.y };
let p2: Point = { x: dto.x + dto.width, y: dto.y + dto.height };
// the coordinates received from the client are based on the edited preview image
// we need to convert them to the coordinate space of the original unedited image
if (edits.length > 0) {
if (!asset.width || !asset.height || !asset.exifInfo?.exifImageWidth || !asset.exifInfo?.exifImageHeight) {
throw new BadRequestException('Asset does not have valid dimensions');
}
// convert from preview to full dimensions
const scaleFactor = asset.width / dto.imageWidth;
p1 = { x: p1.x * scaleFactor, y: p1.y * scaleFactor };
p2 = { x: p2.x * scaleFactor, y: p2.y * scaleFactor };
const {
points: [invertedP1, invertedP2],
} = transformPoints([p1, p2], edits, { width: asset.width, height: asset.height }, { inverse: true });
// make sure p1 is top-left and p2 is bottom-right
p1 = { x: Math.min(invertedP1.x, invertedP2.x), y: Math.min(invertedP1.y, invertedP2.y) };
p2 = { x: Math.max(invertedP1.x, invertedP2.x), y: Math.max(invertedP1.y, invertedP2.y) };
// now coordinates are in original image space
dto.imageHeight = asset.exifInfo.exifImageHeight;
dto.imageWidth = asset.exifInfo.exifImageWidth;
}
await this.personRepository.createAssetFace({
personId: dto.personId,
assetId: dto.assetId,
imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth,
boundingBoxX1: Math.round(p1.x),
boundingBoxX2: Math.round(p2.x),
boundingBoxY1: Math.round(p1.y),
boundingBoxY2: Math.round(p2.y),
boundingBoxX1: dto.x,
boundingBoxX2: dto.x + dto.width,
boundingBoxY1: dto.y,
boundingBoxY2: dto.y + dto.height,
sourceType: SourceType.Manual,
});
}

View File

@@ -61,7 +61,7 @@ export const createAffineMatrix = (
);
};
export type Point = { x: number; y: number };
type Point = { x: number; y: number };
type TransformState = {
points: Point[];
@@ -73,33 +73,29 @@ type TransformState = {
* Transforms an array of points through a series of edit operations (crop, rotate, mirror).
* Points should be in absolute pixel coordinates relative to the starting dimensions.
*/
export const transformPoints = (
const transformPoints = (
points: Point[],
edits: AssetEditActionItem[],
startingDimensions: ImageDimensions,
{ inverse = false } = {},
): TransformState => {
let currentWidth = startingDimensions.width;
let currentHeight = startingDimensions.height;
let transformedPoints = [...points];
// Handle crop first if not inverting
if (!inverse) {
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x - cropX,
y: p.y - cropY,
}));
currentWidth = cropWidth;
currentHeight = cropHeight;
}
// Handle crop first
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x - cropX,
y: p.y - cropY,
}));
currentWidth = cropWidth;
currentHeight = cropHeight;
}
// Apply rotate and mirror transforms
const editSequence = inverse ? edits.toReversed() : edits;
for (const edit of editSequence) {
for (const edit of edits) {
let matrix: Matrix = identity();
if (edit.action === 'rotate') {
const angleDegrees = edit.parameters.angle;
@@ -109,7 +105,7 @@ export const transformPoints = (
matrix = compose(
translate(newWidth / 2, newHeight / 2),
rotate(inverse ? -angleRadians : angleRadians),
rotate(angleRadians),
translate(-currentWidth / 2, -currentHeight / 2),
);
@@ -129,18 +125,6 @@ export const transformPoints = (
transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p));
}
// Handle crop last if inverting
if (inverse) {
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x + cropX,
y: p.y + cropY,
}));
}
}
return {
points: transformedPoints,
currentWidth,

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { purchaseStore } from '$lib/stores/purchase.store';
import { authManager } from '$lib/managers/auth-manager.svelte';
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();
purchaseStore.setPurchaseStatus(true);
authManager.isPurchased = true;
} catch (error) {
handleError(error, $t('purchase_failed_activation'));
} finally {

View File

@@ -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,8 +22,6 @@
let showBuyButton = $state(getButtonVisibility());
const { isPurchased } = purchaseStore;
const openPurchaseModal = async () => {
await modalManager.show(PurchaseModal);
showMessage = false;
@@ -72,7 +70,7 @@
</script>
<div class="license-status ps-4 text-sm">
{#if $isPurchased && $preferences.purchase.showSupportBadge}
{#if authManager.isPurchased && $preferences.purchase.showSupportBadge}
<button
onclick={() => goto(Route.userSettings({ isOpen: OpenQueryParam.PURCHASE_SETTINGS }))}
class="w-full mt-2"
@@ -80,7 +78,7 @@
>
<SupporterBadge size="small" effect="always" />
</button>
{:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
{:else if !authManager.isPurchased && showBuyButton && getAccountAge() > 14}
<button
type="button"
onclick={openPurchaseModal}

View File

@@ -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,7 +22,6 @@
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);
@@ -53,7 +52,7 @@
};
onMount(async () => {
if (!$isPurchased) {
if (!authManager.isPurchased) {
return;
}
@@ -73,7 +72,7 @@
}
await deleteIndividualProductKey();
purchaseStore.setPurchaseStatus(false);
authManager.isPurchased = false;
} catch (error) {
handleError(error, $t('errors.failed_to_remove_product_key'));
}
@@ -92,21 +91,21 @@
}
await deleteServerProductKey();
purchaseStore.setPurchaseStatus(false);
authManager.isPurchased = false;
} catch (error) {
handleError(error, $t('errors.failed_to_remove_product_key'));
}
};
const onProductActivated = async () => {
purchaseStore.setPurchaseStatus(true);
authManager.isPurchased = true;
await checkPurchaseInfo();
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
{#if $isPurchased}
{#if authManager.isPurchased}
<!-- BADGE TOGGLE -->
<div class="mb-4">
<SettingSwitch

View File

@@ -3,12 +3,31 @@ 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 { logout } from '@immich/sdk';
import { getAboutInfo, logout, type UserAdminResponseDto } 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;
@@ -30,6 +49,7 @@ class AuthManager {
globalThis.location.href = redirectUri;
}
} finally {
this.isPurchased = false;
eventManager.emit('AuthLogout');
}
}

View File

@@ -1,16 +0,0 @@
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();

View File

@@ -1,5 +1,4 @@
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';
@@ -13,7 +12,6 @@ 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({

View File

@@ -1,10 +1,9 @@
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 { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import { get } from 'svelte/store';
@@ -18,19 +17,12 @@ export const loadUser = async () => {
try {
let user = get(user$);
let preferences = get(preferences$);
let serverInfo;
if ((!user || !preferences) && hasAuthCookie()) {
[user, preferences, serverInfo] = await Promise.all([getMyUser(), getMyPreferences(), getAboutInfo()]);
[user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]);
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 {

View File

@@ -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,17 +17,16 @@
let { data }: Props = $props();
let showLicenseActivated = $state(false);
const { isPurchased } = purchaseStore;
</script>
<UserPageLayout title={$t('buy')}>
<UserPageLayout title={data.meta.title}>
<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 $isPurchased}
{#if authManager.isPurchased}
<SupporterBadge logoSize="lg" centered />
{/if}

View File

@@ -1,4 +1,4 @@
import { purchaseStore } from '$lib/stores/purchase.store';
import { authManager } from '$lib/managers/auth-manager.svelte';
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;
purchaseStore.setPurchaseStatus(true);
authManager.isPurchased = true;
}
}
} catch (error) {