Browse Source

Merge branch 'main' into feature/oidc-auth

pull/5981/head
Germán Martín 2 days ago
committed by GitHub
parent
commit
f76dd63111
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 13
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  3. 6
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  4. 8
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  5. 94
      libs/ui/src/lib/membership-card/membership-card.component.html
  6. 244
      libs/ui/src/lib/membership-card/membership-card.component.scss
  7. 5
      libs/ui/src/lib/membership-card/membership-card.component.stories.ts
  8. 1
      libs/ui/src/lib/membership-card/membership-card.component.ts

5
CHANGELOG.md

@ -10,11 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added _OpenID Connect_ (`OIDC`) as a new login provider (experimental)
- Introduced data source transformation support in the import functionality for self-hosted environments
- Added an optional 3D hover effect to the membership card component
#### Changed
- Increased the numerical precision for cryptocurrency quantities in the holding detail dialog
- Upgraded `envalid` from version `8.1.0` to `8.1.1`
- Upgraded `prettier` from version `3.7.3` to `3.7.4`
- Upgraded `prettier` fr version `3.7.3` to `3.7.4`
## 2.221.0 - 2025-12-01

13
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -69,6 +69,19 @@ export class TransformDataSourceInRequestInterceptor<
});
}
}
} else {
if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;
} else {
return {
...activity,
dataSource: decodeDataSource(activity.dataSource)
};
}
});
}
}
return next.handle();

6
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -411,10 +411,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) {
this.quantityPrecision = 7;
if (this.quantity < 10) {
this.quantityPrecision = 8;
} else if (this.quantity < 1000) {
this.quantityPrecision = 5;
this.quantityPrecision = 6;
} else if (this.quantity >= 10000000) {
this.quantityPrecision = 0;
}

8
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -54,23 +54,30 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
public durationExtension: StringValue;
public hasPermissionToCreateUser: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
);
public importAndExportTooltipOSS = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
);
public importAndExportTooltipPremium = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM'
);
public isLoggedIn: boolean;
public label: string;
public price: number;
public priceId: string;
public professionalDataProviderTooltipPremium = translate(
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
);
public referralBrokers = [
'Alpian',
'DEGIRO',
'finpension',
'frankly',
@ -80,6 +87,7 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
'VIAC',
'Zak'
];
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public user: User;

94
libs/ui/src/lib/membership-card/membership-card.component.html

