This commit is contained in:
death-claw
2024-01-13 17:57:12 +00:00
committed by GitHub
parent 81cf97cc50
commit 537ad90084
33 changed files with 13199 additions and 4700 deletions
-2
View File
@@ -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": []
+3267 -3833
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -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;
}
+84 -103
View File
@@ -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());
}
}
+33
View File
@@ -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;
}
+36 -9
View File
@@ -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))
}
}
}