feat: timetable module - schedule and calendar

This commit is contained in:
Wieland Schöbl
2021-08-13 12:27:40 +00:00
parent e81b2e161d
commit d8ede006df
59 changed files with 3287 additions and 555 deletions

View File

@@ -3,6 +3,7 @@
*ngIf="applicable['locate']()"
[item]="item"
></stapps-locate-action-chip>
<!-- Add Event Chip needs to load data and should be the last -->
<stapps-add-event-action-chip
*ngIf="applicable['event']()"
[item]="item"

View File

@@ -1,4 +1,5 @@
div {
display: flex;
flex-direction: row;
width: fit-content;
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
@@ -12,10 +13,29 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ChangeDetectorRef, Component, Input, OnInit} from '@angular/core';
import {SCDateSeries} from '@openstapps/core';
import {every, groupBy, some, sortBy, values} from 'lodash-es';
import {
ChangeDetectorRef,
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import {PopoverController} from '@ionic/angular';
import {SCDateSeries, SCUuid} from '@openstapps/core';
import {
difference,
every,
flatMap,
groupBy,
mapValues,
some,
sortBy,
union,
values,
} from 'lodash-es';
import {capitalize, last} from 'lodash-es';
import {Subscription} from 'rxjs';
import {ScheduleProvider} from '../../schedule/schedule.provider';
enum Selection {
ON = 2,
@@ -28,7 +48,6 @@ enum Selection {
*
* The generic is to preserve type safety of how deep the tree goes.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class TreeNode<T extends TreeNode<any> | SelectionValue> {
/**
* Value of this node
@@ -54,19 +73,16 @@ class TreeNode<T extends TreeNode<any> | SelectionValue> {
* Accumulate values of children to set current value
*/
private accumulateApplyValues() {
const selections: number[] = this.children.map(
it =>
/* eslint-disable unicorn/no-nested-ternary */
it instanceof TreeNode
? it.checked
? Selection.ON
: it.indeterminate
? Selection.PARTIAL
: Selection.OFF
: (it as SelectionValue).selected
const selections: number[] = this.children.map(it =>
it instanceof TreeNode
? it.checked
? Selection.ON
: Selection.OFF,
/* eslint-enable unicorn/no-nested-ternary */
: it.indeterminate
? Selection.PARTIAL
: Selection.OFF
: (it as SelectionValue).selected
? Selection.ON
: Selection.OFF,
);
this.checked = every(selections, it => it === Selection.ON);
@@ -83,7 +99,7 @@ class TreeNode<T extends TreeNode<any> | SelectionValue> {
if (child instanceof TreeNode) {
child.checked = this.checked;
child.indeterminate = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// tslint:disable-next-line:no-any
(child as TreeNode<any>).applyValueDownwards();
} else {
(child as SelectionValue).selected = this.checked;
@@ -149,7 +165,7 @@ interface SelectionValue {
templateUrl: 'add-event-popover.html',
styleUrls: ['add-event-popover.scss'],
})
export class AddEventPopoverComponent implements OnInit {
export class AddEventPopoverComponent implements OnInit, OnDestroy {
/**
* Lodash alias
*/
@@ -170,23 +186,70 @@ export class AddEventPopoverComponent implements OnInit {
*/
selection: TreeNode<TreeNode<SelectionValue>>;
constructor(readonly ref: ChangeDetectorRef) {}
/**
* Uuids
*/
uuids: SCUuid[];
/**
* Uuid Subscription
*/
uuidSubscription: Subscription;
constructor(
readonly ref: ChangeDetectorRef,
readonly scheduleProvider: ScheduleProvider,
readonly popoverController: PopoverController,
) {}
/**
* Destroy
*/
ngOnDestroy() {
this.uuidSubscription.unsubscribe();
}
/**
* Init
*/
ngOnInit() {
this.selection = new TreeNode(
values(
groupBy(
sortBy(
this.items.map(item => ({selected: false, item: item})),
it => it.item.frequency,
),
it => it.item.frequency,
),
).map(item => new TreeNode(item, this.ref)),
this.ref,
this.uuidSubscription = this.scheduleProvider.uuids$.subscribe(
async result => {
this.uuids = result;
this.selection = new TreeNode(
values(
groupBy(
sortBy(
this.items.map(item => ({
selected: this.uuids.includes(item.uid),
item: item,
})),
it => it.item.frequency,
),
it => it.item.frequency,
),
).map(item => new TreeNode(item, this.ref)),
this.ref,
);
},
);
}
/**
* On selection change
*/
async onCommit(save: boolean) {
if (save) {
const {false: unselected, true: selected} = mapValues(
groupBy(flatMap(this.selection.children, 'children'), 'selected'),
value => value.map(it => it.item.uid),
);
this.scheduleProvider.uuids$.next(
union(difference(this.uuids, unselected), selected),
);
}
await this.popoverController.dismiss();
}
}

View File

@@ -45,4 +45,12 @@
</ion-item>
</ion-item-group>
</ion-item-group>
<div class="action-buttons">
<ion-button (click)="onCommit(false)" fill="clear">{{
'abort' | translate
}}</ion-button>
<ion-button (click)="onCommit(true)" fill="clear">{{
'ok' | translate
}}</ion-button>
</div>
</ion-card-content>

View File

@@ -5,3 +5,7 @@
ion-card-content {
width: fit-content;
}
.action-buttons {
float: right;
}

View File

@@ -1,4 +1,4 @@
/* eslint-disable class-methods-use-this */
/* tslint:disable:prefer-function-over-method */
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
@@ -13,11 +13,18 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Component, Input, OnInit} from '@angular/core';
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {PopoverController} from '@ionic/angular';
import {SCDateSeries, SCThing, SCThingType} from '@openstapps/core';
import {DataProvider} from '../../data.provider';
import {SCDateSeries, SCThing, SCThingType, SCUuid} from '@openstapps/core';
import {difference, map} from 'lodash-es';
import {Subscription} from 'rxjs';
import {ScheduleProvider} from '../../../schedule/schedule.provider';
import {AddEventPopoverComponent} from '../add-event-popover.component';
import {CoordinatedSearchProvider} from '../../coordinated-search.provider';
import {
chipSkeletonTransition,
chipTransition,
} from '../../../../animation/skeleton-transitions/chip-loading-transition';
enum AddEventStates {
ADDED_ALL,
@@ -33,8 +40,9 @@ enum AddEventStates {
selector: 'stapps-add-event-action-chip',
templateUrl: 'add-event-action-chip.html',
styleUrls: ['add-event-action-chip.scss'],
animations: [chipSkeletonTransition, chipTransition],
})
export class AddEventActionChipComponent implements OnInit {
export class AddEventActionChipComponent implements OnInit, OnDestroy {
/**
* Associated date series
*/
@@ -91,9 +99,20 @@ export class AddEventActionChipComponent implements OnInit {
},
};
/**
* UUIDs
*/
uuids: SCUuid[];
/**
* UUID Subscription
*/
uuidSubscription: Subscription;
constructor(
readonly popoverController: PopoverController,
readonly dataProvider: DataProvider,
readonly dataProvider: CoordinatedSearchProvider,
readonly scheduleProvider: ScheduleProvider,
) {}
/**
@@ -107,6 +126,13 @@ export class AddEventActionChipComponent implements OnInit {
this.disabled = disabled;
}
/**
* TODO
*/
ngOnDestroy() {
this.uuidSubscription?.unsubscribe();
}
/**
* Init
*/
@@ -115,7 +141,7 @@ export class AddEventActionChipComponent implements OnInit {
this.item.type === SCThingType.DateSeries
? Promise.resolve([this.item as SCDateSeries])
: this.dataProvider
.search({
.coordinatedSearch({
filter: {
arguments: {
filters: [
@@ -140,19 +166,36 @@ export class AddEventActionChipComponent implements OnInit {
},
})
.then(it => it.data as SCDateSeries[]);
this.associatedDateSeries.then(it =>
this.applyState(
it.length === 0
? AddEventStates.UNAVAILABLE
: AddEventStates.REMOVED_ALL,
),
this.uuidSubscription = this.scheduleProvider.uuids$.subscribe(
async result => {
this.uuids = result;
const associatedDateSeries = await this.associatedDateSeries;
if (associatedDateSeries.length === 0) {
this.applyState(AddEventStates.UNAVAILABLE);
return;
}
switch (
difference(map(associatedDateSeries, 'uid'), this.uuids).length
) {
case 0:
this.applyState(AddEventStates.ADDED_ALL);
break;
case associatedDateSeries.length:
this.applyState(AddEventStates.REMOVED_ALL);
break;
default:
this.applyState(AddEventStates.ADDED_SOME);
break;
}
},
);
}
/**
* Action
*/
// @Override
async onClick(event: MouseEvent) {
const associatedDateSeries = await this.associatedDateSeries;
const popover = await this.popoverController.create({
@@ -165,12 +208,5 @@ export class AddEventActionChipComponent implements OnInit {
event: event,
});
await popover.present();
// TODO: replace dummy implementation
await popover.onDidDismiss();
this.applyState(
this.state === AddEventStates.ADDED_ALL
? AddEventStates.REMOVED_ALL
: AddEventStates.ADDED_ALL,
);
}
}

View File

@@ -1,14 +1,16 @@
<div *ngIf="associatedDateSeries | async as associatedDateSeries; else loading">
<div class="stack-children">
<ion-chip
*ngIf="associatedDateSeries | async as associatedDateSeries; else loading"
@chipTransition
[disabled]="disabled"
(click)="$event.stopPropagation(); onClick($event)"
>
<ion-icon [name]="icon"></ion-icon>
<ion-label>{{ label | translate }}</ion-label>
</ion-chip>
<ng-template #loading>
<ion-chip @chipSkeletonTransition>
<ion-skeleton-text animated="true"></ion-skeleton-text>
</ion-chip>
</ng-template>
</div>
<ng-template #loading>
<ion-chip>
<ion-skeleton-text animated="true"></ion-skeleton-text>
</ion-chip>
</ng-template>

View File

@@ -1,3 +1,14 @@
::ng-deep ion-skeleton-text {
:host ::ng-deep ion-skeleton-text {
width: 50px;
}
.stack-children {
display: grid;
align-items: start;
justify-items: start;
}
.stack-children > * {
grid-column-start: 1;
grid-row-start: 1;
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {arrayToIndexMap} from './coordinated-search.provider';
describe('CoordinatedSearchProvider', () => {
it('transform arrays correctly', () => {
expect(arrayToIndexMap(['a', 'b', 'c'])).toEqual({0: 'a', 1: 'b', 2: 'c'});
});
});

View File

@@ -0,0 +1,102 @@
/*
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {SCSearchRequest, SCSearchResponse} from '@openstapps/core';
import {Injectable} from '@angular/core';
import {DataProvider} from './data.provider';
/**
* Delay execution for (at least) a set amount of time
*/
async function delay(ms: number): Promise<void> {
return await new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Transforms an array to an object with the indices as keys
*
* ['a', 'b', 'c'] => {0: 'a', 1: 'b', 2: 'c'}
*/
export function arrayToIndexMap<T>(array: T[]): Record<number, T> {
// eslint-disable-next-line unicorn/no-array-reduce
return array.reduce((previous, current, index) => {
previous[index] = current;
return previous;
}, {} as Record<number, T>);
}
interface OngoingQuery {
request: SCSearchRequest;
response?: Promise<SCSearchResponse>;
}
/**
* Coordinated search request that bundles requests from multiple modules into a single one
*/
@Injectable({
providedIn: 'root',
})
export class CoordinatedSearchProvider {
constructor(readonly dataProvider: DataProvider) {}
/**
* Queue of ongoing queries
*/
queue: OngoingQuery[] = [];
/**
* Default latency of search requests
*/
latencyMs = 50;
/**
* Start a coordinated search that merges requests across components
*
* This method collects the request, then:
* 1. If the queue is full, dispatches all immediately
* 2. If not, waits a set amount of time for other requests to come in
*/
async coordinatedSearch(
query: SCSearchRequest,
latencyMs?: number,
): Promise<SCSearchResponse> {
const ongoingQuery: OngoingQuery = {request: query};
this.queue.push(ongoingQuery);
if (this.queue.length < this.dataProvider.backendQueriesLimit) {
await delay(latencyMs ?? this.latencyMs);
}
if (this.queue.length > 0) {
// because we are guaranteed to have limited our queue size to be
// <= to the backendQueriesLimite as of above, we can bypass the wrapper
// in the data provider that usually would be responsible for splitting up the requests
const responses = this.dataProvider.client.multiSearch(
arrayToIndexMap(this.queue.map(it => it.request)),
);
for (const [index, request] of this.queue.entries()) {
request.response = new Promise(resolve =>
responses.then(it => resolve(it[index])),
);
}
this.queue = [];
}
// Response is guaranteed to be defined here
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return await ongoingQuery.response!;
}
}

View File

@@ -24,6 +24,7 @@ import {MarkdownModule} from 'ngx-markdown';
import {MomentModule} from 'ngx-moment';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {MenuModule} from '../menu/menu.module';
import {ScheduleProvider} from '../schedule/schedule.provider';
import {StorageModule} from '../storage/storage.module';
import {ActionChipListComponent} from './chips/action-chip-list.component';
import {AddEventPopoverComponent} from './chips/add-event-popover.component';
@@ -39,7 +40,6 @@ import {AddressDetailComponent} from './elements/address-detail.component';
import {OffersDetailComponent} from './elements/offers-detail.component';
import {OffersInListComponent} from './elements/offers-in-list.component';
import {OriginDetailComponent} from './elements/origin-detail.component';
import {OriginInListComponent} from './elements/origin-in-list.component';
import {SimpleCardComponent} from './elements/simple-card.component';
import {DataListComponent} from './list/data-list.component';
import {FoodDataListComponent} from './list/food-data-list.component';
@@ -59,7 +59,6 @@ import {PlaceDetailContentComponent} from './types/place/place-detail-content.co
import {PlaceListItemComponent} from './types/place/place-list-item.component';
import {PlaceMensaDetailComponent} from './types/place/special/mensa/place-mensa-detail.component';
import {SemesterDetailContentComponent} from './types/semester/semester-detail-content.component';
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
import {MapWidgetComponent} from '../map/widget/map-widget.component';
import {LeafletModule} from '@asymmetrik/ngx-leaflet';
import {ArticleListItemComponent} from './types/article/article-list-item.component';
@@ -73,29 +72,30 @@ import {LongInlineTextComponent} from './elements/long-inline-text.component';
import {MessageListItemComponent} from './types/message/message-list-item.component';
import {OrganizationListItemComponent} from './types/organization/organization-list-item.component';
import {PersonListItemComponent} from './types/person/person-list-item.component';
import {SemesterListItemComponent} from './types/semester/semester-list-item.component';
import {SkeletonListItemComponent} from './elements/skeleton-list-item.component';
import {SkeletonSegmentComponent} from './elements/skeleton-segment-button.component';
import {VideoDetailContentComponent} from './types/video/video-detail-content.component';
import {SemesterListItemComponent} from './types/semester/semester-list-item.component';
import {VideoListItemComponent} from './types/video/video-list-item.component';
import {OriginInListComponent} from './elements/origin-in-list.component';
import {CoordinatedSearchProvider} from './coordinated-search.provider';
/**
* Module for handling data
*/
@NgModule({
declarations: [
ActionChipListComponent,
AddEventActionChipComponent,
AddEventPopoverComponent,
OffersDetailComponent,
OffersInListComponent,
AddressDetailComponent,
ArticleDetailContentComponent,
ArticleListItemComponent,
SimpleCardComponent,
SkeletonSimpleCardComponent,
CatalogDetailContentComponent,
CatalogListItemComponent,
DataDetailComponent,
DataDetailContentComponent,
FoodDataListComponent,
DataIconPipe,
DataListComponent,
DataListItemComponent,
DateSeriesDetailContentComponent,
@@ -106,10 +106,14 @@ import {VideoListItemComponent} from './types/video/video-list-item.component';
EventListItemComponent,
FavoriteDetailContentComponent,
FavoriteListItemComponent,
FoodDataListComponent,
LocateActionChipComponent,
LongInlineTextComponent,
MapWidgetComponent,
MessageDetailContentComponent,
MessageListItemComponent,
OffersDetailComponent,
OffersInListComponent,
OrganizationDetailContentComponent,
OrganizationListItemComponent,
OriginDetailComponent,
@@ -122,21 +126,20 @@ import {VideoListItemComponent} from './types/video/video-list-item.component';
SearchPageComponent,
SemesterDetailContentComponent,
SemesterListItemComponent,
SimpleCardComponent,
SkeletonListItemComponent,
SkeletonSegmentComponent,
SkeletonSimpleCardComponent,
VideoDetailContentComponent,
VideoListItemComponent,
DataIconPipe,
ActionChipListComponent,
AddEventActionChipComponent,
LocateActionChipComponent,
],
entryComponents: [DataListComponent],
imports: [
IonicModule.forRoot(),
CommonModule,
FormsModule,
DataRoutingModule,
FormsModule,
HttpClientModule,
IonicModule.forRoot(),
LeafletModule,
MarkdownModule.forRoot(),
MenuModule,
@@ -150,16 +153,26 @@ import {VideoListItemComponent} from './types/video/video-list-item.component';
TranslateModule.forChild(),
ThingTranslateModule.forChild(),
],
providers: [DataProvider, DataFacetsProvider, Network, StAppsWebHttpClient],
providers: [
CoordinatedSearchProvider,
DataProvider,
DataFacetsProvider,
Network,
ScheduleProvider,
StAppsWebHttpClient,
],
exports: [
DataDetailComponent,
DataDetailContentComponent,
DataIconPipe,
DataListComponent,
DataListItemComponent,
DataDetailComponent,
SkeletonSimpleCardComponent,
SkeletonListItemComponent,
DataIconPipe,
DateSeriesListItemComponent,
PlaceListItemComponent,
DataDetailContentComponent,
SimpleCardComponent,
SkeletonListItemComponent,
SkeletonSimpleCardComponent,
SearchPageComponent,
],
})
export class DataModule {}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018-2021 StApps
* Copyright (C) 2021 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.

View File

@@ -49,6 +49,11 @@ export class SearchPageComponent implements OnInit, OnDestroy {
*/
@Input() forcedFilter?: SCSearchFilter;
/**
* If routing should be done if the user clicks on an item
*/
@Input() itemRouting? = true;
/**
* Thing counter to start query the next page from
*/
@@ -120,46 +125,6 @@ export class SearchPageComponent implements OnInit, OnDestroy {
protected router: Router,
) {
this.initialize();
combineLatest([
this.queryTextChanged.pipe(
debounceTime(this.searchQueryDueTime),
distinctUntilChanged(),
startWith(this.queryText),
),
this.contextMenuService.filterQueryChanged$.pipe(
startWith(this.filterQuery),
),
this.contextMenuService.sortQueryChanged$.pipe(startWith(this.sortQuery)),
]).subscribe(async query => {
this.queryText = query[0];
this.filterQuery = query[1];
this.sortQuery = query[2];
this.from = 0;
await this.fetchAndUpdateItems();
this.queryChanged.next();
});
this.fetchAndUpdateItems();
/**
* Subscribe to 'settings.changed' events
*/
this.subscriptions.push(
this.settingsProvider.settingsActionChanged$.subscribe(
({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
},
),
this.dataRoutingService.itemSelectListener().subscribe(item => {
void this.router.navigate(['data-detail', item.uid]);
}),
);
}
/**
@@ -261,6 +226,48 @@ export class SearchPageComponent implements OnInit, OnDestroy {
* Initialises the possible sort options in ContextMenuService
*/
ngOnInit(): void {
combineLatest([
this.queryTextChanged.pipe(
debounceTime(this.searchQueryDueTime),
distinctUntilChanged(),
startWith(this.queryText),
),
this.contextMenuService.filterQueryChanged$.pipe(
startWith(this.filterQuery),
),
this.contextMenuService.sortQueryChanged$.pipe(startWith(this.sortQuery)),
]).subscribe(async query => {
this.queryText = query[0];
this.filterQuery = query[1];
this.sortQuery = query[2];
this.from = 0;
await this.fetchAndUpdateItems();
this.queryChanged.next();
});
void this.fetchAndUpdateItems();
/**
* Subscribe to 'settings.changed' events
*/
this.subscriptions.push(
this.settingsProvider.settingsActionChanged$.subscribe(
({type, payload}) => {
if (type === 'stapps.settings.changed') {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {category, name, value} = payload!;
this.logger.log(`received event "settings.changed" with category:
${category}, name: ${name}, value: ${JSON.stringify(value)}`);
}
},
),
this.dataRoutingService.itemSelectListener().subscribe(item => {
if (this.itemRouting) {
void this.router.navigate(['data-detail', item.uid]);
}
}),
);
this.contextMenuService.setContextSort({
name: 'sort',
reversed: false,

View File

@@ -3,7 +3,7 @@
{{ 'inPlace' | propertyNameTranslate: item | titlecase }}
</ion-card-header>
<ion-card-content>
<ion-icon name="location"></ion-icon>
<ion-icon name="location"> </ion-icon>
<a [routerLink]="['/data-detail', item.inPlace.uid]">{{
'name' | thingTranslate: item.inPlace
}}</a>
@@ -17,6 +17,10 @@
[title]="'Duration'"
[content]="[item.duration | amDuration: 'minutes']"
></stapps-simple-card>
<stapps-simple-card
[title]="'Time'"
[content]="[item.dates[0] | amDateFormat]"
></stapps-simple-card>
<stapps-simple-card
*ngIf="item.performers"
[title]="'performers' | propertyNameTranslate: item | titlecase"

View File

@@ -22,8 +22,14 @@ import {DataListItemComponent} from '../../list/data-list-item.component';
@Component({
selector: 'stapps-date-series-list-item',
templateUrl: 'date-series-list-item.html',
styleUrls: ['date-series-list-item.scss'],
})
export class DateSeriesListItemComponent extends DataListItemComponent {
/**
* Compact view for schedule
*/
@Input() compact = false;
/**
* TODO
*/

View File

@@ -0,0 +1,8 @@
.remove-button {
&:hover {
--color-hover: var(--ion-color-danger);
--border-color: var(--ion-color-danger);
}
--color: var(--ion-color-success);
--border-color: var(--ion-color-success);
}

View File

@@ -1,4 +1,6 @@
<ng-container *ngIf="item.type === 'academic event'">
<stapps-add-event-action-chip [item]="item" style="margin: 2px">
</stapps-add-event-action-chip>
<stapps-simple-card
*ngIf="item.categories"
[title]="'Categories'"