Browse Source

Merge remote-tracking branch 'origin/main' into feature/extend-holdings-endpoint-for-cash

pull/5650/head
KenTandrian 3 weeks ago
parent
commit
51917b0fe1
  1. 1
      .github/FUNDING.yml
  2. 9
      CHANGELOG.md
  3. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  4. 3
      apps/api/src/app/order/create-order.dto.ts
  5. 16
      apps/api/src/main.ts
  6. 2
      apps/client/src/app/pages/landing/landing-page.component.ts
  7. 105
      apps/client/src/app/pages/landing/landing-page.html
  8. 76
      apps/client/src/app/pages/landing/landing-page.scss
  9. 59
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  10. 35
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  11. 16
      apps/client/src/app/pages/portfolio/fire/fire-page.scss
  12. 1
      libs/ui/src/lib/logo-carousel/index.ts
  13. 7
      libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts
  14. 16
      libs/ui/src/lib/logo-carousel/logo-carousel.component.html
  15. 213
      libs/ui/src/lib/logo-carousel/logo-carousel.component.scss
  16. 13
      libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts
  17. 110
      libs/ui/src/lib/logo-carousel/logo-carousel.component.ts

1
.github/FUNDING.yml

@ -1 +1,2 @@
buy_me_a_coffee: ghostfolio
github: ghostfolio

9
CHANGELOG.md

@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added support for configuring the safe withdrawal rate in the _FIRE_ section (experimental)
### Changed
- Changed the _As seen in_ section on the landing page to an animated carousel
- Refactored various components to use self-closing tags
### Fixed
- Fixed the server startup message to properly display IPv6 addresses
## 2.207.0 - 2025-10-08
### Added

2
apps/api/src/app/endpoints/public/public.controller.ts

@ -40,7 +40,7 @@ export class PublicController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio(
@Param('accessId') accessId
@Param('accessId') accessId: string
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId });

3
apps/api/src/app/order/create-order.dto.ts

