mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2025-12-13 09:46:20 +00:00
feat: backend-supplied id cards on profile page
feat: SCIdCard thing
This commit is contained in:
6
.changeset/old-bananas-live.md
Normal file
6
.changeset/old-bananas-live.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@openstapps/core': minor
|
||||
'@openstapps/app': minor
|
||||
---
|
||||
|
||||
Add support for web-service-provided id cards on the profile page
|
||||
5
.changeset/unlucky-pillows-thank.md
Normal file
5
.changeset/unlucky-pillows-thank.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@openstapps/app': patch
|
||||
---
|
||||
|
||||
Make section swiper buttons reactive
|
||||
@@ -70,6 +70,10 @@ const config = {
|
||||
authProvider: 'paia',
|
||||
url: 'https://hds.hebis.de/paia/core',
|
||||
},
|
||||
/** TODO: idCards: {
|
||||
authProvider: 'default',
|
||||
url: 'TODO',
|
||||
} */
|
||||
},
|
||||
},
|
||||
aboutPages,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const DataIcons: Record<SCThingType, string> = {
|
||||
'dish': SCIcon`lunch_dining`,
|
||||
'favorite': SCIcon`favorite`,
|
||||
'floor': SCIcon`foundation`,
|
||||
'id card': SCIcon`badge`,
|
||||
'message': SCIcon`newspaper`,
|
||||
'organization': SCIcon`business_center`,
|
||||
'periodical': SCIcon`feed`,
|
||||
|
||||
29
frontend/app/src/app/modules/profile/id-card.component.ts
Normal file
29
frontend/app/src/app/modules/profile/id-card.component.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
frontend/app/src/app/modules/profile/id-card.html
Normal file
4
frontend/app/src/app/modules/profile/id-card.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<img [src]="item.image" [alt]="'name' | thingTranslate : item" draggable="false" />
|
||||
<div *ngIf="item.validity && (item.validity | toDateRange | isInRangeNow | async) === false" class="expired">
|
||||
{{ 'profile.userInfo.expired' | translate | titlecase }}
|
||||
</div>
|
||||
34
frontend/app/src/app/modules/profile/id-card.scss
Normal file
34
frontend/app/src/app/modules/profile/id-card.scss
Normal file
@@ -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);
|
||||
}
|
||||
51
frontend/app/src/app/modules/profile/id-cards.component.ts
Normal file
51
frontend/app/src/app/modules/profile/id-cards.component.ts
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<SCIdCard[]> = this.idCardsProvider.getIdCards();
|
||||
|
||||
constructor(readonly idCardsProvider: IdCardsProvider) {}
|
||||
}
|
||||
25
frontend/app/src/app/modules/profile/id-cards.html
Normal file
25
frontend/app/src/app/modules/profile/id-cards.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!--
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<stapps-section buttonColor="primary-contrast">
|
||||
<simple-swiper *ngIf="idCards | async as idCards">
|
||||
<div *ngIf="idCards.length === 0">
|
||||
<div class="log-in">
|
||||
{{'profile.userInfo.logInPrompt' | translate | sentencecase}}
|
||||
<ion-icon name="person" fill="true"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
<stapps-id-card *ngFor="let idCard of idCards" [item]="idCard"></stapps-id-card>
|
||||
</simple-swiper>
|
||||
</stapps-section>
|
||||
74
frontend/app/src/app/modules/profile/id-cards.provider.ts
Normal file
74
frontend/app/src/app/modules/profile/id-cards.provider.ts
Normal file
@@ -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<SCIdCard[]> {
|
||||
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<SCIdCard[]> {
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
return this.httpClient.post(url, null, {
|
||||
headers: {
|
||||
Authorization: `Bearer: ${token}`,
|
||||
},
|
||||
responseType: 'json',
|
||||
}) as Observable<SCIdCard[]>;
|
||||
}
|
||||
|
||||
private fetchFallbackIdCards(user: SCUserConfiguration): Observable<SCIdCard[]> {
|
||||
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<string, string>)[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(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
frontend/app/src/app/modules/profile/id-cards.scss
Normal file
52
frontend/app/src/app/modules/profile/id-cards.scss
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
: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;
|
||||
}
|
||||
}
|
||||
65
frontend/app/src/app/modules/profile/id-cards.spec.ts
Normal file
65
frontend/app/src/app/modules/profile/id-cards.spec.ts
Normal file
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,17 @@
|
||||
<!--
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<ion-accordion-group *ngIf="myCourses | async as myCourses" [value]="myCourses[0][0]">
|
||||
<ion-accordion
|
||||
*ngFor="let myCoursesDay of myCourses"
|
||||
|
||||
@@ -14,11 +14,10 @@
|
||||
*/
|
||||
import {Component} from '@angular/core';
|
||||
import {AuthHelperService} from '../../auth/auth-helper.service';
|
||||
import {SCAuthorizationProviderType, SCUserConfiguration} from '@openstapps/core';
|
||||
import {SCAuthorizationProviderType} from '@openstapps/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {ScheduleProvider} from '../../calendar/schedule.provider';
|
||||
import {profilePageSections} from '../../../../config/profile-page-sections';
|
||||
import {filter, map} from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -26,19 +25,8 @@ import {filter, map} from 'rxjs/operators';
|
||||
styleUrls: ['profile-page.scss'],
|
||||
})
|
||||
export class ProfilePageComponent {
|
||||
user$ = this.authHelper.getProvider('default').user$.pipe(
|
||||
filter(user => 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() {
|
||||
|
||||
@@ -23,48 +23,7 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content color="light" parallax [parallaxSize]="130">
|
||||
<ion-card class="user-card">
|
||||
<ion-card-header>
|
||||
<ion-img src="assets/imgs/header.svg"></ion-img>
|
||||
<span *ngIf="user$ | async as userInfo">
|
||||
{{ userInfo.role ? (userInfo?.role | titlecase) : ('profile.role_guest' | translate | titlecase) }}
|
||||
</span>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-img class="profile-card-img" src="assets/imgs/profile-card-head.svg"></ion-img>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col size="3"></ion-col>
|
||||
<ion-col
|
||||
*ngIf="authHelper.getProvider('default').isAuthenticated$ | async as loggedIn; else logInPrompt"
|
||||
size="9"
|
||||
class="main-info"
|
||||
>
|
||||
<ng-container *ngIf="user$ | async as userInfo">
|
||||
<ion-text class="full-name"> {{ userInfo?.name }} </ion-text>
|
||||
<div class="matriculation-number">
|
||||
<ion-label> {{ 'profile.userInfo.studentId' | translate | uppercase }} </ion-label>
|
||||
<ion-text> {{ userInfo?.studentId }} </ion-text>
|
||||
</div>
|
||||
<div class="user-name">
|
||||
<ion-label> {{ 'profile.userInfo.username' | translate | uppercase }} </ion-label>
|
||||
<ion-text>{{ userInfo?.id }}</ion-text>
|
||||
</div>
|
||||
<div class="email">
|
||||
<ion-label> {{ 'profile.userInfo.email' | translate | uppercase }} </ion-label>
|
||||
<ion-text> {{ userInfo?.email }} </ion-text>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ion-col>
|
||||
<ng-template #logInPrompt>
|
||||
<ion-col size="9">
|
||||
<ion-text class="log-in-prompt"> {{ 'profile.userInfo.logInPrompt' | translate }} </ion-text>
|
||||
</ion-col>
|
||||
</ng-template>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
<stapps-id-cards></stapps-id-cards>
|
||||
<stapps-profile-page-section
|
||||
*ngFor="let section of sections"
|
||||
[item]="section"
|
||||
|
||||
@@ -12,106 +12,3 @@
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
24
frontend/app/src/app/util/full-screen-image.directive.ts
Normal file
24
frontend/app/src/app/util/full-screen-image.directive.ts
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
frontend/app/src/app/util/in-range.pipe.ts
Normal file
41
frontend/app/src/app/util/in-range.pipe.ts
Normal file
@@ -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<T>(value: T, range: SCRange<T>): boolean {
|
||||
return isInRange(value, range);
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toDateRange',
|
||||
pure: true,
|
||||
standalone: true,
|
||||
})
|
||||
export class ToDateRangePipe implements PipeTransform {
|
||||
transform(value: SCISO8601DateRange): SCRange<Date> {
|
||||
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<Date>): Observable<boolean> {
|
||||
return merge(timer(value.lte || value.lt || 0), timer(value.gte || value.gt || 0)).pipe(
|
||||
startWith(0),
|
||||
map(() => isInRange(new Date(), value)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
-->
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
<ion-col *ngIf="title || item">
|
||||
<a *ngIf="item; else titleTemplate" [routerLink]="['/data-detail', item.uid]" [state]="{item}">
|
||||
<ng-container *ngTemplateOutlet="titleTemplate"></ng-container>
|
||||
</a>
|
||||
@@ -29,7 +29,7 @@
|
||||
<ion-col size="auto" class="swiper-button">
|
||||
<ion-button
|
||||
fill="clear"
|
||||
color="medium"
|
||||
[color]="buttonColor"
|
||||
(click)="swiper.scrollBy({left: -swiper.offsetWidth, behavior: 'smooth'})"
|
||||
[disabled]="false"
|
||||
>
|
||||
@@ -39,7 +39,7 @@
|
||||
<ion-col size="auto" class="swiper-button">
|
||||
<ion-button
|
||||
fill="clear"
|
||||
color="medium"
|
||||
[color]="buttonColor"
|
||||
(click)="swiper.scrollBy({left: swiper.offsetWidth, behavior: 'smooth'})"
|
||||
[disabled]="false"
|
||||
>
|
||||
|
||||
@@ -32,6 +32,8 @@ export class SectionComponent implements AfterContentInit {
|
||||
|
||||
@Input() item?: SCThings;
|
||||
|
||||
@Input() buttonColor = 'medium';
|
||||
|
||||
nativeElement = new ReplaySubject<HTMLElement>(1);
|
||||
|
||||
swiper = this.nativeElement.pipe(
|
||||
|
||||
28
frontend/app/src/assets/examples/student-id.sample.svg
Normal file
28
frontend/app/src/assets/examples/student-id.sample.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 68 KiB |
@@ -512,6 +512,7 @@
|
||||
}
|
||||
},
|
||||
"userInfo": {
|
||||
"expired": "Abgelaufen",
|
||||
"studentId": "Matrikelnr.",
|
||||
"username": "Nutzername",
|
||||
"email": "Email",
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
}
|
||||
},
|
||||
"userInfo": {
|
||||
"expired": "expired",
|
||||
"studentId": "Matriculation Nr.",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 extends SCThings> = 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 SCThings> = THING extends SCAssessme
|
||||
? SCFavorite
|
||||
: THING extends SCFloorWithoutReferences
|
||||
? SCFloor
|
||||
: THING extends SCIdCardWithoutReferences
|
||||
? SCIdCard
|
||||
: THING extends SCMessageWithoutReferences
|
||||
? SCMessage
|
||||
: THING extends SCOrganizationWithoutReferences
|
||||
|
||||
@@ -21,6 +21,30 @@ import {SCISO8601Date} from '../../general/time.js';
|
||||
*/
|
||||
export type SCISO8601DateRange = SCRange<SCISO8601Date>;
|
||||
|
||||
/**
|
||||
* Checks if a value is inside a range
|
||||
* @param value the value to check
|
||||
* @param range the range
|
||||
*/
|
||||
export function isInRange<T>(value: T, range: SCRange<T>): 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<T>(range: SCRange<T>): string {
|
||||
return `${range.gt ?? range.gte}${range.gte == undefined ? '' : '='}..${range.lte == undefined ? '' : '='}${
|
||||
range.lt ?? range.lte
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic range type
|
||||
*/
|
||||
|
||||
@@ -38,6 +38,7 @@ export enum SCThingType {
|
||||
Dish = 'dish',
|
||||
Favorite = 'favorite',
|
||||
Floor = 'floor',
|
||||
IdCard = 'id card',
|
||||
Message = 'message',
|
||||
Organization = 'organization',
|
||||
Person = 'person',
|
||||
|
||||
93
packages/core/src/things/id-card.ts
Normal file
93
packages/core/src/things/id-card.ts
Normal file
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<SCIdCardTranslatableProperties>;
|
||||
|
||||
/**
|
||||
* 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<SCIdCard> {
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
41
packages/core/test/range.spec.ts
Normal file
41
packages/core/test/range.spec.ts
Normal file
@@ -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<number>][]> = {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user