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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { PlatformsResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { PlatformService } from '@ghostfolio/api/services/platform/platform.service'; |
|||
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 { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { pick } from 'lodash-es'; |
|||
|
|||
@AllowDuringImpersonation() |
|||
@Controller('platforms') |
|||
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() |
|||
@HasPermission(permissions.readPlatforms) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
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({ |
|||
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 { PlatformController } from './platform.controller'; |
|||
import { PlatformsController } from './platforms.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [PlatformsController], |
|||
controllers: [PlatformController, PlatformsController], |
|||
imports: [PlatformModule] |
|||
}) |
|||
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 { |
|||
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