@ -44,8 +44,7 @@ export class CreateOrderDto {
customCurrency?: string;
@IsEnum(DataSource)
@IsOptional()
dataSource?: DataSource;
dataSource: DataSource;
@IsISO8601()
@Validate(IsAfter1970Constraint)

16
apps/api/src/main.ts

@ -90,7 +90,21 @@ async function bootstrap() {
await app.listen(PORT, HOST, () => {
logLogo();
Logger.log(`Listening at http://${HOST}:${PORT}`);
let address = app.getHttpServer().address();
if (typeof address === 'object') {
const addressObject = address;
let host = addressObject.address;
if (addressObject.family === 'IPv6') {
host = `[${addressObject.address}]`;
}
address = `${host}:${addressObject.port}`;
}
Logger.log(`Listening at http://${address}`);
Logger.log('');
});
}

2
apps/client/src/app/pages/landing/landing-page.component.ts

@ -4,6 +4,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfCarouselComponent } from '@ghostfolio/ui/carousel';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { GfLogoCarouselComponent } from '@ghostfolio/ui/logo-carousel';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
@ -27,6 +28,7 @@ import { Subject } from 'rxjs';
imports: [
CommonModule,
GfCarouselComponent,
GfLogoCarouselComponent,
GfLogoComponent,
GfValueComponent,
GfWorldMapChartComponent,

105
apps/client/src/app/pages/landing/landing-page.html

@ -114,109 +114,8 @@
<div class="col-12 text-center text-muted">
<small i18n>As seen in</small>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-alternative-to mask"
href="https://alternativeto.net"
target="_blank"
title="AlternativeTo - Crowdsourced software recommendations"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-awesome"
href="https://github.com/awesome-selfhosted/awesome-selfhosted"
target="_blank"
title="Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-dev-community mask"
href="https://dev.to"
target="_blank"
title="DEV Community - A constructive and inclusive social network for software developers"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-hacker-news mask"
href="https://news.ycombinator.com"
target="_blank"
title="Hacker News"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-openalternative mask"
href="https://openalternative.co"
target="_blank"
title="OpenAlternative: Open Source Alternatives to Popular Software"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-privacy-tools mask"
href="https://www.privacytools.io"
target="_blank"
title="Privacy Tools: Software Alternatives and Encryption"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-product-hunt"
href="https://www.producthunt.com"
target="_blank"
title="Product Hunt – The best new products in tech."
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-reddit mask"
href="https://www.reddit.com"
target="_blank"
title="Reddit - Dive into anything"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-sackgeld mask"
href="https://www.sackgeld.com"
target="_blank"
title="Sackgeld.com – Apps für ein höheres Sackgeld"
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-selfh-st mask"
href="https://selfh.st"
target="_blank"
title="selfh.st — Self-hosted content and software"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-sourceforge mask"
href="https://sourceforge.net"
target="_blank"
title="SourceForge: The Complete Open-Source and Business Software Platform"
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-umbrel mask"
href="https://umbrel.com"
target="_blank"
title="Umbrel — A personal server OS for self-hosting"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-unraid mask"
href="https://unraid.net"
target="_blank"
title="Unraid | Unleash Your Hardware"
></a>
<div class="col-12">
<gf-logo-carousel class="py-3" />
</div>
</div>

76
apps/client/src/app/pages/landing/landing-page.scss

@ -34,69 +34,6 @@
&.logo-agplv3 {
mask-image: url('/assets/images/logo-AGPLv3.svg');
}
&.logo-alternative-to {
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-dev-community {
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openalternative {
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-privacy-tools {
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
}
.outro-inner-container {
@ -128,18 +65,7 @@
}
.logo {
&.logo-agplv3,
&.logo-alternative-to,
&.logo-dev-community,
&.logo-hacker-news,
&.logo-openalternative,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
&.logo-agplv3 {
background-color: rgba(var(--light-primary-text));
}
}

59
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -7,8 +7,10 @@ import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { NgStyle } from '@angular/common';
import { CommonModule, NgStyle } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormControl } from '@angular/forms';
import { Big } from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -17,11 +19,14 @@ import { takeUntil } from 'rxjs/operators';
@Component({
imports: [
CommonModule,
FormsModule,
GfFireCalculatorComponent,
GfPremiumIndicatorComponent,
GfValueComponent,
NgStyle,
NgxSkeletonLoaderModule
NgxSkeletonLoaderModule,
ReactiveFormsModule
],
selector: 'gf-fire-page',
styleUrls: ['./fire-page.scss'],
@ -33,6 +38,8 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false;
public safeWithdrawalRateControl = new FormControl<number>(undefined);
public safeWithdrawalRateOptions = [0.025, 0.03, 0.035, 0.04, 0.045];
public user: User;
public withdrawalRatePerMonth: Big;
public withdrawalRatePerYear: Big;
@ -70,11 +77,7 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
};
}
this.withdrawalRatePerYear = Big(
this.fireWealth.today.valueInBaseCurrency
).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
this.calculateWithdrawalRates();
this.isLoading = false;
@ -88,6 +91,12 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!impersonationId;
});
this.safeWithdrawalRateControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((value) => {
this.onSafeWithdrawalRateChange(Number(value));
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -102,6 +111,13 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings
);
this.safeWithdrawalRateControl.setValue(
this.user.settings.safeWithdrawalRate,
{ emitEvent: false }
);
this.calculateWithdrawalRates();
this.changeDetectorRef.markForCheck();
}
});
@ -141,6 +157,25 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
});
});
}
public onSafeWithdrawalRateChange(safeWithdrawalRate: number) {
this.dataService
.putUserSetting({ safeWithdrawalRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.calculateWithdrawalRates();
this.changeDetectorRef.markForCheck();
});
});
}
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
@ -180,4 +215,14 @@ export class GfFirePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private calculateWithdrawalRates() {
if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) {
this.withdrawalRatePerYear = new Big(
this.fireWealth.today.valueInBaseCurrency
).mul(this.user.settings.safeWithdrawalRate);
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
}
}
}

