Merge with store

This commit is contained in:
Andras Schmelczer 2026-05-28 08:42:34 +01:00
commit 3ad2766f82
128 changed files with 1185 additions and 0 deletions

13
frontend/.editorconfig Normal file
View file

@ -0,0 +1,13 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

48
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,48 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events.json
speed-measure-plugin.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.firebase

8
frontend/.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"printWidth": 120,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true
}

27
frontend/README.md Normal file
View file

@ -0,0 +1,27 @@
# Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 7.3.8.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

86
frontend/angular.json Normal file
View file

@ -0,0 +1,86 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.app.json",
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"scripts": [],
"es5BrowserSupport": true
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "20mb",
"maximumError": "50mb"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "frontend:build"
},
"configurations": {
"production": {
"browserTarget": "frontend:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "frontend:build"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"],
"exclude": ["**/node_modules/**"]
}
}
}
}
},
"defaultProject": "frontend"
}

13
frontend/firebase.json Normal file
View file

@ -0,0 +1,13 @@
{
"hosting": {
"public": "dist/frontend",
"site": "towers-schmelczer-dev",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

21387
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

51
frontend/package.json Normal file
View file

@ -0,0 +1,51 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --prod",
"format:fix": "pretty-quick --staged",
"precommit": "run-s format:fix lint",
"format:check": "prettier --config ./.prettierrc --list-different \"src/{app,environments,assets}/**/*{.ts,.js,.json,.css,.scss}\"",
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@angular/animations": "^7.2.15",
"@angular/cdk": "^7.3.7",
"@angular/common": "~7.2.0",
"@angular/compiler": "~7.2.0",
"@angular/core": "~7.2.0",
"@angular/forms": "~7.2.0",
"@angular/material": "^7.3.7",
"@angular/platform-browser": "~7.2.0",
"@angular/platform-browser-dynamic": "~7.2.0",
"@angular/router": "~7.2.0",
"core-js": "^2.5.4",
"ng": "^0.0.0",
"rxjs": "~6.3.3",
"tslib": "^1.10.0",
"uuid": "^3.3.3",
"zone.js": "~0.8.26"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.13.10",
"@angular/cli": "~7.3.8",
"@angular/compiler-cli": "~7.2.0",
"@angular/language-service": "~7.2.0",
"@types/jasmine": "~2.8.8",
"@types/jasminewd2": "~2.0.3",
"@types/node": "~8.9.4",
"codelyzer": "~4.5.0",
"husky": "^3.0.4",
"npm-run-all": "^4.1.5",
"prettier": "^1.18.2",
"pretty-quick": "^1.11.1",
"sass": "^1.32.5",
"ts-node": "~7.0.0",
"tslint": "~5.11.0",
"typescript": "~3.2.2"
}
}

View file

@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { PagesComponent } from './components/pages/pages.component';
const routes: Routes = [
{
path: '',
component: PagesComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {}

View file

@ -0,0 +1,4 @@
<main (click)="cancelService.cancelAll()">
<app-modal></app-modal>
<router-outlet></router-outlet>
</main>

View file

@ -0,0 +1,3 @@
main {
height: 100%;
}

View file

@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, DoCheck } from '@angular/core';
import { CancelService } from './services/cancel.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent implements DoCheck {
title = 'life';
constructor(public cancelService: CancelService) {
window.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape') {
this.cancelService.cancelAll();
}
});
}
ngDoCheck() {
// console.log('app change detection');
}
}

View file

@ -0,0 +1,51 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { PageComponent } from './components/pages/page/page.component';
import { TowerComponent } from './components/pages/page/tower/tower.component';
import { DoubleSliderComponent } from './components/shared/double-slider/double-slider.component';
import { PagesComponent } from './components/pages/pages.component';
import { SelectAddComponent } from './components/shared/select-add/select-add.component';
import { ModalComponent } from './components/modal/modal.component';
import { FormsModule } from '@angular/forms';
import { BlockComponent } from './components/pages/page/tower/block/block.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { SettingsComponent } from './components/modal/modals/settings/settings.component';
import { RemoveTowerComponent } from './components/modal/modals/remove-tower/remove-tower.component';
import { RemovePageComponent } from './components/modal/modals/remove-page/remove-page.component';
import { GetStartedComponent } from './components/modal/modals/get-started/get-started.component';
import { ToggleComponent } from './components/shared/toggle/toggle.component';
import { TasksComponent } from './components/pages/page/tower/tasks/tasks.component';
import { ColorPipe } from './pipes/color.pipe';
import { BlocksComponent } from './components/modal/modals/blocks/blocks.component';
import { FormatDatePipe } from './pipes/format-date.pipe';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
declarations: [
AppComponent,
PageComponent,
BlockComponent,
TowerComponent,
DoubleSliderComponent,
PagesComponent,
SelectAddComponent,
ModalComponent,
BlockComponent,
SettingsComponent,
RemoveTowerComponent,
RemovePageComponent,
GetStartedComponent,
ToggleComponent,
TasksComponent,
ColorPipe,
BlocksComponent,
FormatDatePipe
],
imports: [BrowserModule, AppRoutingModule, FormsModule, BrowserAnimationsModule, DragDropModule, HttpClientModule],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}

View 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>

View 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);
}
}

View 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();
}
});
}
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -0,0 +1,3 @@
<p>
get-started works!
</p>

View file

@ -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() {}
}

View file

@ -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>

View file

@ -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();
}
}
}

