mirror of
https://github.com/dev-claw/vripper-project.git
synced 2026-08-19 08:35:41 +02:00
@@ -30,7 +30,6 @@
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
@@ -92,7 +91,6 @@
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
|
||||
Generated
+3267
-3833
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@
|
||||
"@angular/router": "^17.0.5",
|
||||
"@stomp/rx-stomp": "^2.0.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"ag-grid-angular": "^29.3.4",
|
||||
"ag-grid-community": "^29.3.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.2"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Image } from './image.model';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { progress, statusIcon } from '../utils/utils';
|
||||
|
||||
export class ImageRow extends Image {
|
||||
public progress: WritableSignal<number>;
|
||||
public statusIcon: WritableSignal<string>;
|
||||
|
||||
constructor(
|
||||
postId: number,
|
||||
url: string,
|
||||
status: string,
|
||||
index: number,
|
||||
downloaded: number,
|
||||
size: number
|
||||
) {
|
||||
super(postId, url, status, index, downloaded, size);
|
||||
this.progress = signal(progress(downloaded, size));
|
||||
this.statusIcon = signal(statusIcon(status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Log } from './log.model';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
|
||||
export class LogRow extends Log {
|
||||
public statusSignal: WritableSignal<string>;
|
||||
public messageSignal: WritableSignal<string>;
|
||||
|
||||
constructor(
|
||||
id: number,
|
||||
type: string,
|
||||
status: string,
|
||||
time: string,
|
||||
message: string
|
||||
) {
|
||||
super(id, formatType(type), status, time, message);
|
||||
this.statusSignal = signal(status);
|
||||
this.messageSignal = signal(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatType(status: string) {
|
||||
switch (status) {
|
||||
case 'POST':
|
||||
return '🖼️ New gallery';
|
||||
case 'THREAD':
|
||||
return '🧵 New thread';
|
||||
case 'THANKS':
|
||||
return '👍 Sending a like ';
|
||||
case 'SCAN':
|
||||
return '🔍 Links scan';
|
||||
case 'METADATA':
|
||||
case 'METADATA_CACHE_MISS':
|
||||
return '🗄️ Loading post metadata';
|
||||
case 'QUEUED':
|
||||
case 'QUEUED_CACHE_MISS':
|
||||
return '📋 Loading multi-post link';
|
||||
case 'DOWNLOAD':
|
||||
return '📥 Download';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Post } from './post.model';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { progress, statusIcon, totalFormatter } from '../utils/utils';
|
||||
|
||||
export class PostRow extends Post {
|
||||
public statusIcon: WritableSignal<string>;
|
||||
public progress: WritableSignal<number>;
|
||||
public total2: WritableSignal<string>;
|
||||
|
||||
constructor(
|
||||
postId: number,
|
||||
postTitle: string,
|
||||
status: string,
|
||||
url: string,
|
||||
done: number,
|
||||
total: number,
|
||||
hosts: string[],
|
||||
addedOn: string,
|
||||
rank: number,
|
||||
downloadDirectory: string,
|
||||
downloaded: number
|
||||
) {
|
||||
super(
|
||||
postId,
|
||||
postTitle,
|
||||
status,
|
||||
url,
|
||||
done,
|
||||
total,
|
||||
hosts,
|
||||
addedOn,
|
||||
rank,
|
||||
downloadDirectory,
|
||||
downloaded
|
||||
);
|
||||
this.statusIcon = signal(statusIcon(status));
|
||||
this.progress = signal(progress(done, total));
|
||||
this.total2 = signal(totalFormatter(done, total, downloaded));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Thread } from './thread.model';
|
||||
|
||||
export class ThreadRow extends Thread {
|
||||
constructor(link: string, title: string, threadId: number, total: number) {
|
||||
super(link, title, threadId, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@use 'sass:map';
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
@mixin color($theme) {
|
||||
.row:hover {
|
||||
background-color: mat.get-theme-color($theme, background, hover);
|
||||
}
|
||||
|
||||
.selected.row {
|
||||
background-color: mat.get-theme-color($theme, background, selected-button);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin theme($theme) {
|
||||
@if mat.theme-has($theme, color) {
|
||||
@include color($theme);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,105 @@
|
||||
<ag-grid-angular
|
||||
id="download-grid"
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions"
|
||||
(contextmenu)="disableForRows($event)">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
|
||||
matColumnDef="title">
|
||||
<th *matHeaderCellDef mat-header-cell>
|
||||
<mat-checkbox
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
[aria-label]="checkboxLabel()"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
color="primary">
|
||||
</mat-checkbox>
|
||||
Title
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.postTitle"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.postTitle }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'progress')"
|
||||
matColumnDef="progress">
|
||||
<th *matHeaderCellDef mat-header-cell>Progress</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-progress-bar
|
||||
[value]="element.progress()"
|
||||
mode="determinate"></mat-progress-bar>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
|
||||
matColumnDef="status">
|
||||
<th *matHeaderCellDef mat-header-cell>Status</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-icon
|
||||
[fontIcon]="element.statusIcon()"
|
||||
aria-hidden="false"
|
||||
color="primary"></mat-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'path')"
|
||||
matColumnDef="path">
|
||||
<th *matHeaderCellDef mat-header-cell>Path</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.downloadDirectory"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.downloadDirectory }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'total')"
|
||||
matColumnDef="total">
|
||||
<th *matHeaderCellDef mat-header-cell>Total</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.total2() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'hosts')"
|
||||
matColumnDef="hosts">
|
||||
<th *matHeaderCellDef mat-header-cell>Hosts</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.hosts }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'addedOn')"
|
||||
matColumnDef="addedOn">
|
||||
<th *matHeaderCellDef mat-header-cell>AddedOn</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.addedOn }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'order')"
|
||||
matColumnDef="order">
|
||||
<th *matHeaderCellDef mat-header-cell>Order</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.rank }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row, $event)"
|
||||
(contextmenu)="onContextMenu($event, row)"
|
||||
(dblclick)="onRowDoubleClicked(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.mat-column-progress {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.mat-column-status {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.mat-column-addedOn {
|
||||
width: 165px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mat-column-order {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.mat-column-total {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.mat-column-path {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mat-column-hosts {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.row {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ComponentRef,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
Output,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import {
|
||||
CellContextMenuEvent,
|
||||
GridOptions,
|
||||
IRowNode,
|
||||
RowDataUpdatedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
ValueFormatterParams,
|
||||
} from 'ag-grid-community';
|
||||
import { Post } from '../domain/post.model';
|
||||
import { fromEvent, merge, Subscription, take } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription, take } from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import {
|
||||
Overlay,
|
||||
@@ -26,42 +16,52 @@ import {
|
||||
OverlayPositionBuilder,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
|
||||
import { PostContextmenuComponent } from '../post-contextmenu/post-contextmenu.component';
|
||||
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
|
||||
import {
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import {
|
||||
isDisplayed,
|
||||
progress,
|
||||
statusIcon,
|
||||
totalFormatter,
|
||||
} from '../utils/utils';
|
||||
import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
import { PostContextmenuComponent } from '../post-contextmenu/post-contextmenu.component';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
ConfirmDialogData,
|
||||
} from '../confirm/confirm.component';
|
||||
import { ProgressCellComponent } from '../progress-cell/progress-cell.component';
|
||||
import { Image } from '../domain/image.model';
|
||||
import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
import { formatBytes } from '../utils/utils';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { PostRow } from '../domain/post-row.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AgGridModule,
|
||||
OverlayModule,
|
||||
PortalModule,
|
||||
MatDialogModule,
|
||||
ProgressCellComponent,
|
||||
MatListModule,
|
||||
MatTableModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
],
|
||||
templateUrl: './download-table.component.html',
|
||||
styleUrls: ['./download-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DownloadTableComponent implements OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
gridOptions: GridOptions;
|
||||
export class DownloadTableComponent {
|
||||
dataSource = new PostDataSource(this.applicationEndpoint);
|
||||
|
||||
@Output()
|
||||
rowCountChange = new EventEmitter<number>();
|
||||
@@ -69,282 +69,244 @@ export class DownloadTableComponent implements OnDestroy {
|
||||
@Output()
|
||||
selectedChange = new EventEmitter<Post[]>();
|
||||
|
||||
subscriptions: Subscription[] = [];
|
||||
displayedColumns: string[] = [
|
||||
'title',
|
||||
'progress',
|
||||
'status',
|
||||
'path',
|
||||
'total',
|
||||
'hosts',
|
||||
'addedOn',
|
||||
'order',
|
||||
];
|
||||
selection = new SelectionModel<PostRow>(
|
||||
true,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.postId === b.postId
|
||||
);
|
||||
|
||||
isDisplayed = isDisplayed;
|
||||
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
private overlayPositionBuilder: OverlayPositionBuilder,
|
||||
private overlay: Overlay,
|
||||
private dialog: MatDialog,
|
||||
breakpointObserver: BreakpointObserver
|
||||
private breakpointObserver: BreakpointObserver
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
colId: 'title',
|
||||
headerName: 'Title',
|
||||
field: 'postTitle',
|
||||
tooltipField: 'postTitle',
|
||||
headerCheckboxSelection: true,
|
||||
headerCheckboxSelectionFilteredOnly: true,
|
||||
flex: 2,
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
colId: 'progress',
|
||||
headerName: 'Progress',
|
||||
tooltipValueGetter: params => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.done === 0 || params.data.total === 0
|
||||
? 0
|
||||
: (params.data.done / params.data.total) * 100;
|
||||
},
|
||||
valueGetter: (params: ValueGetterParams<Post>) => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.done === 0 || params.data.total === 0
|
||||
? 0
|
||||
: (params.data.done / params.data.total) * 100;
|
||||
},
|
||||
cellRenderer: ProgressCellComponent,
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
colId: 'status',
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
tooltipValueGetter: params => {
|
||||
const value = params.value as string;
|
||||
return (
|
||||
value.at(0)?.toUpperCase() +
|
||||
value.substring(1, value.length).toLowerCase()
|
||||
);
|
||||
},
|
||||
valueFormatter: (params: ValueFormatterParams<Image>) => {
|
||||
const value = params.value as string;
|
||||
return (
|
||||
value.at(0)?.toUpperCase() +
|
||||
value.substring(1, value.length).toLowerCase()
|
||||
);
|
||||
},
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'path',
|
||||
headerName: 'Path',
|
||||
field: 'downloadDirectory',
|
||||
tooltipField: 'downloadDirectory',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'total',
|
||||
headerName: 'Total',
|
||||
tooltipValueGetter: params =>
|
||||
`${params.data?.done}/${params.data?.total} (${formatBytes(
|
||||
params.data?.downloaded
|
||||
)})`,
|
||||
valueGetter: params =>
|
||||
`${params.data?.done}/${params.data?.total} (${formatBytes(
|
||||
params.data?.downloaded
|
||||
)})`,
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'hosts',
|
||||
headerName: 'Hosts',
|
||||
field: 'hosts',
|
||||
tooltipField: 'hosts',
|
||||
valueGetter: (params: ValueGetterParams<Post>) => {
|
||||
return params.data?.hosts.join(', ');
|
||||
},
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'addedOn',
|
||||
headerName: 'Added On',
|
||||
field: 'addedOn',
|
||||
tooltipField: 'addedOn',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'order',
|
||||
headerName: 'Order',
|
||||
field: 'rank',
|
||||
tooltipField: 'rank',
|
||||
sort: 'asc',
|
||||
flex: 0.5,
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
rowSelection: 'multiple',
|
||||
getRowId: row => row.data['postId'],
|
||||
onGridReady: () => {
|
||||
this.connect();
|
||||
breakpointObserver
|
||||
.observe(Breakpoints.HandsetPortrait)
|
||||
.subscribe(result => {
|
||||
this.agGrid.columnApi.setColumnsVisible(
|
||||
['status', 'total', 'hosts', 'path', 'addedOn', 'order'],
|
||||
!result.matches
|
||||
);
|
||||
});
|
||||
},
|
||||
onRowDataUpdated: (event: RowDataUpdatedEvent<Post>) =>
|
||||
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
|
||||
onCellContextMenu: (event: CellContextMenuEvent<Post>) => {
|
||||
if (event.api.getSelectedRows().length > 1) {
|
||||
event.node.setSelected(true);
|
||||
this.dataSource._dataStream.subscribe(v =>
|
||||
this.rowCountChange.emit(v.length)
|
||||
);
|
||||
this.selection.changed.subscribe(() =>
|
||||
this.selectedChange.emit(this.selection.selected)
|
||||
);
|
||||
this.breakpointObserver
|
||||
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
|
||||
.subscribe(result => {
|
||||
if (result.matches) {
|
||||
this.columnsToDisplay.set(['title', 'progress', 'status']);
|
||||
} else {
|
||||
event.node.setSelected(true, true);
|
||||
}
|
||||
const mouseEvent = event.event as MouseEvent;
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
this.columnsToDisplay.set([
|
||||
'title',
|
||||
'progress',
|
||||
'status',
|
||||
'path',
|
||||
'total',
|
||||
'hosts',
|
||||
'addedOn',
|
||||
'order',
|
||||
]);
|
||||
const postContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const postContextMenuPortal = new ComponentPortal(
|
||||
PostContextmenuComponent
|
||||
);
|
||||
const ref: ComponentRef<PostContextmenuComponent> =
|
||||
postContextMenuOverlayRef.attach(postContextMenuPortal);
|
||||
ref.instance.post = event.data as Post;
|
||||
|
||||
ref.instance.onPostStart = () =>
|
||||
this.applicationEndpoint
|
||||
.startPosts(event.api.getSelectedRows())
|
||||
.subscribe();
|
||||
|
||||
ref.instance.onPostStop = () =>
|
||||
this.applicationEndpoint
|
||||
.stopPosts(event.api.getSelectedRows())
|
||||
.subscribe();
|
||||
|
||||
ref.instance.onPostDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
event.api.getSelectedRows().length
|
||||
} post${event.api.getSelectedRows().length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deletePosts(event.api.getSelectedRows())
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const subscription = merge(
|
||||
fromEvent<MouseEvent>(document, 'click'),
|
||||
fromEvent<MouseEvent>(document, 'contextmenu')
|
||||
)
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
},
|
||||
onSelectionChanged: (event: SelectionChangedEvent<Post>) => {
|
||||
this.selectedChange.emit(event.api.getSelectedRows());
|
||||
},
|
||||
onRowDoubleClicked: (event: RowDoubleClickedEvent<Post>) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
const data: ImageDialogData = { postId: event.data.postId };
|
||||
this.dialog.open(ImagesComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.dataSource._dataStream.value.length;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
/** Selects all rows if they are not all selected; otherwise clear selection. */
|
||||
toggleAllRows() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selection.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.select(...this.dataSource._dataStream.value);
|
||||
}
|
||||
|
||||
/** The label for the checkbox on the passed row */
|
||||
checkboxLabel(row?: PostRow): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
|
||||
row.postId
|
||||
}`;
|
||||
}
|
||||
|
||||
onRowDoubleClicked(event: PostRow) {
|
||||
const data: ImageDialogData = { postId: event.postId };
|
||||
this.dialog.open(ImagesComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
}
|
||||
|
||||
onClick(row: PostRow, $event: MouseEvent) {
|
||||
if (!$event.ctrlKey) {
|
||||
this.selection.clear();
|
||||
}
|
||||
this.selection.select(row);
|
||||
}
|
||||
|
||||
onContextMenu(mouseEvent: MouseEvent, row: PostRow) {
|
||||
mouseEvent.preventDefault();
|
||||
if (this.selection.selected.length > 1) {
|
||||
this.selection.select(row);
|
||||
} else {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
]);
|
||||
const postContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const postContextMenuPortal = new ComponentPortal(PostContextmenuComponent);
|
||||
const ref: ComponentRef<PostContextmenuComponent> =
|
||||
postContextMenuOverlayRef.attach(postContextMenuPortal);
|
||||
ref.instance.post = row as PostRow;
|
||||
|
||||
ref.instance.close = () => {
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
};
|
||||
|
||||
ref.instance.onPostStart = () =>
|
||||
this.applicationEndpoint.startPosts(this.selection.selected).subscribe();
|
||||
|
||||
ref.instance.onPostStop = () =>
|
||||
this.applicationEndpoint.stopPosts(this.selection.selected).subscribe();
|
||||
|
||||
ref.instance.onPostDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
this.selection.selected.length
|
||||
} post${this.selection.selected.length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deletePosts(this.selection.selected)
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
postContextMenuOverlayRef
|
||||
.outsidePointerEvents()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
console.log('click away');
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PostDataSource extends DataSource<PostRow> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<PostRow[]>([]);
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<PostRow[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.newPosts$.subscribe((newPosts: Post[]) => {
|
||||
this.agGrid.api.applyTransaction({ add: newPosts });
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
...newPosts.map(
|
||||
e =>
|
||||
new PostRow(
|
||||
e.postId,
|
||||
e.postTitle,
|
||||
e.status,
|
||||
e.url,
|
||||
e.done,
|
||||
e.total,
|
||||
e.hosts,
|
||||
e.addedOn,
|
||||
e.rank,
|
||||
e.downloadDirectory,
|
||||
e.downloaded
|
||||
)
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.deletedPosts$.subscribe((e: string[]) => {
|
||||
const toRemove: string[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(v);
|
||||
if (rowNode != null) {
|
||||
toRemove.push(rowNode.data);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ remove: toRemove });
|
||||
this.applicationEndpoint.deletedPosts$.subscribe((e: number[]) => {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value.filter(
|
||||
v => e.find(d => d === v.postId) == null
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.updatedPosts$.subscribe((e: Post[]) => {
|
||||
const toUpdate: Post[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(
|
||||
String(v.postId)
|
||||
const rowNode = this._dataStream.value.find(
|
||||
d => d.postId === v.postId
|
||||
);
|
||||
if (rowNode != null) {
|
||||
toUpdate.push(v);
|
||||
Object.assign(rowNode, v);
|
||||
rowNode.statusIcon.set(statusIcon(v.status));
|
||||
rowNode.progress.set(progress(v.done, v.total));
|
||||
rowNode.total2.set(totalFormatter(v.done, v.total, v.downloaded));
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate });
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
disableForRows(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const element = document
|
||||
.getElementById('download-grid')
|
||||
?.getElementsByClassName('ag-center-cols-container')
|
||||
?.item(0) as HTMLElement;
|
||||
if (element.contains(target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,58 @@
|
||||
<h2 mat-dialog-title>Images</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'index')"
|
||||
matColumnDef="index">
|
||||
<th *matHeaderCellDef mat-header-cell>Index</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
{{ element.index }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'url')"
|
||||
matColumnDef="url">
|
||||
<th *matHeaderCellDef mat-header-cell>URL</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
{{ element.url }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'progress')"
|
||||
matColumnDef="progress">
|
||||
<th *matHeaderCellDef mat-header-cell>Progress</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-progress-bar
|
||||
[value]="element.progress()"
|
||||
mode="determinate"></mat-progress-bar>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
|
||||
matColumnDef="status">
|
||||
<th *matHeaderCellDef mat-header-cell>Status</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-icon
|
||||
[fontIcon]="element.statusIcon()"
|
||||
aria-hidden="false"
|
||||
color="primary"></mat-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-flat-button [mat-dialog-close]="true" cdkFocusInitial>Close</button>
|
||||
<button [mat-dialog-close]="true" cdkFocusInitial mat-flat-button>
|
||||
Close
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.mat-column-progress {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.mat-column-status {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.mat-column-index {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Component, Inject, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { Component, Inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import { GridOptions, ValueFormatterParams } from 'ag-grid-community';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { Image } from '../domain/image.model';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
|
||||
import { ProgressCellComponent } from '../progress-cell/progress-cell.component';
|
||||
import { ITooltipParams } from 'ag-grid-community/dist/lib/rendering/tooltipComponent';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { DialogRef } from '@angular/cdk/dialog';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { isDisplayed, progress, statusIcon } from '../utils/utils';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { ImageRow } from '../domain/image-row.model';
|
||||
|
||||
export interface ImageDialogData {
|
||||
postId: number;
|
||||
@@ -20,15 +22,30 @@ export interface ImageDialogData {
|
||||
@Component({
|
||||
selector: 'app-images',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule, MatDialogModule, MatButtonModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './images.component.html',
|
||||
styleUrls: ['./images.component.scss'],
|
||||
})
|
||||
export class ImagesComponent implements OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
export class ImagesComponent {
|
||||
displayedColumns: string[] = ['index', 'url', 'progress', 'status'];
|
||||
selection = new SelectionModel<ImageRow>(
|
||||
false,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.url === b.url
|
||||
);
|
||||
dataSource = new ImageDataSource(this.applicationEndpoint, this.data.postId);
|
||||
|
||||
gridOptions: GridOptions<Image>;
|
||||
subscriptions: Subscription[] = [];
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
isDisplayed = isDisplayed;
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImageDialogData,
|
||||
@@ -36,109 +53,73 @@ export class ImagesComponent implements OnDestroy {
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
breakpointObserver: BreakpointObserver
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
colId: 'index',
|
||||
headerName: '#',
|
||||
field: 'index',
|
||||
tooltipField: 'index',
|
||||
sort: 'asc',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'url',
|
||||
headerName: 'URL',
|
||||
field: 'url',
|
||||
tooltipField: 'url',
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
colId: 'progress',
|
||||
headerName: 'Progress',
|
||||
field: 'progress',
|
||||
tooltipValueGetter: (params: ITooltipParams<Image>) => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.downloaded === 0 || params.data.size === 0
|
||||
? 0
|
||||
: (params.data.downloaded / params.data.size) * 100;
|
||||
},
|
||||
valueGetter: (params: ValueGetterParams<Image>) => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.downloaded === 0 || params.data.size === 0
|
||||
? 0
|
||||
: (params.data.downloaded / params.data.size) * 100;
|
||||
},
|
||||
cellRenderer: ProgressCellComponent,
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'status',
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
valueFormatter: (params: ValueFormatterParams<Image>) => {
|
||||
const value = params.value as string;
|
||||
return (
|
||||
value.at(0)?.toUpperCase() +
|
||||
value.substring(1, value.length).toLowerCase()
|
||||
);
|
||||
},
|
||||
flex: 1,
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
getRowId: row => row.data['url'].toString(),
|
||||
onGridReady: () => {
|
||||
this.connect();
|
||||
breakpointObserver
|
||||
.observe(Breakpoints.HandsetPortrait)
|
||||
.subscribe(result => {
|
||||
this.agGrid.columnApi.setColumnsVisible(
|
||||
['index', 'status'],
|
||||
!result.matches
|
||||
);
|
||||
if (result.matches) {
|
||||
this.dialogRef.updateSize('100vw', '80vh');
|
||||
} else {
|
||||
this.dialogRef.updateSize('80vw', '80vh');
|
||||
}
|
||||
this.dialogRef.updatePosition();
|
||||
});
|
||||
},
|
||||
};
|
||||
breakpointObserver
|
||||
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
|
||||
.subscribe(result => {
|
||||
if (result.matches) {
|
||||
this.columnsToDisplay.set(['index', 'progress', 'status']);
|
||||
} else {
|
||||
this.columnsToDisplay.set(['index', 'url', 'progress', 'status']);
|
||||
}
|
||||
if (result.matches) {
|
||||
this.dialogRef.updateSize('100vw', '80vh');
|
||||
} else {
|
||||
this.dialogRef.updateSize('80vw', '80vh');
|
||||
}
|
||||
this.dialogRef.updatePosition();
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
onClick(row: ImageRow) {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageDataSource extends DataSource<ImageRow> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<ImageRow[]>([]);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
private postId: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<ImageRow[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint
|
||||
.postDetails$(this.data.postId)
|
||||
.postDetails$(this.postId)
|
||||
.subscribe((e: Image[]) => {
|
||||
const toAdd: Image[] = [];
|
||||
const toUpdate: Image[] = [];
|
||||
e.forEach(v => {
|
||||
if (this.agGrid.api.getRowNode(v.url.toString()) == null) {
|
||||
toAdd.push(v);
|
||||
e.forEach(image => {
|
||||
const rowNode = this._dataStream.value.find(
|
||||
d => d.url === image.url
|
||||
);
|
||||
if (rowNode == null) {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
new ImageRow(
|
||||
image.postId,
|
||||
image.url,
|
||||
image.status,
|
||||
image.index,
|
||||
image.downloaded,
|
||||
image.size
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
toUpdate.push(v);
|
||||
Object.assign(rowNode, image);
|
||||
rowNode.statusIcon.set(statusIcon(image.status));
|
||||
rowNode.progress.set(progress(image.downloaded, image.size));
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate, add: toAdd });
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'time')"
|
||||
matColumnDef="time">
|
||||
<th *matHeaderCellDef mat-header-cell>Time</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.time"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.time }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'type')"
|
||||
matColumnDef="type">
|
||||
<th *matHeaderCellDef mat-header-cell>Type</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.type"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.type }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
|
||||
matColumnDef="status">
|
||||
<th *matHeaderCellDef mat-header-cell>Status</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.statusSignal()"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.statusSignal() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'message')"
|
||||
matColumnDef="message">
|
||||
<th *matHeaderCellDef mat-header-cell>Message</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.messageSignal()"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.messageSignal() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@@ -3,145 +3,109 @@ import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import { GridOptions, IRowNode, RowDataUpdatedEvent } from 'ag-grid-community';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { Log } from '../domain/log.model';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { formatType, LogRow } from '../domain/log-row.model';
|
||||
import { isDisplayed } from '../utils/utils';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
||||
@Component({
|
||||
selector: 'app-log-table',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule],
|
||||
imports: [CommonModule, MatCheckboxModule, MatTableModule],
|
||||
templateUrl: './log-table.component.html',
|
||||
styleUrls: ['./log-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LogTableComponent implements OnInit, OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
|
||||
export class LogTableComponent implements OnInit {
|
||||
dataSource = new LogDataSource(this.applicationEndpoint);
|
||||
displayedColumns: string[] = ['time', 'type', 'status', 'message'];
|
||||
selection = new SelectionModel<LogRow>(
|
||||
false,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.id === b.id
|
||||
);
|
||||
isDisplayed = isDisplayed;
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
@Output()
|
||||
rowCountChange = new EventEmitter<number>();
|
||||
|
||||
@Input({ required: true })
|
||||
clear!: Observable<void>;
|
||||
|
||||
gridOptions: GridOptions;
|
||||
subscriptions: Subscription[] = [];
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
headerName: 'Time',
|
||||
field: 'time',
|
||||
tooltipField: 'time',
|
||||
sort: 'desc',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
headerName: 'Type',
|
||||
field: 'type',
|
||||
tooltipField: 'type',
|
||||
valueGetter: params => {
|
||||
switch (params.data.type) {
|
||||
case 'POST':
|
||||
return '🖼️ New gallery';
|
||||
case 'THREAD':
|
||||
return '🧵 New thread';
|
||||
case 'THANKS':
|
||||
return '👍 Sending a like ';
|
||||
case 'SCAN':
|
||||
return '🔍 Links scan';
|
||||
case 'METADATA':
|
||||
case 'METADATA_CACHE_MISS':
|
||||
return '🗄️ Loading post metadata';
|
||||
case 'QUEUED':
|
||||
case 'QUEUED_CACHE_MISS':
|
||||
return '📋 Loading multi-post link';
|
||||
case 'DOWNLOAD':
|
||||
return '📥 Download';
|
||||
default:
|
||||
return params.data.type;
|
||||
}
|
||||
},
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
tooltipField: 'status',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
headerName: 'Message',
|
||||
field: 'message',
|
||||
tooltipField: 'message',
|
||||
flex: 1,
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
rowSelection: 'single',
|
||||
getRowId: row => row.data['id'].toString(),
|
||||
onGridReady: () => this.connect(),
|
||||
onRowDataUpdated: (event: RowDataUpdatedEvent) =>
|
||||
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
|
||||
};
|
||||
this.dataSource._dataStream.subscribe(v =>
|
||||
this.rowCountChange.emit(v.length)
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clear.subscribe(() => this.agGrid.api.setRowData([]));
|
||||
this.clear.subscribe(() => {
|
||||
this.dataSource._dataStream.next([]);
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.newLogs$.subscribe((e: Log[]) => {
|
||||
this.agGrid.api.applyTransaction({ add: e });
|
||||
})
|
||||
);
|
||||
onClick(row: LogRow) {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
}
|
||||
|
||||
class LogDataSource extends DataSource<LogRow> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<LogRow[]>([]);
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<LogRow[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.updatedLogs$.subscribe((e: Log[]) => {
|
||||
const toUpdate: Log[] = [];
|
||||
e.forEach(v => {
|
||||
if (this.agGrid.api.getRowNode(v.id.toString()) != null) {
|
||||
toUpdate.push(v);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate });
|
||||
this.applicationEndpoint.newLogs$.subscribe((newLogs: Log[]) => {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
...newLogs.map(
|
||||
e => new LogRow(e.id, e.type, e.status, e.time, e.message)
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.logsRemove$.subscribe((e: number[]) => {
|
||||
const toRemove: any[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(
|
||||
v.toString()
|
||||
);
|
||||
if (rowNode != null) {
|
||||
toRemove.push(rowNode.data);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ remove: toRemove });
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value.filter(
|
||||
v => e.find(d => d === v.id) == null
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.updatedLogs$.subscribe((e: Log[]) => {
|
||||
e.forEach(v => {
|
||||
const rowNode = this._dataStream.value.find(d => d.id === v.id);
|
||||
if (rowNode != null) {
|
||||
Object.assign(rowNode, v);
|
||||
rowNode.type = formatType(v.type);
|
||||
rowNode.statusSignal.set(v.status);
|
||||
rowNode.messageSignal.set(v.message);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
post.status === 'ERROR' ||
|
||||
post.status === 'STOPPED'
|
||||
"
|
||||
(click)="onPostStart()">
|
||||
(click)="onPostStart(); close()">
|
||||
<mat-icon matListItemIcon>play_arrow</mat-icon>
|
||||
<div matListItemTitle>
|
||||
{{ post.done / post.total === 0 ? 'Start' : 'Resume' }}
|
||||
@@ -18,15 +18,15 @@
|
||||
post.status === 'PARTIAL' ||
|
||||
post.status === 'PENDING'
|
||||
"
|
||||
(click)="onPostStop()">
|
||||
(click)="onPostStop(); close()">
|
||||
<mat-icon matListItemIcon>pause</mat-icon>
|
||||
<div matListItemTitle>Stop</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="onPostDelete()">
|
||||
<mat-list-item (click)="onPostDelete(); close()">
|
||||
<mat-icon matListItemIcon>delete</mat-icon>
|
||||
<div matListItemTitle>Remove</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="openImages()">
|
||||
<mat-list-item (click)="openImages(); close()">
|
||||
<mat-icon matListItemIcon>list</mat-icon>
|
||||
<div matListItemTitle>Photos</div>
|
||||
</mat-list-item>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule, NgIf } from '@angular/common';
|
||||
import { Post } from '../domain/post.model';
|
||||
import {
|
||||
animate,
|
||||
state,
|
||||
@@ -18,6 +12,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
import { PostRow } from '../domain/post-row.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-post-contextmenu',
|
||||
@@ -35,7 +30,7 @@ import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PostContextmenuComponent {
|
||||
post!: Post;
|
||||
post!: PostRow;
|
||||
|
||||
constructor(public dialog: MatDialog) {}
|
||||
|
||||
@@ -55,4 +50,6 @@ export class PostContextmenuComponent {
|
||||
onPostStop!: () => void;
|
||||
|
||||
onPostDelete!: () => void;
|
||||
|
||||
close!: () => void;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<div style="display: flex; height: 100%; align-items: center">
|
||||
<mat-progress-bar mode="determinate" [value]="progress()"></mat-progress-bar>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProgressCellComponent } from './progress-cell.component';
|
||||
|
||||
describe('ProgressCellComponent', () => {
|
||||
let component: ProgressCellComponent;
|
||||
let fixture: ComponentFixture<ProgressCellComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ProgressCellComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(ProgressCellComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ICellRendererParams } from 'ag-grid-community';
|
||||
import { AgGridModule, ICellRendererAngularComp } from 'ag-grid-angular';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-progress-cell',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule, MatProgressBarModule],
|
||||
templateUrl: './progress-cell.component.html',
|
||||
styleUrls: ['./progress-cell.component.scss'],
|
||||
})
|
||||
export class ProgressCellComponent implements ICellRendererAngularComp {
|
||||
progress = signal(0);
|
||||
agInit(params: ICellRendererParams): void {
|
||||
this.progress.set(params.value);
|
||||
}
|
||||
|
||||
refresh(params: ICellRendererParams): boolean {
|
||||
this.progress.set(params.value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,9 @@ export class ApplicationEndpointService {
|
||||
return this.updatedPosts;
|
||||
}
|
||||
|
||||
private deletedPosts!: Observable<string[]>;
|
||||
private deletedPosts!: Observable<number[]>;
|
||||
|
||||
get deletedPosts$(): Observable<string[]> {
|
||||
get deletedPosts$(): Observable<number[]> {
|
||||
return this.deletedPosts;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,6 @@ export class ApplicationEndpointService {
|
||||
|
||||
this.newPosts = this.rxStomp.watch('/topic/posts/new').pipe(
|
||||
map(e => {
|
||||
// const posts: Array<Post> = [];
|
||||
return JSON.parse(e.body).map((element: any) => {
|
||||
return new Post(
|
||||
element.postId,
|
||||
@@ -229,7 +228,6 @@ export class ApplicationEndpointService {
|
||||
|
||||
this.updatedPosts = this.rxStomp.watch('/topic/posts/updated').pipe(
|
||||
map(e => {
|
||||
// const posts: Array<Post> = [];
|
||||
return JSON.parse(e.body).map((element: any) => {
|
||||
return new Post(
|
||||
element.postId,
|
||||
@@ -309,9 +307,9 @@ export class ApplicationEndpointService {
|
||||
}
|
||||
|
||||
getThreadPosts(threadId: number) {
|
||||
return this.httpClient
|
||||
.get<PostItem[]>(this.baseUrl + `/api/grab/${threadId}`)
|
||||
.pipe(map(v => v.map(p => ({ ...p, hosts: p.hosts }))));
|
||||
return this.httpClient.get<PostItem[]>(
|
||||
this.baseUrl + `/api/grab/${threadId}`
|
||||
);
|
||||
}
|
||||
|
||||
startDownload() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<mat-card class="mat-elevation-z4">
|
||||
<mat-action-list>
|
||||
<mat-list-item (click)="onThreadSelection()">
|
||||
<mat-list-item (click)="onThreadSelection(); close()">
|
||||
<mat-icon matListItemIcon>check_box</mat-icon>
|
||||
<div matListItemTitle>Select posts</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="onThreadDelete()">
|
||||
<mat-list-item (click)="onThreadDelete(); close()">
|
||||
<mat-icon matListItemIcon>delete</mat-icon>
|
||||
<div matListItemTitle>Remove</div>
|
||||
</mat-list-item>
|
||||
|
||||
@@ -5,10 +5,6 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Thread } from '../domain/thread.model';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import {
|
||||
ThreadDialogData,
|
||||
ThreadSelectionComponent,
|
||||
} from '../thread-selection/thread-selection.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-thread-contextmenu',
|
||||
@@ -31,4 +27,5 @@ export class ThreadContextmenuComponent {
|
||||
onThreadSelection!: () => void;
|
||||
|
||||
onThreadDelete!: () => void;
|
||||
close!: () => void;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,89 @@
|
||||
<h2 mat-dialog-title>Thread</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'number')"
|
||||
matColumnDef="number">
|
||||
<th *matHeaderCellDef mat-header-cell>
|
||||
<mat-checkbox
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
[aria-label]="checkboxLabel()"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
color="primary">
|
||||
</mat-checkbox>
|
||||
Number
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.number"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.number }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
|
||||
matColumnDef="title">
|
||||
<th *matHeaderCellDef mat-header-cell>Title</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.title"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.title }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'url')"
|
||||
matColumnDef="url">
|
||||
<th *matHeaderCellDef mat-header-cell>URL</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.url"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.url }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'hosts')"
|
||||
matColumnDef="hosts">
|
||||
<th *matHeaderCellDef mat-header-cell>URL</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="formatHosts(element.hosts)"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ formatHosts(element.hosts) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row, $event)"
|
||||
(dblclick)="onRowDoubleClicked(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@if (dataSource.loading()) {
|
||||
<div style="display: flex; justify-content: center">
|
||||
<p>Loading</p>
|
||||
</div>
|
||||
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-flat-button [mat-dialog-close]="true" cdkFocusInitial>
|
||||
Cancel
|
||||
</button>
|
||||
<button mat-flat-button color="primary" (click)="downloadSelected()">Download</button>
|
||||
<button (click)="downloadSelected()" color="primary" mat-flat-button>
|
||||
Download
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Component, Inject, ViewChild } from '@angular/core';
|
||||
import { Component, EventEmitter, Inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import {
|
||||
GridOptions,
|
||||
ITooltipParams,
|
||||
RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { GridReadyEvent } from 'ag-grid-community/dist/lib/events';
|
||||
import { PostItem } from '../domain/post-item.model';
|
||||
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { DialogRef } from '@angular/cdk/dialog';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { Thread } from '../domain/thread.model';
|
||||
import { BehaviorSubject, finalize, Observable } from 'rxjs';
|
||||
import { isDisplayed } from '../utils/utils';
|
||||
|
||||
export interface ThreadDialogData {
|
||||
threadId: number;
|
||||
@@ -22,103 +19,111 @@ export interface ThreadDialogData {
|
||||
@Component({
|
||||
selector: 'app-thread-selection',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule, MatButtonModule, MatDialogModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatCheckboxModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './thread-selection.component.html',
|
||||
styleUrls: ['./thread-selection.component.scss'],
|
||||
})
|
||||
export class ThreadSelectionComponent {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular<PostItem>;
|
||||
|
||||
gridOptions: GridOptions;
|
||||
dataSource = new ThreadSelectionDataSource(
|
||||
this.applicationEndpoint,
|
||||
this.data.threadId
|
||||
);
|
||||
displayedColumns: string[] = ['number', 'title', 'url', 'hosts'];
|
||||
selection = new SelectionModel<PostItem>(
|
||||
true,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.url === b.url
|
||||
);
|
||||
isDisplayed = isDisplayed;
|
||||
selectedChange = new EventEmitter<Thread[]>();
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ThreadDialogData,
|
||||
private dialogRef: DialogRef<ThreadDialogData>,
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
breakpointObserver: BreakpointObserver
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
colId: 'number',
|
||||
headerName: 'Number',
|
||||
field: 'number',
|
||||
tooltipField: 'number',
|
||||
checkboxSelection: true,
|
||||
headerCheckboxSelection: true,
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
colId: 'title',
|
||||
headerName: 'Title',
|
||||
field: 'title',
|
||||
tooltipField: 'title',
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
colId: 'url',
|
||||
headerName: 'URL',
|
||||
field: 'url',
|
||||
tooltipField: 'url',
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
colId: 'hosts',
|
||||
headerName: 'Hosts',
|
||||
field: 'hosts',
|
||||
tooltipValueGetter: (params: ITooltipParams<PostItem>) => {
|
||||
return params.data?.hosts
|
||||
.map(v => `${v.first} (${v.second})`)
|
||||
.join(', ');
|
||||
},
|
||||
valueGetter: (params: ValueGetterParams<PostItem>) => {
|
||||
return params.data?.hosts
|
||||
.map(v => `${v.first} (${v.second})`)
|
||||
.join(', ');
|
||||
},
|
||||
flex: 1,
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
},
|
||||
rowSelection: 'multiple',
|
||||
getRowId: row => row.data['postId'].toString(),
|
||||
onGridReady: (event: GridReadyEvent<PostItem>) => {
|
||||
breakpointObserver
|
||||
.observe(Breakpoints.HandsetPortrait)
|
||||
.subscribe(result => {
|
||||
this.agGrid.columnApi.setColumnsVisible(['url'], !result.matches);
|
||||
if (result.matches) {
|
||||
this.dialogRef.updateSize('100vw', '80vh');
|
||||
} else {
|
||||
this.dialogRef.updateSize('80vw', '80vh');
|
||||
}
|
||||
this.dialogRef.updatePosition();
|
||||
});
|
||||
this.applicationEndpoint
|
||||
.getThreadPosts(this.data.threadId)
|
||||
.subscribe(result => {
|
||||
event.api.applyTransaction({ add: result });
|
||||
});
|
||||
},
|
||||
onRowDoubleClicked: (event: RowDoubleClickedEvent<PostItem>) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
this.download([event.data]);
|
||||
},
|
||||
};
|
||||
private applicationEndpoint: ApplicationEndpointService
|
||||
) {}
|
||||
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.dataSource._dataStream.value.length;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
/** Selects all rows if they are not all selected; otherwise clear selection. */
|
||||
toggleAllRows() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selection.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.select(...this.dataSource._dataStream.value);
|
||||
}
|
||||
|
||||
/** The label for the checkbox on the passed row */
|
||||
checkboxLabel(row?: PostItem): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
|
||||
row.url
|
||||
}`;
|
||||
}
|
||||
|
||||
onClick(row: PostItem, event: MouseEvent) {
|
||||
if (!event.ctrlKey) {
|
||||
this.selection.clear();
|
||||
}
|
||||
this.selection.select(row);
|
||||
}
|
||||
|
||||
onRowDoubleClicked(row: PostItem) {
|
||||
this.download([row]);
|
||||
}
|
||||
|
||||
downloadSelected() {
|
||||
this.download(this.agGrid.api.getSelectedRows());
|
||||
this.download(this.selection.selected);
|
||||
}
|
||||
|
||||
formatHosts = (hosts: [{ first: string; second: number }]): string => {
|
||||
return hosts.map(v => `${v.first} (${v.second})`).join(', ');
|
||||
};
|
||||
|
||||
private download = (items: PostItem[]) => {
|
||||
this.applicationEndpoint.download(items).subscribe(() => {
|
||||
this.dialogRef.close();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
class ThreadSelectionDataSource extends DataSource<PostItem> {
|
||||
_dataStream = new BehaviorSubject<PostItem[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
private threadId: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<PostItem[]> {
|
||||
this.applicationEndpoint
|
||||
.getThreadPosts(this.threadId)
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe(result => {
|
||||
this._dataStream.next(result);
|
||||
});
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
<ag-grid-angular
|
||||
id="thread-grid"
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions"
|
||||
(contextmenu)="disableForRows($event)">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
|
||||
matColumnDef="title">
|
||||
<th *matHeaderCellDef mat-header-cell>
|
||||
<mat-checkbox
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
[aria-label]="checkboxLabel()"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
color="primary">
|
||||
</mat-checkbox>
|
||||
Title
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.title"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.title }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'link')"
|
||||
matColumnDef="link">
|
||||
<th *matHeaderCellDef mat-header-cell>Link</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.link"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.link }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'total')"
|
||||
matColumnDef="total">
|
||||
<th *matHeaderCellDef mat-header-cell>Total</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.total }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row)"
|
||||
(contextmenu)="onContextMenu($event, row)"
|
||||
(dblclick)="onRowDoubleClicked(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@@ -3,20 +3,11 @@ import {
|
||||
Component,
|
||||
ComponentRef,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
Output,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import {
|
||||
CellContextMenuEvent,
|
||||
GridOptions,
|
||||
IRowNode,
|
||||
RowDataUpdatedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { fromEvent, merge, Subscription, take } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription, take } from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { Thread } from '../domain/thread.model';
|
||||
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
|
||||
@@ -39,29 +30,46 @@ import {
|
||||
ConfirmComponent,
|
||||
ConfirmDialogData,
|
||||
} from '../confirm/confirm.component';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { isDisplayed } from '../utils/utils';
|
||||
import { ThreadRow } from '../domain/thread-row.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-thread-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AgGridModule,
|
||||
OverlayModule,
|
||||
PortalModule,
|
||||
MatDialogModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './thread-table.component.html',
|
||||
styleUrls: ['./thread-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ThreadTableComponent implements OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
|
||||
export class ThreadTableComponent {
|
||||
dataSource = new ThreadDataSource(this.applicationEndpoint);
|
||||
displayedColumns: string[] = ['title', 'link', 'total'];
|
||||
selection = new SelectionModel<ThreadRow>(
|
||||
true,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.link === b.link
|
||||
);
|
||||
isDisplayed = isDisplayed;
|
||||
@Output()
|
||||
rowCountChange = new EventEmitter<number>();
|
||||
|
||||
gridOptions: GridOptions;
|
||||
subscriptions: Subscription[] = [];
|
||||
@Output()
|
||||
selectedChange = new EventEmitter<Thread[]>();
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
@@ -69,171 +77,138 @@ export class ThreadTableComponent implements OnDestroy {
|
||||
private overlay: Overlay,
|
||||
private dialog: MatDialog
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
this.dataSource._dataStream.subscribe(v =>
|
||||
this.rowCountChange.emit(v.length)
|
||||
);
|
||||
this.selection.changed.subscribe(() =>
|
||||
this.selectedChange.emit(this.selection.selected)
|
||||
);
|
||||
}
|
||||
|
||||
onRowDoubleClicked(row: ThreadRow) {
|
||||
const data: ThreadDialogData = { threadId: row.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
}
|
||||
|
||||
onClick(row: ThreadRow) {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
|
||||
onContextMenu(mouseEvent: MouseEvent, row: ThreadRow) {
|
||||
mouseEvent.preventDefault();
|
||||
if (this.selection.selected.length > 1) {
|
||||
this.selection.select(row);
|
||||
} else {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
headerName: 'Title',
|
||||
field: 'title',
|
||||
tooltipField: 'title',
|
||||
flex: 2,
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
headerName: 'Url',
|
||||
field: 'link',
|
||||
tooltipField: 'link',
|
||||
flex: 2,
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
{
|
||||
headerName: 'Count',
|
||||
field: 'total',
|
||||
tooltipField: 'total',
|
||||
flex: 1,
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
rowSelection: 'multiple',
|
||||
getRowId: row => row.data['threadId'],
|
||||
onGridReady: () => this.connect(),
|
||||
onRowDataUpdated: (event: RowDataUpdatedEvent) =>
|
||||
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
|
||||
onCellContextMenu: (event: CellContextMenuEvent<Thread>) => {
|
||||
if (event.api.getSelectedRows().length > 1) {
|
||||
event.node.setSelected(true);
|
||||
} else {
|
||||
event.node.setSelected(true, true);
|
||||
}
|
||||
const mouseEvent = event.event as MouseEvent;
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
]);
|
||||
const postContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const postContextMenuPortal = new ComponentPortal(
|
||||
ThreadContextmenuComponent
|
||||
);
|
||||
const ref: ComponentRef<ThreadContextmenuComponent> =
|
||||
postContextMenuOverlayRef.attach(postContextMenuPortal);
|
||||
ref.instance.thread = event.data as Thread;
|
||||
]);
|
||||
const threadContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const threadContextMenuPortal = new ComponentPortal(
|
||||
ThreadContextmenuComponent
|
||||
);
|
||||
const ref: ComponentRef<ThreadContextmenuComponent> =
|
||||
threadContextMenuOverlayRef.attach(threadContextMenuPortal);
|
||||
ref.instance.thread = row as ThreadRow;
|
||||
|
||||
ref.instance.onThreadSelection = () => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
const data: ThreadDialogData = { threadId: event.data.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
};
|
||||
|
||||
ref.instance.onThreadDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
event.api.getSelectedRows().length
|
||||
} post${event.api.getSelectedRows().length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deleteThreads(event.api.getSelectedRows())
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const subscription = merge(
|
||||
fromEvent<MouseEvent>(document, 'click'),
|
||||
fromEvent<MouseEvent>(document, 'contextmenu')
|
||||
)
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
},
|
||||
onRowDoubleClicked: (event: RowDoubleClickedEvent<Thread>) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
const data: ThreadDialogData = { threadId: event.data.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
},
|
||||
ref.instance.close = () => {
|
||||
threadContextMenuOverlayRef?.detach();
|
||||
threadContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
};
|
||||
|
||||
ref.instance.onThreadSelection = () => {
|
||||
const data: ThreadDialogData = { threadId: row.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
};
|
||||
|
||||
ref.instance.onThreadDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
this.selection.selected.length
|
||||
} post${this.selection.selected.length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deleteThreads(this.selection.selected)
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
threadContextMenuOverlayRef
|
||||
.outsidePointerEvents()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
console.log('click away');
|
||||
threadContextMenuOverlayRef?.detach();
|
||||
threadContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threads$.subscribe((e: Thread[]) => {
|
||||
const toAdd: Thread[] = [];
|
||||
const toUpdate: Thread[] = [];
|
||||
e.forEach(v => {
|
||||
if (this.agGrid.api.getRowNode(String(v.threadId)) == null) {
|
||||
toAdd.push(v);
|
||||
} else {
|
||||
toUpdate.push(v);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate, add: toAdd });
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemove$.subscribe((e: string[]) => {
|
||||
const toRemove: any[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(v);
|
||||
if (rowNode != null) {
|
||||
toRemove.push(rowNode.data);
|
||||
}
|
||||
return;
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ remove: toRemove });
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemoveAll$.subscribe(() => {
|
||||
this.agGrid.api.setRowData([]);
|
||||
})
|
||||
);
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.dataSource._dataStream.value.length;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
/** Selects all rows if they are not all selected; otherwise clear selection. */
|
||||
toggleAllRows() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selection.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.select(...this.dataSource._dataStream.value);
|
||||
}
|
||||
|
||||
/** The label for the checkbox on the passed row */
|
||||
checkboxLabel(row?: ThreadRow): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
|
||||
row.link
|
||||
}`;
|
||||
}
|
||||
|
||||
disableForRows(event: MouseEvent) {
|
||||
@@ -246,8 +221,57 @@ export class ThreadTableComponent implements OnDestroy {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
class ThreadDataSource extends DataSource<Thread> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<Thread[]>([]);
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<Thread[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threads$.subscribe((newThreads: Thread[]) => {
|
||||
newThreads.forEach(thread => {
|
||||
const rowNode = this._dataStream.value.find(
|
||||
d => d.link === thread.link
|
||||
);
|
||||
if (rowNode == null) {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
new ThreadRow(
|
||||
thread.link,
|
||||
thread.title,
|
||||
thread.threadId,
|
||||
thread.total
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
Object.assign(rowNode, thread);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemove$.subscribe((e: string[]) => {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value.filter(
|
||||
v => e.find(d => d === v.link) == null
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemoveAll$.subscribe(() => {
|
||||
this._dataStream.next([]);
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,36 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function statusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'DOWNLOADING':
|
||||
return 'download_for_offline';
|
||||
case 'PENDING':
|
||||
return 'pending';
|
||||
case 'FINISHED':
|
||||
return 'check_circle';
|
||||
case 'ERROR':
|
||||
return 'error';
|
||||
case 'STOPPED':
|
||||
return 'pause_circle';
|
||||
default:
|
||||
return 'question_mark';
|
||||
}
|
||||
}
|
||||
|
||||
export function progress(done: number, total: number): number {
|
||||
return done === 0 || total === 0 ? 0 : (done / total) * 100;
|
||||
}
|
||||
|
||||
export function totalFormatter(
|
||||
done: number,
|
||||
total: number,
|
||||
downloaded: number
|
||||
): string {
|
||||
return `${done}/${total} (${formatBytes(downloaded)})`;
|
||||
}
|
||||
|
||||
export function isDisplayed(columns: string[], column: string): boolean {
|
||||
return columns.findIndex(v => v === column) > -1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@import 'ag-grid-community/styles/ag-grid.css';
|
||||
@import 'ag-grid-community/styles/ag-theme-alpine.css';
|
||||
@use '@angular/material' as mat;
|
||||
@use './app/download-table/download-table.component-theme' as downloadTable;
|
||||
|
||||
@include mat.core();
|
||||
|
||||
$my-primary: mat.define-palette(mat.$indigo-palette, 500);
|
||||
$my-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
|
||||
|
||||
$my-theme: mat.define-light-theme((
|
||||
color: (
|
||||
primary: $my-primary,
|
||||
accent: $my-accent,
|
||||
),
|
||||
typography: mat.define-typography-config(
|
||||
$font-family: roboto,
|
||||
)
|
||||
));
|
||||
|
||||
@include mat.all-component-themes($my-theme);
|
||||
@include downloadTable.theme($my-theme);
|
||||
|
||||
html,
|
||||
body {
|
||||
@@ -53,7 +71,7 @@ body {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
animation: sk-chase-dot 2.0s infinite ease-in-out both;
|
||||
animation: sk-chase-dot 2s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.sk-chase-dot:before {
|
||||
@@ -63,7 +81,7 @@ body {
|
||||
height: 25%;
|
||||
background-color: #fff;
|
||||
border-radius: 100%;
|
||||
animation: sk-chase-dot-before 2.0s infinite ease-in-out both;
|
||||
animation: sk-chase-dot-before 2s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(1) {
|
||||
@@ -71,7 +89,7 @@ body {
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(2) {
|
||||
animation-delay: -1.0s;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(3) {
|
||||
@@ -95,7 +113,7 @@ body {
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(2):before {
|
||||
animation-delay: -1.0s;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(3):before {
|
||||
@@ -121,7 +139,8 @@ body {
|
||||
}
|
||||
|
||||
@keyframes sk-chase-dot {
|
||||
80%, 100% {
|
||||
80%,
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -130,11 +149,19 @@ body {
|
||||
50% {
|
||||
transform: scale(0.4);
|
||||
}
|
||||
100%, 0% {
|
||||
transform: scale(1.0);
|
||||
100%,
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: 100vh !important;
|
||||
}
|
||||
|
||||
.truncate-cell {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,16 +70,16 @@ class DataBroadcast(
|
||||
|
||||
eventBus.events.ofType(LogCreateEvent::class.java).map { it.logEntry }
|
||||
.subscribe { logCreateEvent ->
|
||||
template.convertAndSend("/topic/logs/new", logCreateEvent)
|
||||
template.convertAndSend("/topic/logs/new", listOf(logCreateEvent))
|
||||
}
|
||||
|
||||
eventBus.events.ofType(LogUpdateEvent::class.java).map { it.logEntry }
|
||||
.subscribe { logUpdateEvent ->
|
||||
template.convertAndSend("/topic/logs/updated", logUpdateEvent)
|
||||
template.convertAndSend("/topic/logs/updated", listOf(logUpdateEvent))
|
||||
}
|
||||
|
||||
eventBus.events.ofType(LogDeleteEvent::class.java).subscribe {
|
||||
template.convertAndSend("/topic/logs/deleted", it.deleted)
|
||||
template.convertAndSend("/topic/logs/deleted", listOf(it.deleted))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user