@ -1,50 +1,54 @@
<div
class="card-container position-relative"
[ngClass]="{ premium: name === 'Premium' }"
>
<a
class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing"
>
<div class="d-flex justify-content-end">
<gf-logo
size="large"
[ngClass]="{ 'text-muted': name === 'Basic' }"
[showLabel]="false"
/>
</div>
@if (hasPermissionToCreateApiKey) {
<div class="mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
<div class="card-wrapper position-relative" [class.hover-3d]="hover3d">
<div class="card-container" [ngClass]="{ premium: name === 'Premium' }">
<a
class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing"
>
@if (hover3d) {
@for (zone of [1, 2, 3, 4, 5, 6, 7, 8, 9]; track zone) {
<span class="hover-zone position-absolute"></span>
}
}
<div class="d-flex justify-content-end">
<gf-logo
size="large"
[ngClass]="{ 'text-muted': name === 'Basic' }"
[showLabel]="false"
/>
</div>
@if (expiresAt) {
<div>
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
@if (hasPermissionToCreateApiKey) {
<div class="mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="bg-transparent no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
</div>
</a>
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
</div>
@if (expiresAt) {
<div>
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
</div>
</div>
}
</div>
</a>
</div>
</div>

244
libs/ui/src/lib/membership-card/membership-card.component.scss

@ -1,71 +1,231 @@
:host {
--borderRadius: 1rem;
--borderWidth: 2px;
--hover3dSpotlightOpacity: 0.2;
display: block;
max-width: 25rem;
padding-top: calc(1 * var(--borderWidth));
width: 100%;
.card-container {
border-radius: var(--borderRadius);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
.card-wrapper {
&.hover-3d {
perspective: 1000px;
}
&:after {
animation: animatedborder 7s ease alternate infinite;
background: linear-gradient(60deg, #5073b8, #1098ad, #07b39b, #6fba82);
background-size: 300% 300%;
.card-container {
border-radius: var(--borderRadius);
content: '';
height: calc(100% + var(--borderWidth) * 2);
left: calc(-1 * var(--borderWidth));
top: calc(-1 * var(--borderWidth));
position: absolute;
width: calc(100% + var(--borderWidth) * 2);
z-index: -1;
@keyframes animatedborder {
0% {
background-position: 0% 50%;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
.card-item {
aspect-ratio: 1.586;
background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text));
line-height: 1.2;
button {
color: rgba(var(--light-primary-text));
height: 1.5rem;
z-index: 3;
}
50% {
background-position: 100% 50%;
.heading {
font-size: 13px;
}
100% {
background-position: 0% 50%;
.value {
font-size: 18px;
}
}
&:not(.premium) {
&::after {
opacity: 0;
}
.card-item {
background-color: #ffffff;
color: rgba(var(--dark-primary-text));
}
}
}
.card-item {
aspect-ratio: 1.586;
background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text));
line-height: 1.2;
&.hover-3d {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 100%;
button {
color: rgba(var(--light-primary-text));
height: 1.5rem;
.card-container {
overflow: hidden;
position: relative;
scale: 1;
transform: rotate3d(
var(--hover3d-rotate-x),
var(--hover3d-rotate-y),
0,
10deg
);
transform-style: preserve-3d;
transition:
box-shadow 400ms ease-out,
scale 500ms ease-out,
transform 500ms ease-out;
will-change: transform, scale;
&::before {
background-image: radial-gradient(
circle at 50%,
rgba(255, 255, 255, var(--hover3dSpotlightOpacity)) 10%,
transparent 50%
);
content: '';
filter: blur(0.75rem);
height: 33.333%;
opacity: 0;
pointer-events: none;
position: absolute;
scale: 500%;
translate: var(--hover3d-shine);
transition:
opacity 400ms ease-out,
translate 400ms ease-out;
width: 33.333%;
z-index: 1;
}
.card-item {
position: relative;
.hover-zone {
height: 33.333%;
width: 33.333%;
z-index: 2;
&:nth-child(1) {
left: 0;
top: 0;
}
&:nth-child(2) {
left: 33.333%;
top: 0;
}
&:nth-child(3) {
right: 0;
top: 0;
}
&:nth-child(4) {
left: 0;
top: 33.333%;
}
&:nth-child(5) {
left: 33.333%;
top: 33.333%;
}
&:nth-child(6) {
right: 0;
top: 33.333%;
}
&:nth-child(7) {
bottom: 0;
left: 0;
}
&:nth-child(8) {
bottom: 0;
left: 33.333%;
}
&:nth-child(9) {
bottom: 0;
right: 0;
}
}
}
}
.heading {
font-size: 13px;
&:has(.hover-zone:hover) .card-container {
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3);
scale: 1.05;
&::before {
opacity: 1;
}
}
.value {
font-size: 18px;
&:has(.hover-zone:nth-child(1):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 0%;
}
}
&:not(.premium) {
&:after {
opacity: 0;
&:has(.hover-zone:nth-child(2):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 0%;
}
.card-item {
background-color: #ffffff;
color: rgba(var(--dark-primary-text));
&:has(.hover-zone:nth-child(3):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 0%;
}
&:has(.hover-zone:nth-child(4):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 100%;
}
&:has(.hover-zone:nth-child(5):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 100%;
}
&:has(.hover-zone:nth-child(6):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 100%;
}
&:has(.hover-zone:nth-child(7):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 200%;
}
&:has(.hover-zone:nth-child(8):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 200%;
}
&:has(.hover-zone:nth-child(9):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 200%;
}
}
}
@media (prefers-reduced-motion: reduce) {
.card-wrapper.hover-3d {
.card-container {
scale: 1 !important;
transform: none !important;
transition: none !important;
&::before {
opacity: 0 !important;
transition: none !important;
}
}
}
}

5
libs/ui/src/lib/membership-card/membership-card.component.stories.ts

@ -26,6 +26,9 @@ export default {
})
],
argTypes: {
hover3d: {
control: { type: 'boolean' }
},
name: {
control: { type: 'select' },
options: ['Basic', 'Premium']
@ -37,6 +40,7 @@ type Story = StoryObj<GfMembershipCardComponent>;
export const Basic: Story = {
args: {
hover3d: false,
name: 'Basic'
}
};
@ -45,6 +49,7 @@ export const Premium: Story = {
args: {
expiresAt: addYears(new Date(), 1).toLocaleDateString(),
hasPermissionToCreateApiKey: true,
hover3d: false,
name: 'Premium'
}
};

1
libs/ui/src/lib/membership-card/membership-card.component.ts

@ -34,6 +34,7 @@ import { GfLogoComponent } from '../logo';
export class GfMembershipCardComponent {
@Input() public expiresAt: string;
@Input() public hasPermissionToCreateApiKey: boolean;
@Input() public hover3d = false;
@Input() public name: string;
@Output() generateApiKeyClicked = new EventEmitter<void>();

Loading…
Cancel
Save