diff --git a/back-end/deploy/main.ts b/back-end/deploy/main.ts index a42ba75..23546ed 100755 --- a/back-end/deploy/main.ts +++ b/back-end/deploy/main.ts @@ -34,7 +34,8 @@ const apiResources: ResourceController[] = [ { name: 'speakers', paths: ['/speakers', '/speakers/{speakerId}'] }, { name: 'sessions', paths: ['/sessions', '/sessions/{sessionId}'] }, { name: 'registrations', paths: ['/registrations', '/registrations/{sessionId}'] }, - { name: 'connections', paths: ['/connections', '/connections/{connectionId}'] } + { name: 'connections', paths: ['/connections', '/connections/{connectionId}'] }, + { name: 'contests', paths: ['/contests', '/contests/{contestId}'] } ]; const tables: { [tableName: string]: DDBTable } = { @@ -115,6 +116,9 @@ const tables: { [tableName: string]: DDBTable } = { projectionType: DDB.ProjectionType.ALL } ] + }, + contests: { + PK: { name: 'contestId', type: DDB.AttributeType.STRING } } }; diff --git a/back-end/src/handlers/contests.ts b/back-end/src/handlers/contests.ts new file mode 100644 index 0000000..71d2bd4 --- /dev/null +++ b/back-end/src/handlers/contests.ts @@ -0,0 +1,167 @@ +/// +/// IMPORTS +/// + +import { DynamoDB, HandledError, ResourceController } from 'idea-aws'; + +import { Contest } from '../models/contest.model'; +import { User } from '../models/user.model'; + +/// +/// CONSTANTS, ENVIRONMENT VARIABLES, HANDLER +/// + +const PROJECT = process.env.PROJECT; +const STAGE = process.env.STAGE; +const DDB_TABLES = { users: process.env.DDB_TABLE_users, contests: process.env.DDB_TABLE_contests }; +const ddb = new DynamoDB(); + +export const handler = (ev: any, _: any, cb: any): Promise => new ContestsRC(ev, cb).handleRequest(); + +/// +/// RESOURCE CONTROLLER +/// + +class ContestsRC extends ResourceController { + user: User; + contest: Contest; + + constructor(event: any, callback: any) { + super(event, callback, { resourceId: 'contestId' }); + if (STAGE === 'prod') this.silentLambdaLogs(); // to make the vote anonymous + } + + protected async checkAuthBeforeRequest(): Promise { + try { + this.user = new User(await ddb.get({ TableName: DDB_TABLES.users, Key: { userId: this.principalId } })); + } catch (err) { + throw new HandledError('User not found'); + } + + if (!this.resourceId) return; + + try { + this.contest = new Contest( + await ddb.get({ TableName: DDB_TABLES.contests, Key: { contestId: this.resourceId } }) + ); + } catch (err) { + throw new HandledError('Contest not found'); + } + } + + protected async getResource(): Promise { + if (!this.user.permissions.canManageContents && !this.contest.publishedResults) delete this.contest.results; + return this.contest; + } + + protected async putResource(): Promise { + if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized'); + + const oldResource = new Contest(this.contest); + this.contest.safeLoad(this.body, oldResource); + + return await this.putSafeResource(); + } + private async putSafeResource(opts: { noOverwrite?: boolean } = {}): Promise { + const errors = this.contest.validate(); + if (errors.length) throw new HandledError(`Invalid fields: ${errors.join(', ')}`); + + const putParams: any = { TableName: DDB_TABLES.contests, Item: this.contest }; + if (opts.noOverwrite) putParams.ConditionExpression = 'attribute_not_exists(contestId)'; + await ddb.put(putParams); + + return this.contest; + } + + protected async patchResource(): Promise { + switch (this.body.action) { + case 'VOTE': + return await this.userVote(this.body.candidate); + case 'PUBLISH_RESULTS': + return await this.publishResults(); + default: + throw new HandledError('Unsupported action'); + } + } + private async userVote(candidateName: string): Promise { + if (!this.contest.isVoteStarted() || this.contest.isVoteEnded()) throw new HandledError('Vote is not open'); + + if (this.user.isExternal()) throw new HandledError("Externals can't vote"); + if (!this.user.spot?.paymentConfirmedAt) throw new HandledError("Can't vote without confirmed spot"); + + const candidateIndex = this.contest.candidates.findIndex(c => c.name === candidateName); + if (candidateIndex === -1) throw new HandledError('Candidate not found'); + + const candidateCountry = this.contest.candidates[candidateIndex].country; + if (candidateCountry && candidateCountry === this.user.sectionCountry) + throw new HandledError("Can't vote for your country"); + + const markUserContestVoted = { + TableName: DDB_TABLES.users, + Key: { userId: this.user.userId }, + ConditionExpression: 'attribute_not_exists(votedInContests) OR NOT contains(votedInContests, :contestId)', + UpdateExpression: 'SET votedInContests = list_append(if_not_exists(votedInContests, :emptyArr), :contestList)', + ExpressionAttributeValues: { + ':contestId': this.contest.contestId, + ':contestList': [this.contest.contestId], + ':emptyArr': [] as string[] + } + }; + const addUserVoteToContest = { + TableName: DDB_TABLES.contests, + Key: { contestId: this.contest.contestId }, + UpdateExpression: `ADD results[${candidateIndex}] :one`, + ExpressionAttributeValues: { ':one': 1 } + }; + + await ddb.transactWrites([{ Update: markUserContestVoted }, { Update: addUserVoteToContest }]); + } + private async publishResults(): Promise { + if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized'); + + if (this.contest.publishedResults) throw new HandledError('Already public'); + + if (!this.contest.isVoteEnded()) throw new HandledError('Vote is not done'); + + await ddb.update({ + TableName: DDB_TABLES.contests, + Key: { contestId: this.contest.contestId }, + UpdateExpression: 'SET publishedResults = :true', + ExpressionAttributeValues: { ':true': true } + }); + } + + protected async deleteResource(): Promise { + if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized'); + + await ddb.delete({ TableName: DDB_TABLES.contests, Key: { contestId: this.resourceId } }); + } + + protected async postResources(): Promise { + if (!this.user.permissions.canManageContents) throw new HandledError('Unauthorized'); + + this.contest = new Contest(this.body); + this.contest.contestId = await ddb.IUNID(PROJECT); + this.contest.createdAt = new Date().toISOString(); + this.contest.enabled = false; + delete this.contest.voteEndsAt; + this.contest.results = []; + this.contest.candidates.forEach((): number => this.contest.results.push(0)); + this.contest.publishedResults = false; + + return await this.putSafeResource({ noOverwrite: true }); + } + + protected async getResources(): Promise { + let contests = (await ddb.scan({ TableName: DDB_TABLES.contests })).map(x => new Contest(x)); + + if (!this.user.permissions.canManageContents) { + contests = contests.filter(c => c.enabled); + contests.forEach(contest => { + if (!contest.publishedResults) delete contest.results; + }); + } + + return contests.sort((a, b): number => b.createdAt.localeCompare(a.createdAt)); + } +} diff --git a/back-end/src/models/contest.model.ts b/back-end/src/models/contest.model.ts new file mode 100644 index 0000000..5921cb3 --- /dev/null +++ b/back-end/src/models/contest.model.ts @@ -0,0 +1,129 @@ +import { Resource, epochISOString } from 'idea-toolbox'; + +/** + * A contest to which users can vote in. + */ +export class Contest extends Resource { + /** + * The ID of the contest. + */ + contestId: string; + /** + * Timestamp of creation (for sorting). + */ + createdAt: epochISOString; + /** + * Whether the contest is enabled and therefore shown in the menu to everyone. + */ + enabled: boolean; + /** + * If set, the vote is active (users can vote), and it ends at the configured timestamp. + */ + voteEndsAt?: epochISOString; + /** + * Name of the contest. + */ + name: string; + /** + * Description of the contest. + */ + description: string; + /** + * The URI to the contest's main image. + */ + imageURI: string; + /** + * The candidates of the contest (vote ballots). + */ + candidates: ContestCandidate[]; + /** + * The count of votes for each of the sorted candidates. + * Note: the order of the candidates list must not change after the vote is open. + * This attribute is not accessible to non-admin users until `publishedResults` is true. + */ + results?: number[]; + /** + * Whether the results are published and hence visible to any users. + */ + publishedResults: boolean; + + load(x: any): void { + super.load(x); + this.contestId = this.clean(x.contestId, String); + this.createdAt = this.clean(x.createdAt, t => new Date(t).toISOString(), new Date().toISOString()); + this.enabled = this.clean(x.enabled, Boolean, false); + if (x.voteEndsAt) this.voteEndsAt = this.clean(x.voteEndsAt, t => new Date(t).toISOString()); + else delete this.voteEndsAt; + this.name = this.clean(x.name, String); + this.description = this.clean(x.description, String); + this.imageURI = this.clean(x.imageURI, String); + this.candidates = this.cleanArray(x.candidates, c => new ContestCandidate(c)); + this.results = []; + for (let i = 0; i < this.candidates.length; i++) this.results[i] = Number(x.results[i] ?? 0); + this.publishedResults = this.clean(x.publishedResults, Boolean, false); + } + + safeLoad(newData: any, safeData: any): void { + super.safeLoad(newData, safeData); + this.contestId = safeData.contestId; + this.createdAt = safeData.createdAt; + if (safeData.isVoteStarted()) { + this.candidates = safeData.candidates; + this.results = safeData.results; + } + } + + validate(): string[] { + const e = super.validate(); + if (this.iE(this.name)) e.push('name'); + if (this.iE(this.candidates)) e.push('candidates'); + this.candidates.forEach((c, index): void => c.validate().forEach(ea => e.push(`candidates[${index}].${ea}`))); + return e; + } + + /** + * Whether the vote is started. + */ + isVoteStarted(): boolean { + return !!this.voteEndsAt; + } + /** + * Whether the vote has started and ended. + */ + isVoteEnded(): boolean { + return this.isVoteStarted() && new Date().toISOString() > this.voteEndsAt; + } +} + +/** + * A candidate in a contest. + */ +export class ContestCandidate extends Resource { + /** + * The name of the candidate. + */ + name: string; + /** + * An URL where to find more info about the candidate. + */ + url: string; + /** + * The country of the candidate. + * This is particularly important beacuse, if set, users can't vote for candidates of their own countries. + */ + country: string | null; + + load(x: any): void { + super.load(x); + this.name = this.clean(x.name, String); + this.url = this.clean(x.url, String); + this.country = this.clean(x.country, String); + } + + validate(): string[] { + const e = super.validate(); + if (this.iE(this.name)) e.push('name'); + if (this.url && this.iE(this.url, 'url')) e.push('url'); + return e; + } +} diff --git a/back-end/src/models/user.model.ts b/back-end/src/models/user.model.ts index c4ea8b1..b5bcf7f 100644 --- a/back-end/src/models/user.model.ts +++ b/back-end/src/models/user.model.ts @@ -77,6 +77,11 @@ export class User extends Resource { */ socialMedia: SocialMedia; + /** + * The list of contests (IDs) the user voted in. + */ + votedInContests: string[]; + load(x: any): void { super.load(x); this.userId = this.clean(x.userId, String); @@ -99,10 +104,12 @@ export class User extends Resource { this.registrationForm = x.registrationForm ?? {}; if (x.registrationAt) this.registrationAt = this.clean(x.registrationAt, t => new Date(t).toISOString()); if (x.spot) this.spot = new EventSpotAttached(x.spot); - this.socialMedia = {} - if (x.socialMedia?.instagram) this.socialMedia.instagram = this.clean(x.socialMedia.instagram, String) - if (x.socialMedia?.linkedIn) this.socialMedia.linkedIn = this.clean(x.socialMedia.linkedIn, String) - if (x.socialMedia?.twitter) this.socialMedia.twitter = this.clean(x.socialMedia.twitter, String) + this.socialMedia = {}; + if (x.socialMedia?.instagram) this.socialMedia.instagram = this.clean(x.socialMedia.instagram, String); + if (x.socialMedia?.linkedIn) this.socialMedia.linkedIn = this.clean(x.socialMedia.linkedIn, String); + if (x.socialMedia?.twitter) this.socialMedia.twitter = this.clean(x.socialMedia.twitter, String); + + this.votedInContests = this.cleanArray(x.votedInContests, String); } safeLoad(newData: any, safeData: any): void { @@ -123,6 +130,8 @@ export class User extends Resource { if (safeData.registrationForm) this.registrationForm = safeData.registrationForm; if (safeData.registrationAt) this.registrationAt = safeData.registrationAt; if (safeData.spot) this.spot = safeData.spot; + + this.votedInContests = safeData.votedInContests; } validate(): string[] { @@ -250,4 +259,4 @@ export interface SocialMedia { instagram?: string; linkedIn?: string; twitter?: string; -} \ No newline at end of file +} diff --git a/back-end/swagger.yaml b/back-end/swagger.yaml index 6b35cae..788414c 100644 --- a/back-end/swagger.yaml +++ b/back-end/swagger.yaml @@ -46,6 +46,8 @@ tags: description: The speakers of the event - name: Sessions description: The sessions of the event + - name: Contests + description: The contests of the event paths: /status: @@ -1168,6 +1170,118 @@ paths: $ref: '#/components/responses/OperationCompleted' 400: $ref: '#/components/responses/BadParameters' + /contests: + get: + summary: Get the contests + tags: [Contests] + security: + - AuthFunction: [] + responses: + 200: + $ref: '#/components/responses/Contests' + post: + summary: Insert a new contest + description: Requires to be content manager + tags: [Contests] + security: + - AuthFunction: [] + requestBody: + required: true + description: Contest + content: + application/json: + schema: + type: object + responses: + 200: + $ref: '#/components/responses/Contest' + 400: + $ref: '#/components/responses/BadParameters' + /contests/{contestId}: + get: + summary: Get a contest + tags: [Contests] + security: + - AuthFunction: [] + parameters: + - name: contestId + in: path + required: true + schema: + type: string + responses: + 200: + $ref: '#/components/responses/Contest' + put: + summary: Edit a contest + description: Requires to be content manager + tags: [Contests] + security: + - AuthFunction: [] + parameters: + - name: contestId + in: path + required: true + schema: + type: string + requestBody: + required: true + description: Contest + content: + application/json: + schema: + type: object + responses: + 200: + $ref: '#/components/responses/Contest' + 400: + $ref: '#/components/responses/BadParameters' + patch: + summary: Actions on a contest + tags: [Contests] + security: + - AuthFunction: [] + parameters: + - name: contestId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + enum: [VOTE, PUBLISH_RESULTS] + candidate: + type: string + description: (VOTE) + responses: + 200: + $ref: '#/components/responses/OperationCompleted' + 400: + $ref: '#/components/responses/BadParameters' + delete: + summary: Delete a contest + description: Requires to be content manager + tags: [Contests] + security: + - AuthFunction: [] + parameters: + - name: contestId + in: path + required: true + schema: + type: string + responses: + 200: + $ref: '#/components/responses/Contest' + 400: + $ref: '#/components/responses/BadParameters' components: schemas: @@ -1207,6 +1321,9 @@ components: Registration: type: object additionalProperties: {} + Contest: + type: object + additionalProperties: {} responses: AppStatus: @@ -1379,6 +1496,22 @@ components: type: array items: $ref: '#/components/schemas/Registration' + Contest: + description: Contest + content: + application/json: + schema: + type: object + items: + $ref: '#/components/schemas/Contest' + Contests: + description: Contest[] + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Contest' BadParameters: description: Bad input parameters content: diff --git a/front-end/angular.json b/front-end/angular.json index 07e3f55..7e2f31d 100644 --- a/front-end/angular.json +++ b/front-end/angular.json @@ -47,7 +47,11 @@ "js-cookie", "qrcode", "maplibre-gl", - "docs-soap" + "docs-soap", + "date-fns/format/index.js", + "date-fns/_lib/getTimezoneOffsetInMilliseconds/index.js", + "date-fns/_lib/toInteger/index.js", + "date-fns/_lib/cloneObject/index.js" ] }, "configurations": { diff --git a/front-end/package-lock.json b/front-end/package-lock.json index 66186b5..77bf0f4 100644 --- a/front-end/package-lock.json +++ b/front-end/package-lock.json @@ -28,6 +28,7 @@ "@ionic/storage-angular": "^4.0.0", "@kolkov/angular-editor": "^3.0.0-beta.0", "@swimlane/ngx-datatable": "^20.1.0", + "date-fns-tz": "^2.0.1", "docs-soap": "^1.2.1", "idea-toolbox": "^7.0.5", "ionicons": "^7.2.2", @@ -2323,7 +2324,6 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -6883,6 +6883,30 @@ "node": ">=4" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/date-fns-tz": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz", + "integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==", + "peerDependencies": { + "date-fns": "2.x" + } + }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -12543,8 +12567,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regenerator-transform": { "version": "0.15.2", diff --git a/front-end/package.json b/front-end/package.json index 08f1698..b9bc79e 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -31,6 +31,7 @@ "@ionic/storage-angular": "^4.0.0", "@kolkov/angular-editor": "^3.0.0-beta.0", "@swimlane/ngx-datatable": "^20.1.0", + "date-fns-tz": "^2.0.1", "docs-soap": "^1.2.1", "idea-toolbox": "^7.0.5", "ionicons": "^7.2.2", diff --git a/front-end/src/app/common/datetimeWithTimezone.ts b/front-end/src/app/common/datetimeWithTimezone.ts new file mode 100644 index 0000000..5233182 --- /dev/null +++ b/front-end/src/app/common/datetimeWithTimezone.ts @@ -0,0 +1,94 @@ +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { formatInTimeZone, zonedTimeToUtc } from 'date-fns-tz'; +import { epochISOString } from 'idea-toolbox'; + +import { AppService } from '@app/app.service'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, IonicModule], + selector: 'app-datetime-timezone', + template: ` + + {{ label }} @if(obligatory) {} + + + + ` +}) +export class DatetimeWithTimezoneStandaloneComponent implements OnInit, OnChanges { + /** + * The date to manage. + */ + @Input() date: epochISOString; + @Output() dateChange = new EventEmitter(); + /** + * The timezone to consider. + * Fallback to the default value set in the configurations. + */ + @Input() timezone: string; + /** + * A label for the item. + */ + @Input() label: string; + /** + * The color of the item. + */ + @Input() color: string; + /** + * The lines attribute of the item. + */ + @Input() lines: string; + /** + * Whether the component is disabled or editable. + */ + @Input() disabled = false; + /** + * Whether the date is obligatory. + */ + @Input() obligatory = false; + + initialValue: epochISOString; + + @ViewChild('dateTime') dateTime: ElementRef; + + constructor(public app: AppService) {} + async ngOnInit(): Promise { + this.timezone = this.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + this.initialValue = this.utcToZonedTimeString(this.date); + } + ngOnChanges(changes: SimpleChanges): void { + // fix the date if the linked timezone changes + if (changes.timezone?.currentValue && this.dateTime) { + setTimeout((): void => { + this.dateChange.emit(this.zonedTimeStringToUTC(this.dateTime.nativeElement.value)); + }, 100); + } + } + + utcToZonedTimeString(isoString: epochISOString): string { + return formatInTimeZone(isoString, this.timezone, "yyyy-MM-dd'T'HH:mm"); + } + zonedTimeStringToUTC(dateLocale: string): epochISOString { + return zonedTimeToUtc(new Date(dateLocale), this.timezone).toISOString(); + } +} diff --git a/front-end/src/app/tabs/contests/contest.page.ts b/front-end/src/app/tabs/contests/contest.page.ts new file mode 100644 index 0000000..965cf20 --- /dev/null +++ b/front-end/src/app/tabs/contests/contest.page.ts @@ -0,0 +1,225 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { AlertController, IonicModule, ModalController } from '@ionic/angular'; +import { + IDEALoadingService, + IDEAMessageService, + IDEATranslationsModule, + IDEATranslationsService +} from '@idea-ionic/common'; + +import { HTMLEditorComponent } from '@common/htmlEditor.component'; +import { ManageContestComponent } from './manageContest.component'; + +import { AppService } from '@app/app.service'; +import { ContestsService } from './contests.service'; + +import { Contest } from '@models/contest.model'; + +@Component({ + standalone: true, + imports: [CommonModule, FormsModule, IonicModule, IDEATranslationsModule, HTMLEditorComponent], + selector: 'app-contest', + template: ` + + + + + + + + {{ 'CONTESTS.DETAILS' | translate }} + @if(_app.user.permissions.canManageContents) { + + + + + + } + + + + @if(contest) { +
+ + + + {{ contest.name }} + + @if(contest.isVoteStarted()) { @if(contest.isVoteEnded()) { @if(contest.publishedResults) { + {{ 'CONTESTS.RESULTS' | translate }} + } @else { + {{ 'CONTESTS.VOTE_ENDED' | translate }} + } } @else { + + {{ 'CONTESTS.VOTE_NOW_UNTIL' | translate : { deadline: (contest.voteEndsAt | dateLocale : 'short') } }} + + } } @else { + {{ 'CONTESTS.VOTE_NOT_OPEN_YET' | translate }} + } + + + + @if(contest.description) { + + } + + + + {{ 'CONTESTS.CANDIDATES' | translate }} + + + + @for(candidate of contest.candidates; track candidate.name) { + + @if(canUserVote()) { + + } + + {{ candidate.name }} +

{{ candidate.country }}

+
+ @if(candidate.url) { + + + + } @if(contest.publishedResults) { @if(isCandidateWinnerByIndex($index)) { + + } + + {{ contest.results[$index] ?? 0 }} {{ 'CONTESTS.VOTES' | translate | lowercase }} + + } +
+ } +
+ @if(canUserVote()) { +

{{ 'CONTESTS.VOTE_I' | translate }}

+ + {{ 'CONTESTS.VOTE' | translate }} + + } @if(userVoted()) { +

+ {{ 'CONTESTS.YOU_ALREADY_VOTED' | translate }} +

+ } +
+
+
+
+ } +
+ `, + styles: [ + ` + ion-card { + ion-img { + height: 300px; + object-fit: cover; + } + ion-card-header { + padding-bottom: 0; + } + } + ion-list-header ion-label b { + font-size: 1.2em; + font-weight: 500; + color: var(--ion-color-step-700); + } + ` + ] +}) +export class ContestPage implements OnInit { + contest: Contest; + + private _route = inject(ActivatedRoute); + private _modal = inject(ModalController); + private _alert = inject(AlertController); + private _loading = inject(IDEALoadingService); + private _message = inject(IDEAMessageService); + private _t = inject(IDEATranslationsService); + private _contests = inject(ContestsService); + _app = inject(AppService); + + voteForCandidate: string; + + async ngOnInit(): Promise { + await this.loadData(); + } + + async loadData(): Promise { + try { + await this._loading.show(); + const contestId = this._route.snapshot.paramMap.get('contestId'); + this.contest = await this._contests.getById(contestId); + } catch (err) { + this._message.error('COMMON.NOT_FOUND'); + } finally { + this._loading.hide(); + } + } + + async manageContest(contest: Contest): Promise { + if (!this._app.user.permissions.canManageContents) return; + + const modal = await this._modal.create({ + component: ManageContestComponent, + componentProps: { contest }, + backdropDismiss: false + }); + modal.onDidDismiss().then(async (): Promise => { + this.contest = await this._contests.getById(contest.contestId); + }); + await modal.present(); + } + + backToList(): void { + this._app.goToInTabs(['contests'], { back: true }); + } + + isCandidateWinnerByIndex(candidateIndex: number): boolean { + return this.contest.candidates.every( + (_, competitorIndex): boolean => this.contest.results[competitorIndex] <= this.contest.results[candidateIndex] + ); + } + canUserVote(checkCountry?: string): boolean { + const voteOpen = this.contest.isVoteStarted() && !this.contest.isVoteEnded(); + const canVoteCountry = !checkCountry || checkCountry === this._app.user.sectionCountry; + const hasConfirmedSpot = this._app.user.spot?.paymentConfirmedAt; + const isESNer = !this._app.user.isExternal(); + return voteOpen && canVoteCountry && !this.userVoted() && hasConfirmedSpot && isESNer; + } + userVoted(): boolean { + return this._app.user.votedInContests.includes(this.contest.contestId); + } + + async vote(): Promise { + const doVote = async (): Promise => { + try { + await this._loading.show(); + await this._contests.vote(this.contest, this.voteForCandidate); + this._app.user.votedInContests.push(this.contest.contestId); + } catch (err) { + this._message.error('COMMON.OPERATION_FAILED'); + } finally { + this._loading.hide(); + } + }; + + const header = this._t._('CONTESTS.YOU_ARE_VOTING'); + const subHeader = this.voteForCandidate; + const message = this._t._('CONTESTS.VOTE_I'); + const buttons = [ + { text: this._t._('CONTESTS.NOT_NOW'), role: 'cancel' }, + { text: this._t._('CONTESTS.VOTE'), handler: doVote } + ]; + const alert = await this._alert.create({ header, subHeader, message, buttons }); + alert.present(); + } +} diff --git a/front-end/src/app/tabs/contests/contests.module.ts b/front-end/src/app/tabs/contests/contests.module.ts new file mode 100644 index 0000000..6f48f08 --- /dev/null +++ b/front-end/src/app/tabs/contests/contests.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; + +import { ContestsRoutingModule } from './contests.routing.module'; +import { ContestsPage } from './contests.page'; +import { ContestPage } from './contest.page'; + +@NgModule({ + imports: [ContestsRoutingModule, ContestsPage, ContestPage] +}) +export class ContestsModule {} diff --git a/front-end/src/app/tabs/contests/contests.page.ts b/front-end/src/app/tabs/contests/contests.page.ts new file mode 100644 index 0000000..a8ef07e --- /dev/null +++ b/front-end/src/app/tabs/contests/contests.page.ts @@ -0,0 +1,94 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonInfiniteScroll, IonicModule } from '@ionic/angular'; +import { IDEAMessageService, IDEATranslationsModule } from '@idea-ionic/common'; + +import { AppService } from '@app/app.service'; +import { ContestsService } from './contests.service'; + +import { Contest } from '@models/contest.model'; + +@Component({ + standalone: true, + imports: [CommonModule, IonicModule, IDEATranslationsModule], + selector: 'app-contests', + template: ` + + @if(_app.isInMobileMode()) { + + {{ 'CONTESTS.LIST' | translate }} + + } + + + + + @for(contest of contests; track contest.contestId) { + + {{ contest.name }} + @if(contest.isVoteStarted()) { @if(contest.isVoteEnded()) { @if(contest.publishedResults) { + {{ 'CONTESTS.RESULTS' | translate }} + } @else { + {{ 'CONTESTS.VOTE_ENDED' | translate }} + } } @else { + {{ 'CONTESTS.VOTE_NOW' | translate }} + } } + + } @empty { @if(contests) { + + {{ 'COMMON.NO_ELEMENT_FOUND' | translate }} + + } @else { + + + + } } + + + + + + `, + styles: [ + ` + ion-list { + padding: 0; + max-width: 500px; + margin: 0 auto; + } + ` + ] +}) +export class ContestsPage implements OnInit { + contests: Contest[]; + + private _message = inject(IDEAMessageService); + private _contests = inject(ContestsService); + _app = inject(AppService); + + async ngOnInit(): Promise { + await this.loadData(); + } + + async loadData(): Promise { + try { + this.contests = await this._contests.getList({}); + } catch (error) { + this._message.error('COMMON.OPERATION_FAILED'); + } + } + + async filterContests(search = '', scrollToNextPage?: IonInfiniteScroll): Promise { + let startPaginationAfterId = null; + if (scrollToNextPage && this.contests?.length) + startPaginationAfterId = this.contests[this.contests.length - 1].contestId; + + this.contests = await this._contests.getList({ search, withPagination: true, startPaginationAfterId }); + + if (scrollToNextPage) setTimeout((): Promise => scrollToNextPage.complete(), 100); + } + + selectContest(contest: Contest): void { + this._app.goToInTabs(['contests', contest.contestId]); + } +} diff --git a/front-end/src/app/tabs/contests/contests.routing.module.ts b/front-end/src/app/tabs/contests/contests.routing.module.ts new file mode 100644 index 0000000..9bac7b2 --- /dev/null +++ b/front-end/src/app/tabs/contests/contests.routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { ContestsPage } from './contests.page'; +import { ContestPage } from './contest.page'; + +const routes: Routes = [ + { path: '', component: ContestsPage }, + { path: ':contestId', component: ContestPage } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class ContestsRoutingModule {} diff --git a/front-end/src/app/tabs/contests/contests.service.ts b/front-end/src/app/tabs/contests/contests.service.ts new file mode 100644 index 0000000..71854ff --- /dev/null +++ b/front-end/src/app/tabs/contests/contests.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; +import { IDEAApiService } from '@idea-ionic/common'; + +import { Contest } from '@models/contest.model'; + +@Injectable({ providedIn: 'root' }) +export class ContestsService { + private contests: Contest[]; + + /** + * The number of contests to consider for the pagination, when active. + */ + MAX_PAGE_SIZE = 24; + + constructor(private api: IDEAApiService) {} + + private async loadList(): Promise { + const contests: Contest[] = await this.api.getResource('contests'); + this.contests = contests.map(c => new Contest(c)); + } + + /** + * Get (and optionally filter) the list of contests. + * Note: it can be paginated. + * Note: it's a slice of the array. + * Note: if venue id is passed, it will filter contests for that venue. + */ + async getList(options: { + force?: boolean; + withPagination?: boolean; + startPaginationAfterId?: string; + search?: string; + }): Promise { + if (!this.contests || options.force) await this.loadList(); + if (!this.contests) return null; + + options.search = options.search ? String(options.search).toLowerCase() : ''; + + let filteredList = this.contests.slice(); + + if (options.search) + filteredList = filteredList.filter(x => + options.search + .split(' ') + .every(searchTerm => + [x.contestId, x.name, x.description, ...x.candidates.map(x => x.name)] + .filter(f => f) + .some(f => f.toLowerCase().includes(searchTerm)) + ) + ); + + if (options.withPagination && filteredList.length > this.MAX_PAGE_SIZE) { + let indexOfLastOfPreviousPage = 0; + if (options.startPaginationAfterId) + indexOfLastOfPreviousPage = filteredList.findIndex(x => x.contestId === options.startPaginationAfterId) || 0; + filteredList = filteredList.slice(0, indexOfLastOfPreviousPage + this.MAX_PAGE_SIZE); + } + + return filteredList; + } + + /** + * Get the full details of a contest by its id. + */ + async getById(contestId: string): Promise { + return new Contest(await this.api.getResource(['contests', contestId])); + } + + /** + * Insert a new contest. + */ + async insert(contest: Contest): Promise { + return new Contest(await this.api.postResource(['contests'], { body: contest })); + } + /** + * Update an existing contest. + */ + async update(contest: Contest): Promise { + return new Contest(await this.api.putResource(['contests', contest.contestId], { body: contest })); + } + /** + * Delete a contest. + */ + async delete(contest: Contest): Promise { + await this.api.deleteResource(['contests', contest.contestId]); + } + + /** + * Vote for a candidate in a contest. + */ + async vote(contest: Contest, candidate: string): Promise { + const body = { action: 'VOTE', candidate }; + await this.api.patchResource(['contests', contest.contestId], { body }); + } + /** + * Publish the results of a contest. + */ + async publishResults(contest: Contest): Promise { + const body = { action: 'PUBLISH_RESULTS' }; + await this.api.patchResource(['contests', contest.contestId], { body }); + } +} diff --git a/front-end/src/app/tabs/contests/manageContest.component.ts b/front-end/src/app/tabs/contests/manageContest.component.ts new file mode 100644 index 0000000..e4e6d0d --- /dev/null +++ b/front-end/src/app/tabs/contests/manageContest.component.ts @@ -0,0 +1,321 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Component, Input, OnInit, inject } from '@angular/core'; +import { AlertController, IonicModule, ModalController } from '@ionic/angular'; +import { + IDEALoadingService, + IDEAMessageService, + IDEATranslationsModule, + IDEATranslationsService +} from '@idea-ionic/common'; + +import { HTMLEditorComponent } from '@common/htmlEditor.component'; +import { DatetimeWithTimezoneStandaloneComponent } from '@common/datetimeWithTimezone'; + +import { AppService } from '@app/app.service'; +import { MediaService } from '@common/media.service'; +import { ContestsService } from './contests.service'; + +import { Contest, ContestCandidate } from '@models/contest.model'; + +@Component({ + standalone: true, + imports: [ + CommonModule, + FormsModule, + IonicModule, + IDEATranslationsModule, + HTMLEditorComponent, + DatetimeWithTimezoneStandaloneComponent + ], + selector: 'app-manage-contest', + template: ` + + + + + + + + {{ 'CONTESTS.MANAGE_CONTEST' | translate }} + + + + + + + + + + + + {{ 'CONTESTS.NAME' | translate }} + + + + + {{ 'CONTESTS.IMAGE_URI' | translate }} + + + + + + + + +