View file

@ -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) {}
}

View file

@ -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>

View file

@ -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();
}
}
}

View file

@ -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;
}

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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();
}
}
}

View 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>

View 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);
}
}
}

View 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
}
}
}

View file

@ -0,0 +1 @@
<div [ngStyle]="{ 'background-color': block.color | color }" (click)="$event.stopPropagation() || handleClick()"></div>

View file

@ -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();
}
}

View file

@ -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
}
}
}

View file

@ -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>
<!-- &#8203; is the zero width space -->
{{ tasks.length == 0 ? '&#8203;' : 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>

View file

@ -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;
}
}
}
}
}

View file

@ -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();
}
}
}

View file

@ -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>

View file

@ -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%;
}
}
}
}

View 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
}
}
}

View 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>

View 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;
}
}
}

View 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();
}
}
}

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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)]
});
}
}

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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%`;
}
}

View file

@ -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>

View file

@ -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);
}
}
}
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,5 @@
export interface IColor {
h: number;
s: number;
l: number;
}

View file

@ -0,0 +1,8 @@
import { IUnique } from './unique';
export interface IBlock extends IUnique {
created: Date;
tag: string;
isDone: boolean;
description: string;
}

View file

@ -0,0 +1,6 @@
import { IPage } from './page';
import { IUnique } from './unique';
export interface IData extends IUnique {
pages: Array<IPage>;
}

View file

@ -0,0 +1,13 @@
import { ITower } from './tower';
import { Range } from '../range';
import { IUnique } from './unique';
export interface IPage extends IUnique {
name: string;
towers: ITower[];
userData: {
hideCreateTowerButton: boolean;
defaultDateRange: Range<Date>;
};
}

View file

@ -0,0 +1,9 @@
import { IBlock } from './block';
import { IColor } from '../color';
import { IUnique } from './unique';
export interface ITower extends IUnique {
name: string;
blocks: IBlock[];
baseColor: IColor;
}

View file

@ -0,0 +1,3 @@
export interface IUnique {
id?: string;
}

View file

@ -0,0 +1,4 @@
export interface Range<T> {
from: T;
to: T;
}

View file

@ -0,0 +1,3 @@
export interface ISerializable {
serialize(referenceSerializer: (ref: object) => any): object;
}

View file

@ -0,0 +1,36 @@
import { IBlock } from '../interfaces/persistance/block';
import { InnerNode, InnerNodeState } from '../store/inner-node';
export interface BlockState extends IBlock, InnerNodeState {}
export class Block extends InnerNode implements IBlock, BlockState {
readonly tag: string;
readonly description: string;
readonly isDone: boolean;
readonly created: Date;
constructor(props: IBlock, referenceDeserializer: (from: any) => any = e => e) {
super([], props.id);
if (props.created.constructor.name !== 'Date') {
props.created = new Date(props.created);
}
this.tag = props.tag;
this.description = props.description;
this.isDone = props.isDone;
this.created = props.created;
}
changeKeys(props: Partial<BlockState>): this {
return super.changeKeys<BlockState>(props);
}
serialize(referenceSerializer: (ref: object) => any): IBlock {
return {
...super.serialize(referenceSerializer),
tag: this.tag,
description: this.description,
isDone: this.isDone,
created: this.created
};
}
}

View file

@ -0,0 +1,44 @@
import { InnerNode, InnerNodeState } from '../store/inner-node';
import { IData } from '../interfaces/persistance/data';
import { Page } from './page';
export interface DataState extends IData, InnerNodeState {}
export class Data extends InnerNode implements IData, DataState {
constructor(props: IData, referenceDeserializer: (from: any) => any = e => e) {
super(props.pages.map(p => new Page(referenceDeserializer(p), referenceDeserializer)), props.id);
}
get pages(): Array<Page> {
return this.children as Array<Page>;
}
addPage(name: string) {
const page = new Page({
name,
userData: {
hideCreateTowerButton: false,
defaultDateRange: {
from: null,
to: null
}
},
towers: []
});
this.addChildren([page]);
page.addTower();
}
removePage(page: Page) {
this.changeKeys<any>({
children: this.children.filter(c => c !== page)
});
}
serialize(referenceSerializer: (ref: object) => any): IData {
return {
...super.serialize(referenceSerializer),
pages: this.pages.map(referenceSerializer)
};
}
}

View file

@ -0,0 +1,95 @@
import { IPage } from '../interfaces/persistance/page';
import { Range } from '../interfaces/range';
import { Tower } from './tower';
import { InnerNode, InnerNodeState } from '../store/inner-node';
export interface PageState extends InnerNodeState, IPage {
towers: Array<Tower>;
}
export class Page extends InnerNode implements IPage, PageState {
readonly name: string;
readonly userData: {
hideCreateTowerButton: boolean;
defaultDateRange: Range<Date>;
};
constructor(props: IPage, referenceDeserializer: (from: any) => any = e => e) {
super(props.towers.map(t => new Tower(referenceDeserializer(t), referenceDeserializer)), props.id);
this.name = props.name;
this.userData = props.userData;
}
get towers(): Array<Tower> {
return this.children as Array<Tower>;
}
changeProps(props: Partial<PageState>): this {
if (props.hasOwnProperty('towers')) {
props.children = props.towers;
delete props.towers;
}
return this.changeKeys<PageState>(props);
}
setHideCreateTowerButton(value: boolean) {
this.changeProps({
userData: {
...this.userData,
hideCreateTowerButton: value
}
});
}
moveTower({ previousIndex, currentIndex }: { previousIndex: number; currentIndex: number }) {
if (previousIndex === currentIndex) {
return;
}
const towers = [...this.towers];
const tower = towers[previousIndex];
towers.splice(previousIndex, 1);
towers.splice(currentIndex, 0, tower);
this.changeProps({
towers
});
}
changeName(to: string) {
this.changeProps({
name: to
});
}
addTower(name = '') {
let hue;
do {
hue = Math.random() * 360;
} while (30 <= hue && hue <= 200);
this.addChildren([
new Tower({
name,
blocks: [],
baseColor: { h: hue, s: 100, l: 50 }
})
]);
}
removeTower(tower: Tower) {
this.changeProps({
towers: this.towers.filter(t => t !== tower)
});
}
serialize(referenceSerializer: (ref: object) => any): IPage {
return {
...super.serialize(referenceSerializer),
name: this.name,
userData: this.userData,
towers: this.towers.map(referenceSerializer)
};
}
}

View file

@ -0,0 +1,83 @@
import { ITower } from '../interfaces/persistance/tower';
import { lighten } from '../utils/color';
import { Block } from './block';
import { hash } from '../utils/hash';
import { IColor } from '../interfaces/color';
import { InnerNode, InnerNodeState } from '../store/inner-node';
export type ColoredBlock = Block & { color: IColor };
export interface TowerState extends ITower, InnerNodeState {
blocks: Array<Block>;
}
export class Tower extends InnerNode implements ITower, TowerState {
readonly name: string;
readonly baseColor: IColor;
tags: string[];
coloredBlocks: Array<ColoredBlock>;
constructor(props: ITower, referenceDeserializer: (from: any) => any = e => e) {
super(props.blocks.map(b => new Block(referenceDeserializer(b), referenceDeserializer)), props.id);
this.name = props.name;
this.baseColor = props.baseColor;
this.onAfterClone();
}
get blocks(): Array<Block> {
return this.children as Array<Block>;
}
changeKeys(props: Partial<TowerState>): this {
if (props.hasOwnProperty('blocks')) {
props.children = props.blocks;
delete props.blocks;
}
return super.changeKeys<TowerState>(props);
}
addBlock(props: { tag: string; description: string; isDone: boolean }) {
this.addChildren([
new Block({
created: new Date(),
...props
})
]);
}
changeName(name: string) {
this.changeKeys({ name });
}
getColorOfTag(tag: string): IColor {
return lighten((hash(tag) - 0.5) * 50, this.baseColor);
}
serialize(referenceSerializer: (ref: object) => any): ITower {
return {
...super.serialize(referenceSerializer),
name: this.name,
baseColor: this.baseColor,
blocks: this.blocks.map(referenceSerializer)
};
}
protected onAfterClone() {
this.blocks.sort((a, b) => {
return a.created.getTime() - b.created.getTime();
});
this.coloredBlocks = this.blocks.map(b => {
const coloredBlock = b as ColoredBlock;
coloredBlock.color = this.getColorOfTag(b.tag);
return coloredBlock;
});
this.tags = [];
for (const block of this.blocks) {
if (!this.tags.includes(block.tag)) {
this.tags.push(block.tag);
}
}
}
}

View file

@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';
import { IColor } from '../interfaces/color';
import { toHslString } from '../utils/color';
@Pipe({
name: 'color'
})
export class ColorPipe implements PipeTransform {
transform(color: IColor, args?: any): string {
return toHslString(color);
}
}

View file

@ -0,0 +1,49 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'formatDate',
pure: false
})
export class FormatDatePipe implements PipeTransform {
transform(value: Date): string {
const now = new Date();
const years = Math.floor(now.getFullYear() - value.getFullYear());
const months = Math.floor(now.getMonth() - value.getMonth());
const days = Math.floor(now.getDay() - value.getDay());
const minutes = Math.floor(now.getMinutes() - value.getMinutes());
const seconds = Math.floor(now.getSeconds() - value.getSeconds());
if (years === 1) {
return 'a year ago';
} else if (years > 1) {
return `${years} years ago`;
}
if (months === 1) {
return 'a month ago';
} else if (months > 1) {
return `${months} months ago`;
}
if (days === 1) {
return 'a day ago';
} else if (days > 1) {
return `${days} days ago`;
}
if (minutes === 1) {
return 'a minute ago';
} else if (minutes > 1) {
return `${minutes} minutes ago`;
}
if (seconds === 1) {
return 'just now';
} else if (seconds > 1) {
return `${seconds} seconds ago`;
}
return 'just now';
}
}

View file

@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Unique } from '../store/unique';
const API_URI = 'https://store.schmelczer.dev/api/store/';
@Injectable({
providedIn: 'root'
})
export class ApiService {
constructor(private http: HttpClient) {}
private static getAuthorizationHeader(id: string): HttpHeaders {
return new HttpHeaders().set('Authorization', `life-towers-v3 ${id}`);
}
async track(id: string): Promise<void> {
await this.http.post(`${API_URI}me`, {}, { headers: ApiService.getAuthorizationHeader(id) }).toPromise();
}
async register(id: string): Promise<void> {
await this.http.post(API_URI, { token: id }).toPromise();
}
async getObject(userId: string, objectId: string): Promise<Unique> {
return await this.http
.get<Unique>(`${API_URI}me/${objectId}`, { headers: ApiService.getAuthorizationHeader(userId) })
.toPromise();
}
async postObject(userId: string, objectId: string, serializedObject: string): Promise<void> {
await this.http
.post(
`${API_URI}me/${objectId}`,
{ data: serializedObject },
{ headers: ApiService.getAuthorizationHeader(userId) }
)
.toPromise();
}
async getRootId(userId: string): Promise<string> {
return await this.http
// @ts-ignore
.get<string>(`${API_URI}me/root`, { headers: ApiService.getAuthorizationHeader(userId), responseType: 'text' })
.toPromise();
}
async setRootId(userId: string, rootId: string): Promise<void> {
await this.http
.put(`${API_URI}me/root`, { root_id: rootId }, { headers: ApiService.getAuthorizationHeader(userId) })
.toPromise();
}
}

View file

@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
interface Subscriber {
callback: () => void;
object: object;
}
@Injectable({
providedIn: 'root'
})
export class CancelService {
private subscribers: Subscriber[] = [];
subscribe(object: object, callback: () => void) {
this.subscribers.push({
object,
callback
});
}
cancelAllExcept(except: object) {
this.subscribers.filter(s => s.object !== except).map(s => s.callback());
}
cancelAll() {
this.subscribers.map(s => s.callback());
}
}

View file

@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
import { Root } from '../store/root';
import { MapStoreService } from './map-store.service';
import { Data } from '../model/data';
@Injectable({
providedIn: 'root'
})
export class DataService extends Root<Data> {
private shouldSave = true;
constructor(private store: MapStoreService) {
super();
this.store.data.subscribe(d => {
if (d) {
this.shouldSave = false;
this.changeKeys({ children: [d] });
}
});
this.children$.subscribe(_ => {
this.log();
});
this.children$.subscribe(data => {
if (data && data.length && data[0]) {
if (!this.shouldSave) {
this.shouldSave = true;
return;
}
this.store.save(data[0]);
}
});
}
}

View file

@ -0,0 +1,191 @@
import { Injectable } from '@angular/core';
import * as uuid from 'uuid';
import { Data } from '../model/data';
import { ITower } from '../interfaces/persistance/tower';
import { IPage } from '../interfaces/persistance/page';
import { IData } from '../interfaces/persistance/data';
import { IUnique } from '../interfaces/persistance/unique';
import { ApiService } from './api.service';
import { Unique } from '../store/unique';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Observable } from 'rxjs/internal/Observable';
const LOCAL_STORAGE_KEY = 'life-towers.data.v.3';
interface LifeTowersData {
token: string;
root: string;
objects: {
[id: string]: IUnique;
};
}
@Injectable({
providedIn: 'root'
})
export class MapStoreService {
private state: LifeTowersData;
private canSaveTrigger: () => void;
private canSave = new Promise(r => (this.canSaveTrigger = r));
private dataToSave: Data;
private saveEverything = false;
private readonly _data: BehaviorSubject<Data> = new BehaviorSubject(null);
readonly data: Observable<Data> = this._data.asObservable();
constructor(private api: ApiService) {
const storedData: string = localStorage.getItem(LOCAL_STORAGE_KEY);
if (storedData) {
this.state = JSON.parse(storedData);
this.initWithLoad().catch();
} else {
this.initWithRegister().catch();
}
this.api.track(this.state.token).catch();
}
private static getSeed(): LifeTowersData {
const towerId = uuid.v4();
const tower: ITower = {
id: towerId,
name: null,
blocks: [],
baseColor: { h: 0, s: 100, l: 50 }
};
const pageId = uuid.v4();
const page: IPage = {
id: pageId,
name: 'My first page',
towers: [towerId],
userData: {
hideCreateTowerButton: false,
defaultDateRange: {
from: null,
to: null
}
}
};
const dataId = uuid.v4();
const data: IData = {
id: dataId,
pages: [pageId]
};
return {
token: uuid.v4(),
root: dataId,
objects: {
[dataId]: data,
[pageId]: page,
[towerId]: tower
}
};
}
save(root: Data) {
this.dataToSave = root;
setTimeout(() => {
if (this.dataToSave === root) {
this.realSave(root).catch();
}
}, 750);
}
private get root(): Data {
return new Data(this.state.objects[this.state.root] as IData, id => this.state.objects[id]);
}
get userToken(): string {
return this.state.token;
}
set userToken(value: string) {
this.state.token = value;
this.initWithLoad().catch();
}
private async realSave(root: Data): Promise<void> {
await this.canSave;
const waiting: Array<Unique> = [root];
const referenceSerializer = (e: Unique): string => {
waiting.push(e);
return e.id;
};
while (waiting.length > 0) {
const candidate = waiting.pop();
if (!this.saveEverything && this.state.objects.hasOwnProperty(candidate.id)) {
continue;
}
const serialized = candidate.serialize(referenceSerializer);
this.state.objects[candidate.id] = serialized;
this.api.postObject(this.state.token, candidate.id, JSON.stringify(serialized)).catch();
}
this.api.setRootId(this.state.token, root.id).catch();
this.state.root = root.id;
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.state));
}
private async initWithRegister(id?: string) {
this.state = MapStoreService.getSeed();
if (id) {
this.state.token = id;
}
this._data.next(this.root);
await this.api.register(this.state.token).catch();
this.canSaveTrigger();
this.saveEverything = true;
await this.realSave(this.root);
this.saveEverything = false;
}
private async initWithLoad() {
this.canSave = new Promise(r => (this.canSaveTrigger = r));
let realRoot: string;
try {
realRoot = await this.api.getRootId(this.state.token).catch();
} catch {
this.initWithRegister(this.state.token).catch();
return;
}
if (this.state.root !== realRoot) {
this.state.root = realRoot;
const root = await this.api.getObject(this.state.token, realRoot);
this.state.objects[this.state.root] = root;
const getUnknowns = async (element: any) => {
const childrenAliases = ['pages', 'towers', 'blocks'];
for (const childrenAlias of childrenAliases) {
if (element.hasOwnProperty(childrenAlias)) {
for (const p of element[childrenAlias]) {
if (!this.state.objects.hasOwnProperty(p)) {
const unknown = await this.api.getObject(this.state.token, p);
this.state.objects[p] = unknown;
await getUnknowns(unknown);
}
}
}
}
};
await getUnknowns(root);
}
this._data.next(this.root);
this.canSaveTrigger();
}
}

View file

@ -0,0 +1,85 @@
import { Injectable } from '@angular/core';
import { Tower } from '../model/tower';
import { top } from '../utils/top';
import { CancelService } from './cancel.service';
import { Page } from '../model/page';
import { Observable } from 'rxjs/internal/Observable';
import { Block } from '../model/block';
import { Data } from '../model/data';
export enum ModalType {
blocks,
settings,
removeTower,
removePage,
getStarted
}
interface Modal {
type: ModalType;
input: any;
resolve: (output: any) => void;
reject: () => void;
}
@Injectable({
providedIn: 'root'
})
export class ModalService {
private modalStack: Modal[] = [];
constructor(private cancelService: CancelService) {}
showBlocks(input: { tower$: Observable<Tower>; onlyDone: boolean; startBlock?: Block }): Promise<void> {
return this.createPromiseAndPushToStack(input, ModalType.blocks);
}
showSettings(options: { page$: Observable<Page>; data$: Observable<Data> }): Promise<void> {
return this.createPromiseAndPushToStack(options, ModalType.settings);
}
showRemoveTower(tower: Tower): Promise<void> {
return this.createPromiseAndPushToStack(tower, ModalType.removeTower);
}
showRemovePage(name: string): Promise<void> {
return this.createPromiseAndPushToStack(name, ModalType.removePage);
}
get active(): Modal {
return top(this.modalStack);
}
submit(output?: any) {
const modal = this.modalStack.pop();
if (modal) {
modal.resolve(output);
}
}
cancel() {
const modal = this.modalStack.pop();
if (modal) {
modal.reject();
}
}
private createPromiseAndPushToStack(input: any, type: ModalType): Promise<any> {
this.cancelService.cancelAll();
const modal = {
input,
type,
resolve: () => {},
reject: () => {}
};
const modalPromise = new Promise((resolve, reject) => {
modal.resolve = resolve;
modal.reject = reject;
});
this.modalStack.push(modal);
return modalPromise;
}
}

View file

@ -0,0 +1,89 @@
import { Node, NodeState } from './node';
export interface InnerNodeState extends NodeState {
dummy: any;
}
export class InnerNode extends Node implements InnerNodeState {
readonly dummy = 3;
parent: Node;
private nextVersion: this = null;
readonly children: Array<InnerNode>;
protected constructor(children: Array<InnerNode> = [], id?: string) {
super(children, id);
this.children = children;
}
get latestVersion(): this {
let version;
for (version = this; version.nextVersion !== null; version = version.nextVersion) {
// pass
}
return version;
}
addChildren(children: Array<InnerNode>): this {
return super.addChildren.call(this.latestVersion, children);
}
replaceChild(update: { oldValue: InnerNode; newValue: InnerNode }): this {
return super.replaceChild.call(this.latestVersion, update);
}
changeKeys<T extends NodeState>(props: Partial<T>): this {
if (this.nextVersion !== null) {
this.latestVersion.changeKeys(props);
}
let shouldClone = false;
for (const prop in props) {
// @ts-ignore
if (props.hasOwnProperty(prop) && props[prop] !== this[prop]) {
shouldClone = true;
break;
}
}
if (!shouldClone) {
return;
}
const clone = this.cloneWithChangedKeys(props);
clone.children.forEach(c => (c.parent = clone));
this.nextVersion = clone;
this.parent.replaceChild({
oldValue: this,
newValue: clone
});
return clone;
}
protected onAfterClone() {}
protected cloneWithChangedKeys<T extends NodeState>(props: Partial<T>): this {
const insides = Object.getOwnPropertyDescriptors(this);
for (const key in props) {
if (props.hasOwnProperty(key)) {
if (insides.hasOwnProperty(key)) {
insides[key].value = props[key];
} else {
// @ts-ignore
insides[key] = {
value: props[key]
};
}
}
}
const clone = Object.create(Object.getPrototypeOf(this), insides);
clone.setUniqueness();
clone.onAfterClone();
return clone;
}
}

View file

@ -0,0 +1,46 @@
import { Unique } from './unique';
import { InnerNode } from './inner-node';
export interface NodeState {
children: Array<InnerNode>;
}
export abstract class Node extends Unique implements NodeState {
abstract readonly children: Array<InnerNode>;
protected constructor(children: Array<InnerNode> = [], id?: string) {
super(id);
children.forEach(c => (c.parent = this));
}
protected abstract changeKeys<T extends NodeState>(props: Partial<T>): this;
addChildren(children: Array<InnerNode>): this {
return this.changeKeys<NodeState>({
children: [...this.children, ...children]
});
}
replaceChild({ oldValue, newValue }: { oldValue: InnerNode; newValue: InnerNode }): this {
if (oldValue === newValue) {
return this;
}
return this.changeKeys<NodeState>({
children: this.children.map(c => (c === oldValue ? newValue : c))
});
}
protected _log(indent = ''): string {
const basicInfo = `${indent} - ${this.constructor.name}, #${this.id}`;
let response = `${basicInfo}${' '.repeat(70 - basicInfo.length)}copies: ${this.copies}\n`;
for (const c of this.children) {
response += `${c._log(indent + ' ')}`;
}
return response;
}
public log() {
// console.log(this._log());
// console.log(`All in all, there are ${Unique.ObjectCount} objects.`);
}
}

View file

@ -0,0 +1,34 @@
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Observable } from 'rxjs/internal/Observable';
import { Node, NodeState } from './node';
import { InnerNode } from './inner-node';
export class Root<T extends InnerNode> extends Node {
readonly children$: Observable<Array<T>>;
private readonly _children: BehaviorSubject<Array<T>>;
constructor(children: Array<T> = []) {
super(children);
this._children = new BehaviorSubject(children);
this.children$ = this._children.asObservable();
}
get children(): Array<T> {
return this._children.getValue();
}
set children(value: Array<T>) {
this._children.next(value);
}
changeKeys<U extends NodeState>(props: Partial<U>): this {
if (props.hasOwnProperty('children')) {
// @ts-ignore
this.children = props.children;
for (const child of this.children) {
child.parent = this;
}
}
return this;
}
}

View file

@ -0,0 +1,45 @@
import * as uuid from 'uuid';
import { ISerializable } from '../interfaces/serializable';
import { IUnique } from '../interfaces/persistance/unique';
export class Unique implements ISerializable, IUnique {
private static count = 0;
constructor(id?: string) {
if (id) {
this._id = id;
// console.log('got id ' + id);
} else {
this.setUniqueness();
// console.log('unique ' + this.id);
}
}
static get ObjectCount(): number {
return Unique.count;
}
private _id: string;
get id(): string {
return this._id;
}
private _copies = 0;
get copies(): number {
return this._copies;
}
protected setUniqueness() {
this._id = uuid.v4();
Unique.count++;
this._copies++;
}
serialize(referenceSerializer: (ref: object) => any): object {
return {
id: this.id
};
}
}

View file

@ -0,0 +1,16 @@
import { IColor } from '../interfaces/color';
export const lighten = (by: number, { h, s, l }: IColor): IColor => {
let newL = l + by;
if (newL > 100) {
newL = 100;
} else if (newL < 0) {
newL = 0;
}
return { h, s, l: newL };
};
export const toHslString = ({ h, s, l }: IColor): string => {
return `hsl(${h}, ${s}%, ${l}%)`;
};

View file

@ -0,0 +1,12 @@
export const hash = (text: string): number => {
// Return number from [0, 1)
if (!text) {
return 0;
}
const hashValue = Array.prototype.reduce.call(
text, // tslint:disable-next-line:no-bitwise
(value, char) => ((value << 5) - value + (char.charCodeAt(0) as number)) | 0,
7
);
return hashValue / (Math.pow(2, 32) - 2) + 0.5;
};

View file

@ -0,0 +1,7 @@
export const range = ({ min = 0, max = Infinity, step = 1 }: { min?: number; max?: number; step?: number }) => {
return {
*[Symbol.iterator]() {
for (let i = min; i < max; yield i, i += step) {}
}
};
};

View file

@ -0,0 +1,3 @@
export const top = <T>(iterable: ArrayLike<T>): T => {
return iterable.length > 0 ? iterable[iterable.length - 1] : null;
};

