Add calls UI ()

Also make a PWA.

Closes 

Reviewed-on: 
Co-authored-by: Daniel Ponte <amigan@gmail.com>
Co-committed-by: Daniel Ponte <amigan@gmail.com>
This commit is contained in:
Daniel Ponte 2024-12-27 13:14:45 -05:00 committed by amigan
parent 77a08679d4
commit dac1f5fa4e
62 changed files with 3423 additions and 2688 deletions

View file

@ -1,6 +1,4 @@
# Admin
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.10.
# Stillbox
## Development server
@ -23,5 +21,3 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View file

@ -54,7 +54,8 @@
"maximumError": "4kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,

View file

@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -10,25 +10,26 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/cdk": "^17.0.0",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/material": "^17.0.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"@angular/animations": "^19.0.5",
"@angular/cdk": "^19.0.4",
"@angular/common": "^19.0.5",
"@angular/compiler": "^19.0.5",
"@angular/core": "^19.0.5",
"@angular/forms": "^19.0.5",
"@angular/material": "^19.0.4",
"@angular/platform-browser": "^19.0.5",
"@angular/platform-browser-dynamic": "^19.0.5",
"@angular/router": "^19.0.5",
"@angular/service-worker": "^19.0.5",
"rxjs": "~7.8.0",
"sass": "^1.82.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10"
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.10",
"@angular/cli": "^18.2.10",
"@angular/compiler-cli": "^18.2.0",
"@angular-devkit/build-angular": "^19.0.6",
"@angular/cli": "^19.0.6",
"@angular/compiler-cli": "^19.0.5",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",

Binary file not shown.

After

(image error) Size: 7.1 KiB

Binary file not shown.

After

(image error) Size: 8.1 KiB

Binary file not shown.

After

(image error) Size: 8.7 KiB

Binary file not shown.

After

(image error) Size: 12 KiB

Binary file not shown.

After

(image error) Size: 29 KiB

Binary file not shown.

After

(image error) Size: 48 KiB

Binary file not shown.

After

(image error) Size: 3.6 KiB

Binary file not shown.

After

(image error) Size: 5.1 KiB

View file

@ -0,0 +1,59 @@
{
"name": "Stillbox",
"short_name": "Stillbox",
"theme_color": "#1976d2",
"background_color": "#fafafa",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

View file

@ -2,7 +2,6 @@ import { Component } from '@angular/core';
@Component({
selector: 'app-alerts',
standalone: true,
imports: [],
templateUrl: './alerts.component.html',
styleUrl: './alerts.component.scss',

View file

@ -1,4 +1,5 @@
<!-- ========== HEADER ========== -->
<app-update-nag></app-update-nag>
<header class="topHeader">
<mat-toolbar>
<div class="navbar">

View file

@ -1,23 +1,30 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatSidenavModule, MatSidenav } from '@angular/material/sidenav';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { AuthService } from './login/auth.service';
import { NavigationComponent } from './navigation/navigation.component';
import { Subject } from 'rxjs';
import { Subject, Subscription } from 'rxjs';
import { Title } from '@angular/platform-browser';
import { UpdateNagComponent } from './version/update-nag/update-nag.component';
import {
Router,
RouterModule,
NavigationEnd,
ActivatedRoute,
} from '@angular/router';
import { filter, map } from 'rxjs';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatSidenavModule,
MatToolbarModule,
FormsModule,
NavigationComponent,
UpdateNagComponent,
],
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
@ -27,12 +34,42 @@ export class AppComponent {
title = 'stillbox';
opened!: boolean;
toggleNavSubject: Subject<void> = new Subject<void>();
titleSub!: Subscription;
constructor(
private router: Router,
private titleService: Title,
) {}
toggleNav() {
this.toggleNavSubject.next();
}
ngOnInit() {
this.titleSub = this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
map(() => {
let route: ActivatedRoute = this.router.routerState.root;
let routeTitle = '';
while (route!.firstChild) {
route = route.firstChild;
}
if (route.snapshot.data['title']) {
routeTitle = route!.snapshot.data['title'];
}
return routeTitle;
}),
)
.subscribe((title: string) => {
if (title) {
this.titleService.setTitle(`${title} | Stillbox`);
}
});
}
logout() {
this.titleSub.unsubscribe();
this.auth.logout();
}
}

View file

@ -14,6 +14,7 @@ import { AuthService } from './login/auth.service';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideServiceWorker } from '@angular/service-worker';
export function apiBaseInterceptor(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
@ -45,5 +46,9 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes),
provideHttpClient(withInterceptors([apiBaseInterceptor, authIntercept])),
provideAnimationsAsync(),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
],
};

View file

