mirror of https://github.com/ghostfolio/ghostfolio
105 changed files with 2202 additions and 506 deletions
File diff suppressed because it is too large
@ -1,24 +1,125 @@ |
|||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; |
import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; |
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
import { PlatformsResponse } from '@ghostfolio/common/interfaces'; |
import { PlatformService } from '@ghostfolio/api/services/platform/platform.service'; |
||||
import { permissions } from '@ghostfolio/common/permissions'; |
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; |
||||
|
import { |
||||
|
PlatformResponse, |
||||
|
PlatformsResponse |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common'; |
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Put, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
import { AuthGuard } from '@nestjs/passport'; |
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
import { pick } from 'lodash-es'; |
||||
|
|
||||
|
@AllowDuringImpersonation() |
||||
@Controller('platforms') |
@Controller('platforms') |
||||
export class PlatformsController { |
export class PlatformsController { |
||||
public constructor(private readonly platformService: PlatformService) {} |
public constructor( |
||||
|
private readonly platformService: PlatformService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@HasPermission(permissions.createPlatform) |
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async createPlatform( |
||||
|
@Body() data: CreatePlatformDto |
||||
|
): Promise<PlatformResponse> { |
||||
|
const platform = await this.platformService.createPlatform(data); |
||||
|
|
||||
|
return pick(platform, ['id', 'name', 'url']); |
||||
|
} |
||||
|
|
||||
|
@Delete(':id') |
||||
|
@HasPermission(permissions.deletePlatform) |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async deletePlatform(@Param('id') id: string): Promise<void> { |
||||
|
const originalPlatform = await this.platformService.getPlatform({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalPlatform) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
await this.platformService.deletePlatform({ id }); |
||||
|
} |
||||
|
|
||||
@Get() |
@Get() |
||||
@HasPermission(permissions.readPlatforms) |
@HasPermission(permissions.readPlatforms) |
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
public async getPlatforms(): Promise<PlatformsResponse> { |
public async getPlatforms(): Promise<PlatformsResponse> { |
||||
|
if ( |
||||
|
hasPermission( |
||||
|
this.request.user.permissions, |
||||
|
permissions.readPlatformsWithAccountCount |
||||
|
) |
||||
|
) { |
||||
|
const platformsWithAccountCount = |
||||
|
await this.platformService.getPlatformsWithAccountCount({ |
||||
|
orderBy: { name: 'asc' } |
||||
|
}); |
||||
|
|
||||
|
return { platforms: platformsWithAccountCount }; |
||||
|
} |
||||
|
|
||||
const platforms = await this.platformService.getPlatforms({ |
const platforms = await this.platformService.getPlatforms({ |
||||
orderBy: { name: 'asc' } |
orderBy: { name: 'asc' } |
||||
}); |
}); |
||||
|
|
||||
return { platforms }; |
return { |
||||
|
platforms: platforms.map((platform) => { |
||||
|
return pick(platform, ['id', 'name', 'url']); |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
@HasPermission(permissions.updatePlatform) |
||||
|
@Put(':id') |
||||
|
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
||||
|
public async updatePlatform( |
||||
|
@Param('id') id: string, |
||||
|
@Body() data: UpdatePlatformDto |
||||
|
): Promise<PlatformResponse> { |
||||
|
const originalPlatform = await this.platformService.getPlatform({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalPlatform) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const platform = await this.platformService.updatePlatform({ |
||||
|
data: { |
||||
|
...data |
||||
|
}, |
||||
|
where: { |
||||
|
id |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return pick(platform, ['id', 'name', 'url']); |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,11 +1,12 @@ |
|||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; |
import { PlatformModule } from '@ghostfolio/api/services/platform/platform.module'; |
||||
|
|
||||
import { Module } from '@nestjs/common'; |
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PlatformController } from './platform.controller'; |
||||
import { PlatformsController } from './platforms.controller'; |
import { PlatformsController } from './platforms.controller'; |
||||
|
|
||||
@Module({ |
@Module({ |
||||
controllers: [PlatformsController], |
controllers: [PlatformController, PlatformsController], |
||||
imports: [PlatformModule] |
imports: [PlatformModule] |
||||
}) |
}) |
||||
export class PlatformsModule {} |
export class PlatformsModule {} |
||||
|
|||||
@ -1,44 +0,0 @@ |
|||||
import { Directive, output } from '@angular/core'; |
|
||||
|
|
||||
@Directive({ |
|
||||
host: { |
|
||||
'(document:paste)': 'onPaste($event)', |
|
||||
'(dragenter)': 'onDragEnter($event)', |
|
||||
'(dragover)': 'onDragOver($event)', |
|
||||
'(drop)': 'onDrop($event)' |
|
||||
}, |
|
||||
selector: '[gfFileDrop]' |
|
||||
}) |
|
||||
export class GfFileDropDirective { |
|
||||
public readonly filesDropped = output<FileList>(); |
|
||||
|
|
||||
public onDragEnter(event: DragEvent) { |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
} |
|
||||
|
|
||||
public onDragOver(event: DragEvent) { |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
} |
|
||||
|
|
||||
public onDrop(event: DragEvent) { |
|
||||
event.preventDefault(); |
|
||||
event.stopPropagation(); |
|
||||
|
|
||||
if (event.dataTransfer) { |
|
||||
// Prevent the browser's default behavior for handling the file drop
|
|
||||
event.dataTransfer.dropEffect = 'copy'; |
|
||||
this.filesDropped.emit(event.dataTransfer.files); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public onPaste(event: ClipboardEvent) { |
|
||||
const files = event.clipboardData?.files; |
|
||||
|
|
||||
if (files?.length) { |
|
||||
event.preventDefault(); |
|
||||
this.filesDropped.emit(files); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,6 @@ |
|||||
|
import type { Platform } from '@ghostfolio/prisma/browser'; |
||||
|
|
||||
|
export interface PlatformResponse extends Pick< |
||||
|
Platform, |
||||
|
'id' | 'name' | 'url' |
||||
|
> {} |
||||
@ -1,5 +1,5 @@ |
|||||
import type { Platform } from '@ghostfolio/prisma/browser'; |
import type { PlatformResponse } from './platform-response.interface'; |
||||
|
|
||||
export interface PlatformsResponse { |
export interface PlatformsResponse { |
||||
platforms: Platform[]; |
platforms: (PlatformResponse & { accountCount?: number })[]; |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,37 @@ |
|||||
|
@if (isLoading()) { |
||||
|
<div class="aura" role="progressbar" [attr.aria-label]="loadingText()"> |
||||
|
<div |
||||
|
class="align-items-center d-flex drop-area flex-column justify-content-center rounded text-body-secondary text-center" |
||||
|
> |
||||
|
<span>{{ loadingText() }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
} @else { |
||||
|
<button |
||||
|
class="drop-area rounded text-body-secondary text-center" |
||||
|
type="button" |
||||
|
(click)="onSelectFile()" |
||||
|
(dragenter)="onDragEnter($event)" |
||||
|
(dragover)="onDragOver($event)" |
||||
|
(drop)="onDrop($event)" |
||||
|
> |
||||
|
<div class="align-items-center d-flex flex-column justify-content-center"> |
||||
|
<ion-icon class="cloud-icon" name="cloud-upload-outline" /> |
||||
|
<span i18n>Choose or drop a file here</span> |
||||
|
</div> |
||||
|
</button> |
||||
|
} |
||||
|
<p class="mb-0 mt-3 text-center"> |
||||
|
<small> |
||||
|
<span class="me-1" i18n>The following file formats are supported:</span> |
||||
|
@for (fileFormatListPart of fileFormatListParts(); track $index) { |
||||
|
@if (fileFormatListPart.sampleUrl) { |
||||
|
<a target="_blank" [href]="fileFormatListPart.sampleUrl">{{ |
||||
|
fileFormatListPart.value |
||||
|
}}</a> |
||||
|
} @else { |
||||
|
<span>{{ fileFormatListPart.value }}</span> |
||||
|
} |
||||
|
} |
||||
|
</small> |
||||
|
</p> |
||||
@ -0,0 +1,113 @@ |
|||||
|
@property --aura-angle { |
||||
|
inherits: true; |
||||
|
initial-value: 0deg; |
||||
|
syntax: '<angle>'; |
||||
|
} |
||||
|
|
||||
|
:host { |
||||
|
--aura-padding: 0.125rem; |
||||
|
--cloud-icon-size: 2.5rem; |
||||
|
--drop-area-padding: 1.5rem; |
||||
|
|
||||
|
a { |
||||
|
color: rgba(var(--palette-primary-500), 1); |
||||
|
} |
||||
|
|
||||
|
// Based on the aura component of daisyUI (https://daisyui.com/components/aura) |
||||
|
.aura { |
||||
|
--aura-duration: 4000ms; |
||||
|
|
||||
|
animation: aura var(--aura-duration) linear infinite; |
||||
|
background-image: repeating-conic-gradient( |
||||
|
from var(--aura-angle), |
||||
|
transparent 0%, |
||||
|
transparent 40%, |
||||
|
rgba(var(--palette-primary-500), 1) 50% |
||||
|
); |
||||
|
border-radius: calc(var(--bs-border-radius) + var(--aura-padding)); |
||||
|
padding: var(--aura-padding); |
||||
|
position: relative; |
||||
|
|
||||
|
&::after, |
||||
|
&::before { |
||||
|
animation: inherit; |
||||
|
background-image: inherit; |
||||
|
border-radius: inherit; |
||||
|
content: ''; |
||||
|
inset: 0; |
||||
|
position: absolute; |
||||
|
} |
||||
|
|
||||
|
&::after { |
||||
|
filter: blur(1rem); |
||||
|
opacity: 0.3; |
||||
|
} |
||||
|
|
||||
|
&::before { |
||||
|
filter: blur(0.25rem); |
||||
|
opacity: 0.7; |
||||
|
} |
||||
|
|
||||
|
.drop-area { |
||||
|
background-color: var(--light-background); |
||||
|
border-style: solid; |
||||
|
margin: 0; |
||||
|
// Keep the height of the drop area with the cloud icon |
||||
|
padding-block: calc( |
||||
|
var(--drop-area-padding) + (var(--cloud-icon-size) / 2) |
||||
|
); |
||||
|
pointer-events: none; |
||||
|
position: relative; |
||||
|
z-index: 1; |
||||
|
} |
||||
|
|
||||
|
@media (prefers-reduced-motion: reduce) { |
||||
|
animation: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.drop-area { |
||||
|
background-color: rgba(var(--palette-foreground-base), 0.02); |
||||
|
border: 1px dashed |
||||
|
rgba( |
||||
|
var(--palette-foreground-divider), |
||||
|
var(--palette-foreground-divider-alpha) |
||||
|
); |
||||
|
// Reserve the space of the aura |
||||
|
margin: var(--aura-padding); |
||||
|
padding: var(--drop-area-padding); |
||||
|
|
||||
|
&:hover { |
||||
|
border-color: rgba(var(--palette-primary-500), 1) !important; |
||||
|
color: rgba(var(--palette-primary-500), 1); |
||||
|
} |
||||
|
|
||||
|
.cloud-icon { |
||||
|
font-size: var(--cloud-icon-size); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:host-context(.theme-dark) { |
||||
|
.aura { |
||||
|
.drop-area { |
||||
|
background-color: var(--dark-background); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.drop-area { |
||||
|
border-color: rgba( |
||||
|
var(--palette-foreground-divider-dark), |
||||
|
var(--palette-foreground-divider-dark-alpha) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@keyframes aura { |
||||
|
to { |
||||
|
--aura-angle: 360deg; |
||||
|
|
||||
|
// Force the GPU acceleration to prevent lags in Safari |
||||
|
transform: translateZ(1px); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
import type { Meta, StoryObj } from '@storybook/angular'; |
||||
|
|
||||
|
import { GfFileDropAreaComponent } from './file-drop-area.component'; |
||||
|
import { FileFormat } from './interfaces/interfaces'; |
||||
|
|
||||
|
const FILE_FORMAT_CSV: FileFormat = { |
||||
|
extensions: ['csv'], |
||||
|
name: 'CSV', |
||||
|
sampleUrl: |
||||
|
'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.csv' |
||||
|
}; |
||||
|
|
||||
|
const FILE_FORMAT_JSON: FileFormat = { |
||||
|
extensions: ['json'], |
||||
|
name: 'JSON', |
||||
|
sampleUrl: |
||||
|
'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.json' |
||||
|
}; |
||||
|
|
||||
|
const FILE_FORMATS_WITH_GHOSTFOLIO_AI: FileFormat[] = [ |
||||
|
FILE_FORMAT_CSV, |
||||
|
{ extensions: ['jpeg', 'jpg'], name: 'JPG' }, |
||||
|
FILE_FORMAT_JSON, |
||||
|
{ extensions: ['pdf'], name: 'PDF' }, |
||||
|
{ extensions: ['png'], name: 'PNG' } |
||||
|
]; |
||||
|
|
||||
|
export default { |
||||
|
args: { |
||||
|
loadingText: 'Processing the file...' |
||||
|
}, |
||||
|
component: GfFileDropAreaComponent, |
||||
|
title: 'File Drop Area' |
||||
|
} as Meta<GfFileDropAreaComponent>; |
||||
|
|
||||
|
type Story = StoryObj<GfFileDropAreaComponent>; |
||||
|
|
||||
|
export const Default: Story = { |
||||
|
args: { |
||||
|
fileFormats: [FILE_FORMAT_CSV, FILE_FORMAT_JSON] |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export const Loading: Story = { |
||||
|
args: { |
||||
|
fileFormats: FILE_FORMATS_WITH_GHOSTFOLIO_AI, |
||||
|
isLoading: true |
||||
|
} |
||||
|
}; |
||||
@ -0,0 +1,129 @@ |
|||||
|
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
computed, |
||||
|
input, |
||||
|
output |
||||
|
} from '@angular/core'; |
||||
|
import { IonIcon } from '@ionic/angular/standalone'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { cloudUploadOutline } from 'ionicons/icons'; |
||||
|
|
||||
|
import { FileFormat } from './interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { |
||||
|
'(document:paste)': 'onPaste($event)', |
||||
|
class: 'd-flex flex-column' |
||||
|
}, |
||||
|
imports: [IonIcon], |
||||
|
selector: 'gf-file-drop-area', |
||||
|
styleUrls: ['./file-drop-area.component.scss'], |
||||
|
templateUrl: './file-drop-area.component.html' |
||||
|
}) |
||||
|
export class GfFileDropAreaComponent { |
||||
|
public readonly fileFormats = input.required<FileFormat[]>(); |
||||
|
public readonly isLoading = input(false); |
||||
|
public readonly language = input(DEFAULT_LANGUAGE_CODE); |
||||
|
public readonly loadingText = input.required<string>(); |
||||
|
|
||||
|
public readonly fileSelected = output<File>(); |
||||
|
|
||||
|
protected readonly accept = computed(() => { |
||||
|
return this.fileFormats() |
||||
|
.flatMap(({ extensions }) => { |
||||
|
return extensions.map((extension) => { |
||||
|
return `.${extension}`; |
||||
|
}); |
||||
|
}) |
||||
|
.join(', '); |
||||
|
}); |
||||
|
|
||||
|
protected readonly fileFormatListParts = computed(() => { |
||||
|
const fileFormats = this.fileFormats(); |
||||
|
|
||||
|
return new Intl.ListFormat(this.language(), { type: 'disjunction' }) |
||||
|
.formatToParts( |
||||
|
fileFormats.map(({ name }) => { |
||||
|
return name; |
||||
|
}) |
||||
|
) |
||||
|
.map(({ type, value }) => { |
||||
|
return { |
||||
|
value, |
||||
|
sampleUrl: |
||||
|
type === 'element' |
||||
|
? fileFormats.find(({ name }) => { |
||||
|
return name === value; |
||||
|
})?.sampleUrl |
||||
|
: undefined |
||||
|
}; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
public constructor() { |
||||
|
addIcons({ cloudUploadOutline }); |
||||
|
} |
||||
|
|
||||
|
protected onDragEnter(event: DragEvent) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
} |
||||
|
|
||||
|
protected onDragOver(event: DragEvent) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
} |
||||
|
|
||||
|
protected onDrop(event: DragEvent) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
if (event.dataTransfer) { |
||||
|
// Prevent the browser's default behavior for handling the file drop
|
||||
|
event.dataTransfer.dropEffect = 'copy'; |
||||
|
this.emitFirstFile(event.dataTransfer.files); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected onPaste(event: ClipboardEvent) { |
||||
|
if (this.isLoading()) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const files = event.clipboardData?.files; |
||||
|
|
||||
|
if (files?.length) { |
||||
|
event.preventDefault(); |
||||
|
this.emitFirstFile(files); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected onSelectFile() { |
||||
|
const fileInputElement = document.createElement('input'); |
||||
|
|
||||
|
fileInputElement.accept = this.accept(); |
||||
|
fileInputElement.type = 'file'; |
||||
|
|
||||
|
fileInputElement.onchange = (event) => { |
||||
|
const files = (event.target as HTMLInputElement).files; |
||||
|
|
||||
|
if (files) { |
||||
|
this.emitFirstFile(files); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
fileInputElement.click(); |
||||
|
} |
||||
|
|
||||
|
private emitFirstFile(files: FileList) { |
||||
|
const file = files.item(0); |
||||
|
|
||||
|
if (file) { |
||||
|
this.fileSelected.emit(file); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,2 @@ |
|||||
|
export * from './file-drop-area.component'; |
||||
|
export * from './interfaces/interfaces'; |
||||
@ -0,0 +1,5 @@ |
|||||
|
export interface FileFormat { |
||||
|
extensions: string[]; |
||||
|
name: string; |
||||
|
sampleUrl?: string; |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue