Merge with store
This commit is contained in:
parent
706fe745d3
commit
3ad2766f82
128 changed files with 1185 additions and 0 deletions
11
frontend/src/app/components/modal/modal.component.html
Normal file
11
frontend/src/app/components/modal/modal.component.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<section
|
||||
(click)="modalService.cancel()"
|
||||
class="{{ modalService.active ? 'active' : '' }}"
|
||||
[ngSwitch]="modalService.active?.type"
|
||||
>
|
||||
<app-blocks (save)="save = $event" (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.blocks"></app-blocks>
|
||||
<app-remove-tower (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.removeTower"></app-remove-tower>
|
||||
<app-settings (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.settings"></app-settings>
|
||||
<app-get-started (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.getStarted"></app-get-started>
|
||||
<app-remove-page (click)="$event.stopPropagation()" *ngSwitchCase="ModalType.removePage"></app-remove-page>
|
||||
</section>
|
||||
29
frontend/src/app/components/modal/modal.component.scss
Normal file
29
frontend/src/app/components/modal/modal.component.scss
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
@import '../../../styles';
|
||||
|
||||
section {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
z-index: 10000;
|
||||
|
||||
@include center-child();
|
||||
|
||||
padding: var(--large-padding);
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
background: $background-gradient;
|
||||
transition: opacity 300ms;
|
||||
|
||||
&:not(.active) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: var(--medium-padding);
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/components/modal/modal.component.ts
Normal file
25
frontend/src/app/components/modal/modal.component.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { ModalService, ModalType } from '../../services/modal.service';
|
||||
import { CancelService } from '../../services/cancel.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-modal',
|
||||
templateUrl: './modal.component.html',
|
||||
styleUrls: ['./modal.component.scss']
|
||||
})
|
||||
export class ModalComponent {
|
||||
ModalType = ModalType;
|
||||
|
||||
save: () => void = null;
|
||||
|
||||
constructor(public modalService: ModalService, private cancelService: CancelService) {
|
||||
this.cancelService.subscribe(this, () => {
|
||||
if (this.save) {
|
||||
this.save();
|
||||
this.save = null;
|
||||
} else {
|
||||
this.modalService.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<section #container *ngIf="tower">
|
||||
<div class="card placeholder"></div>
|
||||
<div
|
||||
*ngFor="let i of range({ max: blocks.length })"
|
||||
(click)="$event.stopPropagation(); scrollToChild(i + 1)"
|
||||
class="card {{ i + 1 === activeChild ? 'active' : '' }} {{
|
||||
i + 2 === activeChild || i === activeChild ? 'near-active' : ''
|
||||
}}"
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="modalService.cancel()"></div>
|
||||
<div class="block" [ngStyle]="{ 'background-color': tower.getColorOfTag(editedValues[i].tag) | color }"></div>
|
||||
<h1 [innerText]="editedValues[i]?.created | formatDate"></h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
<app-select-add
|
||||
class="select"
|
||||
[options]="tower.tags"
|
||||
[default]="editedValues[i].tag"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
[placeholder]="'Tag this item…'"
|
||||
(value)="editedValues[i].tag = $event"
|
||||
></app-select-add>
|
||||
</div>
|
||||
|
||||
<textarea placeholder="Write a description here…" [(ngModel)]="editedValues[i].description"></textarea>
|
||||
|
||||
<div>
|
||||
<app-toggle
|
||||
[beforeText]="'This task hasn\'t been finished yet'"
|
||||
[afterText]="'Goal already accomplished'"
|
||||
[default]="blocks[i].isDone"
|
||||
(value)="editedValues[i].isDone = $event"
|
||||
></app-toggle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
(click)="$event.stopPropagation(); scrollToChild(blocks.length + 1)"
|
||||
class="card {{ blocks.length + 1 === activeChild ? 'active' : '' }} {{
|
||||
blocks.length === activeChild ? 'near-active' : ''
|
||||
}} "
|
||||
>
|
||||
<div class="mask"></div>
|
||||
|
||||
<div class="header">
|
||||
<div class="exit" (click)="modalService.cancel()"></div>
|
||||
<div class="block" [ngStyle]="{ 'background-color': tower.getColorOfTag(top(editedValues).tag) | color }"></div>
|
||||
<h1>Create now</h1>
|
||||
</div>
|
||||
|
||||
<div class="select-add-container">
|
||||
<app-select-add
|
||||
class="select"
|
||||
[options]="tower.tags"
|
||||
[default]="tower.tags.length ? tower.tags[0] : null"
|
||||
[alwaysDropShadow]="true"
|
||||
[onlyShadowBorder]="true"
|
||||
[placeholder]="'Set a category…'"
|
||||
[newValuePlaceholder]="'Add a category…'"
|
||||
(value)="top(editedValues).tag = $event"
|
||||
></app-select-add>
|
||||
</div>
|
||||
|
||||
<textarea placeholder="Write a description here…" [(ngModel)]="top(editedValues).description"></textarea>
|
||||
|
||||
<div>
|
||||
<app-toggle
|
||||
[beforeText]="'This task hasn\'t been finished yet'"
|
||||
[afterText]="'Goal already accomplished'"
|
||||
[default]="onlyDone"
|
||||
(value)="top(editedValues).isDone = $event"
|
||||
></app-toggle>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button (click)="submitAdd()" [disabled]="!top(editedValues).tag">Create and exit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card placeholder"></div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
@import '../../../../../styles';
|
||||
|
||||
:host {
|
||||
@include center-child();
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
@include card();
|
||||
box-shadow: $shadow;
|
||||
display: block;
|
||||
|
||||
transform-origin: center center;
|
||||
|
||||
flex: 0 0 auto;
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 300px;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
margin: calc(var(--large-padding) / 2);
|
||||
position: relative;
|
||||
|
||||
&.near-active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10000;
|
||||
|
||||
@include card();
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: var(--large-padding);
|
||||
}
|
||||
|
||||
&.placeholder {
|
||||
opacity: 0 !important;
|
||||
width: 60vw;
|
||||
max-width: 60vw;
|
||||
}
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
position: relative;
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@include exit();
|
||||
}
|
||||
|
||||
.block {
|
||||
@include square(12px);
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
|
||||
transition: opacity $short-animation-time;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.edit {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0.25;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
@include square(16px);
|
||||
}
|
||||
|
||||
transition: opacity $short-animation-time;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: calc(-1 * #{$line-height});
|
||||
left: 0;
|
||||
height: $line-height;
|
||||
background-color: $text-color;
|
||||
width: 0;
|
||||
transition: width $long-animation-time;
|
||||
}
|
||||
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
&:before {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
&:before {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card:last-child:after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
width: var(--large-padding);
|
||||
right: calc(-1 * var(--large-padding));
|
||||
display: block;
|
||||
position: absolute;
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { ModalService } from '../../../../services/modal.service';
|
||||
import { Tower } from '../../../../model/tower';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Block } from '../../../../model/block';
|
||||
import { IBlock } from '../../../../interfaces/persistance/block';
|
||||
import { CancelService } from '../../../../services/cancel.service';
|
||||
import { range } from 'src/app/utils/range';
|
||||
import { top } from 'src/app/utils/top';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blocks',
|
||||
templateUrl: './blocks.component.html',
|
||||
styleUrls: ['./blocks.component.scss']
|
||||
})
|
||||
export class BlocksComponent implements OnInit, OnDestroy {
|
||||
readonly range = range;
|
||||
readonly top = top;
|
||||
tower: Tower;
|
||||
editedValues: Array<Partial<IBlock>>;
|
||||
endOfScrollToken = 0;
|
||||
activeChild: number;
|
||||
scrollMayEnd = true;
|
||||
onlyDone: boolean;
|
||||
@ViewChild('container') container: ElementRef;
|
||||
|
||||
private intervalID: number;
|
||||
|
||||
constructor(
|
||||
public modalService: ModalService,
|
||||
private cancelService: CancelService,
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
private component: ElementRef
|
||||
) {
|
||||
window.addEventListener('resize', this.onScroll.bind(this));
|
||||
}
|
||||
|
||||
@Output() save: EventEmitter<() => void> = new EventEmitter();
|
||||
|
||||
get blocks(): Array<Block> {
|
||||
return this.tower.blocks.filter(b => b.isDone === this.onlyDone);
|
||||
}
|
||||
|
||||
@HostListener('click') cancel() {
|
||||
this.cancelService.cancelAll();
|
||||
}
|
||||
|
||||
@HostListener('touchstart') fingerDown() {
|
||||
this.scrollMayEnd = false;
|
||||
}
|
||||
|
||||
@HostListener('touchend') fingerUp() {
|
||||
this.scrollMayEnd = true;
|
||||
this.onScroll();
|
||||
}
|
||||
|
||||
@HostListener('scroll') onScroll() {
|
||||
const newToken = ++this.endOfScrollToken;
|
||||
setTimeout(() => {
|
||||
if (newToken === this.endOfScrollToken && this.scrollMayEnd) {
|
||||
this.adjustPosition();
|
||||
}
|
||||
}, 150);
|
||||
this.animateScroll();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const {
|
||||
tower$,
|
||||
onlyDone,
|
||||
startBlock
|
||||
}: { tower$: Observable<Tower>; onlyDone: boolean; startBlock: Block } = this.modalService.active.input;
|
||||
|
||||
this.save.emit(() => this.submitChange());
|
||||
|
||||
this.intervalID = setInterval(() => this.changeDetector.detectChanges(), 1000);
|
||||
|
||||
this.onlyDone = onlyDone;
|
||||
const subscription = tower$.subscribe(value => {
|
||||
if (value) {
|
||||
this.tower = value;
|
||||
this.editedValues = this.blocks.map(({ isDone, description, tag, created }) => ({
|
||||
isDone,
|
||||
description,
|
||||
tag,
|
||||
created
|
||||
}));
|
||||
this.editedValues.push({
|
||||
tag: this.tower.tags.length ? this.tower.tags[0] : null,
|
||||
isDone: this.onlyDone,
|
||||
description: ''
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.scrollToChild(startBlock ? this.blocks.indexOf(startBlock) + 1 : this.blocks.length + 1, true);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
animateScroll() {
|
||||
if (!this.container || !this.component) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c = this.component.nativeElement;
|
||||
|
||||
[...this.container.nativeElement.children]
|
||||
.slice(1, -1)
|
||||
.forEach(element =>
|
||||
this.animate(
|
||||
element.style,
|
||||
element.querySelector('.mask').style,
|
||||
Math.abs(element.offsetLeft - c.scrollLeft + element.clientWidth / 2 - window.innerWidth / 2) /
|
||||
element.clientWidth
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
animate(cardStyle, maskStyle, t: number) {
|
||||
t = Math.min(2, Math.max(0, t));
|
||||
cardStyle.opacity = (1.33 * (1 - t / 2)).toString();
|
||||
t = Math.min(1, Math.max(0, t));
|
||||
maskStyle.opacity = Math.pow(t, 0.5).toString();
|
||||
maskStyle.display = t <= 0.05 ? 'none' : 'block';
|
||||
}
|
||||
|
||||
adjustPosition() {
|
||||
if (!this.container || !this.component) {
|
||||
return;
|
||||
}
|
||||
|
||||
const c = this.component.nativeElement;
|
||||
|
||||
const middle =
|
||||
[...this.container.nativeElement.children]
|
||||
.slice(1, -1)
|
||||
.map(element => Math.abs(element.offsetLeft - c.scrollLeft + element.clientWidth / 2 - window.innerWidth / 2))
|
||||
.map((value, index) => (Math.abs(index + 1 - this.activeChild) === 1 ? Math.abs(value - 100) : value))
|
||||
.reduce(
|
||||
(middleIndex, current, currentIndex, list) => (list[middleIndex] < current ? middleIndex : currentIndex),
|
||||
0
|
||||
) + 1;
|
||||
|
||||
this.scrollToChild(middle);
|
||||
}
|
||||
|
||||
scrollToChild(index: number, instantly?: boolean) {
|
||||
this.activeChild = index;
|
||||
const element = this.container.nativeElement.children[index];
|
||||
|
||||
this.component.nativeElement.scrollTo({
|
||||
left: element.offsetLeft - (window.innerWidth / 2 - element.clientWidth / 2),
|
||||
behavior: instantly ? 'auto' : 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
submitAdd() {
|
||||
top(this.editedValues).created = new Date();
|
||||
this.tower.addBlock(top(this.editedValues) as IBlock);
|
||||
this.cancelService.cancelAll();
|
||||
}
|
||||
|
||||
submitChange() {
|
||||
this.blocks.forEach((b, i) => b.changeKeys(this.editedValues[i]));
|
||||
this.modalService.submit();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
clearInterval(this.intervalID);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<p>
|
||||
get-started works!
|
||||
</p>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-get-started',
|
||||
templateUrl: './get-started.component.html',
|
||||
styleUrls: ['./get-started.component.scss']
|
||||
})
|
||||
export class GetStartedComponent implements OnInit {
|
||||
constructor() {}
|
||||
|
||||
ngOnInit() {}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<section>
|
||||
<div class="header">
|
||||
<div class="exit" (click)="modalService.cancel()"></div>
|
||||
<h1>Are you sure?</h1>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
You are trying to remove <strong>{{ this.modalService.active.input }}</strong
|
||||
>.
|
||||
</p>
|
||||
|
||||
<button (click)="modalService.submit()">Remove</button>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
@import '../../../../../styles';
|
||||
|
||||
section {
|
||||
@include card();
|
||||
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { ModalService } from '../../../../services/modal.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-remove-page',
|
||||
templateUrl: './remove-page.component.html',
|
||||
styleUrls: ['./remove-page.component.scss']
|
||||
})
|
||||
export class RemovePageComponent {
|
||||
constructor(public modalService: ModalService) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<section>
|
||||
<div class="header">
|
||||
<div class="exit" (click)="modalService.cancel()"></div>
|
||||
<h1>Are you sure?</h1>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
You are trying to remove
|
||||
<span [ngStyle]="{ color: tower.baseColor | color }">{{ tower.name ? tower.name : 'an unnamed tower' }}</span
|
||||
>.
|
||||
</p>
|
||||
|
||||
<button (click)="modalService.submit()">Remove</button>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
@import '../../../../../styles';
|
||||
|
||||
section {
|
||||
@include card();
|
||||
|
||||
width: 66vw;
|
||||
max-width: 500px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Component } from '@angular/core';
|
||||
import { ModalService } from '../../../../services/modal.service';
|
||||
import { Tower } from '../../../../model/tower';
|
||||
|
||||
@Component({
|
||||
selector: 'app-remove-tower',
|
||||
templateUrl: './remove-tower.component.html',
|
||||
styleUrls: ['./remove-tower.component.scss']
|
||||
})
|
||||
export class RemoveTowerComponent {
|
||||
constructor(public modalService: ModalService) {}
|
||||
|
||||
tower: Tower = this.modalService.active.input;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<div class="header">
|
||||
<div class="exit" (click)="modalService.cancel()"></div>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<app-toggle
|
||||
[beforeText]="'Hide create tower button'"
|
||||
[afterText]="'Show create tower button'"
|
||||
[default]="!page.userData.hideCreateTowerButton"
|
||||
(value)="page.setHideCreateTowerButton(!$event)"
|
||||
></app-toggle>
|
||||
</div>
|
||||
|
||||
<p *ngIf="page.towers.length == 5">There can be a maximum of <strong>5</strong> towers on each page.</p>
|
||||
|
||||
<input id="token" type="text" [(ngModel)]="token" />
|
||||
|
||||
<button (click)="setNewToken()">Set token</button>
|
||||
|
||||
<button (click)="$event.stopPropagation() || deletePage()">Delete current page</button>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
@import '../../../../../styles';
|
||||
|
||||
:host {
|
||||
@include card();
|
||||
|
||||
width: 66vw;
|
||||
max-width: 400px;
|
||||
@media (max-width: $mobile-width) {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
padding: var(--large-padding);
|
||||
|
||||
position: relative;
|
||||
box-shadow: $shadow;
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.header {
|
||||
@include center-child();
|
||||
|
||||
.exit {
|
||||
position: absolute;
|
||||
left: var(--large-padding);
|
||||
|
||||
@include exit();
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--medium-font-size);
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ModalService } from '../../../../services/modal.service';
|
||||
import { DataService } from '../../../../services/data.service';
|
||||
import { Page } from '../../../../model/page';
|
||||
import { Data } from '../../../../model/data';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { MapStoreService } from '../../../../services/map-store.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss']
|
||||
})
|
||||
export class SettingsComponent implements OnInit, OnDestroy {
|
||||
data: Data;
|
||||
page: Page;
|
||||
|
||||
private dataSubscription: Subscription;
|
||||
private pageSubscription: Subscription;
|
||||
|
||||
token: string;
|
||||
|
||||
constructor(public modalService: ModalService, private store: MapStoreService) {
|
||||
this.token = store.userToken;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const { data$, page$ } = this.modalService.active.input;
|
||||
|
||||
this.dataSubscription = data$.subscribe(d => (this.data = d));
|
||||
this.pageSubscription = page$.subscribe(p => (this.page = p));
|
||||
}
|
||||
|
||||
async deletePage() {
|
||||
try {
|
||||
await this.modalService.showRemovePage(this.page.name);
|
||||
this.data.removePage(this.page);
|
||||
this.modalService.submit();
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
|
||||
setNewToken() {
|
||||
this.store.userToken = this.token;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.dataSubscription) {
|
||||
this.dataSubscription.unsubscribe();
|
||||
}
|
||||
if (this.pageSubscription) {
|
||||
this.pageSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
frontend/src/app/components/pages/page/page.component.html
Normal file
31
frontend/src/app/components/pages/page/page.component.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<section class="towers" cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="dropDrag($event)">
|
||||
<app-tower
|
||||
*ngFor="let tower of towers"
|
||||
[tower$]="tower.asObservable()"
|
||||
[dateRange$]="dateRange"
|
||||
cdkDrag
|
||||
(cdkDragStarted)="startDrag(towers.indexOf(tower))"
|
||||
></app-tower>
|
||||
<div *ngIf="(page$ | async)?.towers.length < 5 && !(page$ | async)?.userData?.hideCreateTowerButton">
|
||||
<img src="assets/plus-sign.svg" alt="add tower" class="add-tower" (click)="page.addTower()" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<img
|
||||
[ngClass]="isDragging ? 'active' : ''"
|
||||
src="assets/trash.svg"
|
||||
alt="trashcan"
|
||||
class="trash"
|
||||
(pointerenter)="trashEnter()"
|
||||
(pointerleave)="trashExit()"
|
||||
(pointerup)="removeTower()"
|
||||
/>
|
||||
|
||||
<div class="double-slider-container" [ngStyle]="{ opacity: isDragging ? '0' : '1' }">
|
||||
<app-double-slider
|
||||
*ngIf="dates.length >= MIN_BLOCK_COUNT_BEFORE_SHOWING_SLIDER"
|
||||
[values]="dates"
|
||||
[labels]="dateLabels"
|
||||
(range)="dateRange.next($event)"
|
||||
></app-double-slider>
|
||||
</div>
|
||||
108
frontend/src/app/components/pages/page/page.component.scss
Normal file
108
frontend/src/app/components/pages/page/page.component.scss
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
@import '../../../../styles';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.towers {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
flex: 1 0 auto;
|
||||
|
||||
transition: box-shadow $short-animation-time;
|
||||
|
||||
max-width: 800px;
|
||||
|
||||
&.cdk-drop-list-dragging {
|
||||
*:not(.cdk-drag-placeholder) {
|
||||
transition: transform $long-animation-time cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
@include center-child();
|
||||
img.add-tower {
|
||||
height: 48px;
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
opacity: 0.33;
|
||||
transition: opacity $long-animation-time;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
max-width: 200px;
|
||||
box-sizing: content-box;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:not(:nth-last-child(1)) {
|
||||
margin-right: var(--medium-padding);
|
||||
@media (max-width: $mobile-width) {
|
||||
margin-right: var(--small-padding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
position: relative;
|
||||
|
||||
@for $i from 1 to 6 {
|
||||
& > *:first-child:nth-last-child(#{$i}),
|
||||
& > *:first-child:nth-last-child(#{$i}) ~ * {
|
||||
width: calc((100% - (#{$i} - 1) * var(--medium-padding)) / #{$i});
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
width: calc((100% - (#{$i} - 1) * var(--small-padding)) / #{$i});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.double-slider-container {
|
||||
@media (max-height: $min-height) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
img.trash {
|
||||
@include square(48px);
|
||||
padding: 16px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1500;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
margin: 0 !important;
|
||||
|
||||
transform: translateX(-50%) scale(0);
|
||||
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
&.active {
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
frontend/src/app/components/pages/page/page.component.ts
Normal file
92
frontend/src/app/components/pages/page/page.component.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Page } from '../../../model/page';
|
||||
import { ModalService } from '../../../services/modal.service';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Range } from '../../../interfaces/range';
|
||||
import { Subject } from 'rxjs/internal/Subject';
|
||||
import { Tower } from '../../../model/tower';
|
||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page',
|
||||
templateUrl: './page.component.html',
|
||||
styleUrls: ['./page.component.scss']
|
||||
})
|
||||
export class PageComponent implements OnInit {
|
||||
@Input() page$: Observable<Page>;
|
||||
private page: Page;
|
||||
|
||||
towers: Array<BehaviorSubject<Tower>> = [];
|
||||
|
||||
@Output() isDragHappening: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
readonly MIN_BLOCK_COUNT_BEFORE_SHOWING_SLIDER = 6;
|
||||
|
||||
isDragging = false;
|
||||
draggedTowerIndex: number;
|
||||
nearTrashcan = false;
|
||||
|
||||
dates: Date[] = [];
|
||||
dateRange: Subject<Range<Date>> = new Subject<Range<Date>>();
|
||||
|
||||
get dateLabels(): string[] {
|
||||
return this.dates.map(d => d.toLocaleDateString());
|
||||
}
|
||||
|
||||
constructor(private modalService: ModalService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.page$.subscribe(value => {
|
||||
if (value) {
|
||||
this.towers = value.towers.map((t, index) => {
|
||||
if (index < this.towers.length) {
|
||||
this.towers[index].next(t);
|
||||
return this.towers[index];
|
||||
}
|
||||
return new BehaviorSubject(t);
|
||||
});
|
||||
|
||||
this.page = value;
|
||||
this.dates = value.towers
|
||||
.reduce((all, t) => [...t.blocks.map(b => b.created), ...all], [])
|
||||
.sort((d1, d2) => d1.getTime() - d2.getTime());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dropDrag(event: any) {
|
||||
this.page.moveTower(event);
|
||||
this.isDragging = false;
|
||||
this.isDragHappening.emit(false);
|
||||
}
|
||||
|
||||
startDrag(id: number) {
|
||||
this.draggedTowerIndex = id;
|
||||
this.isDragging = true;
|
||||
this.isDragHappening.emit(true);
|
||||
}
|
||||
|
||||
trashEnter() {
|
||||
this.nearTrashcan = true;
|
||||
window.document.querySelector('.cdk-drag-preview').className += ' trash-highlight';
|
||||
}
|
||||
|
||||
trashExit() {
|
||||
this.nearTrashcan = false;
|
||||
const elem = window.document.querySelector('.cdk-drag-preview');
|
||||
elem.className = elem.className
|
||||
.split(' ')
|
||||
.slice(0, -1)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
async removeTower() {
|
||||
try {
|
||||
const tower = this.page.towers[this.draggedTowerIndex];
|
||||
await this.modalService.showRemoveTower(tower);
|
||||
this.page.removeTower(tower);
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="$event.stopPropagation() || handleClick()"></div>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
@import '../../../../../../styles';
|
||||
|
||||
:host {
|
||||
position: relative;
|
||||
width: calc(100% / 6);
|
||||
padding-bottom: calc(100% / 6);
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@include gravitate();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { ChangeDetectorRef, Component, Input } from '@angular/core';
|
||||
import { ModalService } from '../../../../../services/modal.service';
|
||||
import { ColoredBlock, Tower } from '../../../../../model/tower';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block',
|
||||
templateUrl: './block.component.html',
|
||||
styleUrls: ['./block.component.scss']
|
||||
})
|
||||
export class BlockComponent {
|
||||
@Input() block: ColoredBlock;
|
||||
@Input() tower$: Observable<Tower>;
|
||||
|
||||
constructor(private modalService: ModalService) {}
|
||||
|
||||
async handleClick() {
|
||||
try {
|
||||
await this.modalService.showBlocks({
|
||||
tower$: this.tower$,
|
||||
startBlock: this.block,
|
||||
onlyDone: true
|
||||
});
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<div *ngIf="tasks" class="container {{ tasks.length > 0 ? 'show-hover' : '' }}" (click)="$event.stopPropagation()">
|
||||
<p class="header" (click)="isOpen = !isOpen">
|
||||
<strong>
|
||||
{{ tasks.length == 0 ? '' : tasks.length }}
|
||||
</strong>
|
||||
<!-- ​ is the zero width space -->
|
||||
{{ tasks.length == 0 ? '​' : tasks.length == 1 ? 'task' : 'tasks' }}
|
||||
</p>
|
||||
<div class="all-task" #allTask [ngStyle]="{ height: (isOpen ? allTask?.scrollHeight : 0) + 'px' }">
|
||||
<div class="task-container" *ngFor="let task of tasks" [ngStyle]="{ color: task.color | color }">
|
||||
<div [ngStyle]="{ 'background-color': task.color | color }"></div>
|
||||
<p (click)="handleClick(task)" [innerText]="task.description ? task.description : 'unknown'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
@import '../../../../../../styles';
|
||||
|
||||
:host {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
z-index: 100000;
|
||||
|
||||
.container {
|
||||
@include card();
|
||||
|
||||
cursor: pointer;
|
||||
transition: box-shadow $long-animation-time;
|
||||
&.show-hover:hover {
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
|
||||
padding: calc(var(--small-padding) / 2);
|
||||
margin: calc(var(--small-padding) / 2);
|
||||
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--medium-font-size);
|
||||
}
|
||||
|
||||
.all-task {
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
||||
:first-child {
|
||||
margin-top: var(--small-padding);
|
||||
}
|
||||
|
||||
height: 0;
|
||||
box-sizing: border-box;
|
||||
transition: height $long-animation-time;
|
||||
overflow-y: hidden;
|
||||
|
||||
.task-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
p {
|
||||
@media (min-width: $mobile-width) {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 calc(var(--small-padding) / 2) 0 0;
|
||||
@include square(var(--small-padding));
|
||||
@media (max-width: $mobile-width) {
|
||||
@include square(calc(var(--small-padding) / 2));
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
text-align: left;
|
||||
|
||||
@media (max-width: $mobile-width) {
|
||||
font-size: calc(var(--small-font-size) / 2 + var(--medium-font-size) / 2);
|
||||
}
|
||||
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core';
|
||||
import { Block } from '../../../../../model/block';
|
||||
import { Tower } from '../../../../../model/tower';
|
||||
import { ModalService } from '../../../../../services/modal.service';
|
||||
import { CancelService } from '../../../../../services/cancel.service';
|
||||
import { IColor } from '../../../../../interfaces/color';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
|
||||
@Component({
|
||||
selector: 'app-tasks',
|
||||
templateUrl: './tasks.component.html',
|
||||
styleUrls: ['./tasks.component.scss']
|
||||
})
|
||||
export class TasksComponent {
|
||||
@Input() tasks: Array<Block & { color: IColor }>;
|
||||
@Input() tower$: Observable<Tower>;
|
||||
|
||||
private _isOpen = false;
|
||||
@Input() set isOpen(value: boolean) {
|
||||
if (value) {
|
||||
this.cancelService.cancelAllExcept(this);
|
||||
}
|
||||
this._isOpen = value;
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
@ViewChild('allTask') allTask: ElementRef;
|
||||
|
||||
constructor(
|
||||
private modalService: ModalService,
|
||||
private cancelService: CancelService,
|
||||
private changeDetection: ChangeDetectorRef
|
||||
) {
|
||||
this.cancelService.subscribe(this, () => {
|
||||
this.isOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
async handleClick(block: Block) {
|
||||
try {
|
||||
await this.modalService.showBlocks({
|
||||
tower$: this.tower$,
|
||||
startBlock: block,
|
||||
onlyDone: false
|
||||
});
|
||||
} catch {
|
||||
// pass
|
||||
} finally {
|
||||
this.changeDetection.markForCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<div class="tower" *ngIf="tower$ | async">
|
||||
<div class="container">
|
||||
<div class="tasks-container">
|
||||
<app-tasks [tasks]="tasks" [tower$]="tower$"></app-tasks>
|
||||
</div>
|
||||
|
||||
<img src="assets/plus-sign.svg" alt="add item" (click)="$event.stopPropagation() || addBlock()" />
|
||||
|
||||
<div class="block-container-container">
|
||||
<div class="block-container" *ngIf="styledBlocks.length > 0">
|
||||
<app-block
|
||||
*ngFor="let block of drawableBlocks"
|
||||
[ngClass]="block.cssClass"
|
||||
[ngStyle]="block.style"
|
||||
[block]="block"
|
||||
[tower$]="tower$"
|
||||
></app-block>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="hidden">
|
||||
Card name
|
||||
<input
|
||||
type="text"
|
||||
placeholder="name…"
|
||||
[(ngModel)]="towerName"
|
||||
[ngStyle]="{ color: (tower$ | async)?.baseColor | color }"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
@import '../../../../../styles';
|
||||
|
||||
:host {
|
||||
cursor: pointer;
|
||||
|
||||
&.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
&.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@media (min-width: $mobile-width) {
|
||||
div.container {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.cdk-drag-preview {
|
||||
div.container {
|
||||
@media (max-width: $mobile-width) {
|
||||
@keyframes shadow {
|
||||
from {
|
||||
box-shadow: none;
|
||||
}
|
||||
to {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
}
|
||||
|
||||
animation: shadow $long-animation-time forwards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.trash-highlight {
|
||||
.container {
|
||||
transform: scale(0.75);
|
||||
position: relative;
|
||||
|
||||
:before {
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tower {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@include inner-spacing(var(--small-padding));
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
|
||||
@include card();
|
||||
overflow: hidden;
|
||||
transition: transform $short-animation-time, box-shadow $long-animation-time;
|
||||
|
||||
@include inner-spacing(var(--medium-padding));
|
||||
|
||||
width: 100%;
|
||||
|
||||
:before {
|
||||
content: '';
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: red;
|
||||
|
||||
opacity: 0;
|
||||
border-radius: var(--border-radius);
|
||||
transition: opacity $short-animation-time;
|
||||
}
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
height: 48px;
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
opacity: 0.33;
|
||||
transition: opacity $long-animation-time;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.block-container-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
.block-container {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
align-content: flex-start;
|
||||
align-items: flex-end;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
transform: scaleY(-1);
|
||||
|
||||
* {
|
||||
transform: translateY(500%);
|
||||
}
|
||||
|
||||
.descend {
|
||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), opacity 500ms cubic-bezier(0.5, 0, 1, 0);
|
||||
}
|
||||
|
||||
.ascend {
|
||||
transition: transform 1.5s cubic-bezier(0.5, 0, 1, 0), opacity 500ms cubic-bezier(0.5, 0, 1, 0) 1s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
font-size: var(--small-font-size);
|
||||
text-align: center;
|
||||
@media (min-width: $mobile-width) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
123
frontend/src/app/components/pages/page/tower/tower.component.ts
Normal file
123
frontend/src/app/components/pages/page/tower/tower.component.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
|
||||
import { ColoredBlock, Tower } from '../../../../model/tower';
|
||||
import { ModalService } from '../../../../services/modal.service';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Range } from '../../../../interfaces/range';
|
||||
import { top } from '../../../../utils/top';
|
||||
import { CancelService } from '../../../../services/cancel.service';
|
||||
|
||||
type StyledBlock = ColoredBlock & { style: { [p: string]: string }; shouldDraw: boolean; cssClass: string };
|
||||
|
||||
@Component({
|
||||
selector: 'app-tower',
|
||||
templateUrl: './tower.component.html',
|
||||
styleUrls: ['./tower.component.scss']
|
||||
})
|
||||
export class TowerComponent implements OnInit {
|
||||
@Input() dateRange$: Observable<Range<Date>>;
|
||||
@Input() tower$: Observable<Tower>;
|
||||
|
||||
private dateRange: Range<Date>;
|
||||
private tower: Tower;
|
||||
|
||||
get towerName(): string {
|
||||
return this.tower ? this.tower.name : 'Loading…';
|
||||
}
|
||||
|
||||
set towerName(value: string) {
|
||||
this.tower.changeName(value);
|
||||
}
|
||||
|
||||
tasks: Array<ColoredBlock>;
|
||||
|
||||
styledBlocks: Array<StyledBlock> = [];
|
||||
|
||||
get drawableBlocks(): Array<StyledBlock> {
|
||||
return this.styledBlocks.filter(b => b.shouldDraw);
|
||||
}
|
||||
|
||||
public constructor(private modalService: ModalService, private changeDetection: ChangeDetectorRef) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.tower$.subscribe(value => {
|
||||
// console.log(this.tower, value);
|
||||
if (value) {
|
||||
this.styledBlocks = value.coloredBlocks
|
||||
.filter(b => b.isDone)
|
||||
.map(b => {
|
||||
const classedBlock = b as StyledBlock;
|
||||
classedBlock.shouldDraw = true;
|
||||
classedBlock.style = { transform: 'translateY(0)', opacity: '1' };
|
||||
classedBlock.cssClass = '';
|
||||
return classedBlock;
|
||||
});
|
||||
|
||||
if (this.tower && this.tower.latestVersion === value) {
|
||||
const difference = this.tower.blocks.map((b, index) => {
|
||||
return b === value.blocks[index];
|
||||
});
|
||||
|
||||
if (
|
||||
(difference.every(i => i) &&
|
||||
this.tower.blocks.length + 1 === value.blocks.length &&
|
||||
top(value.blocks).isDone) ||
|
||||
(this.tower.blocks.length === value.blocks.length &&
|
||||
this.tower.blocks.filter(b => b.isDone).length + 1 === value.blocks.filter(b => b.isDone).length)
|
||||
) {
|
||||
const lastBlock = top(this.styledBlocks);
|
||||
if (lastBlock) {
|
||||
lastBlock.style = { transform: 'translateY(500%)', opacity: '0' };
|
||||
setTimeout(() => {
|
||||
this.makeBlockDescend(lastBlock);
|
||||
this.changeDetection.markForCheck();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.tasks = value.coloredBlocks.filter(block => !block.isDone);
|
||||
this.tower = value;
|
||||
this.changeDetection.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.dateRange$.subscribe(dateRange => {
|
||||
this.initData(dateRange);
|
||||
this.dateRange = dateRange;
|
||||
});
|
||||
}
|
||||
|
||||
makeBlockDescend(block: StyledBlock) {
|
||||
block.cssClass = 'descend';
|
||||
block.style = { transform: 'translateY(0)', opacity: '1' };
|
||||
}
|
||||
|
||||
makeBlockAscend(block: StyledBlock) {
|
||||
block.cssClass = 'ascend';
|
||||
block.style = { transform: 'translateY(500%)', opacity: '0' };
|
||||
}
|
||||
|
||||
initData(newDateRange: Range<Date>) {
|
||||
for (const block of this.styledBlocks) {
|
||||
block.shouldDraw = newDateRange.from <= block.created;
|
||||
|
||||
if (newDateRange.to < block.created) {
|
||||
this.makeBlockAscend(block);
|
||||
}
|
||||
if (block.shouldDraw && block.created <= newDateRange.to) {
|
||||
this.makeBlockDescend(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async addBlock() {
|
||||
try {
|
||||
await this.modalService.showBlocks({
|
||||
tower$: this.tower$,
|
||||
onlyDone: true
|
||||
});
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/src/app/components/pages/pages.component.html
Normal file
22
frontend/src/app/components/pages/pages.component.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<div class="select-add-container">
|
||||
<!-- wrapper for easier styling -->
|
||||
<app-select-add
|
||||
[options]="pageNames"
|
||||
[default]="(selectedPage$ | async)?.name"
|
||||
(value)="selectPage($event)"
|
||||
(optionChange)="changeName($event)"
|
||||
[placeholder]="'Add a new page…'"
|
||||
[editable]="true"
|
||||
></app-select-add>
|
||||
</div>
|
||||
<!-- wrapper for easier styling -->
|
||||
|
||||
<div class="page-container">
|
||||
<!-- wrapper for easier styling -->
|
||||
<app-page [page$]="selectedPage$" (isDragHappening)="isDragHappening = $event"></app-page>
|
||||
</div>
|
||||
<!-- wrapper for easier styling -->
|
||||
|
||||
<button [ngClass]="isDragHappening ? 'transparent' : ''" (click)="$event.stopPropagation(); openSettings()">
|
||||
Settings
|
||||
</button>
|
||||
29
frontend/src/app/components/pages/pages.component.scss
Normal file
29
frontend/src/app/components/pages/pages.component.scss
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
@import '../../../styles';
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
@include inner-spacing(var(--large-padding));
|
||||
|
||||
.select-add-container {
|
||||
width: 250px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
button {
|
||||
transition: opacity $long-animation-time;
|
||||
|
||||
&.transparent {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
113
frontend/src/app/components/pages/pages.component.ts
Normal file
113
frontend/src/app/components/pages/pages.component.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core';
|
||||
import { Page } from '../../model/page';
|
||||
import { DataService } from '../../services/data.service';
|
||||
import { ModalService } from '../../services/modal.service';
|
||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Data } from '../../model/data';
|
||||
import { of } from 'rxjs/internal/observable/of';
|
||||
|
||||
const USER_DATA_KEY = 'life-towers.user-data.v.2';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pages',
|
||||
templateUrl: './pages.component.html',
|
||||
styleUrls: ['./pages.component.scss']
|
||||
})
|
||||
export class PagesComponent implements OnInit {
|
||||
@ViewChild('top') top: ElementRef;
|
||||
@ViewChild('page') page: ElementRef;
|
||||
@ViewChild('bottom') bottom: ElementRef;
|
||||
|
||||
data: Data;
|
||||
pages: Array<Page>;
|
||||
isDragHappening = false;
|
||||
|
||||
get pageNames() {
|
||||
if (this.pages) {
|
||||
return this.pages.map(p => p.name);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
selectedPageName: string;
|
||||
|
||||
private readonly _selectedPage: BehaviorSubject<Page> = new BehaviorSubject(null);
|
||||
readonly selectedPage$: Observable<Page> = this._selectedPage.asObservable();
|
||||
|
||||
constructor(
|
||||
public dataService: DataService,
|
||||
private modalService: ModalService,
|
||||
private changeDetection: ChangeDetectorRef
|
||||
) {
|
||||
const userData = JSON.parse(window.localStorage.getItem(USER_DATA_KEY));
|
||||
if (userData !== null) {
|
||||
this.selectedPageName = userData.selectedPage;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.dataService.children$.subscribe(dataContainer => {
|
||||
if (dataContainer && dataContainer.length > 0) {
|
||||
this.data = dataContainer[0];
|
||||
const pages = this.data.pages;
|
||||
if (this.pages && !pages.includes(this._selectedPage.getValue().latestVersion)) {
|
||||
this.selectedPageName = null;
|
||||
}
|
||||
this.pages = pages;
|
||||
this.selectPage(this.selectedPageName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
changeName({ from, to }: { from: string; to: string }) {
|
||||
const page = this.pages.find(p => p.name === from);
|
||||
if (page) {
|
||||
if (from === this.selectedPageName) {
|
||||
this.selectedPageName = to;
|
||||
}
|
||||
page.changeName(to);
|
||||
}
|
||||
}
|
||||
|
||||
selectPage(name: string) {
|
||||
if (!name) {
|
||||
if (this.pages && this.pages.length > 0) {
|
||||
name = this.pages[0].name;
|
||||
}
|
||||
}
|
||||
this.selectedPageName = name;
|
||||
|
||||
window.localStorage.setItem(
|
||||
USER_DATA_KEY,
|
||||
JSON.stringify({
|
||||
selectedPage: name
|
||||
})
|
||||
);
|
||||
|
||||
if (this.pages && name) {
|
||||
if (!this.pageNames.includes(name)) {
|
||||
this.data.addPage(name);
|
||||
}
|
||||
|
||||
const index = this.pageNames.indexOf(name);
|
||||
this._selectedPage.next(this.pages[index]);
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectedPage.next(null);
|
||||
}
|
||||
|
||||
async openSettings() {
|
||||
try {
|
||||
await this.modalService.showSettings({
|
||||
page$: this.selectedPage$,
|
||||
data$: of(this.data)
|
||||
});
|
||||
} catch {
|
||||
// pass
|
||||
} finally {
|
||||
this.changeDetection.markForCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<div class="container">
|
||||
<label for="date-selector-1">date selector 1</label>
|
||||
<label for="date-selector-2">date selector 2</label>
|
||||
<input id="date-selector-1" type="range" min="0" [max]="MAX - 1" [(ngModel)]="oneValue" />
|
||||
<input id="date-selector-2" type="range" min="0" [max]="MAX - 1" [(ngModel)]="otherValue" />
|
||||
<div class="value-container">
|
||||
<span
|
||||
*ngFor="let i of drawnLabelsIndices"
|
||||
[innerHTML]="drawnLabels[i]"
|
||||
[ngStyle]="{ transform: getOffset(i) }"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
@import '../../../../styles';
|
||||
|
||||
$height: 70px;
|
||||
$width: 300px;
|
||||
$slider-size: 40px;
|
||||
|
||||
.container {
|
||||
width: $width;
|
||||
height: $height;
|
||||
|
||||
position: relative;
|
||||
|
||||
margin: $slider-size / 2 auto 0 auto;
|
||||
|
||||
label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
-webkit-appearance: none;
|
||||
outline: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
|
||||
height: $slider-size;
|
||||
width: $slider-size;
|
||||
border-radius: 1000px;
|
||||
|
||||
background-color: $light-color;
|
||||
transform-origin: center center;
|
||||
transform: translateY(-$slider-size / 2 + $line-height / 2);
|
||||
|
||||
transition: box-shadow $long-animation-time, transform $long-animation-time;
|
||||
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover {
|
||||
box-shadow: $shadow;
|
||||
transform: translateY(-$slider-size / 2 + $line-height / 2) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
-webkit-appearance: none;
|
||||
|
||||
width: 100%;
|
||||
height: $line-height;
|
||||
background-color: $text-color;
|
||||
border-radius: 1000px;
|
||||
}
|
||||
|
||||
&::-moz-focus-outer {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.value-container {
|
||||
@include small-text();
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { range } from '../../../utils/range';
|
||||
import { Range } from '../../../interfaces/range';
|
||||
|
||||
@Component({
|
||||
selector: 'app-double-slider',
|
||||
templateUrl: './double-slider.component.html',
|
||||
styleUrls: ['./double-slider.component.scss']
|
||||
})
|
||||
export class DoubleSliderComponent {
|
||||
@Input() labels: string[];
|
||||
|
||||
@Input() set values(values: any[]) {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._values = values;
|
||||
this.calculateLabels();
|
||||
if (this._oneValue > this._otherValue) {
|
||||
this._oneValue = this.MAX - 1;
|
||||
} else {
|
||||
this._otherValue = this.MAX - 1;
|
||||
}
|
||||
|
||||
this.emitValue();
|
||||
}
|
||||
|
||||
get values(): any[] {
|
||||
return this._values;
|
||||
}
|
||||
|
||||
get oneValue(): number {
|
||||
return this._oneValue;
|
||||
}
|
||||
|
||||
set oneValue(value: number) {
|
||||
this._oneValue = value;
|
||||
this.emitValue();
|
||||
}
|
||||
|
||||
get otherValue(): number {
|
||||
return this._otherValue;
|
||||
}
|
||||
|
||||
set otherValue(value: number) {
|
||||
this._otherValue = value;
|
||||
this.emitValue();
|
||||
}
|
||||
|
||||
private _values: any[];
|
||||
|
||||
@Output() range: EventEmitter<Range<any>> = new EventEmitter();
|
||||
|
||||
drawnLabels: string[];
|
||||
|
||||
readonly MAX = 100;
|
||||
|
||||
private _oneValue = 0;
|
||||
|
||||
private _otherValue: number = this.MAX - 1;
|
||||
|
||||
drawnLabelsIndices: Iterable<number>;
|
||||
|
||||
private calculateLabels() {
|
||||
const labelCount = 6;
|
||||
const jumpLength = Math.round(this.labels.length / labelCount);
|
||||
this.drawnLabels = this.labels.filter((_, index) => index % jumpLength === 0);
|
||||
this.drawnLabelsIndices = range({ max: this.drawnLabels.length });
|
||||
}
|
||||
|
||||
private indexFromValue(value: number): number {
|
||||
return Math.floor((value / this.MAX) * this.values.length);
|
||||
}
|
||||
|
||||
getOffset(index: number): string {
|
||||
const labelIndex = index / this.drawnLabels.length;
|
||||
const slider1Index = this.oneValue / this.MAX - 0.1;
|
||||
const slider2Index = this.otherValue / this.MAX - 0.1;
|
||||
|
||||
const dist = (a, b) => Math.abs(a - b);
|
||||
|
||||
const labelSliderDistance = Math.min(dist(labelIndex, slider1Index), dist(labelIndex, slider2Index));
|
||||
|
||||
const ACTIVE_ZONE = 0.2;
|
||||
const BASE_TRANSFORM = 'translateX(-50%) rotate(-45deg) translateY(100%)';
|
||||
|
||||
if (labelSliderDistance > ACTIVE_ZONE) {
|
||||
return BASE_TRANSFORM;
|
||||
}
|
||||
|
||||
return `translateY(${Math.pow((ACTIVE_ZONE - labelSliderDistance) / ACTIVE_ZONE, 1) * 30}px) ${BASE_TRANSFORM}`;
|
||||
}
|
||||
|
||||
private emitValue() {
|
||||
this.range.emit({
|
||||
from:
|
||||
this.oneValue < this.otherValue
|
||||
? this.values[this.indexFromValue(this.oneValue)]
|
||||
: this.values[this.indexFromValue(this.otherValue)],
|
||||
to:
|
||||
this.oneValue < this.otherValue
|
||||
? this.values[this.indexFromValue(this.otherValue)]
|
||||
: this.values[this.indexFromValue(this.oneValue)]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<div class="select-add {{ onlyShadowBorder ? 'shadow-border' : '' }}" (click)="$event.stopPropagation()">
|
||||
<div #top class="top" (click)="!editMode && toggle()">
|
||||
<p [innerHTML]="selected ? selected : placeholder" *ngIf="!editMode || !selected; else editableSelected"></p>
|
||||
<ng-template #editableSelected>
|
||||
<input type="text" [value]="selected" (change)="changeOption(selected, $event)" />
|
||||
</ng-template>
|
||||
<img src="assets/arrow.svg" (click)="onArrowClick($event)" [className]="isOpen ? 'upside-down' : ''" alt="arrow" />
|
||||
</div>
|
||||
|
||||
<div class="bottom-container">
|
||||
<div #bottom class="bottom {{ isOpen ? 'open' : '' }}">
|
||||
<ng-container *ngIf="!editMode; else editableOthers">
|
||||
<p *ngFor="let option of otherOptions" [innerHTML]="option" (click)="select(option)"></p>
|
||||
</ng-container>
|
||||
<ng-template #editableOthers>
|
||||
<input
|
||||
type="text"
|
||||
*ngFor="let option of otherOptions"
|
||||
[value]="option"
|
||||
(change)="changeOption(option, $event)"
|
||||
/>
|
||||
</ng-template>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
*ngIf="options.length <= maxItemCount"
|
||||
[placeholder]="newValuePlaceholder"
|
||||
[(ngModel)]="newOption"
|
||||
(keyup)="handleKeys($event)"
|
||||
/>
|
||||
|
||||
<div class="buttons">
|
||||
<button *ngIf="options.length <= maxItemCount" (click)="addNewOption()" [disabled]="!newOption">Add</button>
|
||||
<div *ngIf="editable" class="edit {{ editMode ? 'active' : '' }}" (click)="editMode = !editMode">
|
||||
<img src="assets/pen.svg" alt="edit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="background {{ isOpen || alwaysDropShadow ? 'active' : '' }}"
|
||||
[ngStyle]="{ height: backgroundHeight }"
|
||||
></div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
@import '../../../../styles';
|
||||
|
||||
$inner-padding: var(--medium-padding);
|
||||
.select-add {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.top,
|
||||
.bottom {
|
||||
padding: $inner-padding;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
p,
|
||||
input[type='text'] {
|
||||
display: inline-block;
|
||||
@include sub-title-text();
|
||||
}
|
||||
|
||||
img {
|
||||
@include square(16px);
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
&.upside-down {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-container {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
|
||||
position: absolute;
|
||||
overflow-y: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
||||
pointer-events: all;
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
|
||||
padding-top: 0;
|
||||
|
||||
@include inner-spacing($inner-padding);
|
||||
|
||||
transform: translateY(-100%);
|
||||
visibility: hidden;
|
||||
|
||||
&.open {
|
||||
visibility: visible;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
transition: transform $long-animation-time;
|
||||
|
||||
p {
|
||||
@include sub-title-text();
|
||||
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
height: 32px;
|
||||
@media (max-width: $mobile-width) {
|
||||
height: 24px;
|
||||
}
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
}
|
||||
|
||||
.edit {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin: 0;
|
||||
opacity: 0.25;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
@include square(16px);
|
||||
}
|
||||
|
||||
transition: opacity $short-animation-time;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: calc(-1 * #{$line-height});
|
||||
left: 0;
|
||||
height: $line-height;
|
||||
background-color: $text-color;
|
||||
width: 0;
|
||||
transition: width $long-animation-time;
|
||||
}
|
||||
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:hover {
|
||||
&:before {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
&:before {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit {
|
||||
}
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@include card();
|
||||
|
||||
z-index: 3;
|
||||
|
||||
transition: box-shadow $long-animation-time, height $long-animation-time;
|
||||
|
||||
&.active {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@media (min-width: $mobile-width) {
|
||||
.background {
|
||||
box-shadow: $shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border {
|
||||
.background.active {
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
|
||||
&.shadow-border:hover {
|
||||
.background {
|
||||
box-shadow: $shadow-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core';
|
||||
import { CancelService } from '../../../services/cancel.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-select-add',
|
||||
templateUrl: './select-add.component.html',
|
||||
styleUrls: ['./select-add.component.scss']
|
||||
})
|
||||
export class SelectAddComponent {
|
||||
@Input() placeholder = 'Add a new value…';
|
||||
@Input() newValuePlaceholder = 'Add a value…';
|
||||
@Input() maxItemCount = 7;
|
||||
@Input() options: string[];
|
||||
@Input() alwaysDropShadow = false;
|
||||
@Input() onlyShadowBorder = false;
|
||||
@Input() editable = false;
|
||||
|
||||
@Input() set default(value: string) {
|
||||
this.selected = value;
|
||||
}
|
||||
|
||||
backgroundHeight: string;
|
||||
|
||||
private _editMode = false;
|
||||
set editMode(value: boolean) {
|
||||
this._editMode = value;
|
||||
this.backgroundHeight = this.getBackgroundHeight();
|
||||
}
|
||||
|
||||
get editMode(): boolean {
|
||||
return this._editMode;
|
||||
}
|
||||
|
||||
@Output() value: EventEmitter<string> = new EventEmitter();
|
||||
@Output() optionChange: EventEmitter<{ from: string; to: string }> = new EventEmitter();
|
||||
|
||||
@ViewChild('top') top: ElementRef;
|
||||
@ViewChild('bottom') bottom: ElementRef;
|
||||
|
||||
selected: string;
|
||||
newOption: string;
|
||||
isOpen = false;
|
||||
|
||||
constructor(private cancelService: CancelService, private changeDetection: ChangeDetectorRef) {
|
||||
this.cancelService.subscribe(this, () => {
|
||||
this.isOpen = false;
|
||||
this.editMode = false;
|
||||
this.changeDetection.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
changeOption(from: string, event) {
|
||||
// console.log(event);
|
||||
this.optionChange.emit({
|
||||
from,
|
||||
to: event.target.value
|
||||
});
|
||||
}
|
||||
|
||||
get otherOptions(): string[] {
|
||||
return this.options.filter(a => a !== this.selected);
|
||||
}
|
||||
|
||||
handleKeys(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
this.addNewOption();
|
||||
}
|
||||
}
|
||||
|
||||
addNewOption() {
|
||||
if (this.newOption) {
|
||||
this.select(this.newOption);
|
||||
this.newOption = '';
|
||||
}
|
||||
}
|
||||
|
||||
select(option: string) {
|
||||
this.selected = option;
|
||||
this.value.emit(this.selected);
|
||||
this.toggle();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
if (!this.isOpen) {
|
||||
this.editMode = false;
|
||||
}
|
||||
this.backgroundHeight = this.getBackgroundHeight();
|
||||
}
|
||||
|
||||
onArrowClick(event) {
|
||||
if (this.editMode) {
|
||||
this.toggle();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private getBackgroundHeight(): string {
|
||||
if (this.isOpen && this.top && this.bottom) {
|
||||
const topHeight = this.top.nativeElement.clientHeight;
|
||||
const bottomHeight = this.bottom.nativeElement.clientHeight;
|
||||
// console.log(topHeight, bottomHeight);
|
||||
return `${topHeight + bottomHeight}px`;
|
||||
}
|
||||
return `100%`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<span [className]="!on ? 'active' : ''" (click)="on = false" [innerText]="beforeText"></span>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" [(ngModel)]="on" [className]="on ? 'on' : ''" />
|
||||
</label>
|
||||
|
||||
<span [className]="on ? 'active' : ''" (click)="on = true" [innerText]="afterText"></span>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
@import '../../../../styles';
|
||||
|
||||
:host {
|
||||
$size: 30px;
|
||||
|
||||
@include center-child();
|
||||
@include inner-spacing(var(--medium-padding), $horizontal: true);
|
||||
|
||||
span {
|
||||
@include medium-text();
|
||||
max-width: 3 * $size;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
text-align: right;
|
||||
}
|
||||
&:last-of-type {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
|
||||
input[type='checkbox'] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
|
||||
width: 2 * $size;
|
||||
height: $size;
|
||||
|
||||
border-radius: 1000px;
|
||||
box-shadow: $shadow-border;
|
||||
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 0;
|
||||
|
||||
@include square($size);
|
||||
|
||||
border-radius: 1000px;
|
||||
background-color: $text-color;
|
||||
|
||||
transition: box-shadow $long-animation-time, left $long-animation-time, transform $long-animation-time;
|
||||
}
|
||||
|
||||
&.on:after {
|
||||
left: $size;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@media (min-width: $mobile-width) {
|
||||
&:hover:after {
|
||||
box-shadow: $shadow;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
&.on:hover:after {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toggle',
|
||||
templateUrl: './toggle.component.html',
|
||||
styleUrls: ['./toggle.component.scss']
|
||||
})
|
||||
export class ToggleComponent {
|
||||
@Input() beforeText: string;
|
||||
@Input() afterText: string;
|
||||
|
||||
@Output() value: EventEmitter<boolean> = new EventEmitter();
|
||||
|
||||
@Input() set default(value: boolean) {
|
||||
this._on = value;
|
||||
}
|
||||
|
||||
private _on = false;
|
||||
set on(value: boolean) {
|
||||
this._on = value;
|
||||
this.value.emit(value);
|
||||
}
|
||||
|
||||
get on(): boolean {
|
||||
return this._on;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue