mirror of
https://github.com/immich-app/immich.git
synced 2024-11-24 08:52:28 +02:00
feat(web): license UI (#11182)
This commit is contained in:
parent
88f62087fd
commit
ef0e1a81b9
@ -29,3 +29,4 @@ web/node_modules/
|
||||
web/coverage/
|
||||
web/.svelte-kit
|
||||
web/build/
|
||||
web/.env
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: The Immich core team goes full-time
|
||||
authors: [alextran]
|
||||
tags: [update, announcement, futo]
|
||||
tags: [update, announcement, FUTO]
|
||||
date: 2024-05-01T00:00
|
||||
---
|
||||
|
||||
|
91
docs/blog/2024/immich-licensing.mdx
Normal file
91
docs/blog/2024/immich-licensing.mdx
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Licensing announcement - Purchase a license to support Immich
|
||||
authors: [alextran]
|
||||
tags: [update, announcement, FUTO]
|
||||
date: 2024-07-18T00:00
|
||||
---
|
||||
|
||||
Hello everybody,
|
||||
|
||||
Firstly, on behalf of the Immich team, I'd like to thank everybody for your continuous support of Immich since the very first day! Your contributions, encouragement, and community engagement have helped bring Immich to its current state. The team and I are forever grateful for that.
|
||||
|
||||
Since our [last announcement of the core team joining FUTO to work on Immich full-time](https://immich.app/blog/2024/immich-core-team-goes-fulltime), one of the goals of our new position is to foster a healthy relationship between the developers and the users. We believe that this enables us to create great software, establish transparent policies and build trust.
|
||||
|
||||
We want to build a great software application that brings value to you and your loved ones' lives. We are not using you as a product, i.e., selling or tracking your data. We are not putting annoying ads into our software. We respect your privacy. We want to be compensated for the hard work we put in to build Immich for you.
|
||||
|
||||
With those notes, we have enabled a way for you to financially support the continued development of Immich, ensuring the software can move forward and will be maintained, by offering a lifetime license of the software. We think if you like and use software, you should pay for it, but _we're never going to force anyone to pay or try to limit Immich for those who don't._
|
||||
|
||||
There are two types of license that you can choose to purchase: **Server License** and **Individual License**.
|
||||
|
||||
### Server License
|
||||
|
||||
This is a lifetime license costing **$99.99**. The license is applied to the whole server. You and all users that use your server are licensed.
|
||||
|
||||
### Individual License
|
||||
|
||||
This is a lifetime license costing **$24.99**. The license is applied to a single user, and can be used on any server they choose to connect to.
|
||||
|
||||
<img
|
||||
width="837"
|
||||
alt="license-social-gh"
|
||||
src="https://github.com/user-attachments/assets/241932ed-ef3b-44ec-a9e2-ee80754e0cca"
|
||||
/>
|
||||
|
||||
You can purchase the license on [our page - https://buy.immich.app](https://buy.immich.app).
|
||||
|
||||
Starting with release `v1.109.0` you can purchase and enter your purchased license key directly in the app.
|
||||
|
||||
<img
|
||||
width="1414"
|
||||
alt="license-page-gh"
|
||||
src="https://github.com/user-attachments/assets/364fc32a-f6ef-4594-9fea-28d5a26ad77c"
|
||||
/>
|
||||
|
||||
## Thank you
|
||||
|
||||
Thank you again for your support, this will help create a strong foundation and stability for the Immich team to continue developing and maintaining the project that you love to use.
|
||||
|
||||
<p align="center">
|
||||
<img
|
||||
src="https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExbjY2eWc5Y2F0ZW56MmR4aWE0dDhzZXlidXRmYWZyajl1bWZidXZpcyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/87CKDqErVfMqY/giphy.gif"
|
||||
width="550"
|
||||
title="SUPPORT THE PROJECT!"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
Cheers! 🎉
|
||||
|
||||
Immich team
|
||||
|
||||
# FAQ
|
||||
|
||||
### 1. Where can I purchase a license?
|
||||
|
||||
There are several places where you can purchase the license from
|
||||
|
||||
- [https://buy.immich.app](https://buy.immich.app)
|
||||
- [https://pay.futo.org](https://pay.futo.org/)
|
||||
- or directly from the app.
|
||||
|
||||
### 2. Do I need both _Individual License_ and _Server License_?
|
||||
|
||||
No,
|
||||
|
||||
If you are the admin and the sole user, or your instance has less than a total of 4 users, you can buy the **Individual License** for each user.
|
||||
|
||||
If your instance has more than 4 users, it is more cost-effective to buy the **Server License**, which will license all the users on your instance.
|
||||
|
||||
### 3. What do I do if I don't pay?
|
||||
|
||||
You can continue using Immich for an unlimited trial period.
|
||||
|
||||
### 4. Will there be any paywalled features?
|
||||
|
||||
No, there will never be any paywalled features.
|
||||
|
||||
### 5. Where can I get support regarding payment issues?
|
||||
|
||||
You can email us with your `orderId` and your email address `billing@futo.org` or on our Discord server.
|
6
e2e/package-lock.json
generated
6
e2e/package-lock.json
generated
@ -56,12 +56,12 @@
|
||||
"devDependencies": {
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@types/byte-size": "^8.1.0",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^20.14.10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.16.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@vitest/coverage-v8": "^1.2.2",
|
||||
"byte-size": "^8.1.1",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
@ -507,7 +507,7 @@ describe('/asset', () => {
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should geocode country from gps data in the middle of nowhere', async () => {
|
||||
it.skip('should geocode country from gps data in the middle of nowhere', async () => {
|
||||
const { status } = await request(app)
|
||||
.put(`/assets/${user1Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
|
@ -95,7 +95,7 @@ export class ServerController {
|
||||
|
||||
@Get('license')
|
||||
@Authenticated({ admin: true })
|
||||
getServerLicense(): Promise<LicenseKeyDto | null> {
|
||||
getServerLicense(): Promise<LicenseResponseDto | null> {
|
||||
return this.service.getLicense();
|
||||
}
|
||||
}
|
||||
|
186
server/src/emails/license.email.tsx
Normal file
186
server/src/emails/license.email.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
import * as CSS from 'csstype';
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Template to be used for FUTOPay project
|
||||
* Variable is {{LICENSEKEY}}
|
||||
* */
|
||||
export const LicenseEmail = () => (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Your Immich Server License</Preview>
|
||||
<Body
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
backgroundColor: '#f4f4f4',
|
||||
color: 'rgb(28,28,28)',
|
||||
fontFamily: 'Overpass, sans-serif',
|
||||
fontSize: '18px',
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
style={{
|
||||
width: '540px',
|
||||
maxWidth: '100%',
|
||||
padding: '10px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<Section
|
||||
style={{
|
||||
padding: '36px',
|
||||
tableLayout: 'fixed',
|
||||
backgroundColor: '#fefefe',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center' as const,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src="https://immich.app/img/immich-logo-inline-light.png"
|
||||
alt="Immich"
|
||||
style={{
|
||||
height: 'auto',
|
||||
margin: '0 auto 48px auto',
|
||||
width: '50%',
|
||||
alignSelf: 'center',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={text}>Thank you for supporting Immich and open-source software</Text>
|
||||
|
||||
<Text style={text}>
|
||||
Your <strong>Immich</strong> license key is
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
background: 'rgb(225, 225, 225)',
|
||||
borderRadius: '16px',
|
||||
marginBottom: '25px',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'monospace', fontWeight: 600, color: 'rgb(66, 80, 175)' }}>
|
||||
{'{{LICENSEKEY}}'}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* <Text style={text}>
|
||||
To activate your instance, you can click the following button or copy and paste the link below to your
|
||||
browser
|
||||
</Text>
|
||||
|
||||
<Row>
|
||||
<Column align="center">
|
||||
<Button
|
||||
style={button}
|
||||
href={`https://my.immich.app/link?target=activate_license&licenseKey={{LICENSEKEY}}&activationKey={{ACTIVATIONKEY}}`}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Column align="center">
|
||||
<a
|
||||
style={{ marginTop: '50px', color: 'rgb(66, 80, 175)', fontSize: '0.9rem' }}
|
||||
href={`https://my.immich.app/link?target=activate_license&licenseKey={{LICENSEKEY}}&activationKey={{ACTIVATIONKEY}}`}
|
||||
>
|
||||
https://my.immich.app/link?target=activate_license&licenseKey={'{{LICENSEKEY}}'}&activationKey=
|
||||
{'{{ACTIVATIONKEY}}'}
|
||||
</a>
|
||||
</Column>
|
||||
</Row> */}
|
||||
</Section>
|
||||
|
||||
<Section style={{ textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column align="center">
|
||||
<Link href="https://futo.org">
|
||||
<Img
|
||||
src="https://futo.org/images/FutoMainLogo.svg"
|
||||
alt="FUTO"
|
||||
style={{
|
||||
height: '24px',
|
||||
marginTop: '25px',
|
||||
marginBottom: '25px',
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Hr style={{ color: 'rgb(66, 80, 175)', marginTop: '0' }} />
|
||||
|
||||
<Section style={{ textAlign: 'center' }}>
|
||||
<Column align="center">
|
||||
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
|
||||
<Img
|
||||
src={`https://immich.app/img/ios-app-store-badge.png`}
|
||||
alt="Immich"
|
||||
style={{ height: '72px', padding: '14px' }}
|
||||
/>
|
||||
</Link>
|
||||
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich">
|
||||
<Img src={`https://immich.app/img/google-play-badge.png`} height="96px" alt="Immich" />
|
||||
</Link>
|
||||
</Column>
|
||||
</Section>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
color: '#6a737d',
|
||||
fontSize: '0.8rem',
|
||||
textAlign: 'center' as const,
|
||||
marginTop: '14px',
|
||||
}}
|
||||
>
|
||||
<Link href="https://immich.app">Immich</Link> project is available under GNU AGPL v3 license.
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
LicenseEmail.PreviewProps = {};
|
||||
|
||||
export default LicenseEmail;
|
||||
|
||||
const text = {
|
||||
margin: '0 0 24px 0',
|
||||
textAlign: 'left' as const,
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
};
|
||||
|
||||
const button: CSS.Properties = {
|
||||
backgroundColor: 'rgb(66, 80, 175)',
|
||||
margin: '1em 0',
|
||||
padding: '0.75em 3em',
|
||||
color: '#fff',
|
||||
fontSize: '1em',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: '9999px',
|
||||
};
|
@ -164,7 +164,7 @@ export class ServerService implements OnEvents {
|
||||
await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE);
|
||||
}
|
||||
|
||||
async getLicense(): Promise<LicenseKeyDto | null> {
|
||||
async getLicense(): Promise<LicenseResponseDto | null> {
|
||||
return this.systemMetadataRepository.get(SystemMetadataKey.LICENSE);
|
||||
}
|
||||
|
||||
|
13
web/package-lock.json
generated
13
web/package-lock.json
generated
@ -47,6 +47,7 @@
|
||||
"@typescript-eslint/parser": "^7.1.0",
|
||||
"@vitest/coverage-v8": "^1.3.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
@ -3740,6 +3741,18 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz",
|
||||
"integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA=="
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
||||
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
|
@ -40,6 +40,7 @@
|
||||
"@typescript-eslint/parser": "^7.1.0",
|
||||
"@vitest/coverage-v8": "^1.3.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
|
5
web/src/app.d.ts
vendored
5
web/src/app.d.ts
vendored
@ -27,3 +27,8 @@ declare namespace svelteHTML {
|
||||
'on:zoomImage'?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '$env/static/public' {
|
||||
export const PUBLIC_IMMICH_PAY_HOST: string;
|
||||
export const PUBLIC_IMMICH_BUY_HOST: string;
|
||||
}
|
||||
|
@ -39,7 +39,7 @@
|
||||
} else if (width === 'narrow') {
|
||||
modalWidth = 'w-[28rem]';
|
||||
} else {
|
||||
modalWidth = 'sm:max-w-lg';
|
||||
modalWidth = 'sm:max-w-4xl';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { mdiPartyPopper } from '@mdi/js';
|
||||
|
||||
export let onDone: () => void;
|
||||
</script>
|
||||
|
||||
<div class="m-auto w-3/4 text-center flex flex-col place-content-center place-items-center mb-6 dark:text-white">
|
||||
<Icon path={mdiPartyPopper} class="text-immich-primary dark:text-immich-dark-primary" size="96" />
|
||||
<p class="text-4xl mt-8 font-bold">{$t('license_activated_title')}</p>
|
||||
<p class="text-lg mt-6">{$t('license_activated_subtitle')}</p>
|
||||
|
||||
<div class="mt-10 w-full">
|
||||
<Button fullwidth on:click={onDone}>OK</Button>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import ServerLicenseCard from './server-license-card.svelte';
|
||||
import UserLicenseCard from './user-license-card.svelte';
|
||||
import { activateLicense, getActivationKey } from '$lib/utils/license-utils';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import { licenseStore } from '$lib/stores/license.store';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let onActivate: () => void;
|
||||
|
||||
let licenseKey = '';
|
||||
let isLoading = false;
|
||||
|
||||
const activate = async () => {
|
||||
try {
|
||||
licenseKey = licenseKey.trim();
|
||||
isLoading = true;
|
||||
|
||||
const activationKey = await getActivationKey(licenseKey);
|
||||
await activateLicense(licenseKey, activationKey);
|
||||
|
||||
onActivate();
|
||||
licenseStore.setLicenseStatus(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('license_failed_activation'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="p-4">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-immich-primary dark:text-immich-dark-primary tracking-wider">
|
||||
{$t('license_license_title')}
|
||||
</h1>
|
||||
<p class="text-lg mt-2 dark:text-immich-gray">{$t('license_license_subtitle')}</p>
|
||||
</div>
|
||||
<div class="flex gap-6 mt-4 justify-between">
|
||||
{#if $user.isAdmin}
|
||||
<ServerLicenseCard />
|
||||
{/if}
|
||||
<UserLicenseCard />
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<p class="dark:text-immich-gray">{$t('license_input_suggestion')}</p>
|
||||
<form class="mt-2 flex gap-2" on:submit={activate}>
|
||||
<input
|
||||
class="immich-form-input w-full"
|
||||
id="licensekey"
|
||||
type="text"
|
||||
bind:value={licenseKey}
|
||||
required
|
||||
placeholder="IMCL-0KEY-0CAN-00BE-FOUD-FROM-YOUR-EMAIL-INBX"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button type="submit" rounded="lg"
|
||||
>{#if isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else}
|
||||
{$t('license_button_activate')}
|
||||
{/if}</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import LicenseActivationSuccess from '$lib/components/shared-components/license/license-activation-success.svelte';
|
||||
import LicenseContent from '$lib/components/shared-components/license/license-content.svelte';
|
||||
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
|
||||
export let onClose: () => void;
|
||||
|
||||
let showLicenseActivated = false;
|
||||
</script>
|
||||
|
||||
<Portal>
|
||||
<FullScreenModal showLogo title={''} {onClose} width="wide">
|
||||
{#if showLicenseActivated}
|
||||
<LicenseActivationSuccess onDone={onClose} />
|
||||
{:else}
|
||||
<LicenseContent
|
||||
onActivate={() => {
|
||||
showLicenseActivated = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</FullScreenModal>
|
||||
</Portal>
|
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { ImmichLicense } from '$lib/constants';
|
||||
import { getLicenseLink } from '$lib/utils/license-utils';
|
||||
import { mdiCheckCircleOutline, mdiServer } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<!-- SERVER LICENSE -->
|
||||
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<Icon path={mdiServer} size="56" />
|
||||
<p class="font-semibold text-lg mt-1">{$t('license_server_title')}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 dark:text-immich-gray">
|
||||
<p class="text-6xl font-bold">$99<span class="text-2xl font-medium">.99</span></p>
|
||||
<p>{$t('license_per_server')}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between h-[200px] dark:text-immich-gray">
|
||||
<div class="mt-6 flex flex-col gap-1">
|
||||
<div class="grid grid-cols-[36px_auto]">
|
||||
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
|
||||
<p class="self-center">{$t('license_server_description_1')}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[36px_auto]">
|
||||
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
|
||||
<p class="self-center">{$t('license_lifetime_description')}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[36px_auto]">
|
||||
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
|
||||
<p class="self-center">{$t('license_server_description_2')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href={getLicenseLink(ImmichLicense.Server)}>
|
||||
<Button fullwidth>{$t('license_button_select')}</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { ImmichLicense } from '$lib/constants';
|
||||
import { getLicenseLink } from '$lib/utils/license-utils';
|
||||
import { mdiAccount, mdiCheckCircleOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<!-- USER LICENSE -->
|
||||
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||
<Icon path={mdiAccount} size="56" />
|
||||
<p class="font-semibold text-lg mt-1">{$t('license_individual_title')}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 dark:text-immich-gray">
|
||||
<p class="text-6xl font-bold">$24<span class="text-2xl font-medium">.99</span></p>
|
||||
<p>{$t('license_per_user')}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between h-[200px] dark:text-immich-gray">
|
||||
<div class="mt-6 flex flex-col gap-1">
|
||||
<div class="grid grid-cols-[36px_auto]">
|
||||
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
|
||||
<p class="self-center">{$t('license_individual_description_1')}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[36px_auto]">
|
||||
<Icon path={mdiCheckCircleOutline} size="24" class="text-green-500 self-center" />
|
||||
<p class="self-center">{$t('license_lifetime_description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href={getLicenseLink(ImmichLicense.Client)}>
|
||||
<Button fullwidth>{$t('license_button_select')}</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
@ -31,6 +31,7 @@
|
||||
|
||||
const logOut = async () => {
|
||||
const { redirectUri } = await logout();
|
||||
|
||||
if (redirectUri.startsWith('/')) {
|
||||
await goto(redirectUri);
|
||||
} else {
|
||||
|
@ -45,6 +45,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@component
|
||||
Allow rendering a component in a different part of the DOM.
|
||||
|
||||
### Props
|
||||
- `target` - HTMLElement i.e "body", "html", default is "body"
|
||||
|
||||
### Default Slot
|
||||
Used for every occurrence of an HTML tag in a message
|
||||
- `tag` - Name of the tag
|
||||
|
||||
@example
|
||||
```html
|
||||
<Portal target="body">
|
||||
<p>Your component in here</p>
|
||||
</Portal>
|
||||
```
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* DOM Element or CSS Selector
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import SideBarLink from '$lib/components/shared-components/side-bar/side-bar-link.svelte';
|
||||
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
|
||||
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@ -17,7 +17,5 @@
|
||||
<SideBarLink title={$t('repair')} routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} />
|
||||
</nav>
|
||||
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
<BottomInfo />
|
||||
</SideBarSection>
|
||||
|
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import LicenseInfo from './license-info.svelte';
|
||||
import ServerStatus from './server-status.svelte';
|
||||
import StorageSpace from './storage-space.svelte';
|
||||
</script>
|
||||
|
||||
<div class="mt-auto">
|
||||
<StorageSpace />
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<LicenseInfo />
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<ServerStatus />
|
||||
</div>
|
@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiClose, mdiInformationOutline, mdiLicense } from '@mdi/js';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import LicenseModal from '$lib/components/shared-components/license/license-modal.svelte';
|
||||
import { licenseStore } from '$lib/stores/license.store';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getAccountAge } from '$lib/utils/auth';
|
||||
|
||||
let showMessage = false;
|
||||
let isOpen = false;
|
||||
const { isLicenseActivated } = licenseStore;
|
||||
|
||||
const openLicenseModal = () => {
|
||||
isOpen = true;
|
||||
showMessage = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<LicenseModal onClose={() => (isOpen = false)} />
|
||||
{/if}
|
||||
|
||||
<div class="hidden md:block license-status pl-4 text-sm">
|
||||
{#if $isLicenseActivated}
|
||||
<button
|
||||
on:click={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-license-settings`)}
|
||||
class="w-full"
|
||||
type="button"
|
||||
>
|
||||
<div class="flex gap-1 mt-2 place-items-center dark:bg-immich-dark-primary/10 bg-gray-100 py-3 px-2 rounded-lg">
|
||||
<Icon path={mdiLicense} size="18" class="text-immich-primary dark:text-immich-dark-primary" />
|
||||
<p class="dark:text-gray-100">{$t('license_info_licensed')}</p>
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
on:click={openLicenseModal}
|
||||
on:mouseenter={() => (showMessage = true)}
|
||||
class="py-3 px-2 flex justify-between place-items-center place-content-center border border-gray-300 dark:border-immich-dark-primary/50 mt-2 rounded-lg shadow-sm dark:bg-immich-dark-primary/10 w-full"
|
||||
>
|
||||
<div class="flex place-items-center place-content-center gap-1">
|
||||
<Icon path={mdiLicense} size="18" class="text-immich-dark-gray/75 dark:text-immich-gray/85" />
|
||||
<p class="text-immich-dark-gray/75 dark:text-immich-gray">{$t('license_info_unlicensed')}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-immich-primary dark:text-immich-dark-primary flex place-items-center gap-[2px] font-medium">
|
||||
{$t('license_button_buy')}
|
||||
|
||||
<span role="contentinfo">
|
||||
<Icon path={mdiInformationOutline}></Icon>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Portal target="body">
|
||||
{#if showMessage && getAccountAge() > 14}
|
||||
<div
|
||||
class="w-[265px] absolute bottom-[75px] left-[255px] bg-white dark:bg-gray-800 dark:text-white text-black rounded-xl z-10 shadow-2xl px-4 py-5"
|
||||
>
|
||||
<div class="flex justify-between place-items-center">
|
||||
<Icon path={mdiLicense} size="44" class="text-immich-dark-gray/75 dark:text-immich-gray" />
|
||||
<CircleIconButton
|
||||
icon={mdiClose}
|
||||
on:click={() => {
|
||||
showMessage = false;
|
||||
}}
|
||||
title="Close"
|
||||
size="18"
|
||||
class="text-immich-dark-gray/85 dark:text-immich-gray"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="text-lg font-medium my-3">{$t('license_trial_info_1')}</h1>
|
||||
<p class="text-immich-dark-gray/80 dark:text-immich-gray text-balance">
|
||||
{$t('license_trial_info_2')}
|
||||
<span class="text-immich-primary dark:text-immich-dark-primary font-semibold">
|
||||
{$t('license_trial_info_3', { values: { accountAge: getAccountAge() } })}</span
|
||||
>. {$t('license_trial_info_4')}
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<Button size="sm" fullwidth on:click={openLicenseModal}>{$t('license_button_buy_license')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Portal>
|
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import { requestServerInfo } from '$lib/utils/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||
|
||||
const { serverVersion, connected } = websocketStore;
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
|
||||
|
||||
let aboutInfo: ServerAboutResponseDto;
|
||||
|
||||
onMount(async () => {
|
||||
await requestServerInfo();
|
||||
aboutInfo = await getAboutInfo();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="text-sm hidden group-hover:sm:flex md:flex pl-5 pr-1 place-items-center place-content-center justify-between"
|
||||
>
|
||||
{#if $connected}
|
||||
<div class="flex gap-2 place-items-center place-content-center">
|
||||
<div class="w-[7px] h-[7px] bg-green-500 rounded-full" />
|
||||
<p class="dark:text-immich-gray">{$t('server_online')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-2 place-items-center place-content-center">
|
||||
<div class="w-[7px] h-[7px] bg-red-500 rounded-full" />
|
||||
<p class="text-red-500">{$t('server_offline')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between justify-items-center">
|
||||
{#if $connected && version}
|
||||
<button type="button" on:click={() => (isOpen = true)} class="dark:text-immich-gray">{version}</button>
|
||||
{:else}
|
||||
<p class="text-red-500">{$t('unknown')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
@ -21,12 +21,12 @@
|
||||
mdiToolbox,
|
||||
mdiToolboxOutline,
|
||||
} from '@mdi/js';
|
||||
import StatusBox from '../status-box.svelte';
|
||||
import SideBarSection from './side-bar-section.svelte';
|
||||
import SideBarLink from './side-bar-link.svelte';
|
||||
import MoreInformationAssets from '$lib/components/shared-components/side-bar/more-information-assets.svelte';
|
||||
import MoreInformationAlbums from '$lib/components/shared-components/side-bar/more-information-albums.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
|
||||
let isArchiveSelected: boolean;
|
||||
let isFavoritesSelected: boolean;
|
||||
@ -136,8 +136,5 @@
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Status Box -->
|
||||
<div class="mb-6 mt-auto">
|
||||
<StatusBox />
|
||||
</div>
|
||||
<BottomInfo />
|
||||
</SideBarSection>
|
||||
|
@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { serverInfo } from '$lib/stores/server-info.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { requestServerInfo } from '$lib/utils/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getByteUnitString } from '../../../utils/byte-units';
|
||||
import LoadingSpinner from '../loading-spinner.svelte';
|
||||
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||
|
||||
let usageClasses = '';
|
||||
let isOpen = false;
|
||||
|
||||
$: hasQuota = $user?.quotaSizeInBytes !== null;
|
||||
$: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
|
||||
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
|
||||
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
|
||||
|
||||
let aboutInfo: ServerAboutResponseDto;
|
||||
|
||||
const onUpdate = () => {
|
||||
usageClasses = getUsageClass();
|
||||
};
|
||||
|
||||
const getUsageClass = () => {
|
||||
if (usedPercentage >= 95) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
if (usedPercentage > 80) {
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return 'bg-immich-primary dark:bg-immich-dark-primary';
|
||||
};
|
||||
|
||||
$: $user && onUpdate();
|
||||
|
||||
onMount(async () => {
|
||||
await requestServerInfo();
|
||||
aboutInfo = await getAboutInfo();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm"
|
||||
title={$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale, 3),
|
||||
available: getByteUnitString(availableBytes, $locale, 3),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div class="hidden group-hover:sm:block md:block">
|
||||
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
||||
|
||||
{#if $serverInfo}
|
||||
<p class="text-gray-500 dark:text-gray-300">
|
||||
{$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale),
|
||||
available: getByteUnitString(availableBytes, $locale),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-2">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { serverInfo } from '$lib/stores/server-info.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import { requestServerInfo } from '$lib/utils/auth';
|
||||
import { mdiChartPie, mdiDns } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getByteUnitString } from '../../utils/byte-units';
|
||||
import LoadingSpinner from './loading-spinner.svelte';
|
||||
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||
|
||||
const { serverVersion, connected } = websocketStore;
|
||||
|
||||
let usageClasses = '';
|
||||
let isOpen = false;
|
||||
|
||||
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
|
||||
$: hasQuota = $user?.quotaSizeInBytes !== null;
|
||||
$: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
|
||||
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
|
||||
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
|
||||
|
||||
let aboutInfo: ServerAboutResponseDto;
|
||||
|
||||
const onUpdate = () => {
|
||||
usageClasses = getUsageClass();
|
||||
};
|
||||
|
||||
const getUsageClass = () => {
|
||||
if (usedPercentage >= 95) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
if (usedPercentage > 80) {
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return 'bg-immich-primary dark:bg-immich-dark-primary';
|
||||
};
|
||||
|
||||
$: $user && onUpdate();
|
||||
|
||||
onMount(async () => {
|
||||
await requestServerInfo();
|
||||
aboutInfo = await getAboutInfo();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
|
||||
{/if}
|
||||
|
||||
<div class="dark:text-immich-dark-fg">
|
||||
<div
|
||||
class="storage-status grid grid-cols-[64px_auto]"
|
||||
title={$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale, 3),
|
||||
available: getByteUnitString(availableBytes, $locale, 3),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div class="pb-[2.15rem] pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
|
||||
<Icon path={mdiChartPie} size="24" />
|
||||
</div>
|
||||
<div class="hidden group-hover:sm:block md:block">
|
||||
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">{$t('storage')}</p>
|
||||
{#if $serverInfo}
|
||||
<div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
{$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale),
|
||||
available: getByteUnitString(availableBytes, $locale),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-2">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<hr class="my-4 ml-5 dark:border-immich-dark-gray" />
|
||||
</div>
|
||||
<div class="server-status grid grid-cols-[64px_auto]">
|
||||
<div class="pb-11 pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
|
||||
<Icon path={mdiDns} size="26" />
|
||||
</div>
|
||||
<div class="hidden text-xs group-hover:sm:block md:block">
|
||||
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">{$t('server')}</p>
|
||||
|
||||
<div class="mt-2 flex justify-between justify-items-center">
|
||||
<p>{$t('status')}</p>
|
||||
|
||||
{#if $connected}
|
||||
<p class="font-medium text-immich-primary dark:text-immich-dark-primary">{$t('online')}</p>
|
||||
{:else}
|
||||
<p class="font-medium text-red-500">{$t('offline')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex justify-between justify-items-center">
|
||||
<p>{$t('version')}</p>
|
||||
{#if $connected && version}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (isOpen = true)}
|
||||
class="font-medium text-immich-primary dark:text-immich-dark-primary">{version}</button
|
||||
>
|
||||
{:else}
|
||||
<p class="font-medium text-red-500">{$t('unknown')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { licenseStore } from '$lib/stores/license.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import {
|
||||
deleteServerLicense,
|
||||
deleteUserLicense,
|
||||
getAboutInfo,
|
||||
getMyUser,
|
||||
getServerLicense,
|
||||
type LicenseResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiLicense } from '@mdi/js';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import LicenseContent from '$lib/components/shared-components/license/license-content.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { getAccountAge } from '$lib/utils/auth';
|
||||
const { isLicenseActivated } = licenseStore;
|
||||
|
||||
let isServerLicense = false;
|
||||
let serverLicenseInfo: LicenseResponseDto | null = null;
|
||||
const accountAge = getAccountAge();
|
||||
|
||||
const checkLicenseInfo = async () => {
|
||||
const serverInfo = await getAboutInfo();
|
||||
isServerLicense = serverInfo.licensed;
|
||||
|
||||
const userInfo = await getMyUser();
|
||||
if (userInfo.license) {
|
||||
$user = { ...$user, license: userInfo.license };
|
||||
}
|
||||
|
||||
if (isServerLicense && $user.isAdmin) {
|
||||
serverLicenseInfo = (await getServerLicense()) as LicenseResponseDto | null;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!$isLicenseActivated) {
|
||||
return;
|
||||
}
|
||||
|
||||
await checkLicenseInfo();
|
||||
});
|
||||
|
||||
const removeUserLicense = async () => {
|
||||
try {
|
||||
const isConfirmed = await dialogController.show({
|
||||
title: 'Remove License',
|
||||
prompt: 'Are you sure you want to remove the license?',
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteUserLicense();
|
||||
licenseStore.setLicenseStatus(false);
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to remove license');
|
||||
}
|
||||
};
|
||||
|
||||
const removeServerLicense = async () => {
|
||||
try {
|
||||
const isConfirmed = await dialogController.show({
|
||||
title: 'Remove License',
|
||||
prompt: 'Are you sure you want to remove the Server license?',
|
||||
confirmText: 'Remove',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteServerLicense();
|
||||
licenseStore.setLicenseStatus(false);
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to remove license');
|
||||
}
|
||||
};
|
||||
|
||||
const onLicenseActivated = async () => {
|
||||
licenseStore.setLicenseStatus(true);
|
||||
await checkLicenseInfo();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
{#if $isLicenseActivated}
|
||||
{#if isServerLicense}
|
||||
<div
|
||||
class="bg-gray-50 border border-immich-dark-primary/50 dark:bg-immich-dark-primary/15 p-6 pr-12 rounded-xl flex place-content-center gap-4"
|
||||
>
|
||||
<Icon path={mdiLicense} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">Server License</p>
|
||||
|
||||
{#if $user.isAdmin && serverLicenseInfo?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
Activated on {new Date(serverLicenseInfo?.activatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="dark:text-white">Your license is managed by the admin</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right mt-4">
|
||||
<Button size="sm" color="red" on:click={removeServerLicense}>Remove license</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-gray-50 border border-immich-dark-primary/50 dark:bg-immich-dark-primary/15 p-6 pr-12 rounded-xl flex place-content-center gap-4"
|
||||
>
|
||||
<Icon path={mdiLicense} size="56" class="text-immich-primary dark:text-immich-dark-primary" />
|
||||
|
||||
<div>
|
||||
<p class="text-immich-primary dark:text-immich-dark-primary font-semibold text-lg">Individual License</p>
|
||||
{#if $user.license?.activatedAt}
|
||||
<p class="dark:text-white text-sm mt-1 col-start-2">
|
||||
Activated on {new Date($user.license?.activatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right mt-4">
|
||||
<Button size="sm" color="red" on:click={removeUserLicense}>Remove license</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if accountAge > 14}
|
||||
<div
|
||||
class="text-center bg-gray-100 border border-immich-dark-primary/50 dark:bg-immich-dark-primary/15 p-4 rounded-xl"
|
||||
>
|
||||
<p class="text-immich-dark-gray/80 dark:text-immich-gray text-balance">
|
||||
{$t('license_trial_info_2')}
|
||||
<span class="text-immich-primary dark:text-immich-dark-primary font-semibold">
|
||||
{$t('license_trial_info_3', { values: { accountAge } })}</span
|
||||
>. {$t('license_trial_info_4')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<LicenseContent onActivate={onLicenseActivated} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
@ -18,6 +18,7 @@
|
||||
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
|
||||
import LicenseSettings from '$lib/components/user-settings-page/license-settings.svelte';
|
||||
|
||||
export let keys: ApiKeyResponseDto[] = [];
|
||||
export let sessions: SessionResponseDto[] = [];
|
||||
@ -52,6 +53,14 @@
|
||||
<DownloadSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="user-license-settings"
|
||||
title={$t('user_license_settings')}
|
||||
subtitle={$t('user_license_settings_description')}
|
||||
>
|
||||
<LicenseSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="memories" title={$t('memories')} subtitle={$t('memories_setting_description')}>
|
||||
<MemoriesSettings />
|
||||
</SettingAccordion>
|
||||
|
@ -34,6 +34,7 @@ export enum AppRoute {
|
||||
MEMORY = '/memory',
|
||||
TRASH = '/trash',
|
||||
PARTNERS = '/partners',
|
||||
BUY = '/buy',
|
||||
|
||||
AUTH_LOGIN = '/auth/login',
|
||||
AUTH_REGISTER = '/auth/register',
|
||||
@ -309,3 +310,8 @@ export const langs = [
|
||||
},
|
||||
{ name: 'Development (keys only)', code: 'dev', loader: () => Promise.resolve({}) },
|
||||
];
|
||||
|
||||
export enum ImmichLicense {
|
||||
Client = 'immich-client',
|
||||
Server = 'immich-server',
|
||||
}
|
||||
|
@ -403,6 +403,7 @@
|
||||
"bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!",
|
||||
"bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.",
|
||||
"bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.",
|
||||
"buy": "Purchase License",
|
||||
"camera": "Camera",
|
||||
"camera_brand": "Camera brand",
|
||||
"camera_model": "Camera model",
|
||||
@ -742,6 +743,31 @@
|
||||
"level": "Level",
|
||||
"library": "Library",
|
||||
"library_options": "Library options",
|
||||
"license_account_info": "Your account is licensed",
|
||||
"license_activated_subtitle": "Thank you for supporting Immich and open-source software",
|
||||
"license_activated_title": "Your license has been successfully activated",
|
||||
"license_button_activate": "Activate",
|
||||
"license_button_buy": "Buy",
|
||||
"license_button_buy_license": "Buy License",
|
||||
"license_button_select": "Select",
|
||||
"license_failed_activation": "Failed to activate license. Please check your email for the the correct license key!",
|
||||
"license_individual_description_1": "1 license per user on any server",
|
||||
"license_individual_title": "Individual License",
|
||||
"license_info_licensed": "Licensed",
|
||||
"license_info_unlicensed": "Unlicensed",
|
||||
"license_input_suggestion": "Have a license? Enter the key below",
|
||||
"license_license_subtitle": "Buy a license to support Immich",
|
||||
"license_license_title": "LICENSE",
|
||||
"license_lifetime_description": "Lifetime license",
|
||||
"license_per_server": "Per server",
|
||||
"license_per_user": "Per user",
|
||||
"license_server_description_1": "1 license per server",
|
||||
"license_server_description_2": "License for all users on the server",
|
||||
"license_server_title": "Server License",
|
||||
"license_trial_info_1": "You are running an Unlicensed version of Immich",
|
||||
"license_trial_info_2": "You have been using Immich for approximately",
|
||||
"license_trial_info_3": "{accountAge, plural, one {# day} other {# days}}",
|
||||
"license_trial_info_4": "Please considering purchasing a license to support the continued development of the service",
|
||||
"light": "Light",
|
||||
"like_deleted": "Like deleted",
|
||||
"link_options": "Link options",
|
||||
@ -1007,7 +1033,8 @@
|
||||
"selected_count": "{count, plural, other {# selected}}",
|
||||
"send_message": "Send message",
|
||||
"send_welcome_email": "Send welcome email",
|
||||
"server": "Server",
|
||||
"server_offline": "Server Offline",
|
||||
"server_online": "Server Online",
|
||||
"server_stats": "Server Stats",
|
||||
"server_version": "Server Version",
|
||||
"set": "Set",
|
||||
@ -1073,7 +1100,7 @@
|
||||
"stop_photo_sharing": "Stop sharing your photos?",
|
||||
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
|
||||
"stop_sharing_photos_with_user": "Stop sharing your photos with this user",
|
||||
"storage": "Storage",
|
||||
"storage": "Storage space",
|
||||
"storage_label": "Storage label",
|
||||
"storage_usage": "{used} of {available} used",
|
||||
"submit": "Submit",
|
||||
@ -1136,6 +1163,8 @@
|
||||
"use_custom_date_range": "Use custom date range instead",
|
||||
"user": "User",
|
||||
"user_id": "User ID",
|
||||
"user_license_settings": "License",
|
||||
"user_license_settings_description": "Manage your license",
|
||||
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
|
||||
"user_role_set": "Set {user} as {role}",
|
||||
"user_usage_detail": "User usage detail",
|
||||
|
18
web/src/lib/stores/license.store.ts
Normal file
18
web/src/lib/stores/license.store.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
function createLicenseStore() {
|
||||
const isLicenseActivated = writable(false);
|
||||
|
||||
function setLicenseStatus(status: boolean) {
|
||||
isLicenseActivated.set(status);
|
||||
}
|
||||
|
||||
return {
|
||||
isLicenseActivated: {
|
||||
subscribe: isLicenseActivated.subscribe,
|
||||
},
|
||||
setLicenseStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export const licenseStore = createLicenseStore();
|
@ -1,3 +1,4 @@
|
||||
import { licenseStore } from '$lib/stores/license.store';
|
||||
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
@ -11,4 +12,5 @@ export const preferences = writable<UserPreferencesResponseDto>();
|
||||
export const resetSavedUser = () => {
|
||||
user.set(undefined as unknown as UserAdminResponseDto);
|
||||
preferences.set(undefined as unknown as UserPreferencesResponseDto);
|
||||
licenseStore.setLicenseStatus(false);
|
||||
};
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { licenseStore } from '$lib/stores/license.store';
|
||||
import { serverInfo } from '$lib/stores/server-info.store';
|
||||
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
||||
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';
|
||||
import { AppRoute } from '../constants';
|
||||
|
||||
@ -15,10 +17,17 @@ 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);
|
||||
|
||||
// Check for license status
|
||||
if (serverInfo.licensed || user.license?.activatedAt) {
|
||||
licenseStore.setLicenseStatus(true);
|
||||
}
|
||||
}
|
||||
return user;
|
||||
} catch {
|
||||
@ -64,3 +73,17 @@ export const requestServerInfo = async () => {
|
||||
serverInfo.set(data);
|
||||
}
|
||||
};
|
||||
|
||||
export const getAccountAge = (): number => {
|
||||
const user = get(user$);
|
||||
|
||||
if (!user) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const createdDate = DateTime.fromISO(user.createdAt);
|
||||
const now = DateTime.now();
|
||||
const accountAge = now.diff(createdDate, 'days').days.toFixed(0);
|
||||
|
||||
return Number(accountAge);
|
||||
};
|
||||
|
26
web/src/lib/utils/license-utils.ts
Normal file
26
web/src/lib/utils/license-utils.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { PUBLIC_IMMICH_BUY_HOST, PUBLIC_IMMICH_PAY_HOST } from '$env/static/public';
|
||||
import type { ImmichLicense } from '$lib/constants';
|
||||
import { serverConfig } from '$lib/stores/server-config.store';
|
||||
import { setServerLicense, setUserLicense, type LicenseResponseDto } from '@immich/sdk';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const activateLicense = async (licenseKey: string, activationKey: string): Promise<LicenseResponseDto> => {
|
||||
const isServerKey = licenseKey.search('IMSV') !== -1;
|
||||
const licenseKeyDto = { licenseKey, activationKey };
|
||||
return isServerKey ? setServerLicense({ licenseKeyDto }) : setUserLicense({ licenseKeyDto });
|
||||
};
|
||||
|
||||
export const getActivationKey = async (licenseKey: string): Promise<string> => {
|
||||
const response = await fetch(new URL(`/api/v1/activate/${licenseKey}`, PUBLIC_IMMICH_PAY_HOST).href);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch activation key');
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const getLicenseLink = (license: ImmichLicense) => {
|
||||
const url = new URL('/', PUBLIC_IMMICH_BUY_HOST);
|
||||
url.searchParams.append('productId', license);
|
||||
url.searchParams.append('instanceUrl', get(serverConfig).externalDomain || window.origin);
|
||||
return url.href;
|
||||
};
|
53
web/src/routes/(user)/buy/+page.svelte
Normal file
53
web/src/routes/(user)/buy/+page.svelte
Normal file
@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import LicenseActivationSuccess from '$lib/components/shared-components/license/license-activation-success.svelte';
|
||||
import LicenseContent from '$lib/components/shared-components/license/license-content.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { mdiAlertCircleOutline, mdiLicense } from '@mdi/js';
|
||||
import { licenseStore } from '$lib/stores/license.store';
|
||||
|
||||
export let data: PageData;
|
||||
let showLicenseActivated = false;
|
||||
const { isLicenseActivated } = licenseStore;
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={$t('buy')}>
|
||||
<section class="mx-4 flex place-content-center">
|
||||
<div class={`w-full ${$user.isAdmin ? 'max-w-3xl' : 'max-w-xl'}`}>
|
||||
{#if data.isActivated === false}
|
||||
<div
|
||||
class="bg-red-100 text-red-700 px-4 py-3 rounded-md flex place-items-center place-content-center gap-2"
|
||||
role="alert"
|
||||
>
|
||||
<Icon path={mdiAlertCircleOutline} size="18" />
|
||||
<p>{$t('license_failed_activation')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $isLicenseActivated}
|
||||
<div
|
||||
class="bg-immich-primary/10 text-immich-primary px-4 py-3 rounded-md flex place-items-center place-content-center gap-2 mb-5 dark:text-black dark:bg-immich-dark-primary"
|
||||
role="alert"
|
||||
>
|
||||
<Icon path={mdiLicense} size="24" />
|
||||
<p>{$t('license_account_info')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showLicenseActivated || data.isActivated === true}
|
||||
<LicenseActivationSuccess onDone={() => goto(AppRoute.PHOTOS, { replaceState: false })} />
|
||||
{:else}
|
||||
<LicenseContent
|
||||
onActivate={() => {
|
||||
showLicenseActivated = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</UserPageLayout>
|
38
web/src/routes/(user)/buy/+page.ts
Normal file
38
web/src/routes/(user)/buy/+page.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { licenseStore } from '$lib/stores/license.store';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { activateLicense, getActivationKey } from '$lib/utils/license-utils';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate();
|
||||
|
||||
const $t = await getFormatter();
|
||||
const licenseKey = url.searchParams.get('licenseKey');
|
||||
let activationKey = url.searchParams.get('activationKey');
|
||||
let isActivated: boolean | undefined = undefined;
|
||||
|
||||
try {
|
||||
if (licenseKey && !activationKey) {
|
||||
activationKey = await getActivationKey(licenseKey);
|
||||
}
|
||||
|
||||
if (licenseKey && activationKey) {
|
||||
const response = await activateLicense(licenseKey, activationKey);
|
||||
if (response.activatedAt !== '') {
|
||||
isActivated = true;
|
||||
licenseStore.setLicenseStatus(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
isActivated = false;
|
||||
console.log('error navigating to /buy', error);
|
||||
}
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: $t('buy'),
|
||||
},
|
||||
isActivated,
|
||||
};
|
||||
}) satisfies PageLoad;
|
@ -22,7 +22,6 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let showNavigationLoadingBar = false;
|
||||
|
||||
$: changeTheme($colorTheme);
|
||||
|
||||
$: if ($user) {
|
||||
|
@ -7,11 +7,11 @@ export const load = (({ url }) => {
|
||||
HOME = 'home',
|
||||
UNSUBSCRIBE = 'unsubscribe',
|
||||
VIEW_ASSET = 'view_asset',
|
||||
ACTIVATE_LICENSE = 'activate_license',
|
||||
}
|
||||
|
||||
const queryParams = url.searchParams;
|
||||
const target = queryParams.get('target') as LinkTarget;
|
||||
|
||||
switch (target) {
|
||||
case LinkTarget.HOME: {
|
||||
return redirect(302, AppRoute.PHOTOS);
|
||||
@ -28,6 +28,26 @@ export const load = (({ url }) => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case LinkTarget.ACTIVATE_LICENSE: {
|
||||
// https://my.immich.app/link?target=activate_license&licenseKey=IMCL-76S5-B4KG-4HXA-KRQF-C1G1-7PJ6-9V9V-7WQH
|
||||
// https://my.immich.app/link?target=activate_license&licenseKey=IMCL-9XC3-T4S3-37BU-GGJ5-8MWP-F2Y1-BGEX-AQTF
|
||||
const licenseKey = queryParams.get('licenseKey');
|
||||
const activationKey = queryParams.get('activationKey');
|
||||
const redirectUrl = new URL(AppRoute.BUY, url.origin);
|
||||
|
||||
if (licenseKey) {
|
||||
redirectUrl.searchParams.append('licenseKey', licenseKey);
|
||||
|
||||
if (activationKey) {
|
||||
redirectUrl.searchParams.append('activationKey', activationKey);
|
||||
}
|
||||
|
||||
return redirect(302, redirectUrl);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(302, AppRoute.PHOTOS);
|
||||
|
@ -1,5 +1,11 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
process.env.PUBLIC_IMMICH_BUY_HOST = process.env.PUBLIC_IMMICH_BUY_HOST || 'https://buy.immich.app';
|
||||
process.env.PUBLIC_IMMICH_PAY_HOST = process.env.PUBLIC_IMMICH_PAY_HOST || 'https://pay.futo.org';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
|
Loading…
Reference in New Issue
Block a user