{{ 'CONTESTS.OPTIONS' | translate }}

+
+
+ + + {{ 'CONTESTS.VISIBLE' | translate }} + + @if(contest.enabled) { @if(!contest.voteEndsAt) { + + + {{ 'CONTESTS.OPEN_VOTE' | translate }} + + } @else { + + + + + + } } + + +

{{ 'CONTESTS.CANDIDATES' | translate }}

+

{{ 'CONTESTS.CANDIDATES_I' | translate }}

+
+ @if(!contest.isVoteStarted()){ + + + + } +
+ @for(candidate of contest.candidates; track $index) { +

+ + + {{ 'CONTESTS.CANDIDATE_NAME' | translate }} + + + + + {{ 'CONTESTS.CANDIDATE_URL' | translate }} + + + + + + @for(country of _app.configurations.sectionCountries; track $index) { + {{ country }} + } + + + @if(!contest.isVoteStarted()) { + + + + } +

+ } @empty { + + {{ 'COMMON.NO_ELEMENTS' | translate }} + + } + + +

{{ 'CONTESTS.DESCRIPTION' | translate }}

+
+
+ + @if(contest.isVoteEnded()) { + + +

{{ 'CONTESTS.RESULTS' | translate }}

+
+
+ @for(candidate of contest.candidates; track candidate.name) { + + {{ candidate.name }} + @if(isCandidateWinnerByIndex($index)) { + + } + + {{ contest.results[$index] ?? 0 }} {{ 'CONTESTS.VOTES' | translate | lowercase }} + + + } } @if(contest.contestId) { + + @if(contest.isVoteEnded() && !contest.publishedResults) { + + + {{ 'CONTESTS.PUBLISH_RESULTS' | translate }} + + + } + + {{ 'COMMON.DELETE' | translate }} + + + } +
+
+ ` +}) +export class ManageContestComponent implements OnInit { + /** + * The contest to manage. + */ + @Input() contest: Contest; + + errors = new Set(); + + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + private _modal = inject(ModalController); + private _alert = inject(AlertController); + private _t = inject(IDEATranslationsService); + private _loading = inject(IDEALoadingService); + private _message = inject(IDEAMessageService); + private _media = inject(MediaService); + private _contests = inject(ContestsService); + _app = inject(AppService); + + async ngOnInit(): Promise { + this.contest = new Contest(this.contest); + } + + setVoteDeadline(remove = false): void { + if (remove) delete this.contest.voteEndsAt; + else { + const oneWeekAhead = new Date(); + oneWeekAhead.setDate(oneWeekAhead.getDate() + 7); + this.contest.voteEndsAt = oneWeekAhead.toISOString(); + } + } + + hasFieldAnError(field: string): boolean { + return this.errors.has(field); + } + + async uploadImage({ target }): Promise { + const file = target.files[0]; + if (!file) return; + + try { + await this._loading.show(); + const imageURI = await this._media.uploadImage(file); + await sleepForNumSeconds(3); + this.contest.imageURI = imageURI; + } catch (error) { + this._message.error('COMMON.OPERATION_FAILED'); + } finally { + if (target) target.value = ''; + this._loading.hide(); + } + } + + async save(): Promise { + this.errors = new Set(this.contest.validate()); + if (this.errors.size) return this._message.error('COMMON.FORM_HAS_ERROR_TO_CHECK'); + + try { + await this._loading.show(); + let result: Contest; + if (!this.contest.contestId) result = await this._contests.insert(this.contest); + else result = await this._contests.update(this.contest); + this.contest.load(result); + this._message.success('COMMON.OPERATION_COMPLETED'); + this.close(); + } catch (err) { + this._message.error('COMMON.OPERATION_FAILED'); + } finally { + this._loading.hide(); + } + } + close(): void { + this._modal.dismiss(); + } + + async askAndDelete(): Promise { + const doDelete = async (): Promise => { + try { + await this._loading.show(); + await this._contests.delete(this.contest); + this._message.success('COMMON.OPERATION_COMPLETED'); + this.close(); + this._app.goToInTabs(['contests']); + } catch (error) { + this._message.error('COMMON.OPERATION_FAILED'); + } finally { + this._loading.hide(); + } + }; + const header = this._t._('COMMON.ARE_YOU_SURE'); + const message = this._t._('COMMON.ACTION_IS_IRREVERSIBLE'); + const buttons = [ + { text: this._t._('COMMON.CANCEL'), role: 'cancel' }, + { text: this._t._('COMMON.DELETE'), role: 'destructive', handler: doDelete } + ]; + const alert = await this._alert.create({ header, message, buttons }); + alert.present(); + } + + addCandidate(): void { + this.contest.candidates.push(new ContestCandidate()); + } + removeCandidate(candidate: ContestCandidate): void { + const candidateIndex = this.contest.candidates.indexOf(candidate); + if (candidateIndex !== -1) this.contest.candidates.splice(candidateIndex, 1); + } + + isCandidateWinnerByIndex(candidateIndex: number): boolean { + return this.contest.candidates.every( + (_, competitorIndex): boolean => this.contest.results[competitorIndex] <= this.contest.results[candidateIndex] + ); + } + + async publishResults(): Promise { + const doPublish = async (): Promise => { + try { + await this._loading.show(); + await this._contests.publishResults(this.contest); + this.contest.publishedResults = true; + this.close(); + } catch (err) { + this._message.error('COMMON.OPERATION_FAILED'); + } finally { + this._loading.hide(); + } + }; + + const header = this._t._('CONTESTS.PUBLISH_RESULTS'); + const subHeader = this._t._('COMMON.ARE_YOU_SURE'); + const buttons = [ + { text: this._t._('COMMON.CANCEL'), role: 'cancel' }, + { text: this._t._('COMMON.CONFIRM'), handler: doPublish } + ]; + const alert = await this._alert.create({ header, subHeader, buttons }); + alert.present(); + } +} + +const sleepForNumSeconds = (numSeconds = 1): Promise => + new Promise(resolve => setTimeout((): void => resolve(null), 1000 * numSeconds)); diff --git a/front-end/src/app/tabs/manage/manage.page.html b/front-end/src/app/tabs/manage/manage.page.html index 86ab1cf..217f3ad 100644 --- a/front-end/src/app/tabs/manage/manage.page.html +++ b/front-end/src/app/tabs/manage/manage.page.html @@ -41,23 +41,23 @@

{{ 'MANAGE.CONTENTS' | translate }}

- + {{ 'MANAGE.VENUES' | translate }} - + {{ 'MANAGE.ROOMS' | translate }} - + {{ 'MANAGE.ORGANIZATIONS' | translate }} - + {{ 'MANAGE.SPEAKERS' | translate }} - + {{ 'MANAGE.SESSIONS' | translate }} {{ 'MANAGE.CONTENTS' | translate }} + + + {{ 'MANAGE.CONTESTS' | translate }} + diff --git a/front-end/src/app/tabs/manage/manage.page.ts b/front-end/src/app/tabs/manage/manage.page.ts index 19b3943..d8812fb 100644 --- a/front-end/src/app/tabs/manage/manage.page.ts +++ b/front-end/src/app/tabs/manage/manage.page.ts @@ -9,6 +9,7 @@ import { ManageSpeakerComponent } from '../speakers/manageSpeaker.component'; import { ManageVenueComponent } from '../venues/manageVenue.component'; import { ManageRoomComponent } from '../rooms/manageRooms.component'; import { ManageSessionComponent } from '../sessions/manageSession.component'; +import { ManageContestComponent } from '../contests/manageContest.component'; import { AppService } from '@app/app.service'; import { UsefulLinksService } from '@app/common/usefulLinks/usefulLinks.service'; @@ -21,6 +22,7 @@ import { Venue } from '@models/venue.model'; import { Speaker } from '@models/speaker.model'; import { Room } from '@models/room.model'; import { Session } from '@models/session.model'; +import { Contest } from '@models/contest.model'; @Component({ selector: 'manage', @@ -136,4 +138,13 @@ export class ManagePage { this.loading.hide(); } } + + async addContest(): Promise { + const modal = await this.modalCtrl.create({ + component: ManageContestComponent, + componentProps: { contest: new Contest() }, + backdropDismiss: false + }); + await modal.present(); + } } diff --git a/front-end/src/app/tabs/menu/menu.page.html b/front-end/src/app/tabs/menu/menu.page.html index 959511f..12d7745 100644 --- a/front-end/src/app/tabs/menu/menu.page.html +++ b/front-end/src/app/tabs/menu/menu.page.html @@ -1,34 +1,36 @@ - - {{ 'TABS.MENU' | translate }} - + {{ 'TABS.MENU' | translate }} - + {{ 'MENU.PAGES' | translate }} - + {{ 'MENU.HOME' | translate }} - + {{ 'MENU.AGENDA' | translate }} - + {{ 'MENU.VENUES' | translate }} - + {{ 'MENU.ORGANIZATIONS' | translate }} - + {{ 'MENU.SPEAKERS' | translate }} + + + {{ 'MENU.CONTESTS' | translate }} + - \ No newline at end of file + diff --git a/front-end/src/app/tabs/tabs.routing.module.ts b/front-end/src/app/tabs/tabs.routing.module.ts index 91e15d1..186cded 100644 --- a/front-end/src/app/tabs/tabs.routing.module.ts +++ b/front-end/src/app/tabs/tabs.routing.module.ts @@ -42,7 +42,8 @@ const routes: Routes = [ }, { path: 'organizations', - loadChildren: (): Promise => import('./organizations/organizations.module').then(m => m.OrganizationsModule), + loadChildren: (): Promise => + import('./organizations/organizations.module').then(m => m.OrganizationsModule), canActivate: [spotGuard] }, { @@ -54,6 +55,11 @@ const routes: Routes = [ path: 'agenda', loadChildren: (): Promise => import('./sessions/sessions.module').then(m => m.SessionsModule), canActivate: [spotGuard] + }, + { + path: 'contests', + loadChildren: (): Promise => import('./contests/contests.module').then(m => m.ContestsModule), + canActivate: [spotGuard] } ] } diff --git a/front-end/src/assets/i18n/en.json b/front-end/src/assets/i18n/en.json index ec6de7c..bdfcd5d 100644 --- a/front-end/src/assets/i18n/en.json +++ b/front-end/src/assets/i18n/en.json @@ -159,7 +159,8 @@ "AGENDA": "Agenda", "VENUES": "Venues", "ORGANIZATIONS": "Organizations", - "SPEAKERS": "Speakers" + "SPEAKERS": "Speakers", + "CONTESTS": "Contests" }, "USER": { "ESN_ACCOUNTS": "ESN Accounts", @@ -356,7 +357,8 @@ "LIST_OF_ESN_COUNTRIES": "List of current ESN countries", "USEFUL_LINKS": "Useful links", "USEFUL_LINKS_I": "Manage the links you want to make available to all users for quick access.", - "DOWNLOAD_SESSIONS_REGISTRATIONS": "Download a spreadsheet with all the sessions registrations" + "DOWNLOAD_SESSIONS_REGISTRATIONS": "Download a spreadsheet with all the sessions registrations", + "CONTESTS": "Contests" }, "STRIPE": { "BEFORE_YOU_PROCEED": "Before you proceed", @@ -441,5 +443,34 @@ "ROOM": "Room", "DOWNLOAD_REGISTRATIONS": "Download a spreadsheet with all the session registrations", "SESSION_REGISTRATIONS": "Session registrations" + }, + "CONTESTS": { + "MANAGE_CONTEST": "Manage contest", + "NAME": "Name", + "IMAGE_URI": "Image URI", + "DESCRIPTION": "Description", + "VISIBLE": "Visible to users", + "OPTIONS": "Options", + "OPEN_VOTE": "Vote is open", + "VOTE_ENDS_AT": "Vote ends at ({{timezone}})", + "CANDIDATES": "Candidates", + "CANDIDATES_I": "If you set a country for a candidate, users of that country won't be able to vote them.", + "RESULTS": "Results", + "CANDIDATE_NAME": "Name of the candidate", + "CANDIDATE_URL": "URL to a page where to discover the candidate", + "CANDIDATE_COUNTRY": "Country of the candidate", + "LIST": "Contests list", + "DETAILS": "Contest details", + "VOTE_ENDED": "Vote ended", + "VOTE_NOW": "Vote now", + "VOTES": "Votes", + "VOTE_NOT_OPEN_YET": "Vote is not open yet", + "VOTE_NOW_UNTIL": "Vote now, until: {{deadline}}", + "VOTE": "Vote", + "VOTE_I": "The vote is anonymous; please not that you can vote only once and you won't be able to change your vote.", + "YOU_ALREADY_VOTED": "You have voted.", + "YOU_ARE_VOTING": "You are voting", + "NOT_NOW": "Not now", + "PUBLISH_RESULTS": "Publish results" } } diff --git a/front-end/src/global.scss b/front-end/src/global.scss index 671fbd1..5659781 100644 --- a/front-end/src/global.scss +++ b/front-end/src/global.scss @@ -526,4 +526,3 @@ ion-img.inGallery::part(image) { .forceMargins p { margin: 15px 10px !important; } -