View file

View file

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 292.362 292.362" style="enable-background:new 0 0 292.362 292.362;" xml:space="preserve" class=""><g><g>
<path d="M286.935,69.377c-3.614-3.617-7.898-5.424-12.848-5.424H18.274c-4.952,0-9.233,1.807-12.85,5.424 C1.807,72.998,0,77.279,0,82.228c0,4.948,1.807,9.229,5.424,12.847l127.907,127.907c3.621,3.617,7.902,5.428,12.85,5.428 s9.233-1.811,12.847-5.428L286.935,95.074c3.613-3.617,5.427-7.898,5.427-12.847C292.362,77.279,290.548,72.998,286.935,69.377z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
</g></g> </svg>

After

Width:  |  Height:  |  Size: 746 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 528.899 528.899" style="enable-background:new 0 0 528.899 528.899;" xml:space="preserve"><g><g>
<path d="M328.883,89.125l107.59,107.589l-272.34,272.34L56.604,361.465L328.883,89.125z M518.113,63.177l-47.981-47.981 c-18.543-18.543-48.653-18.543-67.259,0l-45.961,45.961l107.59,107.59l53.611-53.611 C532.495,100.753,532.495,77.559,518.113,63.177z M0.3,512.69c-1.958,8.812,5.998,16.708,14.811,14.565l119.891-29.069 L27.473,390.597L0.3,512.69z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
</g></g> </svg>

