mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
11 changed files with 346 additions and 183 deletions
@ -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,36 @@ |
|||
@if (isLoading()) { |
|||
<div |
|||
class="align-items-center d-flex drop-area flex-column justify-content-center p-4 text-body-secondary text-center" |
|||
> |
|||
<mat-spinner [diameter]="20" /> |
|||
<span>{{ loadingText() }}</span> |
|||
</div> |
|||
} @else { |
|||
<button |
|||
class="drop-area p-4 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,45 @@ |
|||
@use '@angular/material' as mat; |
|||
|
|||
:host { |
|||
a { |
|||
color: rgba(var(--palette-primary-500), 1); |
|||
} |
|||
|
|||
.drop-area { |
|||
background-color: rgba(var(--palette-foreground-base), 0.02); |
|||
border: 1px dashed |
|||
rgba( |
|||
var(--palette-foreground-divider), |
|||
var(--palette-foreground-divider-alpha) |
|||
); |
|||
border-radius: 0.25rem; |
|||
|
|||
&:hover { |
|||
border-color: rgba(var(--palette-primary-500), 1) !important; |
|||
color: rgba(var(--palette-primary-500), 1); |
|||
} |
|||
|
|||
.cloud-icon { |
|||
font-size: 2.5rem; |
|||
} |
|||
|
|||
.mat-mdc-progress-spinner { |
|||
@include mat.progress-spinner-overrides( |
|||
( |
|||
active-indicator-color: var(--gf-theme-primary-500) |
|||
) |
|||
); |
|||
|
|||
margin: 0.625rem 0; |
|||
} |
|||
} |
|||
} |
|||
|
|||
:host-context(.theme-dark) { |
|||
.drop-area { |
|||
border-color: rgba( |
|||
var(--palette-foreground-divider-dark), |
|||
var(--palette-foreground-divider-dark-alpha) |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
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 |
|||
} |
|||
}; |
|||
|
|||
export const WithGhostfolioAi: Story = { |
|||
args: { |
|||
fileFormats: FILE_FORMATS_WITH_GHOSTFOLIO_AI |
|||
} |
|||
}; |
|||
@ -0,0 +1,130 @@ |
|||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; |
|||
|
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
computed, |
|||
input, |
|||
output |
|||
} from '@angular/core'; |
|||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; |
|||
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, MatProgressSpinnerModule], |
|||
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; |
|||
} |
|||
Loading…
Reference in new issue