35
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -105,15 +105,32 @@
</span>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>and a safe withdrawal rate (SWR) of</ng-container>
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="1"
[value]="user?.settings?.safeWithdrawalRate" /></span
>.
@if (
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
) {
<select
class="border-0 cursor-pointer d-inline-block font-weight-bold safe-withdrawal-rate-select"
[formControl]="safeWithdrawalRateControl"
>
@for (rate of safeWithdrawalRateOptions; track rate) {
<option [value]="rate">
{{ rate | percent: '1.1-1' }}
</option>
}</select
>.
} @else {
<ng-container>&nbsp;</ng-container>
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[isPercent]="true"
[locale]="user?.settings?.locale"
[precision]="1"
[value]="user?.settings?.safeWithdrawalRate" /></span
>.
}
</div>
}
</div>

16
apps/client/src/app/pages/portfolio/fire/fire-page.scss

@ -1,3 +1,19 @@
:host {
display: block;
.safe-withdrawal-rate-select {
background-color: transparent;
color: rgb(var(--dark-primary-text));
&:focus {
box-shadow: none;
outline: 0;
}
}
}
:host-context(.theme-dark) {
.safe-withdrawal-rate-select {
color: rgb(var(--light-primary-text));
}
}

1
libs/ui/src/lib/logo-carousel/index.ts

@ -0,0 +1 @@
export * from './logo-carousel.component';

7
libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts

@ -0,0 +1,7 @@
export interface LogoItem {
className: string;
isMask?: boolean;
name: string;
title: string;
url: string;
}

16
libs/ui/src/lib/logo-carousel/logo-carousel.component.html

@ -0,0 +1,16 @@
<div class="logo-carousel-container overflow-hidden position-relative w-100">
<div class="align-items-center d-flex logo-carousel-track">
@for (logo of logosRepeated; track $index) {
<div class="logo-carousel-item">
<a
class="d-block logo"
target="_blank"
[attr.aria-label]="logo.name"
[class]="logo.className + (logo.isMask ? ' mask' : '')"
[href]="logo.url"
[title]="logo.title"
></a>
</div>
}
</div>
</div>

213
libs/ui/src/lib/logo-carousel/logo-carousel.component.scss