After

Width:  |  Height:  |  Size: 737 B

View file

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 31.059 31.059" style="enable-background:new 0 0 31.059 31.059;" xml:space="preserve" width="512px" height="512px" class=""><g><g>
<g>
<path d="M15.529,31.059C6.966,31.059,0,24.092,0,15.529C0,6.966,6.966,0,15.529,0 c8.563,0,15.529,6.966,15.529,15.529C31.059,24.092,24.092,31.059,15.529,31.059z M15.529,1.774 c-7.585,0-13.755,6.171-13.755,13.755s6.17,13.754,13.755,13.754c7.584,0,13.754-6.17,13.754-13.754S23.113,1.774,15.529,1.774z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
</g>
<g>
<path d="M21.652,16.416H9.406c-0.49,0-0.888-0.396-0.888-0.887c0-0.49,0.397-0.888,0.888-0.888h12.246 c0.49,0,0.887,0.398,0.887,0.888C22.539,16.02,22.143,16.416,21.652,16.416z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
</g>
<g>
<path d="M15.529,22.539c-0.49,0-0.888-0.397-0.888-0.887V9.406c0-0.49,0.398-0.888,0.888-0.888 c0.49,0,0.887,0.398,0.887,0.888v12.246C16.416,22.143,16.02,22.539,15.529,22.539z" data-original="#010002" class="active-path" data-old_color="#010002" fill="#5D576B"/>
</g>
</g></g> </svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,13 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 729.837 729.838" style="enable-background:new 0 0 729.837 729.838;" xml:space="preserve"><g><g>
<g>
<g>
<path d="M589.193,222.04c0-6.296,5.106-11.404,11.402-11.404S612,215.767,612,222.04v437.476c0,19.314-7.936,36.896-20.67,49.653 c-12.733,12.734-30.339,20.669-49.653,20.669H188.162c-19.315,0-36.943-7.935-49.654-20.669 c-12.734-12.734-20.669-30.313-20.669-49.653V222.04c0-6.296,5.108-11.404,11.403-11.404c6.296,0,11.404,5.131,11.404,11.404 v437.476c0,13.02,5.37,24.922,13.97,33.521c8.6,8.601,20.503,13.993,33.522,13.993h353.517c13.019,0,24.896-5.394,33.498-13.993 c8.624-8.624,13.992-20.503,13.992-33.498V222.04H589.193z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
<path d="M279.866,630.056c0,6.296-5.108,11.403-11.404,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.404,5.108,11.404,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
<path d="M376.323,630.056c0,6.296-5.107,11.403-11.403,11.403s-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.108-11.404,11.404-11.404s11.403,5.108,11.403,11.404V630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
<path d="M472.803,630.056c0,6.296-5.106,11.403-11.402,11.403c-6.297,0-11.404-5.107-11.404-11.403v-405.07 c0-6.296,5.107-11.404,11.404-11.404c6.296,0,11.402,5.108,11.402,11.404V630.056L472.803,630.056z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
<path d="M273.214,70.323c0,6.296-5.108,11.404-11.404,11.404c-6.295,0-11.403-5.108-11.403-11.404 c0-19.363,7.911-36.943,20.646-49.677C283.787,7.911,301.368,0,320.73,0h88.379c19.339,0,36.92,7.935,49.652,20.669 c12.734,12.734,20.67,30.362,20.67,49.654c0,6.296-5.107,11.404-11.403,11.404s-11.403-5.108-11.403-11.404 c0-13.019-5.369-24.922-13.97-33.522c-8.602-8.601-20.503-13.994-33.522-13.994h-88.378c-13.043,0-24.922,5.369-33.546,13.97 C278.583,45.401,273.214,57.28,273.214,70.323z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
<path d="M99.782,103.108h530.273c11.189,0,21.405,4.585,28.818,11.998l0.047,0.048c7.413,7.412,11.998,17.628,11.998,28.818 v29.46c0,6.295-5.108,11.403-11.404,11.403h-0.309H70.323c-6.296,0-11.404-5.108-11.404-11.403v-0.285v-29.175 c0-11.166,4.585-21.406,11.998-28.818l0.048-0.048C78.377,107.694,88.616,103.108,99.782,103.108L99.782,103.108z M630.056,125.916H99.782c-4.965,0-9.503,2.02-12.734,5.274L87,131.238c-3.255,3.23-5.274,7.745-5.274,12.734v18.056h566.361 v-18.056c0-4.965-2.02-9.503-5.273-12.734l-0.049-0.048C639.536,127.936,635.021,125.916,630.056,125.916z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
</g>
</g>
</g></g> </svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 212.982 212.982" style="enable-background:new 0 0 212.982 212.982;" xml:space="preserve" width="512px" height="512px" class=""><g><g id="Close">
<path d="M131.804,106.491l75.936-75.936c6.99-6.99,6.99-18.323,0-25.312 c-6.99-6.99-18.322-6.99-25.312,0l-75.937,75.937L30.554,5.242c-6.99-6.99-18.322-6.99-25.312,0c-6.989,6.99-6.989,18.323,0,25.312 l75.937,75.936L5.242,182.427c-6.989,6.99-6.989,18.323,0,25.312c6.99,6.99,18.322,6.99,25.312,0l75.937-75.937l75.937,75.937 c6.989,6.99,18.322,6.99,25.312,0c6.99-6.99,6.99-18.322,0-25.312L131.804,106.491z" data-original="#000000" class="active-path" data-old_color="#000000" fill="#5D576B"/>
</g></g> </svg>

