Migrate Angular 2 to Angular 19 with standalone components

- Upgrade Angular, Firebase, and AngularFire to latest versions
- Replace NgModules with standalone components and bootstrapApplication
- Replace angular-cli.json with angular.json and ESLint
- Add Firebase App Check with reCAPTCHA v3 for abuse protection
- Move Firebase config out of version control (firebaseConfig.example.ts)
- Fix AngularFire injection context errors using runInInjectionContext
- Implement drag-and-drop reordering within and across columns
- Add inline editing for card title and description
- Add subtask deletion with confirmation modal
- Refresh UI with modern card-based look and feel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 23:23:43 +03:00
parent 978619ff59
commit 189df85ca0
47 changed files with 18689 additions and 1023 deletions
+5 -1
View File
@@ -1,4 +1,4 @@
# Editor configuration, see http://editorconfig.org # Editor configuration, see https://editorconfig.org
root = true root = true
[*] [*]
@@ -8,6 +8,10 @@ indent_size = 2
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md] [*.md]
max_line_length = off max_line_length = off
trim_trailing_whitespace = false trim_trailing_whitespace = false
+24 -14
View File
@@ -1,35 +1,45 @@
# See http://help.github.com/ignore-files/ for more about ignoring files. # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# compiled output # Compiled output
/dist /dist
/tmp /tmp
/out-tsc
/bazel-out
# dependencies # Node
/node_modules /node_modules
/bower_components npm-debug.log
yarn-error.log
# IDEs and editors # IDEs and editors
/.idea .idea/
/.vscode
.project .project
.classpath .classpath
.c9/ .c9/
*.launch *.launch
.settings/ .settings/
*.sublime-workspace
# misc # Visual Studio Code
/.sass-cache .vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock /connect.lock
/coverage/* /coverage
/libpeerconnection.log /libpeerconnection.log
npm-debug.log
testem.log testem.log
/typings /typings
# e2e # Firebase config (contains secrets)
/e2e/*.js src/environments/firebaseConfig.ts
/e2e/*.map
#System Files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+4
View File
@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}
+20
View File
@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}
-60
View File
@@ -1,60 +0,0 @@
{
"project": {
"version": "1.0.0-beta.21",
"name": "kanban2"
},
"apps": [
{
"root": "src",
"outDir": "dist",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main.ts",
"test": "test.ts",
"tsconfig": "tsconfig.json",
"prefix": "app",
"mobile": false,
"styles": [
"styles.css",
"../node_modules/bootstrap/dist/css/bootstrap.min.css"
],
"scripts": [],
"environments": {
"source": "environments/environment.ts",
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
],
"addons": [],
"packages": [],
"e2e": {
"protractor": {
"config": "./protractor.conf.js"
}
},
"test": {
"karma": {
"config": "./karma.conf.js"
}
},
"defaults": {
"styleExt": "css",
"prefixInterfaces": false,
"inline": {
"style": false,
"template": false
},
"spec": {
"class": false,
"component": true,
"directive": true,
"module": false,
"pipe": true,
"service": true
}
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"kanban2": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/kanban2",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
},
"src/favicon.ico"
],
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "1.5MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "kanban2:build:production"
},
"development": {
"buildTarget": "kanban2:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"cli": {
"analytics": false
}
}
-14
View File
@@ -1,14 +0,0 @@
import { ValuationJsPage } from './app.po';
describe('kanban2 App', function() {
let page: ValuationJsPage;
beforeEach(() => {
page = new ValuationJsPage();
});
it('should display message saying app works', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('app works!');
});
});
-11
View File
@@ -1,11 +0,0 @@
import { browser, element, by } from 'protractor';
export class ValuationJsPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}
-16
View File
@@ -1,16 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"declaration": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../dist/out-tsc-e2e",
"sourceMap": true,
"target": "es5",
"typeRoots": [
"../node_modules/@types"
]
}
}
+44
View File
@@ -0,0 +1,44 @@
// @ts-check
const eslint = require("@eslint/js");
const { defineConfig } = require("eslint/config");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = defineConfig([
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
tseslint.configs.recommended,
tseslint.configs.stylistic,
angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase",
},
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
},
},
{
files: ["**/*.html"],
extends: [
angular.configs.templateRecommended,
angular.configs.templateAccessibility,
],
rules: {},
}
]);
-43
View File
@@ -1,43 +0,0 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/0.13/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', 'angular-cli'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-remap-istanbul'),
require('angular-cli/plugins/karma')
],
files: [
{ pattern: './src/test.ts', watched: false }
],
preprocessors: {
'./src/test.ts': ['angular-cli']
},
mime: {
'text/x-typescript': ['ts','tsx']
},
remapIstanbulReporter: {
reports: {
html: 'coverage',
lcovonly: './coverage/coverage.lcov'
}
},
angularCli: {
config: './angular-cli.json',
environment: 'dev'
},
reporters: config.angularCli && config.angularCli.codeCoverage
? ['progress', 'karma-remap-istanbul']
: ['progress'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
});
};
+17299
View File
File diff suppressed because it is too large Load Diff
+35 -39
View File
@@ -1,51 +1,47 @@
{ {
"name": "kanban2", "name": "kanban2",
"version": "0.0.0", "version": "0.0.0",
"license": "MIT",
"angular-cli": {},
"scripts": { "scripts": {
"ng": "ng",
"start": "ng serve", "start": "ng serve",
"lint": "tslint \"src/**/*.ts\"", "build": "ng build",
"test": "ng test", "watch": "ng build --watch --configuration development",
"pree2e": "webdriver-manager update", "test": "ng test"
"e2e": "protractor"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/common": "19.2.16", "@angular/animations": "^19.2.21",
"@angular/compiler": "2.4.7", "@angular/cdk": "^19.2.19",
"@angular/core": "10.2.5", "@angular/common": "^19.2.0",
"@angular/forms": "2.4.7", "@angular/compiler": "^19.2.0",
"@angular/http": "2.4.7", "@angular/core": "^19.2.0",
"@angular/platform-browser": "2.4.7", "@angular/fire": "^19.2.0",
"@angular/platform-browser-dynamic": "2.4.7", "@angular/forms": "^19.2.0",
"@angular/router": "3.4.7", "@angular/platform-browser": "^19.2.0",
"angularfire2": "^2.0.0-beta.7", "@angular/platform-browser-dynamic": "^19.2.0",
"bootstrap": "^4.1.3", "@angular/router": "^19.2.0",
"core-js": "2.4.1", "bootstrap": "^5.3.8",
"firebase": "10.9.0", "firebase": "^11.10.0",
"ng2-bootstrap": "^1.3.3", "rxjs": "~7.8.0",
"ng2-dnd": "^2.2.2", "tslib": "^2.3.0",
"rxjs": "5.0.3", "zone.js": "~0.15.0"
"ts-helpers": "1.1.2",
"zone.js": "0.7.6"
}, },
"devDependencies": { "devDependencies": {
"@angular/compiler-cli": "2.4.7", "@angular-devkit/build-angular": "^19.2.24",
"@types/jasmine": "2.5.38", "@angular/cli": "^19.2.24",
"@types/node": "^6.0.60", "@angular/compiler-cli": "^19.2.0",
"angular-cli": "1.0.0-beta.28.3", "@types/jasmine": "~5.1.0",
"codelyzer": "~1.0.0-beta.3", "angular-eslint": "^21.0.1",
"jasmine-core": "2.5.2", "jasmine-core": "~5.6.0",
"jasmine-spec-reporter": "2.5.0", "karma": "~6.4.0",
"karma": "6.3.16", "karma-chrome-launcher": "~3.2.0",
"karma-chrome-launcher": "2.0.0", "karma-coverage": "~2.2.0",
"karma-cli": "1.0.1", "karma-jasmine": "~5.1.0",
"karma-jasmine": "1.0.2", "karma-jasmine-html-reporter": "~2.1.0",
"karma-remap-istanbul": "0.2.1", "typescript": "~5.7.2"
"protractor": "4.0.13", },
"ts-node": "1.2.1", "overrides": {
"tslint": "4.2.0", "serialize-javascript": "^7.0.5",
"typescript": "~2.1.6" "tar": "^7.5.11"
} }
} }
-32
View File
@@ -1,32 +0,0 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/docs/referenceConf.js
/*global jasmine */
var SpecReporter = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./e2e/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
useAllAngular2AppRoots: true,
beforeLaunch: function() {
require('ts-node').register({
project: 'e2e'
});
},
onPrepare: function() {
jasmine.getEnv().addReporter(new SpecReporter());
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+31
View File
@@ -0,0 +1,31 @@
:host {
display: block;
height: 100%;
}
.board-header {
padding: 16px 0 12px;
border-bottom: 3px solid #5ba4cf;
margin-bottom: 0;
}
.board-header h1 {
font-size: 1.4rem;
font-weight: 600;
color: #172b4d;
margin: 0;
}
.board {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px 0;
overflow-x: auto;
height: calc(100% - 60px);
}
.board-col {
flex: 0 0 320px;
max-height: 100%;
}
+11 -11
View File
@@ -1,12 +1,12 @@
<div class="container"> <div class="container board-header">
<h1 class="page-header"> <h1>{{title}}</h1>
{{title}} </div>
</h1> <div class="container board">
<div class="row"> @for (cardlist of cardlists; track cardlist.$key) {
<div class="board-col">
<div *ngFor="let cardlist of cardlists" class="col-sm-4"> <app-cardlist [item]="cardlist"
<cardlist [item]="cardlist"> [connectedDropLists]="getConnectedDropLists(cardlist.$key!)">
</cardlist> </app-cardlist>
</div> </div>
</div> }
</div> </div>
+47 -58
View File
@@ -1,67 +1,56 @@
import {Component, OnInit} from "@angular/core"; import { Component, OnInit, inject } from '@angular/core';
import {DataService} from "app/shared/data.service"; import { DataService } from './shared/data.service';
import {Observable} from "rxjs"; import { Project } from './models/project-info';
import { CardList } from './models/cardlist-info';
import {Project} from "app/models/project-info"; import { CardListComponent } from './cardlist/cardlist.component';
import {CardList} from "app/models/cardlist-info";
import {Card} from "app/models/card-info";
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', standalone: true,
styleUrls: ['./app.component.css'] imports: [CardListComponent],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
title = 'The Kanban Board'; title = 'The Kanban Board';
projects: Project[]; projects: Project[] = [];
cardlists: CardList[]; cardlists: CardList[] = [];
constructor(private dataService: DataService) { private dataService = inject(DataService);
}
ngOnInit(){ ngOnInit(): void {
this.dataService.getProjects() this.dataService.getProjects()
.subscribe(data => { .subscribe(data => {
this.projects = data; this.projects = data;
let firstProject = this.projects[0]; });
//console.log(firstProject); this.dataService.getCardLists()
// this.addAddCardList( .subscribe(c => this.cardlists = c);
// 'Done', this.dataService.getCards();
// firstProject.$key, this.dataService.getTasks();
// 'green' }
// );
});
this.dataService.getCardLists()
.subscribe(c => this.cardlists = c)
;
this.dataService.getCards();
this.dataService.getTasks();
//this.addProject("TestProject1");
}
addProject(name: string) addProject(name: string): void {
{ const created_at = new Date().toString();
let created_at = new Date().toString(); const newProject = new Project();
let newProject:Project = new Project(); newProject.name = name;
newProject.name = name; newProject.created_at = created_at;
newProject.created_at= created_at; this.dataService.addProject(newProject);
this.dataService.addProject(newProject); }
}
addCardList( getConnectedDropLists(currentKey: string): string[] {
name: string, return this.cardlists
projectId: string, .filter(c => c.$key !== currentKey)
color: string, .map(c => c.$key!);
order: number) }
{
let created_at = new Date().toString();
let newCardList:CardList = new CardList();
newCardList.name = name;
newCardList.projectId = projectId;
newCardList.color = color;
newCardList.order = order;
newCardList.created_at = created_at;
this.dataService.addCardList(newCardList);
}
addCardList(name: string, projectId: string, color: string, order: number): void {
const created_at = new Date().toString();
const newCardList = new CardList();
newCardList.name = name;
newCardList.projectId = projectId;
newCardList.color = color;
newCardList.order = order;
newCardList.created_at = created_at;
this.dataService.addCardList(newCardList);
}
} }
-36
View File
@@ -1,36 +0,0 @@
import {BrowserModule} from "@angular/platform-browser";
import {NgModule} from "@angular/core";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {HttpModule} from "@angular/http";
import {AppComponent} from "./app.component";
import {authConfig, firebaseConfig} from "environments/firebaseConfig";
import {AngularFireModule} from "angularfire2";
import {AlertModule} from "ng2-bootstrap";
import {ModalModule} from 'ng2-bootstrap';
import {DataService} from "app/shared/data.service";
import {CardListComponent} from "app/cardlist/cardlist.component";
import {CardComponent} from "app/card/card.component";
import {DndModule} from 'ng2-dnd';
@NgModule({
declarations: [
AppComponent,
CardListComponent,
CardComponent
],
imports: [
BrowserModule,
FormsModule,
ReactiveFormsModule,
HttpModule,
AlertModule.forRoot(),
ModalModule.forRoot(),
DndModule.forRoot(),
AngularFireModule.initializeApp(firebaseConfig, authConfig)
],
providers: [DataService],
bootstrap: [AppComponent]
})
export class AppModule {
}
+231 -30
View File
@@ -1,38 +1,239 @@
.cardTitle{ /* Card Title */
margin-left: 10px; .cardTitle {
font-size: 1em; display: flex;
font-weight: bold; align-items: center;
gap: 6px;
font-size: 0.9rem;
font-weight: 600;
color: #172b4d;
} }
.cardDesc{
margin-left: 10px; .carret {
display: inline-block;
cursor: pointer;
color: #5e6c84;
font-size: 0.85rem;
padding: 2px;
border-radius: 3px;
} }
.tasklist{ .carret:hover {
margin: 10px; background-color: rgba(9, 30, 66, 0.08);
} }
.newtask
{ .titleText {
font-size: 0.8em; flex: 1;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
line-height: 1.3;
} }
.link{ .titleText:hover {
cursor: pointer; background-color: rgba(9, 30, 66, 0.08);
} }
.carret{
display: inline-block; .placeholder {
cursor: pointer; color: #b0b8c4;
font-style: italic;
font-weight: 400;
} }
.titleText{
display: inline-block; /* Card Description */
.cardDesc {
margin: 6px 0 0 18px;
font-size: 0.82rem;
color: #5e6c84;
cursor: pointer;
padding: 3px 5px;
border-radius: 4px;
line-height: 1.4;
} }
.cardDesc:hover {
background-color: rgba(9, 30, 66, 0.08);
}
/* Edit inputs */
.edit-input {
width: 100%;
padding: 5px 7px;
border: 2px solid #5ba4cf;
border-radius: 4px;
font-size: inherit;
font-family: inherit;
box-sizing: border-box;
outline: none;
}
.title-input {
font-weight: 600;
}
.desc-input {
resize: vertical;
margin-top: 6px;
}
/* Task list */
.tasklist {
margin: 8px 0 4px 18px;
}
.task-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 0;
font-size: 0.82rem;
color: #172b4d;
}
.task-item.completed .task-text {
text-decoration: line-through;
color: #b0b8c4;
}
.task-text {
flex: 1;
}
.task-trash {
color: #b0b8c4;
cursor: pointer;
font-size: 0.75rem;
padding: 2px 4px;
border-radius: 3px;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-item:hover .task-trash {
opacity: 1;
}
.task-trash:hover {
color: #e44;
}
/* New task input */
.newtask-input {
margin-top: 6px;
margin-left: 18px;
}
.newtask-input input {
border: none;
border-bottom: 1px solid #dfe1e6;
border-radius: 0;
font-size: 0.82rem;
padding: 4px 2px;
background: transparent;
width: 100%;
box-sizing: border-box;
}
.newtask-input input:focus {
border-bottom-color: #5ba4cf;
outline: none;
}
/* Delete Modal */
.modal-backdrop-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 100;
cursor: default;
}
.delete-modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 101;
pointer-events: none;
cursor: default;
}
.delete-modal-overlay .create-card-modal {
pointer-events: auto;
width: 340px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
overflow: hidden;
cursor: default;
}
.modal-header { .modal-header {
padding:9px 15px; display: flex;
border-bottom:1px solid #eee; align-items: center;
background-color: #ff4040; justify-content: space-between;
-webkit-border-top-left-radius: 5px; padding: 12px 16px;
-webkit-border-top-right-radius: 5px; background-color: #e44;
-moz-border-radius-topleft: 5px; border-top-left-radius: 12px;
-moz-border-radius-topright: 5px; border-top-right-radius: 12px;
border-top-left-radius: 5px; color: white;
border-top-right-radius: 5px; font-weight: bold;
color: white; }
font-weight: bold; .modal-header h4 {
} margin: 0;
font-size: 0.95rem;
}
.modal-header .close {
color: #fff;
opacity: 0.8;
font-size: 1rem;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s;
}
.modal-header .close:hover {
background: rgba(255, 255, 255, 0.35);
opacity: 1;
}
.modal-header .close:hover {
opacity: 1;
}
.modal-body p {
margin: 0 0 12px;
color: #172b4d;
font-size: 0.9rem;
}
.modal-actions {
display: flex;
gap: 8px;
}
.btn-delete {
background: #e44;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-delete:hover {
background: #d33;
}
.btn-cancel {
background: #f4f5f7;
color: #5e6c84;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-cancel:hover {
background: #ebecf0;
}
+64 -36
View File
@@ -1,48 +1,76 @@
<div class="cardTitle inline"> <div class="cardTitle">
<div class="carret" (click)="clickCarret()"> <div class="carret" (click)="clickCarret()" tabindex="0" role="button" (keydown.enter)="clickCarret()" (keydown.space)="clickCarret()">
<i class="fa fa-caret-right" *ngIf="!item.isExpanded"></i> @if (!item.isExpanded) {
<i class="fa fa-caret-down" *ngIf="item.isExpanded"></i> <i class="fa fa-caret-right"></i>
</div> }
<div class="titleText"> @if (item.isExpanded) {
{{item.name}} <i class="fa fa-caret-down"></i>
}
</div> </div>
@if (editingTitle) {
<input class="edit-input title-input"
[(ngModel)]="editTitle"
(blur)="saveTitle()"
(keydown.enter)="saveTitle()"
(keydown.escape)="cancelEditTitle()">
} @else {
<div class="titleText" tabindex="0" role="button" (click)="startEditTitle()" (keydown.enter)="startEditTitle()">
@if (item.name) {
{{item.name}}
} @else {
<span class="placeholder">Click to add title</span>
}
</div>
}
</div> </div>
<div *ngIf="item.isExpanded"> @if (item.isExpanded) {
<div class="cardDesc"> <div>
{{item.description}} @if (editingDesc) {
</div> <textarea class="edit-input desc-input"
<div class="tasklist"> [(ngModel)]="editDesc"
<div *ngFor="let task of tasks" class="newtask"> (blur)="saveDesc()"
<input type="checkbox" [(ngModel)]="task.isCompleted" (ngModelChange)="changeTaskCompleted(task)" class="inline-block"> (keydown.escape)="cancelEditDesc()"
<span class="inline-block"> rows="3">
{{task.description}} </textarea>
</span> } @else {
<span class="inline-block glyphicon glyphicon-trash link" (click)="deleteTask(task)"></span> <div class="cardDesc" tabindex="0" role="button" (click)="startEditDesc()" (keydown.enter)="startEditDesc()">
{{item.description}}
</div>
}
<div class="tasklist">
@for (task of tasks; track task.$key) {
<div class="task-item" [class.completed]="task.isCompleted">
<input type="checkbox" [(ngModel)]="task.isCompleted" (ngModelChange)="changeTaskCompleted(task)">
<span class="task-text">{{task.description}}</span>
<span class="task-trash fa fa-trash" (click)="deleteTask(task)" tabindex="0" role="button" (keydown.enter)="deleteTask(task)"></span>
</div>
}
</div>
<div class="newtask-input">
<form (submit)="addNewTask()">
<input type="text" id="newtask" placeholder="Add subtask and hit enter" [(ngModel)]="newtaskdesc" name="newtask">
</form>
</div> </div>
</div> </div>
<div class="newtask"> }
<form (submit)="addNewTask()">
<input type="text" class="form-control newtask" id="newtask" placeholder="Add subtask and hit enter" [(ngModel)]="newtaskdesc" name="newtask">
</form>
</div>
</div>
@if (showModal) {
<div class="modal-backdrop-overlay" (click)="hideModal()" (mousedown)="$event.stopPropagation()" tabindex="0" role="button" (keydown.enter)="hideModal()"></div>
<div bsModal #childModal="bs-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="mySmallModalLabel" aria-hidden="true"> <div class="delete-modal-overlay" (mousedown)="$event.stopPropagation()">
<div class="modal-dialog modal-sm"> <div class="create-card-modal">
<div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title pull-left">Delete task</h4> <h4 class="modal-title pull-left">Delete subtask</h4>
<button type="button" class="close pull-right" aria-label="Close" (click)="hideChildModal()"> <button type="button" class="close pull-right" aria-label="Close" (click)="hideModal()">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body" style="padding: 16px;">
Not implement!<br> <p>Delete "{{taskToDelete?.description}}"?</p>
Works as per specs!<br> <div class="modal-actions">
Carret is not carrot :) <button type="button" class="btn-delete" (click)="confirmDeleteTask()">Delete</button>
<button type="button" class="btn-cancel" (click)="hideModal()">Cancel</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> }
+102 -49
View File
@@ -1,65 +1,118 @@
import {Component, OnInit, Input, ViewChild} from "@angular/core"; import { Component, OnInit, Input, HostListener, inject } from '@angular/core';
import {DataService} from "app/shared/data.service"; import { FormsModule } from '@angular/forms';
import {Observable} from "rxjs"; import { DataService } from '../shared/data.service';
import {CardList} from "app/models/cardlist-info"; import { Card } from '../models/card-info';
import {Card} from "app/models/card-info"; import { Task } from '../models/task-info';
import {Task} from "app/models/task-info";
import { ModalDirective } from 'ng2-bootstrap/modal';
@Component({ @Component({
selector: 'card', selector: 'app-card',
templateUrl: './card.component.html', standalone: true,
styleUrls: ['./card.component.css'] imports: [FormsModule],
templateUrl: './card.component.html',
styleUrls: ['./card.component.css']
}) })
export class CardComponent implements OnInit { export class CardComponent implements OnInit {
@ViewChild('childModal') public childModal:ModalDirective; @Input() item!: Card;
@Input() item: Card; tasks: Task[] = [];
tasks : Task[] showModal = false;
taskToDelete: Task | null = null;
newtaskdesc = '';
newtaskdesc; editingTitle = false;
editTitle = '';
editingDesc = false;
editDesc = '';
private dataService = inject(DataService);
constructor(private dataService: DataService) { @HostListener('document:keydown.escape')
//console.log(this.item); onEscape(): void {
if (this.showModal) {
this.hideModal();
} }
}
ngOnInit() { ngOnInit(): void {
//console.log(this.item); this.dataService.getTasksByCardId(this.item.$key!)
this.dataService.getTasksByCardId(this.item.$key) .subscribe(data => {
.subscribe(data => { this.tasks = data;
this.tasks = data; });
}) }
}
addNewTask(){ startEditTitle(): void {
//console.log('Add new subtask!'); this.editTitle = this.item.name ?? '';
let newTask = new Task(); this.editingTitle = true;
newTask.cardId = this.item.$key; }
newTask.description = this.newtaskdesc;
newTask.isCompleted = false;
newTask.order = 0;
newTask.created_at = new Date().toString();
this.dataService.addTask(newTask)
.then(() => {
this.newtaskdesc = '';
});
}
deleteTask(task){ saveTitle(): void {
//console.log(task); if (!this.editingTitle) return;
this.childModal.show(); this.editingTitle = false;
} const trimmed = this.editTitle.trim();
public hideChildModal():void { if (trimmed && trimmed !== this.item.name) {
this.childModal.hide(); this.item.name = trimmed;
this.dataService.updateCard(this.item.$key!, this.item);
} }
}
changeTaskCompleted(task){ cancelEditTitle(): void {
//console.log(task); this.editingTitle = false;
this.dataService.updateTask(task.$key, task); }
}
clickCarret(){ startEditDesc(): void {
this.item.isExpanded = !this.item.isExpanded; this.editDesc = this.item.description ?? '';
this.dataService.updateCard(this.item.$key,this.item); this.editingDesc = true;
}
saveDesc(): void {
if (!this.editingDesc) return;
this.editingDesc = false;
const trimmed = this.editDesc.trim();
if (trimmed !== this.item.description) {
this.item.description = trimmed;
this.dataService.updateCard(this.item.$key!, this.item);
} }
}
cancelEditDesc(): void {
this.editingDesc = false;
}
addNewTask(): void {
const newTask = new Task();
newTask.cardId = this.item.$key!;
newTask.description = this.newtaskdesc;
newTask.isCompleted = false;
newTask.order = 0;
newTask.created_at = new Date().toString();
this.dataService.addTask(newTask)
.then(() => {
this.newtaskdesc = '';
});
}
deleteTask(task: Task): void {
this.taskToDelete = task;
this.showModal = true;
}
hideModal(): void {
this.showModal = false;
this.taskToDelete = null;
}
confirmDeleteTask(): void {
if (this.taskToDelete?.$key) {
this.dataService.deleteTask(this.taskToDelete.$key);
}
this.hideModal();
}
changeTaskCompleted(task: Task): void {
this.dataService.updateTask(task.$key!, task);
}
clickCarret(): void {
this.item.isExpanded = !this.item.isExpanded;
this.dataService.updateCard(this.item.$key!, this.item);
}
} }
+223 -78
View File
@@ -1,86 +1,231 @@
.card{ /* Column Panel */
background-color: lightblue; .column-panel {
border: solid 1px; background: #ebecf0;
border-left-width: 5px; border-radius: 12px;
margin: 15px; max-height: calc(100vh - 140px);
} display: flex;
.cardTitle{ flex-direction: column;
margin-left: 10px;
font-size: 1em;
}
.cardDesc{
margin-left: 10px;
}
.createCard{
padding: 10px;
/*background-color: lightcyan;*/
}
.fullScreen{
position: fixed;
width: 100%;
height: 100%;
left: 0;
top: 0;
clear: both;
background: black;
opacity: 0.3;
padding-left: 35%;
padding-right: 35%;
padding-top: 35%;
padding-bottom: 15%;
z-index: 100;
}
.fullScreentransparent{
position: absolute;
clear: both;
width: 100%;
height: 100%;
left: 0;
top: 0;
background: transparent;
z-index: 101;
}
.link{
cursor: pointer;
}
.createTitle{
font-weight: bold;
color: white;
}
.formfields{
margin-top: 10px;
}
.inline{
display: inline-block;
}
.listTitle{
font-weight: bold;
font-size: 1.2em;
} }
.column-header {
display: flex;
.dnd-drag-start { align-items: center;
-moz-transform:scale(0.8); justify-content: space-between;
-webkit-transform:scale(0.8); padding: 10px 12px 6px;
transform:scale(0.8); border-top: 3px solid #ccc;
opacity:0.7; border-radius: 12px 12px 0 0;
border: 2px dashed #000;
} }
.dnd-drag-enter { .column-header-left {
opacity:0.7; display: flex;
border: 2px dashed #000; align-items: center;
gap: 8px;
} }
.dnd-drag-over { .column-dot {
border: 2px dashed #000; font-size: 0.55rem;
} }
.dnd-sortable-drag { .column-title {
-moz-transform:scale(0.9); font-weight: 700;
-webkit-transform:scale(0.9); font-size: 0.95rem;
transform:scale(0.9); color: #172b4d;
opacity:0.7; }
border: 1px dashed #000;
} .column-count {
background: rgba(9, 30, 66, 0.08);
color: #5e6c84;
border-radius: 10px;
padding: 1px 8px;
font-size: 0.75rem;
font-weight: 600;
}
/* Add button */
.add-card-btn {
background: none;
border: none;
color: #5e6c84;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.85rem;
transition: background-color 0.15s, color 0.15s;
}
.add-card-btn:hover {
background-color: rgba(9, 30, 66, 0.08);
color: #172b4d;
}
/* Card list */
.card-drop-zone {
flex: 1;
overflow-y: auto;
padding: 4px 8px 8px;
min-height: 60px;
}
.card-list {
list-style: none;
margin: 0;
padding: 0;
}
.card-item {
background: #fff;
border-radius: 8px;
border-left-width: 4px;
border-left-style: solid;
padding: 8px 10px;
margin-bottom: 8px;
cursor: grab;
box-shadow: 0 1px 2px rgba(9, 30, 66, 0.1);
transition: box-shadow 0.15s;
}
.card-item:hover {
box-shadow: 0 4px 12px rgba(9, 30, 66, 0.15);
}
/* Create Card Modal */
.modal-backdrop-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 100;
}
.modal-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 101;
pointer-events: none;
}
.create-card-modal {
background: #fff;
border-radius: 12px;
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
overflow: hidden;
pointer-events: auto;
}
.modal-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
color: #fff;
}
.modal-title-text {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.modal-close-btn {
cursor: pointer;
opacity: 0.85;
font-size: 1.1rem;
}
.modal-close-btn:hover {
opacity: 1;
}
.modal-form {
padding: 16px;
}
.modal-form label {
font-size: 0.8rem;
font-weight: 600;
color: #5e6c84;
display: block;
margin-bottom: 4px;
}
.modal-form .form-control {
border: 2px solid #dfe1e6;
border-radius: 6px;
font-size: 0.9rem;
padding: 8px 10px;
transition: border-color 0.15s;
}
.modal-form .form-control:focus {
border-color: #5ba4cf;
box-shadow: 0 0 0 1px #5ba4cf;
outline: none;
}
.modal-form .form-group + .form-group {
margin-top: 12px;
}
.modal-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.btn-create {
background: #5ba4cf;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-create:hover {
background: #4a93be;
}
.btn-cancel {
background: #f4f5f7;
color: #5e6c84;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-cancel:hover {
background: #ebecf0;
}
/* CDK Drag-Drop */
.cdk-drop-list-receiving .card-drop-zone {
background-color: rgba(91, 164, 207, 0.1);
border: 2px dashed rgba(91, 164, 207, 0.5);
border-radius: 6px;
}
.cdk-drag-preview {
opacity: 0.9;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.cdk-drag-placeholder {
opacity: 0.2;
border: 2px dashed #dfe1e6;
border-radius: 8px;
background: #f4f5f7;
}
.cdk-drag-animating {
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drop-list-dragging .cdk-drag {
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
+52 -47
View File
@@ -1,53 +1,58 @@
<div class="panel panel-info" <div class="column-panel"
dnd-droppable cdkDropList
[allowDrop]="allowDropFunction()" [id]="item.$key!"
(onDropSuccess)="cardDropped($event)" [cdkDropListData]="cards"
> [cdkDropListConnectedTo]="connectedDropLists"
<div class="panel-heading"> (cdkDropListDropped)="onCardDrop($event)">
<div> <div class="column-header" [style.border-top-color]="item.color">
<i class="fa fa-1 fa-circle inline" aria-hidden="true" [style.color]="item.color"></i> <div class="column-header-left">
<span class="inline listTitle"> <i class="fa fa-circle column-dot" aria-hidden="true" [style.color]="item.color"></i>
{{item.name}} <span class="column-title">{{item.name}}</span>
</span> <span class="column-count">{{cards.length}}</span>
<button type="button" class="btn btn-default btn-xs inline" (click)="showAddCard()">
<i class="fa fa-plus"></i>
</button>
</div> </div>
<button type="button" class="add-card-btn" (click)="showAddCard()" title="Add card">
<i class="fa fa-plus"></i>
</button>
</div> </div>
<div class=""> <div class="card-drop-zone">
<ul class="list-group"> <ul class="card-list">
<li *ngFor="let card of cards" class="list-group-item panel card" [style.border-left-color]="item.color" @for (card of cards; track card.$key) {
dnd-draggable <li class="card-item"
[dragData]="card" [style.border-left-color]="item.color"
[dragEnabled]="allowDragFunction(card)" cdkDrag
> [cdkDragData]="card">
<card [item]="card"> <app-card [item]="card">
</card> </app-card>
</li> </li>
}
</ul> </ul>
</div> </div>
</div> </div>
<div class="fullScreen" *ngIf="toShowAddCard" (click)="cancelAddCard()"> @if (toShowAddCard) {
</div> <div class="modal-backdrop-overlay" (click)="cancelAddCard()" tabindex="0" role="button" (keydown.enter)="cancelAddCard()"></div>
<div class="fullScreentransparent" *ngIf="toShowAddCard"> <div class="modal-overlay">
<div class="panel panel-default createCard well"> <div class="create-card-modal">
<div class="panel-heading" [style.background-color]="item.color"> <div class="modal-header-bar" [style.background-color]="item.color">
<h4 class="createTitle">New task - {{ item.name }} <h4 class="modal-title-text">New task - {{ item.name }}</h4>
<div class="pull-right link" (click)="cancelAddCard()"> <div class="modal-close-btn" (click)="cancelAddCard()" tabindex="0" role="button" (keydown.enter)="cancelAddCard()">
<i class="fa fa-window-close"></i> <i class="fa fa-window-close"></i>
</div> </div>
</h4> </div>
</div> <div class="modal-form">
<div class="form-group formfields"> <div class="form-group">
<label for="taskname">Name</label> <label for="taskname">Name</label>
<input type="text" class="form-control" id="taskname" placeholder="task name" [(ngModel)]="cardname"> <input type="text" class="form-control" id="taskname" placeholder="Task name" [(ngModel)]="cardname">
<label for="taskdescription">Description</label> </div>
<textarea cols="39" rows="6" class="form-control" id="taskdescription" placeholder="description" [(ngModel)] = "carddescription"></textarea> <div class="form-group">
<!--<input type="text" class="form-control" id="taskdescription" placeholder="description" [(ngModel)]="carddescription">--> <label for="taskdescription">Description</label>
</div> <textarea class="form-control" id="taskdescription" rows="4" placeholder="Description" [(ngModel)]="carddescription"></textarea>
<div class="text-center"> </div>
<button type="button" class="btn btn-primary" (click)="saveAddCard()">CREATE</button> <div class="modal-actions">
</div> <button type="button" class="btn-create" (click)="saveAddCard()">Create</button>
</div> <button type="button" class="btn-cancel" (click)="cancelAddCard()">Cancel</button>
</div> </div>
</div>
</div>
</div>
}
+71 -98
View File
@@ -1,107 +1,80 @@
import {Component, OnInit, Input} from "@angular/core"; import { Component, OnInit, Input, Output, EventEmitter, inject } from '@angular/core';
import {DataService} from "app/shared/data.service"; import { FormsModule } from '@angular/forms';
import {Observable} from "rxjs"; import { CdkDragDrop, transferArrayItem, moveItemInArray, CdkDrag, CdkDropList } from '@angular/cdk/drag-drop';
import {CardList} from "app/models/cardlist-info"; import { DataService } from '../shared/data.service';
import {Card} from "app/models/card-info"; import { CardList } from '../models/cardlist-info';
import {Task} from "app/models/task-info"; import { Card } from '../models/card-info';
import { CardComponent } from '../card/card.component';
@Component({ @Component({
selector: 'cardlist', selector: 'app-cardlist',
templateUrl: './cardlist.component.html', standalone: true,
styleUrls: ['./cardlist.component.css'] imports: [FormsModule, CdkDropList, CdkDrag, CardComponent],
templateUrl: './cardlist.component.html',
styleUrls: ['./cardlist.component.css']
}) })
export class CardListComponent implements OnInit { export class CardListComponent implements OnInit {
@Input() item: CardList; @Input() item!: CardList;
cards : Card[] @Input() connectedDropLists: string[] = [];
@Output() cardDropped = new EventEmitter<void>();
cards: Card[] = [];
toShowAddCard:boolean; toShowAddCard = false;
editCard: Card; cardname = '';
cardname; carddescription = '';
carddescription;
allowedDropFrom = [];
allowedDragTo = false;
private dataService = inject(DataService);
constructor(private dataService: DataService) { ngOnInit(): void {
this.dataService.getCardsByListId(this.item.$key!)
.subscribe(data => {
this.cards = data;
});
}
showAddCard(): void {
this.cardname = '';
this.carddescription = '';
this.toShowAddCard = true;
}
cancelAddCard(): void {
this.toShowAddCard = false;
}
saveAddCard(): void {
this.addCard(this.cardname, this.carddescription, true, this.item.$key!, 0);
this.toShowAddCard = false;
}
addCard(name: string, description: string, isExpanded: boolean, cardListId: string, order: number): void {
const created_at = new Date().toString();
const newCard = new Card();
newCard.name = name;
newCard.description = description;
newCard.cardListId = cardListId;
newCard.isExpanded = isExpanded;
newCard.order = order;
newCard.created_at = created_at;
this.dataService.addCard(newCard);
}
onCardDrop(event: CdkDragDrop<Card[]>): void {
if (event.previousContainer === event.container) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
} }
// Update order for all cards in this list
ngOnInit() { this.cards.forEach((card, i) => {
this.dataService.getCardsByListId(this.item.$key) card.cardListId = this.item.$key!;
.subscribe(data => { card.order = i;
this.cards = data; this.dataService.updateCard(card.$key!, card);
}); });
//fill allowed drop-from containers }
this.dataService.getCardListsByOrder(this.item.order-1)
.subscribe(d => {
if(d.length>0)
this.allowedDropFrom.push(d[0].$key);
}
);
//fill if it has next containers
this.dataService.getCardListsByOrder(this.item.order+1)
.subscribe(d => {
if(d.length>0)
this.allowedDragTo = true;
}
);
}
showAddCard(){
this.cardname = '';
this.carddescription = '';
this.toShowAddCard = true;
}
cancelAddCard(){
this.toShowAddCard = false;
}
saveAddCard(){
//console.log('save card');
this.addCard(
this.cardname,
this.carddescription,
true,
this.item.$key,
0);
this.toShowAddCard = false;
}
addCard(
name: string,
description: string,
isExpanded: boolean,
cardListId: string,
order: number
)
{
let created_at = new Date().toString();
let newCard:Card = new Card();
newCard.name = name;
newCard.description = description;
newCard.cardListId = cardListId;
newCard.isExpanded = isExpanded;
newCard.order = order;
newCard.created_at = created_at;
this.dataService.addCard(newCard);
}
cardDropped(ev){
let card:Card = ev.dragData;
if(card.cardListId !== this.item.$key){
card.cardListId = this.item.$key;
this.dataService.updateCard(card.$key, card);
}
}
allowDragFunction(card: Card){
return this.allowedDragTo;
}
allowDropFunction(): any {
return (dragData: Card) => {
return this.allowedDropFrom.indexOf(dragData.cardListId) > -1;
};
}
} }
-2
View File
@@ -1,2 +0,0 @@
export * from './app.component';
export * from './app.module';
+7 -7
View File
@@ -1,9 +1,9 @@
export class Card { export class Card {
$key?: string; $key?: string;
name?: string; name?: string;
description?: string; description?: string;
cardListId?: string; cardListId?: string;
isExpanded?: boolean; isExpanded?: boolean;
order?: number; order?: number;
created_at?: string; created_at?: string;
} }
+6 -6
View File
@@ -1,8 +1,8 @@
export class CardList { export class CardList {
$key?: string; $key?: string;
name?: string; name?: string;
projectId?: string; projectId?: string;
color?: string; color?: string;
order?: number; order?: number;
created_at?: string; created_at?: string;
} }
+3 -3
View File
@@ -1,5 +1,5 @@
export class Project { export class Project {
$key?: string; $key?: string;
name?: string; name?: string;
created_at?: string; created_at?: string;
} }
+6 -6
View File
@@ -1,8 +1,8 @@
export class Task { export class Task {
$key?: string; $key?: string;
description?: string; description?: string;
isCompleted: boolean; isCompleted = false;
cardId?: string; cardId?: string;
order?: number; order?: number;
created_at?: string; created_at?: string;
} }
+118 -108
View File
@@ -1,122 +1,132 @@
import {Injectable, EventEmitter, Output} from "@angular/core"; import { Injectable, inject, Injector, runInInjectionContext } from '@angular/core';
import {AngularFire, FirebaseObjectObservable, FirebaseListObservable} from "angularfire2"; import { AngularFireDatabase, AngularFireList, QueryFn } from '@angular/fire/compat/database';
import {BehaviorSubject} from "rxjs/BehaviorSubject"; import { Observable } from 'rxjs';
import {Observable, Subject, ReplaySubject, AsyncSubject} from "rxjs"; import { map } from 'rxjs/operators';
import {Project} from "../models/project-info"; import { Project } from '../models/project-info';
import {CardList} from "../models/cardlist-info"; import { CardList } from '../models/cardlist-info';
import {Card} from "../models/card-info"; import { Card } from '../models/card-info';
import {Task} from "../models/task-info"; import { Task } from '../models/task-info';
@Injectable() @Injectable({
providedIn: 'root'
})
export class DataService { export class DataService {
projects: FirebaseListObservable<Project[]>;
cardlists: FirebaseListObservable<CardList[]>;
cards: FirebaseListObservable<Card[]>;
tasks: FirebaseListObservable<Task[]>;
constructor(private af: AngularFire) {
//console.log("DataService");
}
getProjects(){ private db = inject(AngularFireDatabase);
this.projects = this.af.database.list('/projects') as private injector = inject(Injector);
FirebaseListObservable<Project[]>;
return this.projects;
}
addProject(project) { private projectsRef: AngularFireList<Project>;
return this.projects.push(project); private cardlistsRef: AngularFireList<CardList>;
} private cardsRef: AngularFireList<Card>;
private tasksRef: AngularFireList<Task>;
constructor() {
this.projectsRef = this.db.list('/projects');
this.cardlistsRef = this.db.list('/cardlist', ref => ref.orderByChild('order'));
this.cardsRef = this.db.list('/cards');
this.tasksRef = this.db.list('/tasks');
}
private stripKey<T extends { $key?: string }>(obj: T): Omit<T, '$key'> {
const copy = { ...obj };
delete copy.$key;
return copy;
}
getCardLists(){ private queryList<T>(path: string, queryFn: QueryFn): Observable<T[]> {
this.cardlists = this.af.database.list('/cardlist',{ return runInInjectionContext(this.injector, () => {
query: { const ref = this.db.list(path, queryFn);
orderByChild: 'order' return ref.snapshotChanges().pipe(
}} map(changes =>
) as changes.map(c => ({ $key: c.payload.key, ...(c.payload.val() as object) } as T))
FirebaseListObservable<CardList[]>; )
return this.cardlists; );
} });
getCardListsById(cardListId:string): FirebaseObjectObservable<CardList> { }
return this.af.database.object(`/cardlist/${cardListId}`) as FirebaseObjectObservable<CardList>;
}
getCardListsByOrder(order:number): FirebaseListObservable<CardList[]> {
let _cardlist = this.af.database.list('/cardlist',{
query: {
orderByChild: 'order',
equalTo: order,
}}
) as FirebaseListObservable<CardList[]>;
return _cardlist;
}
getCachedCardListsById(cardListId:string):CardList {
return this.cardlists
.filter(d => d.$key == cardListId)
.map(d=> d.$key)
;
//.first();
}
getCardListsByProject(projectId: string){
let _cardlist = this.af.database.list('/cardlist',{
query: {
orderByChild: 'projectId',
equalTo: projectId,
}}
) as FirebaseListObservable<CardList[]>;
return _cardlist
}
addCardList(cardlist){
return this.cardlists.push(cardlist);
}
private snapshotsWithKey<T>(ref: AngularFireList<unknown>): Observable<T[]> {
return ref.snapshotChanges().pipe(
map(changes =>
changes.map(c => ({ $key: c.payload.key, ...(c.payload.val() as object) } as T))
)
);
}
// --- Projects ---
getProjects(): Observable<Project[]> {
return this.snapshotsWithKey<Project>(this.projectsRef);
}
getCards(){ addProject(project: Project) {
this.cards = this.af.database.list('/cards') as return this.projectsRef.push(this.stripKey(project));
FirebaseListObservable<Card[]>; }
return this.cards;
}
getCardsByListId(listId:string){
this.cards = this.af.database.list('/cards',{
query: {
orderByChild: 'cardListId',
equalTo: listId,
}}
) as
FirebaseListObservable<Card[]>;
return this.cards;
}
addCard(card){
return this.cards.push(card);
}
updateCard(key, updCard){
return this.cards.update(key, updCard);
}
// --- CardLists ---
getCardLists(): Observable<CardList[]> {
return this.snapshotsWithKey<CardList>(this.cardlistsRef);
}
getTasks(){ getCardListsById(cardListId: string): Observable<CardList | null> {
this.tasks = this.af.database.list('/tasks') as return this.db.object<CardList>(`/cardlist/${cardListId}`).snapshotChanges().pipe(
FirebaseListObservable<Task[]>; map(c => ({ $key: c.payload.key, ...c.payload.val() } as CardList))
return this.cards; );
} }
getTasksByCardId(cardId:string){
let _tasks = this.af.database.list('/tasks',{ getCardListsByOrder(order: number): Observable<CardList[]> {
query: { return this.queryList<CardList>('/cardlist', ref => ref.orderByChild('order').equalTo(order));
orderByChild: 'cardId', }
equalTo: cardId,
}} getCardListsByProject(projectId: string): Observable<CardList[]> {
) as FirebaseListObservable<Task[]>; return this.queryList<CardList>('/cardlist', ref => ref.orderByChild('projectId').equalTo(projectId));
return _tasks; }
}
addTask(task){ addCardList(cardlist: CardList) {
return this.tasks.push(task); return this.cardlistsRef.push(this.stripKey(cardlist));
} }
updateTask(key, updTask){
return this.tasks.update(key, updTask); // --- Cards ---
}
} getCards(): Observable<Card[]> {
return this.snapshotsWithKey<Card>(this.cardsRef);
}
getCardsByListId(listId: string): Observable<Card[]> {
return this.queryList<Card>('/cards', ref => ref.orderByChild('cardListId').equalTo(listId)).pipe(
map(cards => cards.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)))
);
}
addCard(card: Card) {
return this.cardsRef.push(this.stripKey(card));
}
updateCard(key: string, updCard: Card) {
return this.cardsRef.update(key, this.stripKey(updCard));
}
// --- Tasks ---
getTasks(): Observable<Task[]> {
return this.snapshotsWithKey<Task>(this.tasksRef);
}
getTasksByCardId(cardId: string): Observable<Task[]> {
return this.queryList<Task>('/tasks', ref => ref.orderByChild('cardId').equalTo(cardId));
}
addTask(task: Task) {
return this.tasksRef.push(this.stripKey(task));
}
updateTask(key: string, updTask: Task) {
return this.tasksRef.update(key, this.stripKey(updTask));
}
deleteTask(key: string) {
return runInInjectionContext(this.injector, () => {
return this.db.object('/tasks/' + key).remove();
});
}
}
View File
-5
View File
@@ -1,8 +1,3 @@
// The file contents for the current environment will overwrite these during build.
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `angular-cli.json`.
export const environment = { export const environment = {
production: false production: false
}; };
@@ -0,0 +1,9 @@
export const firebaseConfig = {
apiKey: '',
authDomain: '',
databaseURL: '',
storageBucket: '',
messagingSenderId: ''
};
export const recaptchaSiteKey = '';
-18
View File
@@ -1,18 +0,0 @@
import {AuthMethods, AuthProviders} from "angularfire2";
export const firebaseConfig = {
//get these from your created firebase project at https://console.firebase.google.com
// Paste all this from the Firebase console...
apiKey: "",
authDomain: "",
databaseURL: "",
storageBucket: "",
messagingSenderId: ""
};
export const authConfig = {
provider: AuthProviders.Password,
method: AuthMethods.Password
};
+2 -3
View File
@@ -1,15 +1,14 @@
<!doctype html> <!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>The Kanban Board</title> <title>The Kanban Board</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
</head> </head>
<body> <body>
<app-root>Loading...</app-root> <app-root></app-root>
</body> </body>
</html> </html>
+34 -6
View File
@@ -1,12 +1,40 @@
import './polyfills.ts'; import { bootstrapApplication } from '@angular/platform-browser';
import { enableProdMode, importProvidersFrom } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { enableProdMode } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AngularFireModule } from '@angular/fire/compat';
import { AngularFireDatabaseModule } from '@angular/fire/compat/database';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { initializeApp, getApps } from 'firebase/app';
import { initializeAppCheck, ReCaptchaV3Provider } from '@firebase/app-check';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { AppModule } from './app/'; import { firebaseConfig, recaptchaSiteKey } from './environments/firebaseConfig';
import { AppComponent } from './app/app.component';
if (environment.production) { if (environment.production) {
enableProdMode(); enableProdMode();
} }
platformBrowserDynamic().bootstrapModule(AppModule); // Initialize Firebase and App Check before Angular bootstrap
if (getApps().length === 0) {
const app = initializeApp(firebaseConfig);
if (recaptchaSiteKey) {
initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(recaptchaSiteKey),
isTokenAutoRefreshEnabled: true
});
}
}
bootstrapApplication(AppComponent, {
providers: [
importProvidersFrom(
BrowserAnimationsModule,
FormsModule,
ReactiveFormsModule,
AngularFireModule.initializeApp(firebaseConfig),
AngularFireDatabaseModule,
DragDropModule
)
]
}).catch(err => console.error(err));
-19
View File
@@ -1,19 +0,0 @@
// This file includes polyfills needed by Angular 2 and is loaded before
// the app. You can add your own extra polyfills to this file.
import 'core-js/es6/symbol';
import 'core-js/es6/object';
import 'core-js/es6/function';
import 'core-js/es6/parse-int';
import 'core-js/es6/parse-float';
import 'core-js/es6/number';
import 'core-js/es6/math';
import 'core-js/es6/string';
import 'core-js/es6/date';
import 'core-js/es6/array';
import 'core-js/es6/regexp';
import 'core-js/es6/map';
import 'core-js/es6/set';
import 'core-js/es6/reflect';
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';
+7 -1
View File
@@ -1 +1,7 @@
/* You can add global styles to this file, and also import other style files */ /* Global styles */
html, body {
height: 100%;
margin: 0;
background-color: #f4f5f7;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
-32
View File
@@ -1,32 +0,0 @@
import './polyfills.ts';
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare var __karma__: any;
declare var require: any;
// Prevent Karma from running prematurely.
__karma__.loaded = function () {};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
// Then we find all the tests.
let context = require.context('./', true, /\.spec\.ts/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"baseUrl": "",
"declaration": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"lib": ["es6", "dom"],
"mapRoot": "./",
"module": "es6",
"moduleResolution": "node",
"outDir": "../dist/out-tsc",
"sourceMap": true,
"target": "es5",
"typeRoots": [
"../node_modules/@types"
]
}
}
-2
View File
@@ -1,2 +0,0 @@
// Typings reference file, you can add your own global typings here
// https://www.typescriptlang.org/docs/handbook/writing-declaration-files.html
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}
+27
View File
@@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
-114
View File
@@ -1,114 +0,0 @@
{
"rulesDirectory": [
"node_modules/codelyzer"
],
"rules": {
"class-name": true,
"comment-format": [
true,
"check-space"
],
"curly": true,
"eofline": true,
"forin": true,
"indent": [
true,
"spaces"
],
"label-position": true,
"label-undefined": true,
"max-line-length": [
true,
140
],
"member-access": false,
"member-ordering": [
true,
"static-before-instance",
"variables-before-functions"
],
"no-arg": true,
"no-bitwise": true,
"no-console": [
true,
"debug",
"info",
"time",
"timeEnd",
"trace"
],
"no-construct": true,
"no-debugger": true,
"no-duplicate-key": true,
"no-duplicate-variable": true,
"no-empty": false,
"no-eval": true,
"no-inferrable-types": true,
"no-shadowed-variable": true,
"no-string-literal": false,
"no-switch-case-fall-through": true,
"no-trailing-whitespace": true,
"no-unused-expression": true,
"no-unused-variable": true,
"no-unreachable": true,
"no-use-before-declare": true,
"no-var-keyword": true,
"object-literal-sort-keys": false,
"one-line": [
true,
"check-open-brace",
"check-catch",
"check-else",
"check-whitespace"
],
"quotemark": [
true,
"single"
],
"radix": true,
"semicolon": [
"always"
],
"triple-equals": [
true,
"allow-null-check"
],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
}
],
"variable-name": false,
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type"
],
"directive-selector-prefix": [true, "app"],
"component-selector-prefix": [true, "app"],
"directive-selector-name": [true, "camelCase"],
"component-selector-name": [true, "kebab-case"],
"directive-selector-type": [true, "attribute"],
"component-selector-type": [true, "element"],
"use-input-property-decorator": true,
"use-output-property-decorator": true,
"use-host-property-decorator": true,
"no-input-rename": true,
"no-output-rename": true,
"use-life-cycle-interface": true,
"use-pipe-transform-interface": true,
"component-class-suffix": true,
"directive-class-suffix": true,
"templates-use-public": true,
"invoke-injectable": true
}
}