@ -17,6 +17,7 @@ export const routes: Routes = [
loadComponent: () =>
import('./home/home.component').then((m) => m.HomeComponent),
pathMatch: 'full',
data: { title: 'Home' },
},
{
path: 'talkgroups',
@ -24,6 +25,7 @@ export const routes: Routes = [
import('./talkgroups/talkgroups.component').then(
(m) => m.TalkgroupsComponent,
),
data: { title: 'Talkgroups' },
},
{
path: 'talkgroups/import',
@ -31,6 +33,7 @@ export const routes: Routes = [
import('./talkgroups/import/import.component').then(
(m) => m.ImportComponent,
),
data: { title: 'Import Talkgroups' },
},
{
path: 'talkgroups/export',
@ -38,6 +41,7 @@ export const routes: Routes = [
import('./talkgroups/export/export.component').then(
(m) => m.ExportComponent,
),
data: { title: 'Export Talkgroups' },
},
{
path: 'talkgroups/:sys/:tg',
@ -45,11 +49,13 @@ export const routes: Routes = [
import(
'./talkgroups/talkgroup-record/talkgroup-record.component'
).then((m) => m.TalkgroupRecordComponent),
data: { title: 'Edit Talkgroup' },
},
{
path: 'calls',
loadComponent: () =>
import('./calls/calls.component').then((m) => m.CallsComponent),
data: { title: 'Calls' },
},
{
path: 'incidents',
@ -57,11 +63,13 @@ export const routes: Routes = [
import('./incidents/incidents.component').then(
(m) => m.IncidentsComponent,
),
data: { title: 'Incidents' },
},
{
path: 'alerts',
loadComponent: () =>
import('./alerts/alerts.component').then((m) => m.AlertsComponent),
data: { title: 'Alerts' },
},
],
},

View file

@ -5,18 +5,18 @@ import { inject } from '@angular/core';
export const AuthGuard: CanActivateFn = (route, state) => {
const router: Router = inject(Router);
const authSvc: AuthService = inject(AuthService);
if (sessionStorage.getItem('jwt') == null) {
if (localStorage.getItem('jwt') == null) {
let success = false;
authSvc.refresh().subscribe(
(event) => {
authSvc.refresh().subscribe({
next: (event) => {
if (event?.status == 200) {
success = true;
}
},
(err) => {
error: (err) => {
router.navigate(['/login']);
},
);
});
return success;
} else {
return true;

View file

@ -0,0 +1,9 @@
export interface CallRecord {
id: string;
call_date: Date;
duration: number;
system_id: number;
tgid: number;
system_name: string;
tg_name: string;
}

View file

@ -1 +1,181 @@
<p>calls works!</p>
<div [ngClass]="{ hidden: (tcSvc.filterPanel | async) }" class="toolbar">
<form [formGroup]="form">
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
<mat-label>Start</mat-label>
<input
matInput
type="datetime-local"
name="start"
placeholder="Start date"
formControlName="start"
/>
</mat-form-field>
<mat-form-field subscriptSizing="dynamic" class="timeFilterBox">
<mat-label>End</mat-label>
<input
matInput
type="datetime-local"
name="end"
placeholder="End date"
formControlName="end"
/>
</mat-form-field>
<mat-form-field subscriptSizing="dynamic" class="durationFilter">
<mat-label>Dur (s) &gt;</mat-label>
<input
matInput
type="number"
name="duration"
placeholder="Seconds"
formControlName="duration"
/>
</mat-form-field>
<mat-form-field class="filterBox" subscriptSizing="dynamic">
<mat-label>Filter</mat-label>
<input
matInput
name="filter"
type="text"
autocomplete="off"
formControlName="filter"
/>
<button
class="clearBtn"
*ngIf="form.controls['filter'].value"
matSuffix
mat-icon-button
(click)="form.controls['filter'].setValue('')"
>
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<mat-form-field class="tagSelect" subscriptSizing="dynamic">
<mat-label>Any Tags</mat-label>
<mat-select
matNativeControl
name="tagsAny"
formControlName="tagsAny"
multiple
>
@for (tag of tgSvc.allTags() | async; track tag) {
<mat-option value="{{ tag }}">{{ tag }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="tagSelect" subscriptSizing="dynamic">
<mat-label>Not Tags</mat-label>
<mat-select
matNativeControl
name="tagsNot"
class="tagSelect"
formControlName="tagsNot"
multiple
>
@for (tag of tgSvc.allTags() | async; track tag) {
<mat-option value="{{ tag }}">{{ tag }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="toolbarButtons">
<button class="sbButton" (click)="resetFilter()">
<mat-icon class="material-symbols-outlined">reset_settings</mat-icon>
</button>
<button class="sbButton" (click)="refresh()">
<mat-icon>refresh</mat-icon>
</button>
</div>
</form>
</div>
<div class="tabContainer" *ngIf="!isLoading; else spinner">
<table class="callsTable" mat-table [dataSource]="callsResult">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
>
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
>
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="play">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let call">
<a class="playButton" (click)="playAudio($event, call)"
><mat-icon>play_arrow</mat-icon></a
>
</td>
</ng-container>
<ng-container matColumnDef="download">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let call">
<a [href]="call | audioDownloadURL"><mat-icon>download</mat-icon></a>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef>Date</th>
<td mat-cell *matCellDef="let call">
{{ call.call_date | grabDate }}
</td>
</ng-container>
<ng-container matColumnDef="time">
<th mat-header-cell *matHeaderCellDef>Time</th>
<td [title]="call.call_date" mat-cell *matCellDef="let call">
{{ call.call_date | time }}
</td>
</ng-container>
<ng-container matColumnDef="system">
<th mat-header-cell *matHeaderCellDef>System</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "system" | async }}
</td>
</ng-container>
<ng-container matColumnDef="group">
<th mat-header-cell *matHeaderCellDef>Group</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "group" | async }}
</td>
</ng-container>
<ng-container matColumnDef="talkgroup">
<th mat-header-cell *matHeaderCellDef>Talkgroup</th>
<td mat-cell *matCellDef="let call">
{{ call | talkgroup: "alpha" | async }}
</td>
</ng-container>
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef class="durationHdr">Duration</th>
<td mat-cell *matCellDef="let call" class="duration">
{{ call.duration | fixedPoint: 1000 : 2 }}s
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
</table>
</div>
<div class="pagFoot">
<mat-paginator
#paginator
class="paginator"
(page)="setPage($event)"
[length]="count"
showFirstLastButtons="true"
[pageSize]="curPage.pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="curPage.pageIndex"
aria-label="Select page"
>
</mat-paginator>
</div>
<ng-template #spinner>
<div class="spinner">
<mat-spinner></mat-spinner>
</div>
</ng-template>

View file

@ -0,0 +1,98 @@
.timeFilterBox {
flex: 0 0 240px;
}
table,
.callsTable {
width: 100%;
}
.duration {
text-align: right;
}
.tabContainer {
max-height: calc(
(
100vh - var(--mat-mat-maginator-container-size, 56px) - 2 *
(var(--mat-toolbar-standard-height, 64px))
) + 7px
);
overflow: auto;
}
.toolbarButtons button,
.toolbarButtons form,
.toolbarButtons form button {
justify-content: flex-end;
align-content: center;
}
.playButton {
cursor: pointer;
}
tr.mat-mdc-row {
height: 2.3rem !important;
font-size: 12pt;
}
.mdc-text-field__input::-webkit-calendar-picker-indicator {
display: block !important;
}
.mat-column-select,
.mat-column-time,
.mat-column-play,
.mat-column-download,
.mat-column-duration {
width: 10px;
padding: 0 2px;
}
.mat-column-date {
width: 10px;
padding: 0 6px;
}
.mat-column-system {
width: 4%;
}
.mat-column-talkgroup {
width: 40%;
}
@media screen and (max-width: 768px) {
.mat-column-group,
.mat-column-system {
display: none;
}
.tabFootContainer {
padding: 0;
}
}
.toolbar,
.toolbar form {
display: flex;
flex-flow: row wrap;
}
form {
flex: 1 0;
display: flex;
}
.filterBox {
flex: 1 1 300px;
}
.durationFilter {
flex: 0 0 80px;
}
.tagSelect {
flex: 1 1 250px;
}

View file

@ -1,10 +1,342 @@
import { Component } from '@angular/core';
import { Component, Pipe, PipeTransform, ViewChild } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTableModule } from '@angular/material/table';
import {
MatPaginator,
MatPaginatorModule,
PageEvent,
} from '@angular/material/paginator';
import { PrefsService } from '../prefs/prefs.service';
import { MatIconModule } from '@angular/material/icon';
import { SelectionModel } from '@angular/cdk/collections';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { CallsListParams, CallsService } from './calls.service';
import { CallRecord } from '../calls';
import { TalkgroupService } from '../talkgroups/talkgroups.service';
import { Talkgroup } from '../talkgroup';
import { MatFormFieldModule } from '@angular/material/form-field';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatTimepickerModule } from '@angular/material/timepicker';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { debounceTime } from 'rxjs/operators';
import { ToolbarContextService } from '../navigation/toolbar-context.service';
import { MatSelect, MatSelectModule } from '@angular/material/select';
@Pipe({
name: 'grabDate',
standalone: true,
pure: true,
})
export class DatePipe implements PipeTransform {
transform(ts: string, args?: any): string {
const timestamp = new Date(ts);
return timestamp.getMonth() + 1 + '/' + timestamp.getDate();
}
}
@Pipe({
name: 'time',
standalone: true,
pure: true,
})
export class TimePipe implements PipeTransform {
transform(ts: string, args?: any): string {
const timestamp = new Date(ts);
return timestamp.toLocaleTimeString(navigator.language, {
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h24',
});
}
}
@Pipe({
name: 'talkgroup',
standalone: true,
pure: true,
})
export class TalkgroupPipe implements PipeTransform {
constructor(private tgService: TalkgroupService) {}
transform(call: CallRecord, field: string): Observable<string> {
return this.tgService.getTalkgroup(call.system_id, call.tgid).pipe(
map((tg: Talkgroup) => {
switch (field) {
case 'alpha': {
return tg.alpha_tag ?? call.tgid;
break;
}
case 'group': {
return tg.tg_group ?? '\u2014';
break;
}
case 'system': {
return tg.system?.name ?? tg.system_id.toString();
}
default: {
return tg.name ?? '\u2014';
break;
}
}
}),
);
}
}
@Pipe({
name: 'fixedPoint',
standalone: true,
pure: true,
})
export class FixedPointPipe implements PipeTransform {
constructor() {}
transform(quant: number, divisor: number, places: number): string {
const seconds = quant / divisor;
return seconds.toFixed(places);
}
}
@Pipe({
name: 'audioDownloadURL',
standalone: true,
pure: true,
})
export class DownloadURLPipe implements PipeTransform {
constructor(private callsSvc: CallsService) {}
transform(call: CallRecord, args?: any): string {
return this.callsSvc.callAudioDownloadURL(call.id);
}
}
const reqPageSize = 200;
@Component({
selector: 'app-calls',
standalone: true,
imports: [],
imports: [
MatIconModule,
FixedPointPipe,
TalkgroupPipe,
DatePipe,
TimePipe,
MatPaginatorModule,
MatTableModule,
AsyncPipe,
DownloadURLPipe,
MatFormFieldModule,
ReactiveFormsModule,
FormsModule,
MatInputModule,
MatDatepickerModule,
MatTimepickerModule,
MatCheckboxModule,
CommonModule,
MatProgressSpinnerModule,
MatSelectModule,
],
templateUrl: './calls.component.html',
styleUrl: './calls.component.scss',
})
export class CallsComponent {}
export class CallsComponent {
callsResult = new BehaviorSubject(new Array<CallRecord>(0));
@ViewChild('paginator') paginator!: MatPaginator;
count = 0;
page = 0;
perPage = 25;
pageSizeOptions = [25, 50, 75, 100, 200];
columns = [
'select',
'play',
'download',
'date',
'time',
'system',
'group',
'talkgroup',
'duration',
];
curPage = <PageEvent>{ pageIndex: 0, pageSize: 0 };
currentSet!: CallRecord[];
currentServerPage = 0; // page is never 0, forces load
isLoading = true;
selection = new SelectionModel<CallRecord>(true, []);
form = new FormGroup({
start: new FormControl(this.lTime(new Date())),
end: new FormControl(null),
filter: new FormControl(''),
duration: new FormControl(0),
tagsAny: new FormControl<string[]>([]),
tagsNot: new FormControl<string[]>([]),
});
subscriptions = new Subscription();
pageWindow = 0;
fetchCalls = new BehaviorSubject<CallsListParams>(
this.buildParams(this.curPage, this.curPage.pageIndex),
);
constructor(
private callsSvc: CallsService,
private prefsSvc: PrefsService,
public tcSvc: ToolbarContextService,
public tgSvc: TalkgroupService,
) {
this.tcSvc.showFilterButton();
}
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.curPage.pageSize;
return numSelected === numRows;
}
buildParams(p: PageEvent, serverPage: number): CallsListParams {
const par: CallsListParams = {
start: new Date(this.form.controls['start'].value!),
page: serverPage,
perPage: reqPageSize,
end:
this.form.controls['end'].value != null
? new Date(this.form.controls['end'].value!)
: null,
tagsAny:
(this.form.controls['tagsAny'].value?.length ?? 0 > 0)
? this.form.controls['tagsAny'].value
: null,
tagsNot:
(this.form.controls['tagsNot'].value?.length ?? 0 > 0)
? this.form.controls['tagsNot'].value
: null,
dir: 'desc',
tgFilter:
this.form.controls['filter'].value != ''
? this.form.controls['filter'].value
: null,
atLeastSeconds:
this.form.controls['duration'].value != null &&
this.form.controls['duration'].value > 0
? this.form.controls['duration'].value
: null,
};
return par;
}
masterToggle() {
this.isAllSelected()
? this.selection.clear()
: this.callsResult.value.forEach((row) => this.selection.select(row));
}
lTime(now: Date): string {
now.setDate(new Date().getDate() - 7);
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 16);
}
playAudio(ev: Event, call: CallRecord) {
let au = new Audio();
au.src = this.callsSvc.callAudioURL(call.id);
au.load();
au.play();
}
setPage(p: PageEvent, force?: boolean) {
this.selection.clear();
this.curPage = p;
if (p && p!.pageSize != this.perPage) {
this.perPage = p!.pageSize;
this.prefsSvc.set('callsPerPage', p!.pageSize);
}
this.getCalls(p, force);
}
refresh() {
this.selection.clear();
this.getCalls(this.curPage, true);
}
getCalls(p: PageEvent, force?: boolean) {
const pageStart = p.pageIndex * p.pageSize;
const serverPage = Math.floor(pageStart / reqPageSize) + 1;
this.pageWindow = pageStart % reqPageSize;
if (serverPage == this.currentServerPage && !force && this.currentSet) {
this.callsResult.next(
this.callsResult ? this.currentSet.slice(this.pageWindow, this.pageWindow + p.pageSize) : [],
);
} else {
this.currentServerPage = serverPage;
this.fetchCalls.next(this.buildParams(p, serverPage));
}
}
zeroPage(): PageEvent {
return <PageEvent>{
pageIndex: 0,
pageSize: this.curPage.pageSize,
};
}
ngOnDestroy() {
this.tcSvc.hideFilterButton();
this.subscriptions.unsubscribe();
}
ngOnInit() {
this.form.valueChanges.pipe(debounceTime(300)).subscribe(() => {
this.currentServerPage = 0;
this.setPage(this.zeroPage(), true);
});
this.subscriptions.add(
this.prefsSvc.get('callsPerPage').subscribe((cpp) => {
if (cpp && cpp != this.perPage) {
this.perPage = cpp;
this.setPage(<PageEvent>{
pageIndex: 0,
pageSize: cpp,
});
}
}),
);
this.subscriptions.add(
this.fetchCalls
.pipe(
switchMap((params) => {
return this.callsSvc.getCalls(params);
}),
)
.subscribe((calls) => {
this.isLoading = false;
this.count = calls.count;
this.currentSet = calls.calls;
this.callsResult.next(
this.currentSet
? this.currentSet.slice(
this.pageWindow,
this.pageWindow + this.perPage,
)
: [],
);
}),
);
}
resetFilter() {
this.form.reset();
this.form.controls['start'].setValue(this.lTime(new Date()));
this.form.controls['duration'].setValue(0);
}
}

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { CallsService } from './calls.service';
describe('CallsService', () => {
let service: CallsService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CallsService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { CallRecord } from '../calls';
import { environment } from '.././../environments/environment';
export interface CallsListParams {
start: Date | null;
end: Date | null;
tagsAny: string[] | null;
tagsNot: string[] | null;
dir: string;
page: number;
perPage: number;
tgFilter: string | null;
atLeastSeconds: number | null;
}
export interface CallsPaginated {
calls: CallRecord[];
count: number;
}
@Injectable({
providedIn: 'root',
})
export class CallsService {
constructor(private http: HttpClient) {}
getCalls(p: CallsListParams): Observable<CallsPaginated> {
return this.http.post<CallsPaginated>('/api/call/', p);
}
callAudioURL(id: string): string {
return environment.baseUrl + '/api/call/' + id;
}
callAudioDownloadURL(id: string): string {
return environment.baseUrl + '/api/call/' + id + '/download';
}
}

View file

@ -2,7 +2,6 @@ import { Component } from '@angular/core';
@Component({
selector: 'app-home',
standalone: true,
imports: [],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',

View file

@ -2,7 +2,6 @@ import { Component } from '@angular/core';
@Component({
selector: 'app-incidents',
standalone: true,
imports: [],
templateUrl: './incidents.component.html',
styleUrl: './incidents.component.scss',

View file

@ -17,7 +17,7 @@ export class AuthService {
private http: HttpClient,
private _router: Router,
) {
let ssJWT = sessionStorage.getItem('jwt');
let ssJWT = localStorage.getItem('jwt');
if (ssJWT) {
this.loggedIn = true;
}
@ -33,7 +33,7 @@ export class AuthService {
.pipe(
tap((event) => {
if (event.status == 200) {
sessionStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
localStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
this.loggedIn = true;
this._router.navigateByUrl('/home');
}
@ -49,7 +49,7 @@ export class AuthService {
this.loggedIn = false;
}
});
sessionStorage.removeItem('jwt');
localStorage.removeItem('jwt');
this.loggedIn = false;
this._router.navigateByUrl('/login');
}
@ -60,7 +60,7 @@ export class AuthService {
.pipe(
tap((event) => {
if (event.status == 200) {
sessionStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
localStorage.setItem('jwt', event.body?.jwt.toString() ?? '');
this.loggedIn = true;
}
}),
@ -68,6 +68,6 @@ export class AuthService {
}
getToken(): string | null {
return sessionStorage.getItem('jwt');
return localStorage.getItem('jwt');
}
}

View file

@ -25,7 +25,7 @@
</mat-form-field>
</div>
<div>
<button class="login" (click)="onSubmit()">Login</button>
<button class="login sbButton" (click)="onSubmit()">Login</button>
</div>
</form>
@if (failed) {

View file

@ -1,14 +1,13 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../login/auth.service';
import { catchError, of } from 'rxjs';
import { catchError, of, Subscription } from 'rxjs';
import { Router } from '@angular/router';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, MatInputModule, MatFormFieldModule],
templateUrl: './login.component.html',
styleUrl: './login.component.scss',
@ -19,23 +18,30 @@ export class LoginComponent {
username: string = '';
password: string = '';
failed: boolean = false;
private subscriptions = new Subscription();
onSubmit() {
this.failed = false;
this.apiService
.login(this.username, this.password)
.pipe(
catchError(() => {
this.failed = true;
return of(null);
this.subscriptions.add(
this.apiService
.login(this.username, this.password)
.pipe(
catchError(() => {
this.failed = true;
return of(null);
}),
)
.subscribe((event) => {
if (event?.status == 200) {
this.router.navigateByUrl('/');
} else {
this.failed = true;
}
}),
)
.subscribe((event) => {
if (event?.status == 200) {
this.router.navigateByUrl('/');
} else {
this.failed = true;
}
});
);
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
}

View file

@ -2,9 +2,9 @@
<mat-sidenav
#drawer
class="sidenav"
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
[mode]="(isHandset$ | async) ? 'over' : 'side'"
[opened]="(isHandset$ | async) === false"
[attr.role]="(tcSvc.isHandset$ | async) ? 'dialog' : 'navigation'"
[mode]="(tcSvc.isHandset$ | async) ? 'over' : 'side'"
[opened]="(tcSvc.isHandset$ | async) === false"
>
<mat-nav-list>
@for (r of homeRoutes; track r) {
@ -25,7 +25,7 @@
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
@if (isHandset$ | async) {
@if (tcSvc.isHandset$ | async) {
<button
type="button"
aria-label="Toggle sidenav"
@ -34,6 +34,17 @@
>
<mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
</button>
@if (tcSvc.filterBtn | async) {
<button
*ngIf="showFilter"
type="button"
aria-label="Toggle filters"
mat-icon-button
(click)="toggleFilterPanel()"
>
<mat-icon aria-label="Filter toggle icon">tune</mat-icon>
</button>
}
}
<div class="content">
<router-outlet />

View file

@ -1,5 +1,4 @@
import { Component, inject, Input, ViewChild } from '@angular/core';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Component, Input, ViewChild } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
@ -7,7 +6,6 @@ import { MatDrawer, MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { Observable } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { FormsModule } from '@angular/forms';
import {
RouterModule,
@ -18,6 +16,7 @@ import {
import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs';
import { ToolbarContextService } from './toolbar-context.service';
interface HomeRoute {
name: string;
@ -30,7 +29,6 @@ interface HomeRoute {
selector: 'app-navigation',
templateUrl: './navigation.component.html',
styleUrl: './navigation.component.scss',
standalone: true,
imports: [
MatToolbarModule,
MatButtonModule,
@ -48,22 +46,32 @@ interface HomeRoute {
providers: [],
})
export class NavigationComponent {
private breakpointObserver = inject(BreakpointObserver);
@ViewChild('drawer', { static: true }) drawer!: MatDrawer;
toggleSubscription!: Subscription;
isExpanded = false;
showFilter = false;
showFilterSub!: Subscription;
@Input() events!: Observable<void>;
constructor(public tcSvc: ToolbarContextService) {}
ngOnInit() {
this.toggleSubscription = this.events.subscribe(() => {
this.isExpanded = !this.isExpanded;
});
}
ngAfterViewChecked() {
this.showFilterSub = this.tcSvc.filterBtn.subscribe((show) => {
this.showFilter = show;
});
}
ngOnDestroy() {
this.toggleSubscription.unsubscribe();
this.showFilterSub.unsubscribe();
}
homeRoutes = <HomeRoute[]>[
@ -73,16 +81,16 @@ export class NavigationComponent {
icon: 'home',
exact: true,
},
{
name: 'Talkgroups',
url: '/talkgroups',
icon: 'forum',
},
{
name: 'Calls',
url: '/calls',
icon: 'campaign',
},
{
name: 'Talkgroups',
url: '/talkgroups',
icon: 'forum',
},
{
name: 'Incidents',
url: '/incidents',
@ -95,10 +103,7 @@ export class NavigationComponent {
},
];
isHandset$: Observable<boolean> = this.breakpointObserver
.observe(Breakpoints.Handset)
.pipe(
map((result) => result.matches),
shareReplay(),
);
toggleFilterPanel() {
this.tcSvc.filterPanel.next(!this.tcSvc.filterPanel.getValue());
}
}

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ToolbarContextService } from './toolbar-context.service';
describe('ToolbarContextService', () => {
let service: ToolbarContextService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ToolbarContextService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,44 @@
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { map, shareReplay } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class ToolbarContextService {
private breakpointObserver = inject(BreakpointObserver);
filterBtn: BehaviorSubject<boolean>;
isHandset$: Observable<boolean>;
filterPanel: BehaviorSubject<boolean> = new BehaviorSubject(false);
subscriptions = new Subscription();
showFilterButton() {
this.filterBtn.next(true);
}
hideFilterButton() {
this.filterBtn.next(false);
}
showFilterPanel() {
this.filterPanel.next(true);
}
hideFilterPanel() {
this.filterPanel.next(false);
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
constructor() {
this.filterBtn = new BehaviorSubject(false);
this.isHandset$ = this.breakpointObserver.observe(Breakpoints.Handset).pipe(
map((result) => result.matches),
shareReplay(),
);
this.subscriptions.add(this.isHandset$.subscribe(this.filterPanel));
}
}

View file

@ -1,42 +1,95 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Observable, ReplaySubject } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import {
BehaviorSubject,
concatMap,
Observable,
ReplaySubject,
share,
shareReplay,
Subscription,
switchMap,
} from 'rxjs';
export interface Preferences {
tgsPerPage: number;
[key: string]: any;
}
function mapToObj(map: Map<any, any>): { [key: string]: any } {
const obj: { [key: string]: any } = {};
map.forEach((value, key) => {
obj[key] = value;
});
return obj;
}
@Injectable({
providedIn: 'root',
})
export class PrefsService {
private readonly _getPref = new Map<string, ReplaySubject<any>>();
prefs$: Observable<Preferences>;
last!: Preferences;
subscriptions = new Subscription();
constructor(private http: HttpClient) {
this.fetch();
}
last = <Preferences>{};
private prefs = new ReplaySubject<Preferences>(1);
prefs$: Observable<Preferences> = this.prefs.asObservable();
fetch() {
this.http.get<Preferences>('/api/user/prefs/stillbox').subscribe((res) => {
if (res != null) {
this.last = res;
}
this.prefs.next(res);
});
this.prefs$ = this.fetch().pipe(shareReplay(1));
this.fillPrefs();
}
setTGsPerPage(pp: number) {
if (this.last.tgsPerPage != pp) {
this.last.tgsPerPage = pp;
this.setPrefs(this.last);
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
fillPrefs() {
this.subscriptions.add(
this.prefs$.subscribe((prefs) => {
if (prefs) {
this.last = prefs;
Object.entries(prefs).forEach((pref) => {
const rs = this._getPref.get(pref[0]);
if (rs) {
(rs as ReplaySubject<any>).next(pref[1]);
} else {
const bs = new ReplaySubject<any>(1);
bs.next(pref[1]);
this._getPref.set(pref[0], bs);
}
});
} else {
this.last = {};
}
}),
);
}
fetch(): Observable<Preferences> {
return this.http.get<Preferences>('/api/user/prefs/stillbox');
}
get(k: string): Observable<any> {
if (!this._getPref.get(k)) {
return this.prefs$.pipe(
switchMap((pref) => {
let ns = new ReplaySubject<any>(1);
ns.next(pref ? pref[k] : null);
return ns;
}),
);
}
return this._getPref.get(k)!;
}
setPrefs(p: Preferences) {
set(pref: string, value: any) {
this.last[pref] = value;
let ex = this._getPref.get(pref);
if (!ex) {
ex = new ReplaySubject<any>(1);
this._getPref.set(pref, ex);
}
this.http
.put<Preferences>('/api/user/prefs/stillbox', p)
.subscribe((event) => {});
this.prefs.next(p);
.put<Preferences>('/api/user/prefs/stillbox', this.last)
.subscribe((ev) => {});
}
}

View file

@ -9,7 +9,6 @@ import { Component, inject, Pipe, PipeTransform } from '@angular/core';
@Component({
selector: 'app-export',
standalone: true,
imports: [ReactiveFormsModule, FormsModule],
templateUrl: './export.component.html',
styleUrl: './export.component.scss',

View file

@ -13,7 +13,6 @@ import { catchError, of } from 'rxjs';
@Component({
selector: 'app-import',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FormsModule],
templateUrl: './import.component.html',
styleUrl: './import.component.scss',

View file

@ -3,7 +3,6 @@ import { AlertRule } from '../../../talkgroup';
@Component({
selector: 'alert-rule-builder',
standalone: true,
imports: [],
templateUrl: './alert-rule-builder.component.html',
styleUrl: './alert-rule-builder.component.scss',

View file

@ -1,12 +1,4 @@
import {
Component,
computed,
inject,
model,
ChangeDetectionStrategy,
Signal,
ViewChild,
} from '@angular/core';
import { Component, computed, inject, ViewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs/operators';
import {
@ -25,7 +17,7 @@ import {
MatAutocompleteActivatedEvent,
} from '@angular/material/autocomplete';
import { CommonModule } from '@angular/common';
import { catchError, of } from 'rxjs';
import { BehaviorSubject, catchError, of, Subscription } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
import { Observable } from 'rxjs';
import {
@ -34,7 +26,7 @@ import {
FormControl,
FormsModule,
} from '@angular/forms';
import { Router, ActivatedRoute, ParamMap } from '@angular/router';
import { Router, ActivatedRoute } from '@angular/router';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatCheckboxModule } from '@angular/material/checkbox';
@ -43,7 +35,6 @@ import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'talkgroup-record',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
@ -58,7 +49,6 @@ import { MatIconModule } from '@angular/material/icon';
],
templateUrl: './talkgroup-record.component.html',
styleUrl: './talkgroup-record.component.scss',
// changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TalkgroupRecordComponent {
tg!: Talkgroup;
@ -90,6 +80,7 @@ export class TalkgroupRecordComponent {
);
@ViewChild('auto') autocomp!: MatAutocomplete;
active: string | null = null;
subscriptions = new Subscription();
constructor(
private route: ActivatedRoute,
@ -125,7 +116,6 @@ export class TalkgroupRecordComponent {
}
activated(event: MatAutocompleteActivatedEvent) {
console.log('activated');
this.active = event.option?.value;
}
@ -147,31 +137,35 @@ export class TalkgroupRecordComponent {
this.form.controls['tagInput'].reset();
}
loadTags() {
this._allTags.subscribe((event) => {
this.allTags = event;
});
}
ngOnInit() {
this.loadTags();
const sysId = this.route.snapshot.paramMap.get('sys');
const tgId = this.route.snapshot.paramMap.get('tg');
this.tgService
.getTalkgroup(Number(sysId), Number(tgId))
.subscribe((data: Talkgroup) => {
this.tg = data;
this.form.controls['name'].setValue(this.tg.name);
this.form.controls['alpha_tag'].setValue(this.tg.alpha_tag);
this.form.controls['tg_group'].setValue(this.tg.tg_group);
this.form.controls['frequency'].setValue(this.tg.frequency);
this.form.controls['alert'].setValue(this.tg.alert);
this.form.controls['weight'].setValue(this.tg.weight);
this.form.controls['icon'].setValue(this.tg?.metadata?.icon ?? '');
this.form.controls['tagInput'].setValue('');
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
});
this.subscriptions.add(
this.tgService
.getTalkgroup(Number(sysId), Number(tgId))
.subscribe((data: Talkgroup) => {
this.tg = data;
this.form.controls['name'].setValue(this.tg.name);
this.form.controls['alpha_tag'].setValue(this.tg.alpha_tag);
this.form.controls['tg_group'].setValue(this.tg.tg_group);
this.form.controls['frequency'].setValue(this.tg.frequency);
this.form.controls['alert'].setValue(this.tg.alert);
this.form.controls['weight'].setValue(this.tg.weight);
this.form.controls['icon'].setValue(this.tg?.metadata?.icon ?? '');
this.form.controls['tagInput'].setValue('');
this.form.controls['tagsControl'].setValue(this.tg?.tags ?? []);
}),
);
this.subscriptions.add(
this._allTags.subscribe((event) => {
this.allTags = event;
}),
);
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
submit() {

View file

@ -73,18 +73,18 @@
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
<tr mat-row *matRowDef="let myRowData; columns: columns"></tr>
</table>
<div class="pagFoot">
<mat-paginator
#paginator
class="paginator"
(page)="setPage($event)"
[length]="count"
showFirstLastButtons="true"
[pageSize]="perPage"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="page"
aria-label="Select page"
>
</mat-paginator>
</div>
</div>
<div class="pagFoot">
<mat-paginator
#paginator
class="paginator"
(page)="setPage($event)"
[length]="count"
showFirstLastButtons="true"
[pageSize]="perPage"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="page"
aria-label="Select page"
>
</mat-paginator>
</div>

View file

@ -6,6 +6,13 @@ table {
display: flex;
flex-direction: column;
box-sizing: border-box;
max-height: calc(
(
100vh - var(--mat-mat-maginator-container-size, 56px) - 2 *
(var(--mat-toolbar-standard-height, 64px))
) + 7px
);
overflow: auto;
}
mat-icon.tgIcon {

View file

@ -57,7 +57,6 @@ export class SanitizeHtmlPipe implements PipeTransform {
@Component({
selector: 'talkgroup-table',
standalone: true,
imports: [
RouterModule,
RouterLink,
@ -84,7 +83,7 @@ export class TalkgroupTableComponent {
talkgroups = input<TalkgroupsPaginated>();
talkgroups$ = toObservable(this.talkgroups);
dataSource = new MatTableDataSource<Talkgroup>();
pageSizeOptions = [25, 50, 75, 100, 200, 500];
pageSizeOptions = [25, 50, 75, 100, 200];
perPage: number = 25;
count = 0;
columns = [
@ -124,7 +123,9 @@ export class TalkgroupTableComponent {
this.suppress = false;
});
this.perPage = this.prefsService.last.tgsPerPage;
this.prefsService.get('tgsPerPage').subscribe((tgpp) => {
this.perPage = tgpp;
});
this.talkgroups$.subscribe((event) => {
if (event != null) {
this.dataSource.data = event!.talkgroups;

View file

@ -33,9 +33,3 @@ talkgroups {
margin-left: auto;
margin-right: 5px;
}
.spinner {
display: flex;
margin-top: 40px;
justify-content: center;
}

View file

@ -1,4 +1,4 @@
import { Component, inject, ViewChild } from '@angular/core';
import { Component, inject } from '@angular/core';
import { TalkgroupService, TalkgroupsPaginated } from './talkgroups.service';
import { ActivatedRoute } from '@angular/router';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@ -8,16 +8,15 @@ import { TalkgroupTableComponent } from './talkgroup-table/talkgroup-table.compo
import { PageEvent } from '@angular/material/paginator';
import { MatToolbarModule } from '@angular/material/toolbar';
import { PrefsService } from '../prefs/prefs.service';
import { Subject } from 'rxjs';
import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatInputModule, MatInput } from '@angular/material/input';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'talkgroups',
standalone: true,
imports: [
RouterModule,
RouterLink,
@ -41,10 +40,12 @@ export class TalkgroupsComponent {
tgs!: TalkgroupsPaginated;
tgService: TalkgroupService = inject(TalkgroupService);
prefsService: PrefsService = inject(PrefsService);
perPage = 25;
perPage = 0;
filter = new FormControl('');
curPage = <PageEvent>{ pageIndex: 0, pageSize: this.perPage };
private getPage = new ReplaySubject<PageEvent>(1);
constructor(private route: ActivatedRoute) {}
subs = new Subscription();
pageReset: Subject<void> = new Subject<void>();
@ -54,30 +55,60 @@ export class TalkgroupsComponent {
switchPage(p: PageEvent) {
this.curPage = p;
this.route.paramMap
.pipe(
switchMap((params) => {
this.perPage = p!.pageSize;
this.selectedSys = Number(params.get('sys'));
this.selectedId = Number(params.get('tg'));
return this.tgService.getTalkgroupsPag({
page: p!.pageIndex + 1,
perPage: p!.pageSize,
filter: this.filter.value == '' ? null : this.filter.value,
});
}),
)
.subscribe((ev) => {
this.tgs = ev;
});
this.prefsService.setTGsPerPage(p!.pageSize);
if (p.pageSize != this.perPage) {
this.perPage = p.pageSize;
this.prefsService.set('tgsPerPage', this.perPage);
}
this.getPage.next(p);
}
ngOnDestroy() {
this.subs.unsubscribe();
}
ngOnInit() {
this.prefsService.prefs$.subscribe((event) => {
this.perPage = event?.tgsPerPage ?? 25;
this.switchPage(this.curPage);
this.prefsService.get('tgsPerPage').subscribe((tgpp) => {
if (!tgpp) {
tgpp = 25;
}
if (tgpp != this.perPage) {
this.perPage = tgpp;
this.switchPage(<PageEvent>{
pageIndex: 0,
pageSize: tgpp,
});
}
});
this.subs.add(
this.getPage
.pipe(
switchMap((p) =>
this.route.paramMap.pipe(
switchMap((params) => {
this.selectedSys = Number(params.get('sys'));
this.selectedId = Number(params.get('tg'));
return this.tgService.getTalkgroupsPag({
page: p.pageIndex + 1,
perPage: p.pageSize,
filter: this.filter.value == '' ? null : this.filter.value,
});
}),
),
),
)
.subscribe((ev) => {
this.tgs = ev;
}),
);
}
zeroPage(): PageEvent {
return <PageEvent>{
pageIndex: 0,
pageSize: this.curPage.pageSize,
};
}
ngAfterViewInit() {

View file

@ -1,7 +1,15 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Talkgroup, TalkgroupUpdate } from '../talkgroup';
import {
BehaviorSubject,
concatMap,
Observable,
ReplaySubject,
shareReplay,
Subscription,
switchMap,
} from 'rxjs';
import { Talkgroup, TalkgroupUpdate, TGID } from '../talkgroup';
export interface Pagination {
page: number;
@ -18,18 +26,94 @@ export interface TalkgroupsPaginated {
providedIn: 'root',
})
export class TalkgroupService {
constructor(private http: HttpClient) {}
private readonly _getTalkgroup = new Map<string, ReplaySubject<Talkgroup>>();
private tgs$: Observable<Talkgroup[]>;
private tags$!: Observable<string[]>;
private fetchAll = new BehaviorSubject<'fetch'>('fetch');
private subscriptions = new Subscription();
constructor(private http: HttpClient) {
this.tgs$ = this.fetchAll.pipe(switchMap(() => this.getTalkgroups()));
this.tags$ = this.fetchAll.pipe(switchMap(() => this.getAllTags()));
this.fillTgMap();
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
getAllTags(): Observable<string[]> {
return this.http.get<string[]>('/api/talkgroup/tags').pipe(shareReplay());
}
getTalkgroups(): Observable<Talkgroup[]> {
return this.http.get<Talkgroup[]>('/api/talkgroup/');
}
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
const key = this.tgKey(sys, tg);
if (!this._getTalkgroup.get(key)) {
return this.tgs$.pipe(
switchMap((talkg) =>
talkg.filter((tgv) => tgv.tgid == tg && tgv.system_id == sys),
),
);
}
return this._getTalkgroup.get(key)!;
}
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
let tgid = this.tgKey(tu.system_id, tu.tgid);
this.http
.put<Talkgroup>(`/api/talkgroup/${tu.system_id}/${tu.tgid}`, tu)
.pipe(
switchMap((tg) => {
let tObs = this._getTalkgroup.get(tgid);
if (!tObs) {
tObs = new ReplaySubject<Talkgroup>(1);
this._getTalkgroup.set(tgid, tObs);
}
tObs.next(tg);
return tObs;
}),
);
return this._getTalkgroup.get(tgid)!;
}
putTalkgroups(
sysID: Number,
tgs: TalkgroupUpdate[],
): Observable<Talkgroup[]> {
this._getTalkgroup.clear();
return this.http.put<Talkgroup[]>(`/api/talkgroup/${sysID}`, tgs);
}
tgKey(sys: number, tg: number): string {
return sys + ':' + tg;
}
getTalkgroupsPag(pagination: Pagination): Observable<TalkgroupsPaginated> {
return this.http.post<TalkgroupsPaginated>('/api/talkgroup/', pagination);
}
getTalkgroup(sys: number, tg: number): Observable<Talkgroup> {
return this.http.get<Talkgroup>(`/api/talkgroup/${sys}/${tg}`);
fillTgMap() {
this.subscriptions.add(
this.tgs$.subscribe((tgs) => {
tgs.forEach((tg) => {
let tgid = this.tgKey(tg.system_id, tg.tgid);
const rs = this._getTalkgroup.get(tgid);
if (rs) {
(rs as ReplaySubject<Talkgroup>).next(tg);
} else {
const bs = new ReplaySubject<Talkgroup>(1);
bs.next(tg);
this._getTalkgroup.set(tgid, bs);
}
});
}),
);
}
importRR(sysID: number, content: string): Observable<Talkgroup[]> {
@ -41,7 +125,7 @@ export class TalkgroupService {
}
allTags(): Observable<string[]> {
return this.http.get<string[]>('/api/talkgroup/tags');
return this.tags$;
}
exportTGs(
@ -58,18 +142,4 @@ export class TalkgroupService {
responseType: 'blob' as 'json',
});
}
putTalkgroup(tu: TalkgroupUpdate): Observable<Talkgroup> {
return this.http.put<Talkgroup>(
`/api/talkgroup/${tu.system_id}/${tu.tgid}`,
tu,
);
}
putTalkgroups(
sysID: Number,
tgs: TalkgroupUpdate[],
): Observable<Talkgroup[]> {
return this.http.put<Talkgroup[]>(`/api/talkgroup/${sysID}`, tgs);
}
}

View file

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { CheckerService } from './checker.service';
describe('CheckerService', () => {
let service: CheckerService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CheckerService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View file

@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
import { Subscription } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class CheckerService {
updateAvailable = false;
versionSub!: Subscription;
doUpdate() {
location.reload();
}
constructor(private swUpd: SwUpdate) {
this.checkUpdate();
}
checkUpdate() {
this.versionSub?.unsubscribe();
if (!this.swUpd.isEnabled) {
return;
}
this.versionSub = this.swUpd.versionUpdates.subscribe((evt) => {
switch (evt.type) {
case 'VERSION_DETECTED':
console.log('Detected new version ${evt.version.hash}');
break;
case 'VERSION_READY':
console.log(`Current app version: ${evt.currentVersion.hash}`);
console.log(
`New app version ready for use: ${evt.latestVersion.hash}`,
);
this.updateAvailable = true;
break;
case 'VERSION_INSTALLATION_FAILED':
console.log(
`Failed to install app version '${evt.version.hash}': ${evt.error}`,
);
}
});
}
}

View file

@ -0,0 +1,6 @@
@if (checkerSvc.updateAvailable) {
<div class="updater" (click)="update()">
<p>New version available!</p>
<button class="sbButton" (click)="checkerSvc.doUpdate()">Update</button>
</div>
}

View file

@ -0,0 +1,32 @@
@media screen and (max-width: 768px) {
.updater {
width: 100%;
}
}
@media not screen and (max-width: 768px) {
.updater {
width: 400px;
position: absolute;
bottom: 20px;
left: 20px;
height: 80px;
}
}
.updater {
display: flex;
align-items: center;
background-color: #311818;
}
.updater p {
flex: 2 0;
margin-left: 20px;
}
.updater button {
flex: 0 0 100px;
justify-content: flex-end;
margin-right: 10px;
}

View file

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UpdateNagComponent } from './update-nag.component';
describe('UpdateNagComponent', () => {
let component: UpdateNagComponent;
let fixture: ComponentFixture<UpdateNagComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UpdateNagComponent],
}).compileComponents();
fixture = TestBed.createComponent(UpdateNagComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,16 @@
import { Component } from '@angular/core';
import { CheckerService } from '../checker.service';
@Component({
selector: 'app-update-nag',
imports: [],
templateUrl: './update-nag.component.html',
styleUrl: './update-nag.component.scss',
})
export class UpdateNagComponent {
constructor(public checkerSvc: CheckerService) {}
update() {
this.checkerSvc.doUpdate();
}
}

Binary file not shown.

View file

@ -2,12 +2,17 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<title>Admin</title>
<title>Stillbox</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#1976d2" />
</head>
<body>
<app-root></app-root>
<noscript
>Please enable JavaScript to continue using this application.</noscript
>
</body>
</html>

View file

@ -6,32 +6,33 @@
// Include the common styles for Angular Material. We include this here so that you only
// have to load a single css file for Angular Material in your app.
// Be sure that you only ever include this mixin once!
@include mat.core();
@include mat.elevation-classes();
@include mat.app-background();
// Define the palettes for your theme using the Material Design palettes available in palette.scss
// (imported above). For each palette, you can optionally specify a default, lighter, and darker
// hue. Available color palettes: https://material.io/design/color/
$stillbox-primary: mat.define-palette(mat.$grey-palette, A700);
$stillbox-accent: mat.define-palette(
mat.$deep-purple-palette,
$stillbox-primary: mat.m2-define-palette(mat.$m2-grey-palette, A700);
$stillbox-accent: mat.m2-define-palette(
mat.$m2-deep-purple-palette,
A200,
A100,
A400
);
// The warn palette is optional (defaults to red).
$stillbox-warn: mat.define-palette(mat.$red-palette);
$stillbox-warn: mat.m2-define-palette(mat.$m2-red-palette);
// Create the theme object. A theme consists of configurations for individual
// theming systems such as "color" or "typography".
$stillbox-theme: mat.define-dark-theme(
$stillbox-theme: mat.m2-define-dark-theme(
(
color: (
primary: $stillbox-primary,
accent: $stillbox-accent,
warn: $stillbox-warn,
),
typography: mat.define-typography-config(),
typography: mat.m2-define-typography-config(),
density: 0,
)
);
@ -107,6 +108,27 @@ html {
direction: ltr;
}
@font-face {
font-family: "Material Symbols Outlined";
font-style: normal;
font-weight: 400;
src: url("./assets/MatSymOutline.ttf") format("truetype");
}
.material-symbols-outlined {
font-family: "Material Symbols Outlined";
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
}
a,
a:visited {
text-decoration: none;
@ -203,3 +225,13 @@ body {
input {
caret-color: var(--color-dark-fg) !important;
}
.hidden {
display: none !important;
}
.spinner {
display: flex;
margin-top: 40px;
justify-content: center;
}

View file

@ -18,6 +18,10 @@ import (
"github.com/rs/zerolog/log"
)
const (
CookieName = "stillboxJwt"
)
type jwtAuth interface {
// Authenticated returns whether the request is authenticated. It also returns the claims.
Authenticated(r *http.Request) (claims, bool)
@ -67,7 +71,7 @@ func (a *Auth) VerifyMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
hfn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token, err := jwtauth.VerifyRequest(a.jwt, r, jwtauth.TokenFromHeader, jwtauth.TokenFromCookie)
token, err := jwtauth.VerifyRequest(a.jwt, r, jwtauth.TokenFromHeader, TokenFromCookie)
ctx = jwtauth.NewContext(ctx, token, err)
next.ServeHTTP(w, r.WithContext(ctx))
}
@ -75,6 +79,14 @@ func (a *Auth) VerifyMiddleware() func(http.Handler) http.Handler {
}
}
func TokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie(CookieName)
if err != nil {
return ""
}
return cookie.Value
}
func (a *Auth) AuthMiddleware() func(http.Handler) http.Handler {
return jwtauth.Authenticator(a.jwt)
}
@ -154,7 +166,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
tok := a.newToken(uid)
cookie := &http.Cookie{
Name: "jwt",
Name: CookieName,
Value: tok,
Path: "/",
HttpOnly: true,
@ -167,7 +179,7 @@ func (a *Auth) routeRefresh(w http.ResponseWriter, r *http.Request) {
}
if cookie.Secure {
cookie.Domain = a.cfg.Domain
cookie.Domain = r.Host
}
http.SetCookie(w, cookie)
@ -215,18 +227,17 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
return
}
cookie := &http.Cookie{
Name: "jwt",
Name: CookieName,
Value: tok,
Path: "/",
HttpOnly: true,
Secure: true,
}
cookie.Domain = r.Host
if a.allowInsecureCookie(r) {
cookie.Secure = false
cookie.SameSite = http.SameSiteLaxMode
} else {
cookie.Domain = a.cfg.Domain
}
http.SetCookie(w, cookie)
@ -242,7 +253,7 @@ func (a *Auth) routeAuth(w http.ResponseWriter, r *http.Request) {
func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
cookie := &http.Cookie{
Name: "jwt",
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
@ -250,11 +261,10 @@ func (a *Auth) routeLogout(w http.ResponseWriter, r *http.Request) {
MaxAge: -1,
}
cookie.Domain = r.Host
if a.allowInsecureCookie(r) {
cookie.Secure = false
cookie.SameSite = http.SameSiteLaxMode
} else {
cookie.Domain = a.cfg.Domain
}
http.SetCookie(w, cookie)

View file

@ -2,6 +2,7 @@ package callstore
import (
"context"
"fmt"
"dynatron.me/x/stillbox/internal/common"
"dynatron.me/x/stillbox/internal/jsontypes"
@ -11,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type Store interface {
@ -65,10 +67,12 @@ type CallsParams struct {
common.Pagination
Direction *common.SortDirection `json:"dir"`
Start *jsontypes.Time `json:"start"`
End *jsontypes.Time `json:"end"`
TagsAny []string `json:"tagsAny"`
TagsNot []string `json:"tagsNot"`
Start *jsontypes.Time `json:"start"`
End *jsontypes.Time `json:"end"`
TagsAny []string `json:"tagsAny"`
TagsNot []string `json:"tagsNot"`
TGFilter *string `json:"tgFilter"`
AtLeastSeconds *float32 `json:"atLeastSeconds"`
}
func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListCallsPRow, totalCount int, err error) {
@ -83,16 +87,27 @@ func (s *store) Calls(ctx context.Context, p CallsParams) (rows []database.ListC
Offset: offset,
PerPage: perPage,
Direction: p.Direction.DirString(common.DirAsc),
TGFilter: p.TGFilter,
}
if p.AtLeastSeconds != nil {
var n pgtype.Numeric
if err := n.Scan(fmt.Sprint(*p.AtLeastSeconds * 1000)); err != nil {
return nil, 0, err
}
par.LongerThan = n
}
var count int64
txErr := db.InTx(ctx, func(db database.Store) error {
var err error
count, err = db.ListCallsCount(ctx, database.ListCallsCountParams{
Start: par.Start,
End: par.End,
TagsAny: par.TagsAny,
TagsNot: par.TagsNot,
Start: par.Start,
End: par.End,
TagsAny: par.TagsAny,
TagsNot: par.TagsNot,
TGFilter: par.TGFilter,
})
if err != nil {
return err

View file

@ -30,7 +30,6 @@ type Config struct {
type Auth struct {
JWTSecret string `yaml:"jwtsecret"`
Domain string `yaml:"domain"`
AllowInsecure map[string]bool `yaml:"allowInsecureFor"`
}

View file

@ -23,7 +23,6 @@ var expCfg = &Config{
},
Auth: Auth{
JWTSecret: "somesecret",
Domain: "xenon",
AllowInsecure: map[string]bool{
"localhost": true,
"stillbox": true,

View file

@ -203,14 +203,24 @@ CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
CASE WHEN $3::TEXT[] IS NOT NULL THEN
tgs.tags @> ARRAY[$3] ELSE TRUE END AND
CASE WHEN $4::TEXT[] IS NOT NULL THEN
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END AND
(CASE WHEN $5::TEXT IS NOT NULL THEN (
tgs.tg_group ILIKE '%' || $5 || '%' OR
tgs.name ILIKE '%' || $5 || '%' OR
tgs.alpha_tag ILIKE '%' || $5 || '%'
) ELSE TRUE END) AND
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
c.duration > $6
) ELSE TRUE END)
`
type ListCallsCountParams struct {
Start pgtype.Timestamptz `json:"start"`
End pgtype.Timestamptz `json:"end"`
TagsAny []string `json:"tags_any"`
TagsNot []string `json:"tags_not"`
Start pgtype.Timestamptz `json:"start"`
End pgtype.Timestamptz `json:"end"`
TagsAny []string `json:"tags_any"`
TagsNot []string `json:"tags_not"`
TGFilter *string `json:"tg_filter"`
LongerThan pgtype.Numeric `json:"longer_than"`
}
func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams) (int64, error) {
@ -219,6 +229,8 @@ func (q *Queries) ListCallsCount(ctx context.Context, arg ListCallsCountParams)
arg.End,
arg.TagsAny,
arg.TagsNot,
arg.TGFilter,
arg.LongerThan,
)
var count int64
err := row.Scan(&count)
@ -245,22 +257,32 @@ CASE WHEN $2::TIMESTAMPTZ IS NOT NULL THEN
CASE WHEN $3::TEXT[] IS NOT NULL THEN
tgs.tags @> ARRAY[$3] ELSE TRUE END AND
CASE WHEN $4::TEXT[] IS NOT NULL THEN
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END
(NOT (tgs.tags @> ARRAY[$4])) ELSE TRUE END AND
(CASE WHEN $5::TEXT IS NOT NULL THEN (
tgs.tg_group ILIKE '%' || $5 || '%' OR
tgs.name ILIKE '%' || $5 || '%' OR
tgs.alpha_tag ILIKE '%' || $5 || '%'
) ELSE TRUE END) AND
(CASE WHEN $6::NUMERIC IS NOT NULL THEN (
c.duration > $6
) ELSE TRUE END)
ORDER BY
CASE WHEN $5::TEXT = 'asc' THEN c.call_date END ASC,
CASE WHEN $5 = 'desc' THEN c.call_date END DESC
OFFSET $6 ROWS
FETCH NEXT $7 ROWS ONLY
CASE WHEN $7::TEXT = 'asc' THEN c.call_date END ASC,
CASE WHEN $7 = 'desc' THEN c.call_date END DESC
OFFSET $8 ROWS
FETCH NEXT $9 ROWS ONLY
`
type ListCallsPParams struct {
Start pgtype.Timestamptz `json:"start"`
End pgtype.Timestamptz `json:"end"`
TagsAny []string `json:"tags_any"`
TagsNot []string `json:"tags_not"`
Direction string `json:"direction"`
Offset int32 `json:"offset"`
PerPage int32 `json:"per_page"`
Start pgtype.Timestamptz `json:"start"`
End pgtype.Timestamptz `json:"end"`
TagsAny []string `json:"tags_any"`
TagsNot []string `json:"tags_not"`
TGFilter *string `json:"tg_filter"`
LongerThan pgtype.Numeric `json:"longer_than"`
Direction string `json:"direction"`
Offset int32 `json:"offset"`
PerPage int32 `json:"per_page"`
}
type ListCallsPRow struct {
@ -279,6 +301,8 @@ func (q *Queries) ListCallsP(ctx context.Context, arg ListCallsPParams) ([]ListC
arg.End,
arg.TagsAny,
arg.TagsNot,
arg.TGFilter,
arg.LongerThan,
arg.Direction,
arg.Offset,
arg.PerPage,

View file

@ -33,7 +33,7 @@ func (ca *callsAPI) Subrouter() http.Handler {
r.Get(`/{call:[a-f0-9-]+}`, ca.getAudio)
r.Get(`/{call:[a-f0-9-]+}/{download:download}`, ca.getAudio)
r.Post(`/list`, ca.listCalls)
r.Post(`/`, ca.listCalls)
return r
}

View file

@ -101,7 +101,15 @@ CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
CASE WHEN sqlc.narg('tags_any')::TEXT[] IS NOT NULL THEN
tgs.tags @> ARRAY[@tags_any] ELSE TRUE END AND
CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END AND
(CASE WHEN sqlc.narg('tg_filter')::TEXT IS NOT NULL THEN (
tgs.tg_group ILIKE '%' || @tg_filter || '%' OR
tgs.name ILIKE '%' || @tg_filter || '%' OR
tgs.alpha_tag ILIKE '%' || @tg_filter || '%'
) ELSE TRUE END) AND
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
c.duration > @longer_than
) ELSE TRUE END)
ORDER BY
CASE WHEN @direction::TEXT = 'asc' THEN c.call_date END ASC,
CASE WHEN @direction = 'desc' THEN c.call_date END DESC
@ -122,5 +130,13 @@ CASE WHEN sqlc.narg('end')::TIMESTAMPTZ IS NOT NULL THEN
CASE WHEN sqlc.narg('tags_any')::TEXT[] IS NOT NULL THEN
tgs.tags @> ARRAY[@tags_any] ELSE TRUE END AND
CASE WHEN sqlc.narg('tags_not')::TEXT[] IS NOT NULL THEN
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END
(NOT (tgs.tags @> ARRAY[@tags_not])) ELSE TRUE END AND
(CASE WHEN sqlc.narg('tg_filter')::TEXT IS NOT NULL THEN (
tgs.tg_group ILIKE '%' || @tg_filter || '%' OR
tgs.name ILIKE '%' || @tg_filter || '%' OR
tgs.alpha_tag ILIKE '%' || @tg_filter || '%'
) ELSE TRUE END) AND
(CASE WHEN sqlc.narg('longer_than')::NUMERIC IS NOT NULL THEN (
c.duration > @longer_than
) ELSE TRUE END)
;