After

Width:  |  Height:  |  Size: 816 B

11
frontend/src/browserslist Normal file
View file

@ -0,0 +1,11 @@
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
#
# For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11

View file

@ -0,0 +1,3 @@
export const environment = {
production: true
};

View file

@ -0,0 +1,16 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

BIN
frontend/src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

23
frontend/src/index.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Frontend</title>
<base href="/" />
<link
href="https://fonts.googleapis.com/css?family=Open+Sans+Condensed:300|Raleway&display=swap&subset=latin-ext"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#5d576b" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View file

@ -0,0 +1,32 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../coverage/frontend'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

View file

@ -0,0 +1,22 @@
@import 'common-variables';
$long-animation-time: 200ms;
$short-animation-time: 100ms;
@mixin gravitate {
cursor: pointer;
transition: box-shadow $long-animation-time, transform $long-animation-time;
&:hover {
box-shadow: $shadow;
transform: scale(1.1);
}
}
@mixin jump {
cursor: pointer;
transition: transform $long-animation-time;
&:hover {
transform: translateY(-2px);
}
}

View file

@ -0,0 +1,15 @@
$accent-color: #a2666f;
$text-color: #5d576b;
$light-color: #ffffff;
$background-gradient: linear-gradient(90deg, #fff9e07f 0, #ffd6d67f 100%);
$background-gradient-opaque: linear-gradient(90deg, #fffcf0 0, #ffebeb 100%);
$shadow: 0 0 1.5px 1.5px rgba(0, 0, 0, 0.1), 0 0 3px 2px rgba(0, 0, 0, 0.05);
$shadow-border: 0 0 0 0.75px rgba(0, 0, 0, 0.1);
$normal-font: 'Open Sans Condensed', sans-serif;
$title-font: 'Raleway', serif;
$mobile-width: 520px;
$min-height: 400px;

View file

@ -0,0 +1,84 @@
@import 'text';
@import 'animations';
textarea {
@include normal-text();
&:disabled {
background-color: $light-color;
}
display: block;
width: 100%;
height: 150px;
@media (max-width: $mobile-width) {
height: 100px;
}
resize: none;
box-sizing: border-box;
border: none;
}
input[type='text'] {
@include sub-title-text();
width: 100%;
background: transparent;
display: block;
border: 0;
&::placeholder {
color: inherit;
opacity: 0.6;
}
&:focus {
box-shadow: 0 1px $text-color;
}
}
button {
-webkit-appearance: none;
margin: 8px auto 0 auto;
user-select: none;
background: transparent;
border: 0;
@include medium-text();
font-size: var(--large-font-size);
$height: 2px;
cursor: pointer;
border-bottom: solid $height #5d576b55;
position: relative;
&:disabled {
color: #5d576b55;
border-bottom: solid $height #5d576b33;
cursor: not-allowed;
}
&:not(:disabled):hover {
&:after {
width: 100%;
}
}
&:after {
content: '';
width: 0;
height: $height;
position: absolute;
left: 0;
bottom: calc(-1 * #{$height});
background-color: $text-color;
transition: width 300ms;
}
}
label {
display: none;
}

View file

@ -0,0 +1,68 @@
@import 'common-variables';
@import 'animations';
@import 'text';
@import 'spacing';
@import 'forms';
@import 'utils';
:root {
--border-radius: 5px;
--large-padding: 30px;
--medium-padding: 15px;
--small-padding: 10px;
@media (max-width: $mobile-width) {
--border-radius: 3px;
--large-padding: 20px;
--medium-padding: 15px;
--small-padding: 7.5px;
}
}
@mixin card {
border-radius: var(--border-radius);
background-color: $light-color;
}
@mixin center-child {
display: flex;
justify-content: center;
align-items: center;
}
@mixin exit {
@include square(16px);
background: url('/assets/x-sign.svg') no-repeat center center;
background-size: 50% 50%;
box-sizing: content-box;
padding: 8px;
@include jump();
}
img {
user-select: none;
}
*::-webkit-scrollbar {
width: 4px;
height: 4px;
}
*::-webkit-scrollbar-track {
box-shadow: $shadow-border;
border-radius: var(--border-radius);
}
*::-webkit-scrollbar-thumb {
background-color: $text-color;
border-radius: var(--border-radius);
cursor: pointer;
}
* {
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}

View file

@ -0,0 +1,9 @@
@mixin inner-spacing($spacing, $horizontal: false) {
& > *:not(:last-child) {
@if $horizontal {
margin-right: $spacing;
} @else {
margin-bottom: $spacing;
}
}
}

View file

@ -0,0 +1,57 @@
@import 'common-variables';
:root {
--larger-font-size: 22px;
--large-font-size: 18px;
--medium-font-size: 16px;
--small-font-size: 11px;
@media (max-width: $mobile-width) {
--larger-font-size: 20px;
--large-font-size: 16px;
--medium-font-size: 14px;
--small-font-size: 10px;
}
}
@mixin title-text {
font-family: $title-font;
color: $text-color;
font-size: var(--larger-font-size);
user-select: none;
}
@mixin sub-title-text {
font-family: $title-font;
color: $text-color;
font-size: var(--medium-font-size);
user-select: none;
}
@mixin normal-text {
font-family: $normal-font;
color: $text-color;
font-size: var(--larger-font-size);
}
@mixin medium-text {
font-family: $normal-font;
color: $text-color;
font-size: var(--medium-font-size);
}
@mixin small-text {
font-family: $normal-font;
color: $text-color;
font-size: var(--small-font-size);
}
h1,
h2,
h3 {
@include title-text();
}
p {
@include normal-text();
}

Some files were not shown because too many files have changed in this diff Show more