From 1f62b5c5b09c078f040b702b4e095e1ecca85a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thea=20Sch=C3=B6bl?= Date: Mon, 21 Aug 2023 12:49:57 +0200 Subject: [PATCH] feat: backend-supplied id cards on profile page feat: SCIdCard thing --- .changeset/old-bananas-live.md | 6 + .changeset/unlucky-pillows-thank.md | 5 + backend/backend/config/f-u/backendrc.js | 4 + .../src/app/modules/data/data-icon.config.ts | 1 + .../app/modules/profile/id-card.component.ts | 29 +++++ .../app/src/app/modules/profile/id-card.html | 4 + .../app/src/app/modules/profile/id-card.scss | 34 ++++++ .../app/modules/profile/id-cards.component.ts | 51 +++++++++ .../app/src/app/modules/profile/id-cards.html | 25 +++++ .../app/modules/profile/id-cards.provider.ts | 74 +++++++++++++ .../app/src/app/modules/profile/id-cards.scss | 52 +++++++++ .../src/app/modules/profile/id-cards.spec.ts | 65 +++++++++++ .../app/modules/profile/page/my-courses.html | 14 +++ .../profile/page/profile-page.component.ts | 15 +-- .../modules/profile/page/profile-page.html | 43 +------- .../modules/profile/page/profile-page.scss | 103 ------------------ .../src/app/modules/profile/profile.module.ts | 2 + .../app/util/full-screen-image.directive.ts | 24 ++++ frontend/app/src/app/util/in-range.pipe.ts | 41 +++++++ .../app/src/app/util/section.component.html | 6 +- .../app/src/app/util/section.component.ts | 2 + .../src/assets/examples/student-id.sample.svg | 28 +++++ frontend/app/src/assets/i18n/de.json | 1 + frontend/app/src/assets/i18n/en.json | 1 + packages/core/src/index.ts | 1 + packages/core/src/meta.ts | 7 ++ packages/core/src/things/abstract/range.ts | 24 ++++ packages/core/src/things/abstract/thing.ts | 1 + packages/core/src/things/id-card.ts | 93 ++++++++++++++++ packages/core/test/range.spec.ts | 41 +++++++ 30 files changed, 635 insertions(+), 162 deletions(-) create mode 100644 .changeset/old-bananas-live.md create mode 100644 .changeset/unlucky-pillows-thank.md create mode 100644 frontend/app/src/app/modules/profile/id-card.component.ts create mode 100644 frontend/app/src/app/modules/profile/id-card.html create mode 100644 frontend/app/src/app/modules/profile/id-card.scss create mode 100644 frontend/app/src/app/modules/profile/id-cards.component.ts create mode 100644 frontend/app/src/app/modules/profile/id-cards.html create mode 100644 frontend/app/src/app/modules/profile/id-cards.provider.ts create mode 100644 frontend/app/src/app/modules/profile/id-cards.scss create mode 100644 frontend/app/src/app/modules/profile/id-cards.spec.ts create mode 100644 frontend/app/src/app/util/full-screen-image.directive.ts create mode 100644 frontend/app/src/app/util/in-range.pipe.ts create mode 100644 frontend/app/src/assets/examples/student-id.sample.svg create mode 100644 packages/core/src/things/id-card.ts create mode 100644 packages/core/test/range.spec.ts diff --git a/.changeset/old-bananas-live.md b/.changeset/old-bananas-live.md new file mode 100644 index 00000000..2c91451d --- /dev/null +++ b/.changeset/old-bananas-live.md @@ -0,0 +1,6 @@ +--- +'@openstapps/core': minor +'@openstapps/app': minor +--- + +Add support for web-service-provided id cards on the profile page diff --git a/.changeset/unlucky-pillows-thank.md b/.changeset/unlucky-pillows-thank.md new file mode 100644 index 00000000..6d53de73 --- /dev/null +++ b/.changeset/unlucky-pillows-thank.md @@ -0,0 +1,5 @@ +--- +'@openstapps/app': patch +--- + +Make section swiper buttons reactive diff --git a/backend/backend/config/f-u/backendrc.js b/backend/backend/config/f-u/backendrc.js index 6afdc7f9..6665ee13 100644 --- a/backend/backend/config/f-u/backendrc.js +++ b/backend/backend/config/f-u/backendrc.js @@ -70,6 +70,10 @@ const config = { authProvider: 'paia', url: 'https://hds.hebis.de/paia/core', }, + /** TODO: idCards: { + authProvider: 'default', + url: 'TODO', + } */ }, }, aboutPages, diff --git a/frontend/app/src/app/modules/data/data-icon.config.ts b/frontend/app/src/app/modules/data/data-icon.config.ts index 81bad882..bfbeb7a0 100644 --- a/frontend/app/src/app/modules/data/data-icon.config.ts +++ b/frontend/app/src/app/modules/data/data-icon.config.ts @@ -29,6 +29,7 @@ export const DataIcons: Record = { 'dish': SCIcon`lunch_dining`, 'favorite': SCIcon`favorite`, 'floor': SCIcon`foundation`, + 'id card': SCIcon`badge`, 'message': SCIcon`newspaper`, 'organization': SCIcon`business_center`, 'periodical': SCIcon`feed`, diff --git a/frontend/app/src/app/modules/profile/id-card.component.ts b/frontend/app/src/app/modules/profile/id-card.component.ts new file mode 100644 index 00000000..780dbf37 --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-card.component.ts @@ -0,0 +1,29 @@ +import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; +import {SCIdCard} from '@openstapps/core'; +import {FullScreenImageDirective} from '../../util/full-screen-image.directive'; +import {ThingTranslateModule} from '../../translation/thing-translate.module'; +import {AsyncPipe, NgIf, TitleCasePipe} from '@angular/common'; +import {InRangeNowPipe, ToDateRangePipe} from '../../util/in-range.pipe'; +import {TranslateModule} from '@ngx-translate/core'; + +@Component({ + selector: 'stapps-id-card', + templateUrl: 'id-card.html', + styleUrls: ['id-card.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + hostDirectives: [FullScreenImageDirective], + imports: [ + FullScreenImageDirective, + ThingTranslateModule, + NgIf, + InRangeNowPipe, + ToDateRangePipe, + AsyncPipe, + TranslateModule, + TitleCasePipe, + ], +}) +export class IdCardComponent { + @Input({required: true}) item: SCIdCard; +} diff --git a/frontend/app/src/app/modules/profile/id-card.html b/frontend/app/src/app/modules/profile/id-card.html new file mode 100644 index 00000000..aaf28e2d --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-card.html @@ -0,0 +1,4 @@ + +
+ {{ 'profile.userInfo.expired' | translate | titlecase }} +
diff --git a/frontend/app/src/app/modules/profile/id-card.scss b/frontend/app/src/app/modules/profile/id-card.scss new file mode 100644 index 00000000..5f7e9a1f --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-card.scss @@ -0,0 +1,34 @@ +:host { + position: relative; + overflow: hidden; +} + +:host:fullscreen { + margin: 0; + padding: 0; +} + +img { + border-radius: 3mm; +} + +.expired { + position: absolute; + top: 48px; + left: 48px; + transform-origin: center; + translate: -50% -50%; + rotate: -45deg; + + display: flex; + align-items: center; + justify-content: center; + + width: 256px; + padding: var(--spacing-xs); + + font-weight: bold; + color: var(--ion-color-danger-contrast); + + background: var(--ion-color-danger); +} diff --git a/frontend/app/src/app/modules/profile/id-cards.component.ts b/frontend/app/src/app/modules/profile/id-cards.component.ts new file mode 100644 index 00000000..b4982b64 --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-cards.component.ts @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2023 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 . + */ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {IdCardsProvider} from './id-cards.provider'; +import {SCIdCard} from '@openstapps/core'; +import {IonicModule} from '@ionic/angular'; +import {AsyncPipe, NgForOf, NgIf, TitleCasePipe} from '@angular/common'; +import {ThingTranslateModule} from '../../translation/thing-translate.module'; +import {UtilModule} from '../../util/util.module'; +import {FullScreenImageDirective} from '../../util/full-screen-image.directive'; +import {IdCardComponent} from './id-card.component'; +import {TranslateModule} from '@ngx-translate/core'; +import {Observable} from 'rxjs'; + +@Component({ + selector: 'stapps-id-cards', + templateUrl: 'id-cards.html', + styleUrls: ['id-cards.scss'], + providers: [IdCardsProvider], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + IonicModule, + NgForOf, + NgIf, + AsyncPipe, + ThingTranslateModule, + UtilModule, + FullScreenImageDirective, + IdCardComponent, + TranslateModule, + TitleCasePipe, + ], +}) +export class IdCardsComponent { + idCards: Observable = this.idCardsProvider.getIdCards(); + + constructor(readonly idCardsProvider: IdCardsProvider) {} +} diff --git a/frontend/app/src/app/modules/profile/id-cards.html b/frontend/app/src/app/modules/profile/id-cards.html new file mode 100644 index 00000000..e3bb74e2 --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-cards.html @@ -0,0 +1,25 @@ + + + +
+ +
+ +
+
diff --git a/frontend/app/src/app/modules/profile/id-cards.provider.ts b/frontend/app/src/app/modules/profile/id-cards.provider.ts new file mode 100644 index 00000000..84e90073 --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-cards.provider.ts @@ -0,0 +1,74 @@ +import {Injectable} from '@angular/core'; +import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core'; +import {from, Observable, of} from 'rxjs'; +import {AuthHelperService} from '../auth/auth-helper.service'; +import {mergeMap, filter, map, startWith} from 'rxjs/operators'; +import {ConfigProvider} from '../config/config.provider'; +import {HttpClient} from '@angular/common/http'; + +@Injectable({providedIn: 'root'}) +export class IdCardsProvider { + constructor( + private authHelper: AuthHelperService, + private config: ConfigProvider, + private httpClient: HttpClient, + ) {} + + getIdCards(): Observable { + const feature = this.config.config.app.features.extern?.['idCards']; + const auth = this.authHelper.getProvider(feature?.authProvider ?? 'default'); + + return auth.isAuthenticated$.pipe( + mergeMap(isAuthenticated => + isAuthenticated + ? feature + ? from(auth.getValidToken()).pipe( + mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)), + ) + : auth.user$.pipe( + filter(user => user !== undefined), + map(userInfo => this.authHelper.getUserFromUserInfo(userInfo as object)), + mergeMap(user => this.fetchFallbackIdCards(user)), + startWith([]), + ) + : // TODO: find a better solution here (async pipe stuff...) + of([]), + ), + ); + } + + private fetchIdCards(url: string, token: string): Observable { + // eslint-disable-next-line unicorn/no-null + return this.httpClient.post(url, null, { + headers: { + Authorization: `Bearer: ${token}`, + }, + responseType: 'json', + }) as Observable; + } + + private fetchFallbackIdCards(user: SCUserConfiguration): Observable { + return this.httpClient.get('/assets/examples/student-id.sample.svg', {responseType: 'text'}).pipe( + map(svg => { + let result = svg; + for (const key in user) { + result = result.replaceAll(`{{${key}}`, (user as unknown as Record)[key]); + } + return `data:image/svg+xml;base64,${Buffer.from(result, 'base64').toString('base64')}`; + }), + map(image => [ + { + name: 'Student ID', + image, + type: SCThingType.IdCard, + uid: '1234', + origin: { + name: 'Sample Origin', + type: SCThingOriginType.Remote, + indexed: new Date().toISOString(), + }, + }, + ]), + ); + } +} diff --git a/frontend/app/src/app/modules/profile/id-cards.scss b/frontend/app/src/app/modules/profile/id-cards.scss new file mode 100644 index 00000000..acc6438f --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-cards.scss @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 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 . + */ + +:host { + display: contents; +} + +simple-swiper { + --swiper-slide-width: calc(min(90cqw, 12cm)); + + filter: drop-shadow(0 0 16px rgba(0 0 0 / 10%)); +} + +.log-in { + position: relative; + + display: flex; + align-items: flex-end; + justify-content: center; + + height: calc(min(90cqw, 12cm) * (53.98 / 85.6)); + padding: var(--spacing-xxl); + + color: var(--ion-color-medium); + + background: var(--ion-color-light); + border-radius: 3mm; + outline: 1px dashed var(--ion-color-medium); + outline-offset: calc(-1 * var(--spacing-sm)); + + > ion-icon { + position: absolute; + top: 0; + left: 0; + + font-size: calc(min(90cqw, 12cm) / 2); + + opacity: 0.1; + } +} diff --git a/frontend/app/src/app/modules/profile/id-cards.spec.ts b/frontend/app/src/app/modules/profile/id-cards.spec.ts new file mode 100644 index 00000000..5db6b24d --- /dev/null +++ b/frontend/app/src/app/modules/profile/id-cards.spec.ts @@ -0,0 +1,65 @@ +import {ConfigProvider} from '../config/config.provider'; +import {IdCardsProvider} from './id-cards.provider'; +import {HttpClient} from '@angular/common/http'; +import {AuthHelperService} from '../auth/auth-helper.service'; +import {BehaviorSubject, firstValueFrom, of} from 'rxjs'; +import {SCAuthorizationProviderType} from '@openstapps/core'; + +class FakeAuth { + isAuthenticated$ = new BehaviorSubject(false); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + getValidToken() {} +} + +describe('IdCards', () => { + let configProvider: ConfigProvider; + let httpClient: HttpClient; + let authHelper: AuthHelperService; + let fakeAuth: FakeAuth; + + beforeEach(() => { + configProvider = jasmine.createSpyObj('ConfigProvider', ['config']); + configProvider.config = { + app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}}, + } as never; + httpClient = jasmine.createSpyObj('HttpClient', ['post']); + fakeAuth = new FakeAuth(); + authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']); + authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth); + }); + + it('should emit undefined if not logged in', async () => { + const provider = new IdCardsProvider(authHelper, configProvider, httpClient); + expect(await firstValueFrom(provider.getIdCards())).toEqual([]); + expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType); + }); + + it('should emit network result when logged in', async () => { + fakeAuth.isAuthenticated$.next(true); + httpClient.post = jasmine.createSpy().and.returnValue(of(['abc'])); + fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'}); + const provider = new IdCardsProvider(authHelper, configProvider, httpClient); + expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]); + expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType); + // eslint-disable-next-line unicorn/no-null + expect(httpClient.post).toHaveBeenCalledOnceWith('http://id-cards.local', null, { + headers: { + Authorization: 'Bearer: fake-token', + }, + responseType: 'json', + }); + }); + + it('should react to logins', async () => { + const provider = new IdCardsProvider(authHelper, configProvider, httpClient); + const observable = provider.getIdCards(); + expect(await firstValueFrom(observable)).toEqual([]); + httpClient.post = jasmine.createSpy().and.returnValue(of(['abc'])); + fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'}); + fakeAuth.isAuthenticated$.next(true); + // this is counter-intuitive, but because we unsubscribed above the first value + // will now contain the network result. + expect(await firstValueFrom(observable)).toEqual(['abc' as never]); + }); +}); diff --git a/frontend/app/src/app/modules/profile/page/my-courses.html b/frontend/app/src/app/modules/profile/page/my-courses.html index 1766b631..f1d0f2de 100644 --- a/frontend/app/src/app/modules/profile/page/my-courses.html +++ b/frontend/app/src/app/modules/profile/page/my-courses.html @@ -1,3 +1,17 @@ + user !== undefined), - map(userInfo => { - return this.authHelper.getUserFromUserInfo(userInfo as object); - }), - ); - sections = profilePageSections; - logins: SCAuthorizationProviderType[] = []; - - userInfo?: SCUserConfiguration; - constructor( readonly authHelper: AuthHelperService, readonly activatedRoute: ActivatedRoute, @@ -53,7 +41,6 @@ export class ProfilePageComponent { async signOut(providerType: SCAuthorizationProviderType) { await this.authHelper.getProvider(providerType).signOut(); - this.userInfo = undefined; } ionViewWillEnter() { diff --git a/frontend/app/src/app/modules/profile/page/profile-page.html b/frontend/app/src/app/modules/profile/page/profile-page.html index 2e03e686..7b5d333f 100644 --- a/frontend/app/src/app/modules/profile/page/profile-page.html +++ b/frontend/app/src/app/modules/profile/page/profile-page.html @@ -23,48 +23,7 @@ - - - - - {{ userInfo.role ? (userInfo?.role | titlecase) : ('profile.role_guest' | translate | titlecase) }} - - - - - - - - - - {{ userInfo?.name }} -
- {{ 'profile.userInfo.studentId' | translate | uppercase }} - {{ userInfo?.studentId }} -
-
- {{ 'profile.userInfo.username' | translate | uppercase }} - {{ userInfo?.id }} -
- -
-
- - - - - -
-
-
-
+ . */ -// TODO: clean up this mess -.user-card { - position: relative; - - max-width: 400px; - margin: var(--spacing-xl); - - border-radius: var(--border-radius-default); - box-shadow: var(--shadow-profile-card); - - ion-card-header { - --background: var(--ion-color-tertiary); - - display: flex; - align-items: center; - padding-top: var(--spacing-sm); - padding-bottom: var(--spacing-sm); - - ion-img { - display: block; - height: 36px; - margin-right: auto; - object-position: left 50%; - } - - span { - padding-top: 3px; - - font-size: var(--font-size-lg); - font-weight: var(--font-weight-bold); - line-height: 1; - color: var(--ion-color-light); - } - } - - ion-card-content { - min-height: 15vh; - - .profile-card-img { - position: absolute; - - width: 50%; - height: 100%; - margin-left: calc(var(--spacing-md) * -4); - - opacity: 0.13; - object-position: left bottom; - } - - .main-info { - display: grid; - grid-template-areas: - 'fullName fullName' - 'matriculationNumber userName' - 'email email'; - - ion-label { - display: block; - font-size: var(--font-size-sm); - font-weight: var(--font-weight-bold); - color: var(--ion-color-medium-shade); - } - - ion-text { - display: block; - font-size: var(--font-size-md); - font-weight: var(--font-weight-bold); - color: var(--ion-color-text); - } - - .full-name { - display: block; - grid-area: fullName; - - margin-bottom: var(--spacing-sm); - - font-size: var(--font-size-lg); - font-weight: var(--font-weight-bold); - } - - .matriculation-number { - grid-area: matriculationNumber; - margin-bottom: var(--spacing-sm); - } - - .user-name { - grid-area: userName; - margin-bottom: var(--spacing-sm); - } - - .email { - grid-area: email; - } - } - - .log-in-prompt { - margin: auto 0; - font-size: var(--font-size-lg); - font-weight: var(--font-weight-semi-bold); - color: var(--ion-color-text); - } - } -} diff --git a/frontend/app/src/app/modules/profile/profile.module.ts b/frontend/app/src/app/modules/profile/profile.module.ts index 36d274af..6a83e994 100644 --- a/frontend/app/src/app/modules/profile/profile.module.ts +++ b/frontend/app/src/app/modules/profile/profile.module.ts @@ -27,6 +27,7 @@ import {ThingTranslateModule} from '../../translation/thing-translate.module'; import {DataModule} from '../data/data.module'; import {MyCoursesComponent} from './page/my-courses.component'; import {MomentModule} from 'ngx-moment'; +import {IdCardsComponent} from './id-cards.component'; const routes: Routes = [ { @@ -49,6 +50,7 @@ const routes: Routes = [ ThingTranslateModule, DataModule, MomentModule, + IdCardsComponent, ], }) export class ProfilePageModule {} diff --git a/frontend/app/src/app/util/full-screen-image.directive.ts b/frontend/app/src/app/util/full-screen-image.directive.ts new file mode 100644 index 00000000..9afe857a --- /dev/null +++ b/frontend/app/src/app/util/full-screen-image.directive.ts @@ -0,0 +1,24 @@ +import {Directive, ElementRef, HostListener} from '@angular/core'; + +@Directive({ + selector: 'img[fullScreenImage]', + standalone: true, +}) +export class FullScreenImageDirective { + constructor(private host: ElementRef) {} + + @HostListener('click') + async onClick() { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } else { + await this.host.nativeElement.requestFullscreen(); + if ( + Math.sign(screen.width - screen.height) === + Math.sign(this.host.nativeElement.width - this.host.nativeElement.height) + ) { + await screen.orientation.lock('landscape'); + } + } + } +} diff --git a/frontend/app/src/app/util/in-range.pipe.ts b/frontend/app/src/app/util/in-range.pipe.ts new file mode 100644 index 00000000..85b99868 --- /dev/null +++ b/frontend/app/src/app/util/in-range.pipe.ts @@ -0,0 +1,41 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {SCRange, isInRange, SCISO8601DateRange} from '@openstapps/core'; +import {merge, Observable, timer} from 'rxjs'; +import {distinctUntilChanged, map, startWith} from 'rxjs/operators'; + +@Pipe({ + name: 'isInRange', + pure: true, + standalone: true, +}) +export class InRangePipe implements PipeTransform { + transform(value: T, range: SCRange): boolean { + return isInRange(value, range); + } +} + +@Pipe({ + name: 'toDateRange', + pure: true, + standalone: true, +}) +export class ToDateRangePipe implements PipeTransform { + transform(value: SCISO8601DateRange): SCRange { + return Object.fromEntries(Object.entries(value).map(([key, value]) => [key, new Date(value)])); + } +} + +@Pipe({ + name: 'isInRangeNow', + pure: true, + standalone: true, +}) +export class InRangeNowPipe implements PipeTransform { + transform(value: SCRange): Observable { + return merge(timer(value.lte || value.lt || 0), timer(value.gte || value.gt || 0)).pipe( + startWith(0), + map(() => isInRange(new Date(), value)), + distinctUntilChanged(), + ); + } +} diff --git a/frontend/app/src/app/util/section.component.html b/frontend/app/src/app/util/section.component.html index 98453149..c72ca019 100644 --- a/frontend/app/src/app/util/section.component.html +++ b/frontend/app/src/app/util/section.component.html @@ -14,7 +14,7 @@ --> - + @@ -29,7 +29,7 @@ @@ -39,7 +39,7 @@ diff --git a/frontend/app/src/app/util/section.component.ts b/frontend/app/src/app/util/section.component.ts index ef228d27..ca8e080e 100644 --- a/frontend/app/src/app/util/section.component.ts +++ b/frontend/app/src/app/util/section.component.ts @@ -32,6 +32,8 @@ export class SectionComponent implements AfterContentInit { @Input() item?: SCThings; + @Input() buttonColor = 'medium'; + nativeElement = new ReplaySubject(1); swiper = this.nativeElement.pipe( diff --git a/frontend/app/src/assets/examples/student-id.sample.svg b/frontend/app/src/assets/examples/student-id.sample.svg new file mode 100644 index 00000000..a131dbff --- /dev/null +++ b/frontend/app/src/assets/examples/student-id.sample.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + {{name}} + {{id}} + + + + {{email}} + diff --git a/frontend/app/src/assets/i18n/de.json b/frontend/app/src/assets/i18n/de.json index a49a6a89..90fc1975 100644 --- a/frontend/app/src/assets/i18n/de.json +++ b/frontend/app/src/assets/i18n/de.json @@ -512,6 +512,7 @@ } }, "userInfo": { + "expired": "Abgelaufen", "studentId": "Matrikelnr.", "username": "Nutzername", "email": "Email", diff --git a/frontend/app/src/assets/i18n/en.json b/frontend/app/src/assets/i18n/en.json index 115792f7..e3651849 100644 --- a/frontend/app/src/assets/i18n/en.json +++ b/frontend/app/src/assets/i18n/en.json @@ -512,6 +512,7 @@ } }, "userInfo": { + "expired": "expired", "studentId": "Matriculation Nr.", "username": "Username", "email": "Email", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a77e6204..29060dc8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,7 @@ export * from './things/diff.js'; export * from './things/dish.js'; export * from './things/favorite.js'; export * from './things/floor.js'; +export * from './things/id-card.js'; export * from './things/message.js'; export * from './things/organization.js'; export * from './things/periodical.js'; diff --git a/packages/core/src/meta.ts b/packages/core/src/meta.ts index 589c3501..4dadc33e 100644 --- a/packages/core/src/meta.ts +++ b/packages/core/src/meta.ts @@ -62,6 +62,7 @@ import {SCTicket, SCTicketMeta, SCTicketWithoutReferences} from './things/ticket import {SCToDo, SCToDoMeta, SCToDoWithoutReferences} from './things/todo.js'; import {SCTour, SCTourMeta, SCTourWithoutReferences} from './things/tour.js'; import {SCVideo, SCVideoMeta, SCVideoWithoutReferences} from './things/video.js'; +import {SCIdCard, SCIdCardMeta, SCIdCardWithoutReferences} from './things/id-card.js'; /** * A map of things, from type to meta data @@ -80,6 +81,7 @@ export const SCClasses: {[K in SCThingType]: object} = { 'dish': SCDishMeta, 'favorite': SCFavoriteMeta, 'floor': SCFloorMeta, + 'id card': SCIdCardMeta, 'message': SCMessageMeta, 'organization': SCOrganizationMeta, 'periodical': SCPeriodicalMeta, @@ -111,6 +113,7 @@ export type SCIndexableThings = | SCDateSeries | SCDish | SCFloor + | SCIdCard | SCMessage | SCOrganization | SCPeriodical @@ -167,6 +170,8 @@ export type SCAssociatedThingWithoutReferences = THING e ? SCFavoriteWithoutReferences : THING extends SCFloor ? SCFloorWithoutReferences + : THING extends SCIdCard + ? SCIdCardWithoutReferences : THING extends SCMessage ? SCMessageWithoutReferences : THING extends SCOrganization @@ -230,6 +235,8 @@ export type SCAssociatedThing = THING extends SCAssessme ? SCFavorite : THING extends SCFloorWithoutReferences ? SCFloor + : THING extends SCIdCardWithoutReferences + ? SCIdCard : THING extends SCMessageWithoutReferences ? SCMessage : THING extends SCOrganizationWithoutReferences diff --git a/packages/core/src/things/abstract/range.ts b/packages/core/src/things/abstract/range.ts index 8e4d0d1c..279c8a05 100644 --- a/packages/core/src/things/abstract/range.ts +++ b/packages/core/src/things/abstract/range.ts @@ -21,6 +21,30 @@ import {SCISO8601Date} from '../../general/time.js'; */ export type SCISO8601DateRange = SCRange; +/** + * Checks if a value is inside a range + * @param value the value to check + * @param range the range + */ +export function isInRange(value: T, range: SCRange): boolean { + return ( + (range.lt == undefined ? (range.lte == undefined ? true : range.lte >= value) : range.lt > value) && + (range.gt == undefined ? (range.gte == undefined ? true : range.gte <= value) : range.gt < value) + ); +} + +/** + * Format a range + * @example '0..4' + * @example '1=..=3' + * @example '0..=3' + */ +export function formatRange(range: SCRange): string { + return `${range.gt ?? range.gte}${range.gte == undefined ? '' : '='}..${range.lte == undefined ? '' : '='}${ + range.lt ?? range.lte + }`; +} + /** * Generic range type */ diff --git a/packages/core/src/things/abstract/thing.ts b/packages/core/src/things/abstract/thing.ts index cba40bec..8c8fa059 100644 --- a/packages/core/src/things/abstract/thing.ts +++ b/packages/core/src/things/abstract/thing.ts @@ -38,6 +38,7 @@ export enum SCThingType { Dish = 'dish', Favorite = 'favorite', Floor = 'floor', + IdCard = 'id card', Message = 'message', Organization = 'organization', Person = 'person', diff --git a/packages/core/src/things/id-card.ts b/packages/core/src/things/id-card.ts new file mode 100644 index 00000000..4a7b38f8 --- /dev/null +++ b/packages/core/src/things/id-card.ts @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2019-2022 Open 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 . + */ +import {SCMetaTranslations, SCTranslations} from '../general/i18n.js'; +import { + SCThing, + SCThingMeta, + SCThingTranslatableProperties, + SCThingType, + SCThingWithoutReferences, +} from './abstract/thing.js'; +import {SCISO8601DateRange} from './abstract/range.js'; + +/** + * An ID-Card without references + */ +export interface SCIdCardWithoutReferences extends SCThingWithoutReferences { + /** + * Validity range + */ + validity?: SCISO8601DateRange; + + /** + * Type + */ + type: SCThingType.IdCard; +} + +/** + * A message + * @validatable + * @indexable + */ +export interface SCIdCard extends SCIdCardWithoutReferences, SCThing { + /** + * Translated fields of a message + */ + translations?: SCTranslations; + + /** + * Type + */ + type: SCThingType.IdCard; +} + +/** + * Translatable properties of a message + */ +export interface SCIdCardTranslatableProperties extends SCThingTranslatableProperties {} + +/** + * Meta information about messages + */ +export class SCIdCardMeta extends SCThingMeta implements SCMetaTranslations { + /** + * Translations of fields + */ + fieldTranslations = { + de: { + ...new SCThingMeta().fieldTranslations.de, + validity: 'Gültigkeit', + }, + en: { + ...new SCThingMeta().fieldTranslations.en, + validity: 'validity', + }, + }; + + /** + * Translations of values of fields + */ + fieldValueTranslations = { + de: { + ...new SCThingMeta().fieldValueTranslations.de, + type: 'Ausweis', + }, + en: { + ...new SCThingMeta().fieldValueTranslations.en, + type: SCThingType.Message, + }, + }; +} diff --git a/packages/core/test/range.spec.ts b/packages/core/test/range.spec.ts new file mode 100644 index 00000000..7e5181e7 --- /dev/null +++ b/packages/core/test/range.spec.ts @@ -0,0 +1,41 @@ +import {expect} from 'chai'; +import {formatRange, isInRange} from '../src/index.js'; +import {SCRange} from '../lib/index.js'; + +const cases: Record<'accept' | 'reject', [number, SCRange][]> = { + accept: [ + [4, {gt: 3, lt: 5}], + [4, {gte: 4, lte: 4}], + [3, {gt: 2, lt: 4}], + [5, {gte: 3, lte: 5}], + [10, {gt: 5, lt: 15}], + [0, {gte: 0, lte: 10}], + ], + reject: [ + [4, {gt: 3, lt: 4}], + [4, {gte: 5, lte: 6}], + [2, {gt: 5, lt: 10}], + [6, {gte: 7, lte: 8}], + [-1, {gt: 0, lt: 5}], + [20, {gte: 10, lte: 15}], + ], +}; + +describe('Range', () => { + for (const constructor of ['Number', 'Date'] as const) { + describe(`${constructor} range`, () => { + for (const [accept, [value, range]] of Object.entries(cases).flatMap(([accept, cases]) => + cases.map(it => [accept, it] as const), + )) { + it(`should ${accept} ${value} in the range ${formatRange(range)}`, () => { + const result = isInRange(constructor === 'Number' ? value : new Date(value), range); + if (accept === 'accept') { + expect(result).to.be.true; + } else { + expect(result).to.be.false; + } + }); + } + }); + } +});