@ -0,0 +1,213 @@
:host {
display: block;
overflow: hidden;
position: relative;
width: 100%;
.logo-carousel-container {
&::before,
&::after {
content: '';
height: 100%;
pointer-events: none;
position: absolute;
top: 0;
width: 100px;
z-index: 2;
}
&::before {
background: linear-gradient(
to right,
var(--light-background) 0%,
rgba(var(--palette-background-background), 0) 100%
);
left: 0;
}
&::after {
background: linear-gradient(
to left,
var(--light-background) 0%,
rgba(var(--palette-background-background), 0) 100%
);
right: 0;
}
@media (max-width: 768px) {
&::before,
&::after {
width: 50px;
}
}
@media (max-width: 576px) {
&::before,
&::after {
width: 30px;
}
}
.logo-carousel-track {
animation: scroll 60s linear infinite;
width: fit-content;
&:hover {
animation-play-state: paused;
}
.logo-carousel-item {
flex-shrink: 0;
min-width: 200px;
padding: 0 2rem;
@media (max-width: 768px) {
min-width: 150px;
padding: 0 1.5rem;
}
@media (max-width: 576px) {
min-width: 120px;
padding: 0 1rem;
}
.logo {
height: 3rem;
transition:
opacity 0.3s ease,
transform 0.3s ease;
width: 7.5rem;
&:hover {
opacity: 0.8;
}
&.mask {
background-color: rgba(var(--dark-secondary-text));
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
&.logo-alternative-to {
mask-image: url('/assets/images/logo-alternative-to.svg');
}
&.logo-awesome {
background-image: url('/assets/images/logo-awesome.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-dev-community {
mask-image: url('/assets/images/logo-dev-community.svg');
}
&.logo-hacker-news {
mask-image: url('/assets/images/logo-hacker-news.svg');
}
&.logo-openalternative {
mask-image: url('/assets/images/logo-openalternative.svg');
}
&.logo-privacy-tools {
mask-image: url('/assets/images/logo-privacy-tools.svg');
}
&.logo-product-hunt {
background-image: url('/assets/images/logo-product-hunt.png');
background-position: center;
background-repeat: no-repeat;
background-size: contain;
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
&.logo-selfh-st {
mask-image: url('/assets/images/logo-selfh-st.svg');
max-height: 1.25rem;
}
&.logo-sourceforge {
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
@media (max-width: 768px) {
height: 2.5rem;
width: 6rem;
}
@media (max-width: 576px) {
height: 2rem;
width: 5rem;
}
}
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
}
}
}
:host-context(.theme-dark) {
.logo-carousel-container {
&::before {
background: linear-gradient(
to right,
var(--dark-background) 0%,
rgba(var(--palette-background-background-dark), 0) 100%
);
}
&::after {
background: linear-gradient(
to left,
var(--dark-background) 0%,
rgba(var(--palette-background-background-dark), 0) 100%
);
}
.logo {
&.logo-alternative-to,
&.logo-dev-community,
&.logo-hacker-news,
&.logo-openalternative,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
background-color: rgba(var(--light-primary-text));
}
}
}
}

13
libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { GfLogoCarouselComponent } from './logo-carousel.component';
const meta: Meta<GfLogoCarouselComponent> = {
title: 'Logo Carousel',
component: GfLogoCarouselComponent
};
export default meta;
type Story = StoryObj<GfLogoCarouselComponent>;
export const Default: Story = {};

110
libs/ui/src/lib/logo-carousel/logo-carousel.component.ts

@ -0,0 +1,110 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { LogoItem } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
selector: 'gf-logo-carousel',
styleUrls: ['./logo-carousel.component.scss'],
templateUrl: './logo-carousel.component.html'
})
export class GfLogoCarouselComponent {
public readonly logos: LogoItem[] = [
{
className: 'logo-alternative-to',
isMask: true,
name: 'AlternativeTo',
title: 'AlternativeTo - Crowdsourced software recommendations',
url: 'https://alternativeto.net'
},
{
className: 'logo-awesome',
name: 'Awesome Selfhosted',
title:
'Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers',
url: 'https://github.com/awesome-selfhosted/awesome-selfhosted'
},
{
className: 'logo-dev-community',
isMask: true,
name: 'DEV Community',
title:
'DEV Community - A constructive and inclusive social network for software developers',
url: 'https://dev.to'
},
{
className: 'logo-hacker-news',
isMask: true,
name: 'Hacker News',
title: 'Hacker News',
url: 'https://news.ycombinator.com'
},
{
className: 'logo-openalternative',
isMask: true,
name: 'OpenAlternative',
title: 'OpenAlternative: Open Source Alternatives to Popular Software',
url: 'https://openalternative.co'
},
{
className: 'logo-privacy-tools',
isMask: true,
name: 'Privacy Tools',
title: 'Privacy Tools: Software Alternatives and Encryption',
url: 'https://www.privacytools.io'
},
{
className: 'logo-product-hunt',
name: 'Product Hunt',
title: 'Product Hunt – The best new products in tech.',
url: 'https://www.producthunt.com'
},
{
className: 'logo-reddit',
isMask: true,
name: 'Reddit',
title: 'Reddit - Dive into anything',
url: 'https://www.reddit.com'
},
{
className: 'logo-sackgeld',
isMask: true,
name: 'Sackgeld',
title: 'Sackgeld.com – Apps für ein höheres Sackgeld',
url: 'https://www.sackgeld.com'
},
{
className: 'logo-selfh-st',
isMask: true,
name: 'selfh.st',
title: 'selfh.st — Self-hosted content and software',
url: 'https://selfh.st'
},
{
className: 'logo-sourceforge',
isMask: true,
name: 'SourceForge',
title:
'SourceForge: The Complete Open-Source and Business Software Platform',
url: 'https://sourceforge.net'
},
{
className: 'logo-umbrel',
isMask: true,
name: 'Umbrel',
title: 'Umbrel — A personal server OS for self-hosting',
url: 'https://umbrel.com'
},
{
className: 'logo-unraid',
isMask: true,
name: 'Unraid',
title: 'Unraid | Unleash Your Hardware',
url: 'https://unraid.net'
}
];
public readonly logosRepeated = [...this.logos, ...this.logos];
}
Loading…
Cancel
Save