mirror of
https://github.com/richard-loafle/fuse-angular.git
synced 2025-12-23 02:17:06 +00:00
Compare commits
16 Commits
v16.0.0-st
...
v17.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1968227e19 | ||
|
|
28180c7491 | ||
|
|
de7a598c07 | ||
|
|
8ae3cdfbea | ||
|
|
533e39261b | ||
|
|
7870e312b9 | ||
|
|
1662042e35 | ||
|
|
08266c3e51 | ||
|
|
8000e53bae | ||
|
|
8fbef029c0 | ||
|
|
b7b849ee60 | ||
|
|
872dffe42b | ||
|
|
646b084a12 | ||
|
|
c88d30a4f4 | ||
|
|
f2d5bf3041 | ||
|
|
388456b937 |
@@ -1,16 +0,0 @@
|
|||||||
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
|
||||||
# For additional information regarding the format and rule options, please see:
|
|
||||||
# https://github.com/browserslist/browserslist#queries
|
|
||||||
|
|
||||||
# For the full list of supported browsers by the Angular framework, please see:
|
|
||||||
# https://angular.io/guide/browser-support
|
|
||||||
|
|
||||||
# You can see what browsers were selected by your queries by running:
|
|
||||||
# npx browserslist
|
|
||||||
|
|
||||||
last 1 Chrome version
|
|
||||||
last 1 Firefox version
|
|
||||||
last 2 Edge major versions
|
|
||||||
last 2 Safari major versions
|
|
||||||
last 2 iOS major versions
|
|
||||||
Firefox ESR
|
|
||||||
@@ -4,7 +4,7 @@ This project was generated with [Angular CLI](https://github.com/angular/angular
|
|||||||
|
|
||||||
## Development server
|
## 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.
|
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
|
||||||
|
|
||||||
## Code scaffolding
|
## Code scaffolding
|
||||||
|
|
||||||
|
|||||||
32
angular.json
32
angular.json
@@ -1,11 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
|
||||||
"schematicCollections": [
|
|
||||||
"@angular-eslint/schematics"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
"fuse": {
|
"fuse": {
|
||||||
@@ -25,7 +20,9 @@
|
|||||||
"outputPath": "dist/fuse",
|
"outputPath": "dist/fuse",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"polyfills": "src/polyfills.ts",
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
@@ -35,7 +32,6 @@
|
|||||||
"crypto-js/hmac-sha256",
|
"crypto-js/hmac-sha256",
|
||||||
"crypto-js/enc-base64",
|
"crypto-js/enc-base64",
|
||||||
"flat",
|
"flat",
|
||||||
"moment",
|
|
||||||
"quill"
|
"quill"
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
@@ -77,12 +73,6 @@
|
|||||||
"maximumError": "90kb"
|
"maximumError": "90kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fileReplacements": [
|
|
||||||
{
|
|
||||||
"replace": "src/environments/environment.ts",
|
|
||||||
"with": "src/environments/environment.prod.ts"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
@@ -117,10 +107,11 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"main": "src/test.ts",
|
"polyfills": [
|
||||||
"polyfills": "src/polyfills.ts",
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"karmaConfig": "karma.conf.js",
|
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon-16x16.png",
|
"src/favicon-16x16.png",
|
||||||
@@ -132,15 +123,6 @@
|
|||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": []
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"builder": "@angular-eslint/builder:lint",
|
|
||||||
"options": {
|
|
||||||
"lintFilePatterns": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.html"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
// 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'),
|
|
||||||
require('@angular-devkit/build-angular/plugins/karma')
|
|
||||||
],
|
|
||||||
client : {
|
|
||||||
jasmine : {
|
|
||||||
// you can add configuration options for Jasmine here
|
|
||||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
|
||||||
// for example, you can disable the random execution with `random: false`
|
|
||||||
// or set a specific seed with `seed: 4321`
|
|
||||||
},
|
|
||||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
|
||||||
},
|
|
||||||
jasmineHtmlReporter: {
|
|
||||||
suppressAll: true // removes the duplicated traces
|
|
||||||
},
|
|
||||||
coverageReporter : {
|
|
||||||
dir : require('path').join(__dirname, './coverage/fuse'),
|
|
||||||
subdir : '.',
|
|
||||||
reporters: [
|
|
||||||
{type: 'html'},
|
|
||||||
{type: 'text-summary'}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
reporters : ['progress', 'kjhtml'],
|
|
||||||
port : 9876,
|
|
||||||
colors : true,
|
|
||||||
logLevel : config.LOG_INFO,
|
|
||||||
autoWatch : true,
|
|
||||||
browsers : ['Chrome'],
|
|
||||||
singleRun : false,
|
|
||||||
restartOnFileChange: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
10577
package-lock.json
generated
10577
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
73
package.json
73
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fuse-angular",
|
"name": "fuse-angular",
|
||||||
"version": "16.0.0",
|
"version": "17.0.1",
|
||||||
"description": "Fuse - Angular Admin Template and Starter Project",
|
"description": "Fuse - Angular Admin Template and Starter Project",
|
||||||
"author": "https://themeforest.net/user/srcn",
|
"author": "https://themeforest.net/user/srcn",
|
||||||
"license": "https://themeforest.net/licenses/standard",
|
"license": "https://themeforest.net/licenses/standard",
|
||||||
@@ -10,71 +10,58 @@
|
|||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test"
|
||||||
"lint": "ng lint"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "14.2.4",
|
"@angular/animations": "15.0.0",
|
||||||
"@angular/cdk": "14.2.3",
|
"@angular/cdk": "15.0.0",
|
||||||
"@angular/common": "14.2.4",
|
"@angular/common": "15.0.0",
|
||||||
"@angular/compiler": "14.2.4",
|
"@angular/compiler": "15.0.0",
|
||||||
"@angular/core": "14.2.4",
|
"@angular/core": "15.0.0",
|
||||||
"@angular/forms": "14.2.4",
|
"@angular/forms": "15.0.0",
|
||||||
"@angular/material": "14.2.3",
|
"@angular/material": "15.0.0",
|
||||||
"@angular/material-luxon-adapter": "14.2.3",
|
"@angular/material-luxon-adapter": "15.0.0",
|
||||||
"@angular/platform-browser": "14.2.4",
|
"@angular/platform-browser": "15.0.0",
|
||||||
"@angular/platform-browser-dynamic": "14.2.4",
|
"@angular/platform-browser-dynamic": "15.0.0",
|
||||||
"@angular/router": "14.2.4",
|
"@angular/router": "15.0.0",
|
||||||
"@ngneat/transloco": "4.1.1",
|
"@ngneat/transloco": "4.1.1",
|
||||||
"apexcharts": "3.35.5",
|
"apexcharts": "3.36.3",
|
||||||
"crypto-js": "3.3.0",
|
"crypto-js": "3.3.0",
|
||||||
"highlight.js": "11.6.0",
|
"highlight.js": "11.6.0",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
"luxon": "3.0.4",
|
"luxon": "3.1.0",
|
||||||
"ng-apexcharts": "1.7.1",
|
"ng-apexcharts": "1.7.4",
|
||||||
"ngx-markdown": "13.1.0",
|
|
||||||
"ngx-quill": "19.0.1",
|
"ngx-quill": "19.0.1",
|
||||||
"perfect-scrollbar": "1.5.5",
|
"perfect-scrollbar": "1.5.5",
|
||||||
"quill": "1.3.7",
|
"quill": "1.3.7",
|
||||||
"rxjs": "7.5.7",
|
"rxjs": "7.5.7",
|
||||||
"tslib": "2.4.0",
|
"tslib": "2.4.1",
|
||||||
"zone.js": "0.11.8"
|
"zone.js": "0.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "14.2.4",
|
"@angular-devkit/build-angular": "15.0.0",
|
||||||
"@angular-eslint/builder": "14.1.2",
|
"@angular/cli": "15.0.0",
|
||||||
"@angular-eslint/eslint-plugin": "14.1.2",
|
"@angular/compiler-cli": "15.0.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "14.1.2",
|
|
||||||
"@angular-eslint/schematics": "14.1.2",
|
|
||||||
"@angular-eslint/template-parser": "14.1.2",
|
|
||||||
"@angular/cli": "14.2.4",
|
|
||||||
"@angular/compiler-cli": "14.2.4",
|
|
||||||
"@tailwindcss/line-clamp": "0.4.2",
|
"@tailwindcss/line-clamp": "0.4.2",
|
||||||
"@tailwindcss/typography": "0.5.7",
|
"@tailwindcss/typography": "0.5.8",
|
||||||
"@types/chroma-js": "2.1.4",
|
"@types/chroma-js": "2.1.4",
|
||||||
"@types/crypto-js": "3.1.47",
|
"@types/crypto-js": "3.1.47",
|
||||||
"@types/highlight.js": "10.1.0",
|
"@types/highlight.js": "10.1.0",
|
||||||
"@types/jasmine": "4.0.3",
|
"@types/jasmine": "4.3.0",
|
||||||
"@types/lodash": "4.14.186",
|
"@types/lodash": "4.14.189",
|
||||||
"@types/lodash-es": "4.17.6",
|
"@types/lodash-es": "4.17.6",
|
||||||
"@types/luxon": "3.0.1",
|
"@types/luxon": "3.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "5.39.0",
|
"autoprefixer": "10.4.13",
|
||||||
"@typescript-eslint/parser": "5.39.0",
|
|
||||||
"autoprefixer": "10.4.12",
|
|
||||||
"chroma-js": "2.4.2",
|
"chroma-js": "2.4.2",
|
||||||
"eslint": "8.24.0",
|
"jasmine-core": "4.5.0",
|
||||||
"eslint-plugin-import": "2.26.0",
|
|
||||||
"eslint-plugin-jsdoc": "39.3.6",
|
|
||||||
"eslint-plugin-prefer-arrow": "1.2.3",
|
|
||||||
"jasmine-core": "4.3.0",
|
|
||||||
"karma": "6.4.1",
|
"karma": "6.4.1",
|
||||||
"karma-chrome-launcher": "3.1.1",
|
"karma-chrome-launcher": "3.1.1",
|
||||||
"karma-coverage": "2.2.0",
|
"karma-coverage": "2.2.0",
|
||||||
"karma-jasmine": "5.1.0",
|
"karma-jasmine": "5.1.0",
|
||||||
"karma-jasmine-html-reporter": "2.0.0",
|
"karma-jasmine-html-reporter": "2.0.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"postcss": "8.4.17",
|
"postcss": "8.4.19",
|
||||||
"tailwindcss": "3.1.8",
|
"tailwindcss": "3.2.4",
|
||||||
"typescript": "4.7.4"
|
"typescript": "4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import { FuseConfirmationConfig } from '@fuse/services/confirmation/confirmation
|
|||||||
styles : [
|
styles : [
|
||||||
`
|
`
|
||||||
.fuse-confirmation-dialog-panel {
|
.fuse-confirmation-dialog-panel {
|
||||||
|
|
||||||
@screen md {
|
@screen md {
|
||||||
@apply w-128;
|
@apply w-128;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-dialog-container {
|
.mat-mdc-dialog-container {
|
||||||
padding: 0 !important;
|
|
||||||
|
.mat-mdc-dialog-surface {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -63,11 +63,15 @@
|
|||||||
-webkit-text-fill-color: currentColor;
|
-webkit-text-fill-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Set the background and foreground colors */
|
||||||
body, .dark, .light {
|
body, .dark, .light {
|
||||||
@apply text-default bg-default #{'!important'};
|
@apply text-default bg-default #{'!important'};
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
/* Set the border color */
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
--tw-border-opacity: 1 !important;
|
--tw-border-opacity: 1 !important;
|
||||||
border-color: rgba(var(--fuse-border-rgb), var(--tw-border-opacity));
|
border-color: rgba(var(--fuse-border-rgb), var(--tw-border-opacity));
|
||||||
|
|
||||||
@@ -116,6 +120,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Set the foreground color for disabled elements */
|
||||||
[disabled] * {
|
[disabled] * {
|
||||||
@apply text-disabled #{'!important'};
|
@apply text-disabled #{'!important'};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +1,164 @@
|
|||||||
@use '@angular/material' as mat;
|
|
||||||
@use "sass:map";
|
@use "sass:map";
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
@use "user-themes" as userThemes;
|
||||||
|
|
||||||
|
/* Set the base colors for light themes */
|
||||||
|
$light-base: (
|
||||||
|
foreground: (
|
||||||
|
base: #000000,
|
||||||
|
divider: #E2E8F0, /* slate.200 */
|
||||||
|
dividers: #E2E8F0, /* slate.200 */
|
||||||
|
disabled: #94A3B8, /* slate.400 */
|
||||||
|
disabled-button: #94A3B8, /* slate.400 */
|
||||||
|
disabled-text: #94A3B8, /* slate.400 */
|
||||||
|
elevation: #000000,
|
||||||
|
hint-text: #94A3B8, /* slate.400 */
|
||||||
|
secondary-text: #64748B, /* slate.500 */
|
||||||
|
icon: #64748B, /* slate.500 */
|
||||||
|
icons: #64748B, /* slate.500 */
|
||||||
|
mat-icon: #64748B, /* slate.500 */
|
||||||
|
text: #1E293B, /* slate.800 */
|
||||||
|
slider-min: #1E293B, /* slate.800 */
|
||||||
|
slider-off: #CBD5E1, /* slate.300 */
|
||||||
|
slider-off-active: #94A3B8 /* slate.400 */
|
||||||
|
),
|
||||||
|
background: (
|
||||||
|
status-bar: #CBD5E1, /* slate.300 */
|
||||||
|
app-bar: #FFFFFF,
|
||||||
|
background: #F1F5F9, /* slate.100 */
|
||||||
|
hover: rgba(148, 163, 184, 0.12), /* slate.400 + opacity */
|
||||||
|
card: #FFFFFF,
|
||||||
|
dialog: #FFFFFF,
|
||||||
|
disabled-button: rgba(148, 163, 184, 0.38), /* slate.400 + opacity */
|
||||||
|
raised-button: #FFFFFF,
|
||||||
|
focused-button: #64748B, /* slate.500 */
|
||||||
|
selected-button: #E2E8F0, /* slate.200 */
|
||||||
|
selected-disabled-button: #E2E8F0, /* slate.200 */
|
||||||
|
disabled-button-toggle: #CBD5E1, /* slate.300 */
|
||||||
|
unselected-chip: #E2E8F0, /* slate.200 */
|
||||||
|
disabled-list-option: #CBD5E1, /* slate.300 */
|
||||||
|
tooltip: #1E293B /* slate.800 */
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Set the base colors for dark themes */
|
||||||
|
$dark-base: (
|
||||||
|
foreground: (
|
||||||
|
base: #FFFFFF,
|
||||||
|
divider: rgba(241, 245, 249, 0.12), /* slate.100 + opacity */
|
||||||
|
dividers: rgba(241, 245, 249, 0.12), /* slate.100 + opacity */
|
||||||
|
disabled: #475569, /* slate.600 */
|
||||||
|
disabled-button: #1E293B, /* slate.800 */
|
||||||
|
disabled-text: #475569, /* slate.600 */
|
||||||
|
elevation: #000000,
|
||||||
|
hint-text: #64748B, /* slate.500 */
|
||||||
|
secondary-text: #94A3B8, /* slate.400 */
|
||||||
|
icon: #F1F5F9, /* slate.100 */
|
||||||
|
icons: #F1F5F9, /* slate.100 */
|
||||||
|
mat-icon: #94A3B8, /* slate.400 */
|
||||||
|
text: #FFFFFF,
|
||||||
|
slider-min: #FFFFFF,
|
||||||
|
slider-off: #64748B, /* slate.500 */
|
||||||
|
slider-off-active: #94A3B8 /* slate.400 */
|
||||||
|
),
|
||||||
|
background: (
|
||||||
|
status-bar: #0F172A, /* slate.900 */
|
||||||
|
app-bar: #0F172A, /* slate.900 */
|
||||||
|
background: #0F172A, /* slate.900 */
|
||||||
|
hover: rgba(255, 255, 255, 0.05),
|
||||||
|
card: #1E293B, /* slate.800 */
|
||||||
|
dialog: #1E293B, /* slate.800 */
|
||||||
|
disabled-button: rgba(15, 23, 42, 0.38), /* slate.900 + opacity */
|
||||||
|
raised-button: #0F172A, /* slate.900 */
|
||||||
|
focused-button: #E2E8F0, /* slate.200 */
|
||||||
|
selected-button: rgba(255, 255, 255, 0.05),
|
||||||
|
selected-disabled-button: #1E293B, /* slate.800 */
|
||||||
|
disabled-button-toggle: #0F172A, /* slate.900 */
|
||||||
|
unselected-chip: #475569, /* slate.600 */
|
||||||
|
disabled-list-option: #E2E8F0, /* slate.200 */
|
||||||
|
tooltip: #64748B /* slate.500 */
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
/* Include the core Angular Material styles */
|
/* Include the core Angular Material styles */
|
||||||
@include mat.core();
|
@include mat.core();
|
||||||
|
|
||||||
/* Create a base theme without color.
|
/* Create a base theme without any color to set the density and typography */
|
||||||
This will globally set the density and typography for all future color themes. */
|
|
||||||
@include mat.all-component-themes((
|
@include mat.all-component-themes((
|
||||||
color: null,
|
color: null,
|
||||||
density: -2,
|
density: 0,
|
||||||
typography: mat.define-typography-config(
|
typography: mat.define-typography-config(
|
||||||
$font-family: theme('fontFamily.sans'),
|
$font-family: theme('fontFamily.sans'),
|
||||||
$title: mat.define-typography-level(1.25rem, 2rem, 600),
|
$headline-1: mat.define-typography-level(1.875rem, 2.25rem, 800, theme('fontFamily.sans')),
|
||||||
$body-2: mat.define-typography-level(0.875rem, 1.5rem, 600),
|
$headline-2: mat.define-typography-level(1.25rem, 1.75rem, 700, theme('fontFamily.sans')),
|
||||||
$button: mat.define-typography-level(0.875rem, 0.875rem, 500),
|
$headline-3: mat.define-typography-level(1.125rem, 1.75rem, 600, theme('fontFamily.sans')),
|
||||||
$input: mat.define-typography-level(0.875rem, 1.2857142857, 400) /* line-height: 20px */
|
$headline-4: mat.define-typography-level(0.875rem, 1.25rem, 600, theme('fontFamily.sans')),
|
||||||
|
$headline-5: mat.define-typography-level(0.875rem, 1.5rem, 400, theme('fontFamily.sans')),
|
||||||
|
$headline-6: mat.define-typography-level(0.875rem, 1.5rem, 400, theme('fontFamily.sans')),
|
||||||
|
$subtitle-1: mat.define-typography-level(1rem, 1.75rem, 400, theme('fontFamily.sans')),
|
||||||
|
$subtitle-2: mat.define-typography-level(0.875rem, 1.25rem, 600, theme('fontFamily.sans')),
|
||||||
|
$body-1: mat.define-typography-level(0.875rem, 1.5rem, 400, theme('fontFamily.sans')),
|
||||||
|
$body-2: mat.define-typography-level(0.875rem, 1.5rem, 400, theme('fontFamily.sans')),
|
||||||
|
$caption: mat.define-typography-level(0.75rem, 1rem, 400, theme('fontFamily.sans')),
|
||||||
|
$button: mat.define-typography-level(0.875rem, 0.875rem, 500, theme('fontFamily.sans')),
|
||||||
|
$overline: mat.define-typography-level(0.75rem, 2rem, 500, theme('fontFamily.sans'))
|
||||||
)
|
)
|
||||||
));
|
));
|
||||||
|
|
||||||
/* Generate Primary, Accent and Warn palettes */
|
/* Loop through user themes and generate Angular Material themes */
|
||||||
$palettes: ();
|
@each $name, $theme in userThemes.$user-themes {
|
||||||
@each $name in (primary, accent, warn) {
|
|
||||||
$palettes: map.merge($palettes, (#{$name}: (
|
|
||||||
50: var(--fuse-#{$name}-50),
|
|
||||||
100: var(--fuse-#{$name}-100),
|
|
||||||
200: var(--fuse-#{$name}-200),
|
|
||||||
300: var(--fuse-#{$name}-300),
|
|
||||||
400: var(--fuse-#{$name}-400),
|
|
||||||
500: var(--fuse-#{$name}-500),
|
|
||||||
600: var(--fuse-#{$name}-600),
|
|
||||||
700: var(--fuse-#{$name}-700),
|
|
||||||
800: var(--fuse-#{$name}-800),
|
|
||||||
900: var(--fuse-#{$name}-900),
|
|
||||||
contrast: (
|
|
||||||
50: var(--fuse-on-#{$name}-50),
|
|
||||||
100: var(--fuse-on-#{$name}-100),
|
|
||||||
200: var(--fuse-on-#{$name}-200),
|
|
||||||
300: var(--fuse-on-#{$name}-300),
|
|
||||||
400: var(--fuse-on-#{$name}-400),
|
|
||||||
500: var(--fuse-on-#{$name}-500),
|
|
||||||
600: var(--fuse-on-#{$name}-600),
|
|
||||||
700: var(--fuse-on-#{$name}-700),
|
|
||||||
800: var(--fuse-on-#{$name}-800),
|
|
||||||
900: var(--fuse-on-#{$name}-900)
|
|
||||||
),
|
|
||||||
default: var(--fuse-#{$name}),
|
|
||||||
lighter: var(--fuse-#{$name}-100),
|
|
||||||
darker: var(--fuse-#{$name}-700),
|
|
||||||
text: var(--fuse-#{$name}),
|
|
||||||
default-contrast: var(--fuse-on-#{$name}),
|
|
||||||
lighter-contrast: var(--fuse-on-#{$name}-100),
|
|
||||||
darker-contrast: var(--fuse-on-#{$name}-700)
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Generate Angular Material themes. Since we are using CSS Custom Properties,
|
/* Generate the palettes */
|
||||||
we don't have to generate a separate Angular Material theme for each color
|
$palettes: ();
|
||||||
set. We can just create one light and one dark theme and then switch the
|
@each $name in (primary, accent, warn) {
|
||||||
CSS Custom Properties to dynamically switch the colors. */
|
|
||||||
body.light,
|
/* Define the Angular Material theme */
|
||||||
body .light {
|
$palette: mat.define-palette(map.get($theme, $name));
|
||||||
$base-light-theme: mat.define-light-theme((
|
|
||||||
|
/* Replace the default colors on the defined Material palette */
|
||||||
|
$palette: map.merge($palette, (
|
||||||
|
default: map.get(map.get($theme, $name), DEFAULT),
|
||||||
|
lighter: map.get(map.get($theme, $name), 100),
|
||||||
|
darker: map.get(map.get($theme, $name), 700),
|
||||||
|
text: map.get(map.get($theme, $name), DEFAULT),
|
||||||
|
default-contrast: map.get(map.get(map.get($theme, $name), contrast), DEFAULT),
|
||||||
|
lighter-contrast: map.get(map.get(map.get($theme, $name), contrast), 100),
|
||||||
|
darker-contrast: map.get(map.get(map.get($theme, $name), contrast), 700)
|
||||||
|
));
|
||||||
|
|
||||||
|
$palettes: map.merge($palettes, (#{$name}: $palette));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Define a light & dark Angular Material theme with the generated palettes */
|
||||||
|
$light-theme: mat.define-light-theme((
|
||||||
color: ($palettes)
|
color: ($palettes)
|
||||||
));
|
));
|
||||||
|
|
||||||
$light-theme: (
|
$dark-theme: mat.define-dark-theme((
|
||||||
color: (
|
|
||||||
primary: map.get(map.get($base-light-theme, color), primary),
|
|
||||||
accent: map.get(map.get($base-light-theme, color), accent),
|
|
||||||
warn: map.get(map.get($base-light-theme, color), warn),
|
|
||||||
is-dark: map.get(map.get($base-light-theme, color), is-dark),
|
|
||||||
foreground: (
|
|
||||||
base: #000000,
|
|
||||||
divider: #E2E8F0, /* slate.200 */
|
|
||||||
dividers: #E2E8F0, /* slate.200 */
|
|
||||||
disabled: #94A3B8, /* slate.400 */
|
|
||||||
disabled-button: #94A3B8, /* slate.400 */
|
|
||||||
disabled-text: #94A3B8, /* slate.400 */
|
|
||||||
elevation: #000000,
|
|
||||||
hint-text: #94A3B8, /* slate.400 */
|
|
||||||
secondary-text: #64748B, /* slate.500 */
|
|
||||||
icon: #64748B, /* slate.500 */
|
|
||||||
icons: #64748B, /* slate.500 */
|
|
||||||
mat-icon: #64748B, /* slate.500 */
|
|
||||||
text: #1E293B, /* slate.800 */
|
|
||||||
slider-min: #1E293B, /* slate.800 */
|
|
||||||
slider-off: #CBD5E1, /* slate.300 */
|
|
||||||
slider-off-active: #94A3B8 /* slate.400 */
|
|
||||||
),
|
|
||||||
background: (
|
|
||||||
status-bar: #CBD5E1, /* slate.300 */
|
|
||||||
app-bar: #FFFFFF,
|
|
||||||
background: #F1F5F9, /* slate.100 */
|
|
||||||
hover: rgba(148, 163, 184, 0.12), /* slate.400 + opacity */
|
|
||||||
card: #FFFFFF,
|
|
||||||
dialog: #FFFFFF,
|
|
||||||
disabled-button: rgba(148, 163, 184, 0.38), /* slate.400 + opacity */
|
|
||||||
raised-button: #FFFFFF,
|
|
||||||
focused-button: #64748B, /* slate.500 */
|
|
||||||
selected-button: #E2E8F0, /* slate.200 */
|
|
||||||
selected-disabled-button: #E2E8F0, /* slate.200 */
|
|
||||||
disabled-button-toggle: #CBD5E1, /* slate.300 */
|
|
||||||
unselected-chip: #E2E8F0, /* slate.200 */
|
|
||||||
disabled-list-option: #CBD5E1, /* slate.300 */
|
|
||||||
tooltip: #1E293B /* slate.800 */
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Use all-component-colors to only generate the colors */
|
|
||||||
@include mat.all-component-colors($light-theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark,
|
|
||||||
body .dark {
|
|
||||||
$base-dark-theme: mat.define-dark-theme((
|
|
||||||
color: ($palettes)
|
color: ($palettes)
|
||||||
));
|
));
|
||||||
|
|
||||||
$dark-theme: (
|
/* Merge the custom base colors with the generated themes */
|
||||||
color: (
|
$light-theme-colors: map.merge(map.get($light-theme, color), $light-base);
|
||||||
primary: map.get(map.get($base-dark-theme, color), primary),
|
$light-theme: map.merge(
|
||||||
accent: map.get(map.get($base-dark-theme, color), accent),
|
(color: $light-theme-colors),
|
||||||
warn: map.get(map.get($base-dark-theme, color), warn),
|
$light-theme-colors
|
||||||
is-dark: map.get(map.get($base-dark-theme, color), is-dark),
|
|
||||||
foreground: (
|
|
||||||
base: #FFFFFF,
|
|
||||||
divider: rgba(241, 245, 249, 0.12), /* slate.100 + opacity */
|
|
||||||
dividers: rgba(241, 245, 249, 0.12), /* slate.100 + opacity */
|
|
||||||
disabled: #475569, /* slate.600 */
|
|
||||||
disabled-button: #1E293B, /* slate.800 */
|
|
||||||
disabled-text: #475569, /* slate.600 */
|
|
||||||
elevation: #000000,
|
|
||||||
hint-text: #64748B, /* slate.500 */
|
|
||||||
secondary-text: #94A3B8, /* slate.400 */
|
|
||||||
icon: #F1F5F9, /* slate.100 */
|
|
||||||
icons: #F1F5F9, /* slate.100 */
|
|
||||||
mat-icon: #94A3B8, /* slate.400 */
|
|
||||||
text: #FFFFFF,
|
|
||||||
slider-min: #FFFFFF,
|
|
||||||
slider-off: #64748B, /* slate.500 */
|
|
||||||
slider-off-active: #94A3B8 /* slate.400 */
|
|
||||||
),
|
|
||||||
background: (
|
|
||||||
status-bar: #0F172A, /* slate.900 */
|
|
||||||
app-bar: #0F172A, /* slate.900 */
|
|
||||||
background: #0F172A, /* slate.900 */
|
|
||||||
hover: rgba(255, 255, 255, 0.05),
|
|
||||||
card: #1E293B, /* slate.800 */
|
|
||||||
dialog: #1E293B, /* slate.800 */
|
|
||||||
disabled-button: rgba(15, 23, 42, 0.38), /* slate.900 + opacity */
|
|
||||||
raised-button: #0F172A, /* slate.900 */
|
|
||||||
focused-button: #E2E8F0, /* slate.200 */
|
|
||||||
selected-button: rgba(255, 255, 255, 0.05),
|
|
||||||
selected-disabled-button: #1E293B, /* slate.800 */
|
|
||||||
disabled-button-toggle: #0F172A, /* slate.900 */
|
|
||||||
unselected-chip: #475569, /* slate.600 */
|
|
||||||
disabled-list-option: #E2E8F0, /* slate.200 */
|
|
||||||
tooltip: #64748B /* slate.500 */
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Use all-component-colors to only generate the colors */
|
$dark-theme-colors: map.merge(map.get($dark-theme, color), $dark-base);
|
||||||
@include mat.all-component-colors($dark-theme);
|
$dark-theme: map.merge(
|
||||||
|
(color: $dark-theme-colors),
|
||||||
|
$dark-theme-colors
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Generate and encapsulate Angular Material themes */
|
||||||
|
#{map.get($theme, selector)} .light,
|
||||||
|
#{map.get($theme, selector)}.light {
|
||||||
|
@include mat.all-component-colors($light-theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
#{map.get($theme, selector)} .dark,
|
||||||
|
#{map.get($theme, selector)}.dark {
|
||||||
|
@include mat.all-component-colors($dark-theme);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/@fuse/styles/user-themes.scss
Normal file
1
src/@fuse/styles/user-themes.scss
Normal file
File diff suppressed because one or more lines are too long
@@ -1,10 +1,12 @@
|
|||||||
const chroma = require('chroma-js');
|
const chroma = require('chroma-js');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const colors = require('tailwindcss/colors');
|
const colors = require('tailwindcss/colors');
|
||||||
const plugin = require('tailwindcss/plugin');
|
const plugin = require('tailwindcss/plugin');
|
||||||
const flattenColorPalette = require('tailwindcss/lib/util/flattenColorPalette').default;
|
const flattenColorPalette = require('tailwindcss/lib/util/flattenColorPalette').default;
|
||||||
const generateContrasts = require(path.resolve(__dirname, ('../utils/generate-contrasts')));
|
const generateContrasts = require(path.resolve(__dirname, ('../utils/generate-contrasts')));
|
||||||
|
const jsonToSassMap = require(path.resolve(__dirname, ('../utils/json-to-sass-map')));
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------------------------------
|
||||||
// @ Utilities
|
// @ Utilities
|
||||||
@@ -39,6 +41,88 @@ const theming = plugin.withOptions((options) => ({
|
|||||||
theme
|
theme
|
||||||
}) =>
|
}) =>
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Create user themes object by going through the provided themes and
|
||||||
|
* merging them with the provided "default" so, we can have a complete
|
||||||
|
* set of color palettes for each user theme.
|
||||||
|
*/
|
||||||
|
const userThemes = _.fromPairs(_.map(options.themes, (theme, themeName) => [
|
||||||
|
themeName,
|
||||||
|
_.defaults({}, theme, options.themes['default'])
|
||||||
|
]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the themes and assign it to the themes object. This will
|
||||||
|
* be the final object that we create a SASS map from
|
||||||
|
*/
|
||||||
|
let themes = _.fromPairs(_.map(userThemes, (theme, themeName) => [
|
||||||
|
themeName,
|
||||||
|
normalizeTheme(theme)
|
||||||
|
]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go through the themes to generate the contrasts and filter the
|
||||||
|
* palettes to only have "primary", "accent" and "warn" objects.
|
||||||
|
*/
|
||||||
|
themes = _.fromPairs(_.map(themes, (theme, themeName) => [
|
||||||
|
themeName,
|
||||||
|
_.pick(
|
||||||
|
_.fromPairs(_.map(theme, (palette, paletteName) => [
|
||||||
|
paletteName,
|
||||||
|
{
|
||||||
|
...palette,
|
||||||
|
contrast: _.fromPairs(_.map(generateContrasts(palette), (color, hue) => [
|
||||||
|
hue,
|
||||||
|
_.get(userThemes[themeName], [`on-${paletteName}`, hue]) || color
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
])),
|
||||||
|
['primary', 'accent', 'warn']
|
||||||
|
)
|
||||||
|
]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go through the themes and attach appropriate class selectors so,
|
||||||
|
* we can use them to encapsulate each theme.
|
||||||
|
*/
|
||||||
|
themes = _.fromPairs(_.map(themes, (theme, themeName) => [
|
||||||
|
themeName,
|
||||||
|
{
|
||||||
|
selector: `".theme-${themeName}"`,
|
||||||
|
...theme
|
||||||
|
}
|
||||||
|
]));
|
||||||
|
|
||||||
|
/* Generate the SASS map using the themes object */
|
||||||
|
const sassMap = jsonToSassMap(JSON.stringify({'user-themes': themes}));
|
||||||
|
|
||||||
|
/* Get the file path */
|
||||||
|
const filename = path.resolve(__dirname, ('../../styles/user-themes.scss'));
|
||||||
|
|
||||||
|
/* Read the file and get its data */
|
||||||
|
let data;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = fs.readFileSync(filename, {encoding: 'utf8'});
|
||||||
|
}
|
||||||
|
catch ( err )
|
||||||
|
{
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Write the file if the map has been changed */
|
||||||
|
if ( data !== sassMap )
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fs.writeFileSync(filename, sassMap, {encoding: 'utf8'});
|
||||||
|
}
|
||||||
|
catch ( err )
|
||||||
|
{
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterate through the user's themes and build Tailwind components containing
|
* Iterate through the user's themes and build Tailwind components containing
|
||||||
* CSS Custom Properties using the colors from them. This allows switching
|
* CSS Custom Properties using the colors from them. This allows switching
|
||||||
@@ -61,9 +145,9 @@ const theming = plugin.withOptions((options) => ({
|
|||||||
]))
|
]))
|
||||||
);
|
);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------------------------------
|
/**
|
||||||
// @ Generate scheme based css custom properties and utility classes
|
* Generate scheme based css custom properties and utility classes
|
||||||
// -----------------------------------------------------------------------------------------------------
|
*/
|
||||||
const schemeCustomProps = _.map(['light', 'dark'], (colorScheme) =>
|
const schemeCustomProps = _.map(['light', 'dark'], (colorScheme) =>
|
||||||
{
|
{
|
||||||
const isDark = colorScheme === 'dark';
|
const isDark = colorScheme === 'dark';
|
||||||
@@ -78,7 +162,7 @@ const theming = plugin.withOptions((options) => ({
|
|||||||
/**
|
/**
|
||||||
* If a custom property is not available, browsers will use
|
* If a custom property is not available, browsers will use
|
||||||
* the fallback value. In this case, we want to use '--is-dark'
|
* the fallback value. In this case, we want to use '--is-dark'
|
||||||
* as the indicator of a dark theme so we can use it like this:
|
* as the indicator of a dark theme so, we can use it like this:
|
||||||
* background-color: var(--is-dark, red);
|
* background-color: var(--is-dark, red);
|
||||||
*
|
*
|
||||||
* If we set '--is-dark' as "true" on dark themes, the above rule
|
* If we set '--is-dark' as "true" on dark themes, the above rule
|
||||||
@@ -99,7 +183,7 @@ const theming = plugin.withOptions((options) => ({
|
|||||||
*/
|
*/
|
||||||
...(!isDark ? {'--is-dark': 'false'} : {}),
|
...(!isDark ? {'--is-dark': 'false'} : {}),
|
||||||
|
|
||||||
// Generate custom properties from customProps
|
/* Generate custom properties from customProps */
|
||||||
..._.fromPairs(_.flatten(_.map(background, (value, key) => [[`--fuse-${e(key)}`, value], [`--fuse-${e(key)}-rgb`, chroma(value).rgb().join(',')]]))),
|
..._.fromPairs(_.flatten(_.map(background, (value, key) => [[`--fuse-${e(key)}`, value], [`--fuse-${e(key)}-rgb`, chroma(value).rgb().join(',')]]))),
|
||||||
..._.fromPairs(_.flatten(_.map(foreground, (value, key) => [[`--fuse-${e(key)}`, value], [`--fuse-${e(key)}-rgb`, chroma(value).rgb().join(',')]])))
|
..._.fromPairs(_.flatten(_.map(foreground, (value, key) => [[`--fuse-${e(key)}`, value], [`--fuse-${e(key)}-rgb`, chroma(value).rgb().join(',')]])))
|
||||||
}
|
}
|
||||||
@@ -108,7 +192,7 @@ const theming = plugin.withOptions((options) => ({
|
|||||||
|
|
||||||
const schemeUtilities = (() =>
|
const schemeUtilities = (() =>
|
||||||
{
|
{
|
||||||
// Generate general styles & utilities
|
/* Generate general styles & utilities */
|
||||||
return {};
|
return {};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
67
src/@fuse/tailwind/utils/json-to-sass-map.js
Normal file
67
src/@fuse/tailwind/utils/json-to-sass-map.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
module.exports = (data) =>
|
||||||
|
{
|
||||||
|
if ( !data )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data = JSON.parse(data);
|
||||||
|
|
||||||
|
const getSCSS = (chunk) =>
|
||||||
|
{
|
||||||
|
let scss = '';
|
||||||
|
|
||||||
|
if ( typeof chunk === "object" && !Array.isArray(chunk) )
|
||||||
|
{
|
||||||
|
_.mapKeys(chunk, (value, key) =>
|
||||||
|
{
|
||||||
|
scss += key + ': ';
|
||||||
|
|
||||||
|
if ( typeof value === "object" )
|
||||||
|
{
|
||||||
|
if ( Array.isArray(value) )
|
||||||
|
{
|
||||||
|
scss += '(';
|
||||||
|
_.each(value, (val1) =>
|
||||||
|
{
|
||||||
|
if ( Array.isArray(val1) )
|
||||||
|
{
|
||||||
|
_.each(val1, (val2) =>
|
||||||
|
{
|
||||||
|
scss += val2 + ' ';
|
||||||
|
});
|
||||||
|
scss = scss.slice(0, -1) + ', ';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
scss += val1 + ', ';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scss = scss.slice(0, -2);
|
||||||
|
scss += ')';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
scss += '(' + getSCSS(value) + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
scss += getSCSS(value);
|
||||||
|
}
|
||||||
|
scss += ', ';
|
||||||
|
});
|
||||||
|
scss = scss.slice(0, -2);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
scss += chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
return scss;
|
||||||
|
};
|
||||||
|
|
||||||
|
return '$' + getSCSS(data) + ';';
|
||||||
|
};
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import { Version } from '@fuse/version/version';
|
import { Version } from '@fuse/version/version';
|
||||||
|
|
||||||
export const FUSE_VERSION = new Version('16.0.0').full;
|
export const FUSE_VERSION = new Version('17.0.1').full;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core';
|
|||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
import { ExtraOptions, PreloadAllModules, RouterModule } from '@angular/router';
|
import { ExtraOptions, PreloadAllModules, RouterModule } from '@angular/router';
|
||||||
import { MarkdownModule } from 'ngx-markdown';
|
|
||||||
import { FuseModule } from '@fuse';
|
import { FuseModule } from '@fuse';
|
||||||
import { FuseConfigModule } from '@fuse/services/config';
|
import { FuseConfigModule } from '@fuse/services/config';
|
||||||
import { FuseMockApiModule } from '@fuse/lib/mock-api';
|
import { FuseMockApiModule } from '@fuse/lib/mock-api';
|
||||||
@@ -36,10 +35,7 @@ const routerConfig: ExtraOptions = {
|
|||||||
CoreModule,
|
CoreModule,
|
||||||
|
|
||||||
// Layout module of your application
|
// Layout module of your application
|
||||||
LayoutModule,
|
LayoutModule
|
||||||
|
|
||||||
// 3rd party modules that require global configuration via forRoot
|
|
||||||
MarkdownModule.forRoot({})
|
|
||||||
],
|
],
|
||||||
bootstrap : [
|
bootstrap : [
|
||||||
AppComponent
|
AppComponent
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ import { InitialDataResolver } from 'app/app.resolvers';
|
|||||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
|
|
||||||
// Redirect empty path to '/example'
|
// Redirect empty path to '/dashboards/project'
|
||||||
{path: '', pathMatch : 'full', redirectTo: 'example'},
|
{path: '', pathMatch : 'full', redirectTo: 'dashboards/project'},
|
||||||
|
|
||||||
// Redirect signed in user to the '/example'
|
// Redirect signed in user to the '/dashboards/project'
|
||||||
//
|
//
|
||||||
// After the user signs in, the sign in page will redirect the user to the 'signed-in-redirect'
|
// After the user signs in, the sign in page will redirect the user to the 'signed-in-redirect'
|
||||||
// path. Below is another redirection for that path to redirect the user to the desired
|
// path. Below is another redirection for that path to redirect the user to the desired
|
||||||
// location. This is a small convenience to keep all main routes together here on this file.
|
// location. This is a small convenience to keep all main routes together here on this file.
|
||||||
{path: 'signed-in-redirect', pathMatch : 'full', redirectTo: 'example'},
|
{path: 'signed-in-redirect', pathMatch : 'full', redirectTo: 'dashboards/project'},
|
||||||
|
|
||||||
// Auth routes for guests
|
// Auth routes for guests
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,136 @@ export const appRoutes: Route[] = [
|
|||||||
initialData: InitialDataResolver,
|
initialData: InitialDataResolver,
|
||||||
},
|
},
|
||||||
children : [
|
children : [
|
||||||
{path: 'example', loadChildren: () => import('app/modules/admin/example/example.module').then(m => m.ExampleModule)},
|
|
||||||
|
// Dashboards
|
||||||
|
{path: 'dashboards', children: [
|
||||||
|
{path: 'project', loadChildren: () => import('app/modules/admin/dashboards/project/project.module').then(m => m.ProjectModule)},
|
||||||
|
{path: 'analytics', loadChildren: () => import('app/modules/admin/dashboards/analytics/analytics.module').then(m => m.AnalyticsModule)},
|
||||||
|
{path: 'finance', loadChildren: () => import('app/modules/admin/dashboards/finance/finance.module').then(m => m.FinanceModule)},
|
||||||
|
{path: 'crypto', loadChildren: () => import('app/modules/admin/dashboards/crypto/crypto.module').then(m => m.CryptoModule)},
|
||||||
|
]},
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
{path: 'apps', children: [
|
||||||
|
{path: 'academy', loadChildren: () => import('app/modules/admin/apps/academy/academy.module').then(m => m.AcademyModule)},
|
||||||
|
{path: 'chat', loadChildren: () => import('app/modules/admin/apps/chat/chat.module').then(m => m.ChatModule)},
|
||||||
|
{path: 'contacts', loadChildren: () => import('app/modules/admin/apps/contacts/contacts.module').then(m => m.ContactsModule)},
|
||||||
|
{path: 'ecommerce', loadChildren: () => import('app/modules/admin/apps/ecommerce/ecommerce.module').then(m => m.ECommerceModule)},
|
||||||
|
{path: 'file-manager', loadChildren: () => import('app/modules/admin/apps/file-manager/file-manager.module').then(m => m.FileManagerModule)},
|
||||||
|
{path: 'help-center', loadChildren: () => import('app/modules/admin/apps/help-center/help-center.module').then(m => m.HelpCenterModule)},
|
||||||
|
{path: 'mailbox', loadChildren: () => import('app/modules/admin/apps/mailbox/mailbox.module').then(m => m.MailboxModule)},
|
||||||
|
{path: 'notes', loadChildren: () => import('app/modules/admin/apps/notes/notes.module').then(m => m.NotesModule)},
|
||||||
|
{path: 'scrumboard', loadChildren: () => import('app/modules/admin/apps/scrumboard/scrumboard.module').then(m => m.ScrumboardModule)},
|
||||||
|
{path: 'tasks', loadChildren: () => import('app/modules/admin/apps/tasks/tasks.module').then(m => m.TasksModule)},
|
||||||
|
]},
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
{path: 'pages', children: [
|
||||||
|
|
||||||
|
// Activities
|
||||||
|
{path: 'activities', loadChildren: () => import('app/modules/admin/pages/activities/activities.module').then(m => m.ActivitiesModule)},
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
{path: 'authentication', loadChildren: () => import('app/modules/admin/pages/authentication/authentication.module').then(m => m.AuthenticationModule)},
|
||||||
|
|
||||||
|
// Coming Soon
|
||||||
|
{path: 'coming-soon', loadChildren: () => import('app/modules/admin/pages/coming-soon/coming-soon.module').then(m => m.ComingSoonModule)},
|
||||||
|
|
||||||
|
// Error
|
||||||
|
{path: 'error', children: [
|
||||||
|
{path: '404', loadChildren: () => import('app/modules/admin/pages/error/error-404/error-404.module').then(m => m.Error404Module)},
|
||||||
|
{path: '500', loadChildren: () => import('app/modules/admin/pages/error/error-500/error-500.module').then(m => m.Error500Module)}
|
||||||
|
]},
|
||||||
|
|
||||||
|
// Invoice
|
||||||
|
{path: 'invoice', children: [
|
||||||
|
{path: 'printable', children: [
|
||||||
|
{path: 'compact', loadChildren: () => import('app/modules/admin/pages/invoice/printable/compact/compact.module').then(m => m.CompactModule)},
|
||||||
|
{path: 'modern', loadChildren: () => import('app/modules/admin/pages/invoice/printable/modern/modern.module').then(m => m.ModernModule)}
|
||||||
|
]}
|
||||||
|
]},
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
{path: 'maintenance', loadChildren: () => import('app/modules/admin/pages/maintenance/maintenance.module').then(m => m.MaintenanceModule)},
|
||||||
|
|
||||||
|
// Pricing
|
||||||
|
{path: 'pricing', children: [
|
||||||
|
{path: 'modern', loadChildren: () => import('app/modules/admin/pages/pricing/modern/modern.module').then(m => m.PricingModernModule)},
|
||||||
|
{path: 'simple', loadChildren: () => import('app/modules/admin/pages/pricing/simple/simple.module').then(m => m.PricingSimpleModule)},
|
||||||
|
{path: 'single', loadChildren: () => import('app/modules/admin/pages/pricing/single/single.module').then(m => m.PricingSingleModule)},
|
||||||
|
{path: 'table', loadChildren: () => import('app/modules/admin/pages/pricing/table/table.module').then(m => m.PricingTableModule)}
|
||||||
|
]},
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
{path: 'profile', loadChildren: () => import('app/modules/admin/pages/profile/profile.module').then(m => m.ProfileModule)},
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
{path: 'settings', loadChildren: () => import('app/modules/admin/pages/settings/settings.module').then(m => m.SettingsModule)},
|
||||||
|
]},
|
||||||
|
|
||||||
|
// User Interface
|
||||||
|
{path: 'ui', children: [
|
||||||
|
|
||||||
|
// Material Components
|
||||||
|
{path: 'material-components', loadChildren: () => import('app/modules/admin/ui/material-components/material-components.module').then(m => m.MaterialComponentsModule)},
|
||||||
|
|
||||||
|
// Fuse Components
|
||||||
|
{path: 'fuse-components', loadChildren: () => import('app/modules/admin/ui/fuse-components/fuse-components.module').then(m => m.FuseComponentsModule)},
|
||||||
|
|
||||||
|
// Other Components
|
||||||
|
{path: 'other-components', loadChildren: () => import('app/modules/admin/ui/other-components/other-components.module').then(m => m.OtherComponentsModule)},
|
||||||
|
|
||||||
|
// TailwindCSS
|
||||||
|
{path: 'tailwindcss', loadChildren: () => import('app/modules/admin/ui/tailwindcss/tailwindcss.module').then(m => m.TailwindCSSModule)},
|
||||||
|
|
||||||
|
// Advanced Search
|
||||||
|
{path: 'advanced-search', loadChildren: () => import('app/modules/admin/ui/advanced-search/advanced-search.module').then(m => m.AdvancedSearchModule)},
|
||||||
|
|
||||||
|
// Animations
|
||||||
|
{path: 'animations', loadChildren: () => import('app/modules/admin/ui/animations/animations.module').then(m => m.AnimationsModule)},
|
||||||
|
|
||||||
|
// Cards
|
||||||
|
{path: 'cards', loadChildren: () => import('app/modules/admin/ui/cards/cards.module').then(m => m.CardsModule)},
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
{path: 'colors', loadChildren: () => import('app/modules/admin/ui/colors/colors.module').then(m => m.ColorsModule)},
|
||||||
|
|
||||||
|
// Confirmation Dialog
|
||||||
|
{path: 'confirmation-dialog', loadChildren: () => import('app/modules/admin/ui/confirmation-dialog/confirmation-dialog.module').then(m => m.ConfirmationDialogModule)},
|
||||||
|
|
||||||
|
// Datatable
|
||||||
|
{path: 'datatable', loadChildren: () => import('app/modules/admin/ui/datatable/datatable.module').then(m => m.DatatableModule)},
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
{path: 'forms', children: [
|
||||||
|
{path: 'fields', loadChildren: () => import('app/modules/admin/ui/forms/fields/fields.module').then(m => m.FormsFieldsModule)},
|
||||||
|
{path: 'layouts', loadChildren: () => import('app/modules/admin/ui/forms/layouts/layouts.module').then(m => m.FormsLayoutsModule)},
|
||||||
|
{path: 'wizards', loadChildren: () => import('app/modules/admin/ui/forms/wizards/wizards.module').then(m => m.FormsWizardsModule)}
|
||||||
|
]},
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
{path: 'icons', loadChildren: () => import('app/modules/admin/ui/icons/icons.module').then(m => m.IconsModule)},
|
||||||
|
|
||||||
|
// Page Layouts
|
||||||
|
{path: 'page-layouts', loadChildren: () => import('app/modules/admin/ui/page-layouts/page-layouts.module').then(m => m.PageLayoutsModule)},
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
{path: 'typography', loadChildren: () => import('app/modules/admin/ui/typography/typography.module').then(m => m.TypographyModule)}
|
||||||
|
]},
|
||||||
|
|
||||||
|
// Documentation
|
||||||
|
{path: 'docs', children: [
|
||||||
|
|
||||||
|
// Changelog
|
||||||
|
{path: 'changelog', loadChildren: () => import('app/modules/admin/docs/changelog/changelog.module').then(m => m.ChangelogModule)},
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
{path: 'guides', loadChildren: () => import('app/modules/admin/docs/guides/guides.module').then(m => m.GuidesModule)}
|
||||||
|
]},
|
||||||
|
|
||||||
|
// 404 & Catch all
|
||||||
|
{path: '404-not-found', pathMatch: 'full', loadChildren: () => import('app/modules/admin/pages/error/error-404/error-404.module').then(m => m.Error404Module)},
|
||||||
|
{path: '**', redirectTo: '404-not-found'}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Translation, TRANSLOCO_CONFIG, TRANSLOCO_LOADER, translocoConfig, TranslocoModule, TranslocoService } from '@ngneat/transloco';
|
import { Translation, TRANSLOCO_CONFIG, TRANSLOCO_LOADER, translocoConfig, TranslocoModule, TranslocoService } from '@ngneat/transloco';
|
||||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||||
import { environment } from 'environments/environment';
|
|
||||||
import { TranslocoHttpLoader } from 'app/core/transloco/transloco.http-loader';
|
import { TranslocoHttpLoader } from 'app/core/transloco/transloco.http-loader';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@@ -25,7 +24,7 @@ import { TranslocoHttpLoader } from 'app/core/transloco/transloco.http-loader';
|
|||||||
defaultLang : 'en',
|
defaultLang : 'en',
|
||||||
fallbackLang : 'en',
|
fallbackLang : 'en',
|
||||||
reRenderOnLangChange: true,
|
reRenderOnLangChange: true,
|
||||||
prodMode : environment.production
|
prodMode : true
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -151,12 +151,12 @@
|
|||||||
|
|
||||||
<!-- Message field -->
|
<!-- Message field -->
|
||||||
<div class="flex items-end p-4 border-t bg-gray-50 dark:bg-transparent">
|
<div class="flex items-end p-4 border-t bg-gray-50 dark:bg-transparent">
|
||||||
<mat-form-field class="fuse-mat-dense fuse-mat-no-subscript fuse-mat-rounded fuse-mat-bold w-full">
|
<mat-form-field
|
||||||
|
class="fuse-mat-dense fuse-mat-rounded fuse-mat-bold w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
<textarea
|
<textarea
|
||||||
class="min-h-5 my-0 resize-none"
|
|
||||||
style="margin: 11px 0 !important; padding: 0 !important;"
|
|
||||||
[rows]="1"
|
|
||||||
matInput
|
matInput
|
||||||
|
cdkTextareaAutosize
|
||||||
#messageInput></textarea>
|
#messageInput></textarea>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<div class="flex items-center h-11 my-px ml-4">
|
<div class="flex items-center h-11 my-px ml-4">
|
||||||
|
|||||||
@@ -68,7 +68,9 @@
|
|||||||
<!-- Basic search -->
|
<!-- Basic search -->
|
||||||
<ng-container *ngIf="appearance === 'basic'">
|
<ng-container *ngIf="appearance === 'basic'">
|
||||||
<div class="w-full sm:min-w-80">
|
<div class="w-full sm:min-w-80">
|
||||||
<mat-form-field class="fuse-mat-no-subscript w-full">
|
<mat-form-field
|
||||||
|
class="w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
<mat-icon
|
<mat-icon
|
||||||
matPrefix
|
matPrefix
|
||||||
[svgIcon]="'heroicons_outline:search'"></mat-icon>
|
[svgIcon]="'heroicons_outline:search'"></mat-icon>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
17
src/app/modules/admin/apps/academy/academy.component.ts
Normal file
17
src/app/modules/admin/apps/academy/academy.component.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'academy',
|
||||||
|
templateUrl : './academy.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AcademyComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/modules/admin/apps/academy/academy.module.ts
Normal file
44
src/app/modules/admin/apps/academy/academy.module.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { FuseFindByKeyPipeModule } from '@fuse/pipes/find-by-key';
|
||||||
|
import { SharedModule } from 'app/shared/shared.module';
|
||||||
|
import { academyRoutes } from 'app/modules/admin/apps/academy/academy.routing';
|
||||||
|
import { AcademyComponent } from 'app/modules/admin/apps/academy/academy.component';
|
||||||
|
import { AcademyDetailsComponent } from 'app/modules/admin/apps/academy/details/details.component';
|
||||||
|
import { AcademyListComponent } from 'app/modules/admin/apps/academy/list/list.component';
|
||||||
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
AcademyComponent,
|
||||||
|
AcademyDetailsComponent,
|
||||||
|
AcademyListComponent
|
||||||
|
],
|
||||||
|
imports : [
|
||||||
|
RouterModule.forChild(academyRoutes),
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
FuseFindByKeyPipeModule,
|
||||||
|
SharedModule,
|
||||||
|
MatTabsModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AcademyModule
|
||||||
|
{
|
||||||
|
}
|
||||||
109
src/app/modules/admin/apps/academy/academy.resolvers.ts
Normal file
109
src/app/modules/admin/apps/academy/academy.resolvers.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { catchError, Observable, throwError } from 'rxjs';
|
||||||
|
import { Category, Course } from 'app/modules/admin/apps/academy/academy.types';
|
||||||
|
import { AcademyService } from 'app/modules/admin/apps/academy/academy.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AcademyCategoriesResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _academyService: AcademyService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Category[]>
|
||||||
|
{
|
||||||
|
return this._academyService.getCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AcademyCoursesResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _academyService: AcademyService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Course[]>
|
||||||
|
{
|
||||||
|
return this._academyService.getCourses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AcademyCourseResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _router: Router,
|
||||||
|
private _academyService: AcademyService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Course>
|
||||||
|
{
|
||||||
|
return this._academyService.getCourseById(route.paramMap.get('id'))
|
||||||
|
.pipe(
|
||||||
|
// Error here means the requested task is not available
|
||||||
|
catchError((error) => {
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
// Get the parent url
|
||||||
|
const parentUrl = state.url.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Navigate to there
|
||||||
|
this._router.navigateByUrl(parentUrl);
|
||||||
|
|
||||||
|
// Throw an error
|
||||||
|
return throwError(error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/modules/admin/apps/academy/academy.routing.ts
Normal file
32
src/app/modules/admin/apps/academy/academy.routing.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { AcademyComponent } from 'app/modules/admin/apps/academy/academy.component';
|
||||||
|
import { AcademyListComponent } from 'app/modules/admin/apps/academy/list/list.component';
|
||||||
|
import { AcademyDetailsComponent } from 'app/modules/admin/apps/academy/details/details.component';
|
||||||
|
import { AcademyCategoriesResolver, AcademyCourseResolver, AcademyCoursesResolver } from 'app/modules/admin/apps/academy/academy.resolvers';
|
||||||
|
|
||||||
|
export const academyRoutes: Route[] = [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: AcademyComponent,
|
||||||
|
resolve : {
|
||||||
|
categories: AcademyCategoriesResolver
|
||||||
|
},
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
component: AcademyListComponent,
|
||||||
|
resolve : {
|
||||||
|
courses: AcademyCoursesResolver
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : ':id',
|
||||||
|
component: AcademyDetailsComponent,
|
||||||
|
resolve : {
|
||||||
|
course: AcademyCourseResolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
104
src/app/modules/admin/apps/academy/academy.service.ts
Normal file
104
src/app/modules/admin/apps/academy/academy.service.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, map, Observable, of, switchMap, tap, throwError } from 'rxjs';
|
||||||
|
import { Category, Course } from 'app/modules/admin/apps/academy/academy.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class AcademyService
|
||||||
|
{
|
||||||
|
// Private
|
||||||
|
private _categories: BehaviorSubject<Category[] | null> = new BehaviorSubject(null);
|
||||||
|
private _course: BehaviorSubject<Course | null> = new BehaviorSubject(null);
|
||||||
|
private _courses: BehaviorSubject<Course[] | null> = new BehaviorSubject(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _httpClient: HttpClient)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Accessors
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for categories
|
||||||
|
*/
|
||||||
|
get categories$(): Observable<Category[]>
|
||||||
|
{
|
||||||
|
return this._categories.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for courses
|
||||||
|
*/
|
||||||
|
get courses$(): Observable<Course[]>
|
||||||
|
{
|
||||||
|
return this._courses.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for course
|
||||||
|
*/
|
||||||
|
get course$(): Observable<Course>
|
||||||
|
{
|
||||||
|
return this._course.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories
|
||||||
|
*/
|
||||||
|
getCategories(): Observable<Category[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Category[]>('api/apps/academy/categories').pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._categories.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get courses
|
||||||
|
*/
|
||||||
|
getCourses(): Observable<Course[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Course[]>('api/apps/academy/courses').pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._courses.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get course by id
|
||||||
|
*/
|
||||||
|
getCourseById(id: string): Observable<Course>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Course>('api/apps/academy/courses/course', {params: {id}}).pipe(
|
||||||
|
map((course) => {
|
||||||
|
|
||||||
|
// Update the course
|
||||||
|
this._course.next(course);
|
||||||
|
|
||||||
|
// Return the course
|
||||||
|
return course;
|
||||||
|
}),
|
||||||
|
switchMap((course) => {
|
||||||
|
|
||||||
|
if ( !course )
|
||||||
|
{
|
||||||
|
return throwError('Could not found course with id of ' + id + '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(course);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/modules/admin/apps/academy/academy.types.ts
Normal file
29
src/app/modules/admin/apps/academy/academy.types.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface Category
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Course
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
duration?: number;
|
||||||
|
steps?: {
|
||||||
|
order?: number;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
content?: string;
|
||||||
|
}[];
|
||||||
|
totalSteps?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
featured?: boolean;
|
||||||
|
progress?: {
|
||||||
|
currentStep?: number;
|
||||||
|
completed?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<div class="absolute inset-0 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
|
||||||
|
<mat-drawer-container class="flex-auto h-full">
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<mat-drawer
|
||||||
|
class="w-90 dark:bg-gray-900"
|
||||||
|
[autoFocus]="false"
|
||||||
|
[mode]="drawerMode"
|
||||||
|
[opened]="drawerOpened"
|
||||||
|
#matDrawer>
|
||||||
|
<div class="flex flex-col items-start p-8 border-b">
|
||||||
|
<!-- Back to courses -->
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center leading-6 text-primary hover:underline"
|
||||||
|
[routerLink]="['..']">
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 text-current"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-sm-left'"></mat-icon>
|
||||||
|
<span class="ml-1.5 font-medium leading-5">Back to courses</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<!-- Course category -->
|
||||||
|
<ng-container *ngIf="(course.category | fuseFindByKey:'slug':categories) as category">
|
||||||
|
<div
|
||||||
|
class="mt-7 py-0.5 px-3 rounded-full text-sm font-semibold"
|
||||||
|
[ngClass]="{'text-blue-800 bg-blue-100 dark:text-blue-50 dark:bg-blue-500': category.slug === 'web',
|
||||||
|
'text-green-800 bg-green-100 dark:text-green-50 dark:bg-green-500': category.slug === 'android',
|
||||||
|
'text-pink-800 bg-pink-100 dark:text-pink-50 dark:bg-pink-500': category.slug === 'cloud',
|
||||||
|
'text-amber-800 bg-amber-100 dark:text-amber-50 dark:bg-amber-500': category.slug === 'firebase'}">
|
||||||
|
{{category.title}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Course title & description -->
|
||||||
|
<div class="mt-3 text-2xl font-semibold">{{course.title}}</div>
|
||||||
|
<div class="text-secondary">{{course.description}}</div>
|
||||||
|
<!-- Course time -->
|
||||||
|
<div class="mt-6 flex items-center leading-5 text-md text-secondary">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 text-hint"
|
||||||
|
[svgIcon]="'heroicons_solid:clock'"></mat-icon>
|
||||||
|
<div class="ml-1.5">{{course.duration}} minutes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div class="py-2 px-8">
|
||||||
|
<ol>
|
||||||
|
<ng-container *ngFor="let step of course.steps; let last = last; trackBy: trackByFn">
|
||||||
|
<li
|
||||||
|
class="relative group py-6"
|
||||||
|
[class.current-step]="step.order === currentStep">
|
||||||
|
<ng-container *ngIf="!last">
|
||||||
|
<div
|
||||||
|
class="absolute top-6 left-4 w-0.5 h-full -ml-px"
|
||||||
|
[ngClass]="{'bg-primary': step.order < currentStep,
|
||||||
|
'bg-gray-300 dark:bg-gray-600': step.order >= currentStep}"></div>
|
||||||
|
</ng-container>
|
||||||
|
<div
|
||||||
|
class="relative flex items-start cursor-pointer"
|
||||||
|
(click)="goToStep(step.order)">
|
||||||
|
<div
|
||||||
|
class="flex flex-0 items-center justify-center w-8 h-8 rounded-full ring-2 ring-inset bg-card dark:bg-default"
|
||||||
|
[ngClass]="{'bg-primary dark:bg-primary text-on-primary group-hover:bg-primary-800 ring-transparent': step.order < currentStep,
|
||||||
|
'ring-primary': step.order === currentStep,
|
||||||
|
'ring-gray-300 dark:ring-gray-600 group-hover:ring-gray-400': step.order > currentStep}">
|
||||||
|
<!-- Check icon, show if the step is completed -->
|
||||||
|
<ng-container *ngIf="step.order < currentStep">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 text-current"
|
||||||
|
[svgIcon]="'heroicons_solid:check'"></mat-icon>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Step order, show if the step is the current step -->
|
||||||
|
<ng-container *ngIf="step.order === currentStep">
|
||||||
|
<div class="text-md font-semibold text-primary dark:text-primary-500">{{step.order + 1}}</div>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Step order, show if the step is not completed -->
|
||||||
|
<ng-container *ngIf="step.order > currentStep">
|
||||||
|
<div class="text-md font-semibold text-hint group-hover:text-secondary">{{step.order + 1}}</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="font-medium leading-4">{{step.title}}</div>
|
||||||
|
<div class="mt-1.5 text-md leading-4 text-secondary">{{step.subtitle}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ng-container>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</mat-drawer>
|
||||||
|
|
||||||
|
<!-- Drawer content -->
|
||||||
|
<mat-drawer-content class="flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="lg:hidden flex flex-0 items-center py-2 pl-4 pr-6 sm:py-4 md:pl-6 md:pr-8 border-b lg:border-b-0 bg-card dark:bg-transparent">
|
||||||
|
<!-- Title & Actions -->
|
||||||
|
<a
|
||||||
|
mat-icon-button
|
||||||
|
[routerLink]="['..']">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-sm-left'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
<h2 class="ml-2.5 text-md sm:text-xl font-medium tracking-tight truncate">
|
||||||
|
{{course.title}}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<mat-progress-bar
|
||||||
|
class="hidden lg:block flex-0 h-0.5 w-full"
|
||||||
|
[value]="100 * (currentStep + 1) / course.totalSteps"></mat-progress-bar>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div
|
||||||
|
class="flex-auto overflow-y-auto"
|
||||||
|
cdkScrollable>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<mat-tab-group
|
||||||
|
class="fuse-mat-no-header"
|
||||||
|
[animationDuration]="'200'"
|
||||||
|
#courseSteps>
|
||||||
|
<ng-container *ngFor="let step of course.steps; trackBy: trackByFn">
|
||||||
|
<mat-tab>
|
||||||
|
<ng-template matTabContent>
|
||||||
|
<div
|
||||||
|
class="prose prose-sm max-w-3xl mx-auto sm:my-2 lg:mt-4 p-6 sm:p-10 sm:py-12 rounded-2xl shadow overflow-hidden bg-card"
|
||||||
|
[innerHTML]="step.content"></div>
|
||||||
|
</ng-template>
|
||||||
|
</mat-tab>
|
||||||
|
</ng-container>
|
||||||
|
</mat-tab-group>
|
||||||
|
|
||||||
|
<!-- Navigation - Desktop -->
|
||||||
|
<div class="z-10 sticky hidden lg:flex bottom-4 p-4">
|
||||||
|
<div class="flex items-center justify-center mx-auto p-2 rounded-full shadow-lg bg-primary">
|
||||||
|
<button
|
||||||
|
class="flex-0"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'"
|
||||||
|
(click)="goToPreviousStep()">
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<mat-icon
|
||||||
|
class="mr-2"
|
||||||
|
[svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
<span class="mr-1">Prev</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center justify-center mx-2.5 font-medium leading-5 text-on-primary">
|
||||||
|
<span>{{currentStep + 1}}</span>
|
||||||
|
<span class="mx-0.5 text-hint">/</span>
|
||||||
|
<span>{{course.totalSteps}}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex-0"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'"
|
||||||
|
(click)="goToNextStep()">
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<span class="ml-1">Next</span>
|
||||||
|
<mat-icon
|
||||||
|
class="ml-2"
|
||||||
|
[svgIcon]="'heroicons_outline:arrow-narrow-right'"></mat-icon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress & Navigation - Mobile -->
|
||||||
|
<div class="lg:hidden flex items-center p-4 border-t bg-card">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="matDrawer.toggle()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:view-list'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center justify-center ml-1 lg:ml-2 font-medium leading-5">
|
||||||
|
<span>{{currentStep + 1}}</span>
|
||||||
|
<span class="mx-0.5 text-hint">/</span>
|
||||||
|
<span>{{course.totalSteps}}</span>
|
||||||
|
</div>
|
||||||
|
<mat-progress-bar
|
||||||
|
class="flex-auto ml-6 rounded-full"
|
||||||
|
[value]="100 * (currentStep + 1) / course.totalSteps"></mat-progress-bar>
|
||||||
|
<button
|
||||||
|
class="ml-4"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="goToPreviousStep()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ml-0.5"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="goToNextStep()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-right'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</mat-drawer-content>
|
||||||
|
|
||||||
|
</mat-drawer-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
203
src/app/modules/admin/apps/academy/details/details.component.ts
Normal file
203
src/app/modules/admin/apps/academy/details/details.component.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { MatTabGroup } from '@angular/material/tabs';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { FuseMediaWatcherService } from '@fuse/services/media-watcher';
|
||||||
|
import { Category, Course } from 'app/modules/admin/apps/academy/academy.types';
|
||||||
|
import { AcademyService } from 'app/modules/admin/apps/academy/academy.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'academy-details',
|
||||||
|
templateUrl : './details.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AcademyDetailsComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
@ViewChild('courseSteps', {static: true}) courseSteps: MatTabGroup;
|
||||||
|
categories: Category[];
|
||||||
|
course: Course;
|
||||||
|
currentStep: number = 0;
|
||||||
|
drawerMode: 'over' | 'side' = 'side';
|
||||||
|
drawerOpened: boolean = true;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
@Inject(DOCUMENT) private _document: Document,
|
||||||
|
private _academyService: AcademyService,
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _elementRef: ElementRef,
|
||||||
|
private _fuseMediaWatcherService: FuseMediaWatcherService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the categories
|
||||||
|
this._academyService.categories$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((categories: Category[]) => {
|
||||||
|
|
||||||
|
// Get the categories
|
||||||
|
this.categories = categories;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the course
|
||||||
|
this._academyService.course$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((course: Course) => {
|
||||||
|
|
||||||
|
// Get the course
|
||||||
|
this.course = course;
|
||||||
|
|
||||||
|
// Go to step
|
||||||
|
this.goToStep(course.progress.currentStep);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to media changes
|
||||||
|
this._fuseMediaWatcherService.onMediaChange$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe(({matchingAliases}) => {
|
||||||
|
|
||||||
|
// Set the drawerMode and drawerOpened
|
||||||
|
if ( matchingAliases.includes('lg') )
|
||||||
|
{
|
||||||
|
this.drawerMode = 'side';
|
||||||
|
this.drawerOpened = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.drawerMode = 'over';
|
||||||
|
this.drawerOpened = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to given step
|
||||||
|
*
|
||||||
|
* @param step
|
||||||
|
*/
|
||||||
|
goToStep(step: number): void
|
||||||
|
{
|
||||||
|
// Set the current step
|
||||||
|
this.currentStep = step;
|
||||||
|
|
||||||
|
// Go to the step
|
||||||
|
this.courseSteps.selectedIndex = this.currentStep;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to previous step
|
||||||
|
*/
|
||||||
|
goToPreviousStep(): void
|
||||||
|
{
|
||||||
|
// Return if we already on the first step
|
||||||
|
if ( this.currentStep === 0 )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to step
|
||||||
|
this.goToStep(this.currentStep - 1);
|
||||||
|
|
||||||
|
// Scroll the current step selector from sidenav into view
|
||||||
|
this._scrollCurrentStepElementIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to next step
|
||||||
|
*/
|
||||||
|
goToNextStep(): void
|
||||||
|
{
|
||||||
|
// Return if we already on the last step
|
||||||
|
if ( this.currentStep === this.course.totalSteps - 1 )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to step
|
||||||
|
this.goToStep(this.currentStep + 1);
|
||||||
|
|
||||||
|
// Scroll the current step selector from sidenav into view
|
||||||
|
this._scrollCurrentStepElementIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Private methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the current step element from
|
||||||
|
* sidenav into the view. This only happens when
|
||||||
|
* previous/next buttons pressed as we don't want
|
||||||
|
* to change the scroll position of the sidebar
|
||||||
|
* when the user actually clicks around the sidebar.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private _scrollCurrentStepElementIntoView(): void
|
||||||
|
{
|
||||||
|
// Wrap everything into setTimeout so we can make sure that the 'current-step' class points to correct element
|
||||||
|
setTimeout(() => {
|
||||||
|
|
||||||
|
// Get the current step element and scroll it into view
|
||||||
|
const currentStepElement = this._document.getElementsByClassName('current-step')[0];
|
||||||
|
if ( currentStepElement )
|
||||||
|
{
|
||||||
|
currentStepElement.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block : 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/app/modules/admin/apps/academy/list/list.component.html
Normal file
198
src/app/modules/admin/apps/academy/list/list.component.html
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col min-w-0 overflow-y-auto"
|
||||||
|
cdkScrollable>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="relative flex-0 py-8 px-4 sm:p-16 overflow-hidden bg-gray-800 dark">
|
||||||
|
<!-- Background - @formatter:off -->
|
||||||
|
<!-- Rings -->
|
||||||
|
<svg class="absolute inset-0 pointer-events-none"
|
||||||
|
viewBox="0 0 960 540" width="100%" height="100%" preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="text-gray-700 opacity-25" fill="none" stroke="currentColor" stroke-width="100">
|
||||||
|
<circle r="234" cx="196" cy="23"></circle>
|
||||||
|
<circle r="234" cx="790" cy="491"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<!-- @formatter:on -->
|
||||||
|
<div class="z-10 relative flex flex-col items-center">
|
||||||
|
<h2 class="text-xl font-semibold">FUSE ACADEMY</h2>
|
||||||
|
<div class="mt-1 text-4xl sm:text-7xl font-extrabold tracking-tight leading-tight text-center">
|
||||||
|
What do you want to learn today?
|
||||||
|
</div>
|
||||||
|
<div class="max-w-2xl mt-6 sm:text-2xl text-center tracking-tight text-secondary">
|
||||||
|
Our courses will step you through the process of a building small applications, or adding new features to existing applications.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-auto p-6 sm:p-10">
|
||||||
|
|
||||||
|
<div class="flex flex-col flex-auto w-full max-w-xs sm:max-w-5xl mx-auto">
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-between w-full max-w-xs sm:max-w-none">
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full sm:w-36"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-select
|
||||||
|
[value]="'all'"
|
||||||
|
(selectionChange)="filterByCategory($event)">
|
||||||
|
<mat-option [value]="'all'">All</mat-option>
|
||||||
|
<ng-container *ngFor="let category of categories; trackBy: trackByFn">
|
||||||
|
<mat-option [value]="category.slug">{{category.title}}</mat-option>
|
||||||
|
</ng-container>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full sm:w-72 mt-4 sm:mt-0 sm:ml-4"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:search'"></mat-icon>
|
||||||
|
<input
|
||||||
|
(input)="filterByQuery(query.value)"
|
||||||
|
placeholder="Search by title or description"
|
||||||
|
matInput
|
||||||
|
#query>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-slide-toggle
|
||||||
|
class="mt-8 sm:mt-0 sm:ml-auto"
|
||||||
|
[color]="'primary'"
|
||||||
|
(change)="toggleCompleted($event)">
|
||||||
|
Hide completed
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
<!-- Courses -->
|
||||||
|
<ng-container *ngIf="this.filteredCourses.length; else noCourses">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 mt-8 sm:mt-10">
|
||||||
|
<ng-container *ngFor="let course of filteredCourses; trackBy: trackByFn">
|
||||||
|
<!-- Course -->
|
||||||
|
<div class="flex flex-col h-96 shadow rounded-2xl overflow-hidden bg-card">
|
||||||
|
<div class="flex flex-col p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<!-- Course category -->
|
||||||
|
<ng-container *ngIf="(course.category | fuseFindByKey:'slug':categories) as category">
|
||||||
|
<div
|
||||||
|
class="py-0.5 px-3 rounded-full text-sm font-semibold"
|
||||||
|
[ngClass]="{'text-blue-800 bg-blue-100 dark:text-blue-50 dark:bg-blue-500': category.slug === 'web',
|
||||||
|
'text-green-800 bg-green-100 dark:text-green-50 dark:bg-green-500': category.slug === 'android',
|
||||||
|
'text-pink-800 bg-pink-100 dark:text-pink-50 dark:bg-pink-500': category.slug === 'cloud',
|
||||||
|
'text-amber-800 bg-amber-100 dark:text-amber-50 dark:bg-amber-500': category.slug === 'firebase'}">
|
||||||
|
{{category.title}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Completed at least once -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ng-container *ngIf="course.progress.completed > 0">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 text-green-600"
|
||||||
|
[svgIcon]="'heroicons_solid:badge-check'"
|
||||||
|
[matTooltip]="'You completed this course at least once'"></mat-icon>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Course title & description -->
|
||||||
|
<div class="mt-4 text-lg font-medium">{{course.title}}</div>
|
||||||
|
<div class="mt-0.5 line-clamp-2 text-secondary">{{course.description}}</div>
|
||||||
|
<div class="w-12 h-1 my-6 border-t-2"></div>
|
||||||
|
<!-- Course time -->
|
||||||
|
<div class="flex items-center leading-5 text-md text-secondary">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 text-hint"
|
||||||
|
[svgIcon]="'heroicons_solid:clock'"></mat-icon>
|
||||||
|
<div class="ml-1.5">{{course.duration}} minutes</div>
|
||||||
|
</div>
|
||||||
|
<!-- Course completion -->
|
||||||
|
<div class="flex items-center mt-2 leading-5 text-md text-secondary">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 text-hint"
|
||||||
|
[svgIcon]="'heroicons_solid:academic-cap'"></mat-icon>
|
||||||
|
<ng-container *ngIf="course.progress.completed === 0">
|
||||||
|
<div class="ml-1.5">Never completed</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="course.progress.completed > 0">
|
||||||
|
<div class="ml-1.5">
|
||||||
|
<span>Completed</span>
|
||||||
|
<span class="ml-1">
|
||||||
|
<!-- Once -->
|
||||||
|
<ng-container *ngIf="course.progress.completed === 1">once</ng-container>
|
||||||
|
<!-- Twice -->
|
||||||
|
<ng-container *ngIf="course.progress.completed === 2">twice</ng-container>
|
||||||
|
<!-- Others -->
|
||||||
|
<ng-container *ngIf="course.progress.completed > 2">{{course.progress.completed}}
|
||||||
|
{{course.progress.completed | i18nPlural: {
|
||||||
|
'=0' : 'time',
|
||||||
|
'=1' : 'time',
|
||||||
|
'other': 'times'
|
||||||
|
} }}
|
||||||
|
</ng-container>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex flex-col w-full mt-auto">
|
||||||
|
<!-- Course progress -->
|
||||||
|
<div class="relative h-0.5">
|
||||||
|
<div
|
||||||
|
class="z-10 absolute inset-x-0 h-6 -mt-3"
|
||||||
|
[matTooltip]="course.progress.currentStep / course.totalSteps | percent"
|
||||||
|
[matTooltipPosition]="'above'"
|
||||||
|
[matTooltipClass]="'-mb-0.5'"></div>
|
||||||
|
<mat-progress-bar
|
||||||
|
class="h-0.5"
|
||||||
|
[value]="(100 * course.progress.currentStep) / course.totalSteps"></mat-progress-bar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Course launch button -->
|
||||||
|
<div class="px-6 py-4 text-right bg-gray-50 dark:bg-transparent">
|
||||||
|
<a
|
||||||
|
mat-stroked-button
|
||||||
|
[routerLink]="[course.id]">
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
|
||||||
|
<!-- Not started -->
|
||||||
|
<ng-container *ngIf="course.progress.currentStep === 0">
|
||||||
|
<!-- Never completed -->
|
||||||
|
<ng-container *ngIf="course.progress.completed === 0">
|
||||||
|
<span>Start</span>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Completed before -->
|
||||||
|
<ng-container *ngIf="course.progress.completed > 0">
|
||||||
|
<span>Start again</span>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Started -->
|
||||||
|
<ng-container *ngIf="course.progress.currentStep > 0">
|
||||||
|
<span>Continue</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<mat-icon
|
||||||
|
class="ml-1.5 icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-sm-right'"></mat-icon>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- No courses -->
|
||||||
|
<ng-template #noCourses>
|
||||||
|
<div class="flex flex-auto flex-col items-center justify-center bg-gray-100 dark:bg-transparent">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-24"
|
||||||
|
[svgIcon]="'heroicons_outline:document-search'"></mat-icon>
|
||||||
|
<div class="mt-6 text-2xl font-semibold tracking-tight text-secondary">No courses found!</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
156
src/app/modules/admin/apps/academy/list/list.component.ts
Normal file
156
src/app/modules/admin/apps/academy/list/list.component.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatSelectChange } from '@angular/material/select';
|
||||||
|
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
|
||||||
|
import { BehaviorSubject, combineLatest, Subject, takeUntil } from 'rxjs';
|
||||||
|
import { AcademyService } from 'app/modules/admin/apps/academy/academy.service';
|
||||||
|
import { Category, Course } from 'app/modules/admin/apps/academy/academy.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'academy-list',
|
||||||
|
templateUrl : './list.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class AcademyListComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
categories: Category[];
|
||||||
|
courses: Course[];
|
||||||
|
filteredCourses: Course[];
|
||||||
|
filters: {
|
||||||
|
categorySlug$: BehaviorSubject<string>;
|
||||||
|
query$: BehaviorSubject<string>;
|
||||||
|
hideCompleted$: BehaviorSubject<boolean>;
|
||||||
|
} = {
|
||||||
|
categorySlug$ : new BehaviorSubject('all'),
|
||||||
|
query$ : new BehaviorSubject(''),
|
||||||
|
hideCompleted$: new BehaviorSubject(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _activatedRoute: ActivatedRoute,
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _router: Router,
|
||||||
|
private _academyService: AcademyService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the categories
|
||||||
|
this._academyService.categories$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((categories: Category[]) => {
|
||||||
|
this.categories = categories;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the courses
|
||||||
|
this._academyService.courses$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((courses: Course[]) => {
|
||||||
|
this.courses = this.filteredCourses = courses;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter the courses
|
||||||
|
combineLatest([this.filters.categorySlug$, this.filters.query$, this.filters.hideCompleted$])
|
||||||
|
.subscribe(([categorySlug, query, hideCompleted]) => {
|
||||||
|
|
||||||
|
// Reset the filtered courses
|
||||||
|
this.filteredCourses = this.courses;
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if ( categorySlug !== 'all' )
|
||||||
|
{
|
||||||
|
this.filteredCourses = this.filteredCourses.filter(course => course.category === categorySlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if ( query !== '' )
|
||||||
|
{
|
||||||
|
this.filteredCourses = this.filteredCourses.filter(course => course.title.toLowerCase().includes(query.toLowerCase())
|
||||||
|
|| course.description.toLowerCase().includes(query.toLowerCase())
|
||||||
|
|| course.category.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by completed
|
||||||
|
if ( hideCompleted )
|
||||||
|
{
|
||||||
|
this.filteredCourses = this.filteredCourses.filter(course => course.progress.completed === 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by search query
|
||||||
|
*
|
||||||
|
* @param query
|
||||||
|
*/
|
||||||
|
filterByQuery(query: string): void
|
||||||
|
{
|
||||||
|
this.filters.query$.next(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by category
|
||||||
|
*
|
||||||
|
* @param change
|
||||||
|
*/
|
||||||
|
filterByCategory(change: MatSelectChange): void
|
||||||
|
{
|
||||||
|
this.filters.categorySlug$.next(change.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show/hide completed courses
|
||||||
|
*
|
||||||
|
* @param change
|
||||||
|
*/
|
||||||
|
toggleCompleted(change: MatSlideToggleChange): void
|
||||||
|
{
|
||||||
|
this.filters.hideCompleted$.next(change.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/modules/admin/apps/chat/chat.component.html
Normal file
8
src/app/modules/admin/apps/chat/chat.component.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="absolute inset-0 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-auto overflow-hidden">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
17
src/app/modules/admin/apps/chat/chat.component.ts
Normal file
17
src/app/modules/admin/apps/chat/chat.component.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'chat',
|
||||||
|
templateUrl : './chat.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ChatComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/modules/admin/apps/chat/chat.module.ts
Normal file
44
src/app/modules/admin/apps/chat/chat.module.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
|
import { SharedModule } from 'app/shared/shared.module';
|
||||||
|
import { chatRoutes } from 'app/modules/admin/apps/chat/chat.routing';
|
||||||
|
import { ChatComponent } from 'app/modules/admin/apps/chat/chat.component';
|
||||||
|
import { ChatsComponent } from 'app/modules/admin/apps/chat/chats/chats.component';
|
||||||
|
import { ContactInfoComponent } from 'app/modules/admin/apps/chat/contact-info/contact-info.component';
|
||||||
|
import { EmptyConversationComponent } from 'app/modules/admin/apps/chat/empty-conversation/empty-conversation.component';
|
||||||
|
import { ConversationComponent } from 'app/modules/admin/apps/chat/conversation/conversation.component';
|
||||||
|
import { NewChatComponent } from 'app/modules/admin/apps/chat/new-chat/new-chat.component';
|
||||||
|
import { ProfileComponent } from 'app/modules/admin/apps/chat/profile/profile.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
ChatComponent,
|
||||||
|
ChatsComponent,
|
||||||
|
ContactInfoComponent,
|
||||||
|
ConversationComponent,
|
||||||
|
EmptyConversationComponent,
|
||||||
|
NewChatComponent,
|
||||||
|
ProfileComponent
|
||||||
|
],
|
||||||
|
imports : [
|
||||||
|
RouterModule.forChild(chatRoutes),
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
SharedModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ChatModule
|
||||||
|
{
|
||||||
|
}
|
||||||
146
src/app/modules/admin/apps/chat/chat.resolvers.ts
Normal file
146
src/app/modules/admin/apps/chat/chat.resolvers.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { catchError, Observable, throwError } from 'rxjs';
|
||||||
|
import { ChatService } from 'app/modules/admin/apps/chat/chat.service';
|
||||||
|
import { Chat, Contact, Profile } from 'app/modules/admin/apps/chat/chat.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ChatChatsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _chatService: ChatService,
|
||||||
|
private _router: Router
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Chat[]> | any
|
||||||
|
{
|
||||||
|
return this._chatService.getChats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ChatChatResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _chatService: ChatService,
|
||||||
|
private _router: Router
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Chat>
|
||||||
|
{
|
||||||
|
return this._chatService.getChatById(route.paramMap.get('id'))
|
||||||
|
.pipe(
|
||||||
|
// Error here means the requested chat is not available
|
||||||
|
catchError((error) => {
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
// Get the parent url
|
||||||
|
const parentUrl = state.url.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Navigate to there
|
||||||
|
this._router.navigateByUrl(parentUrl);
|
||||||
|
|
||||||
|
// Throw an error
|
||||||
|
return throwError(error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ChatContactsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _chatService: ChatService,
|
||||||
|
private _router: Router
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Contact[]> | any
|
||||||
|
{
|
||||||
|
return this._chatService.getContacts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ChatProfileResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _chatService: ChatService,
|
||||||
|
private _router: Router
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Profile> | any
|
||||||
|
{
|
||||||
|
return this._chatService.getProfile();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/modules/admin/apps/chat/chat.routing.ts
Normal file
38
src/app/modules/admin/apps/chat/chat.routing.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { ChatChatResolver, ChatChatsResolver, ChatContactsResolver, ChatProfileResolver } from 'app/modules/admin/apps/chat/chat.resolvers';
|
||||||
|
import { ChatComponent } from 'app/modules/admin/apps/chat/chat.component';
|
||||||
|
import { ChatsComponent } from 'app/modules/admin/apps/chat/chats/chats.component';
|
||||||
|
import { ConversationComponent } from 'app/modules/admin/apps/chat/conversation/conversation.component';
|
||||||
|
import { EmptyConversationComponent } from 'app/modules/admin/apps/chat/empty-conversation/empty-conversation.component';
|
||||||
|
|
||||||
|
export const chatRoutes: Route[] = [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: ChatComponent,
|
||||||
|
resolve : {
|
||||||
|
chats : ChatChatsResolver,
|
||||||
|
contacts: ChatContactsResolver,
|
||||||
|
profile : ChatProfileResolver
|
||||||
|
},
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: ChatsComponent,
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
component: EmptyConversationComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : ':id',
|
||||||
|
component: ConversationComponent,
|
||||||
|
resolve : {
|
||||||
|
conversation: ChatChatResolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
201
src/app/modules/admin/apps/chat/chat.service.ts
Normal file
201
src/app/modules/admin/apps/chat/chat.service.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, filter, map, Observable, of, switchMap, take, tap, throwError } from 'rxjs';
|
||||||
|
import { Chat, Contact, Profile } from 'app/modules/admin/apps/chat/chat.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ChatService
|
||||||
|
{
|
||||||
|
private _chat: BehaviorSubject<Chat> = new BehaviorSubject(null);
|
||||||
|
private _chats: BehaviorSubject<Chat[]> = new BehaviorSubject(null);
|
||||||
|
private _contact: BehaviorSubject<Contact> = new BehaviorSubject(null);
|
||||||
|
private _contacts: BehaviorSubject<Contact[]> = new BehaviorSubject(null);
|
||||||
|
private _profile: BehaviorSubject<Profile> = new BehaviorSubject(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _httpClient: HttpClient)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Accessors
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for chat
|
||||||
|
*/
|
||||||
|
get chat$(): Observable<Chat>
|
||||||
|
{
|
||||||
|
return this._chat.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for chats
|
||||||
|
*/
|
||||||
|
get chats$(): Observable<Chat[]>
|
||||||
|
{
|
||||||
|
return this._chats.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for contact
|
||||||
|
*/
|
||||||
|
get contact$(): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this._contact.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for contacts
|
||||||
|
*/
|
||||||
|
get contacts$(): Observable<Contact[]>
|
||||||
|
{
|
||||||
|
return this._contacts.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for profile
|
||||||
|
*/
|
||||||
|
get profile$(): Observable<Profile>
|
||||||
|
{
|
||||||
|
return this._profile.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chats
|
||||||
|
*/
|
||||||
|
getChats(): Observable<any>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Chat[]>('api/apps/chat/chats').pipe(
|
||||||
|
tap((response: Chat[]) => {
|
||||||
|
this._chats.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contact
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
getContact(id: string): Observable<any>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Contact>('api/apps/chat/contacts', {params: {id}}).pipe(
|
||||||
|
tap((response: Contact) => {
|
||||||
|
this._contact.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contacts
|
||||||
|
*/
|
||||||
|
getContacts(): Observable<any>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Contact[]>('api/apps/chat/contacts').pipe(
|
||||||
|
tap((response: Contact[]) => {
|
||||||
|
this._contacts.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profile
|
||||||
|
*/
|
||||||
|
getProfile(): Observable<any>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Profile>('api/apps/chat/profile').pipe(
|
||||||
|
tap((response: Profile) => {
|
||||||
|
this._profile.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chat
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
getChatById(id: string): Observable<any>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Chat>('api/apps/chat/chat', {params: {id}}).pipe(
|
||||||
|
map((chat) => {
|
||||||
|
|
||||||
|
// Update the chat
|
||||||
|
this._chat.next(chat);
|
||||||
|
|
||||||
|
// Return the chat
|
||||||
|
return chat;
|
||||||
|
}),
|
||||||
|
switchMap((chat) => {
|
||||||
|
|
||||||
|
if ( !chat )
|
||||||
|
{
|
||||||
|
return throwError('Could not found chat with id of ' + id + '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(chat);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update chat
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* @param chat
|
||||||
|
*/
|
||||||
|
updateChat(id: string, chat: Chat): Observable<Chat>
|
||||||
|
{
|
||||||
|
return this.chats$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(chats => this._httpClient.patch<Chat>('api/apps/chat/chat', {
|
||||||
|
id,
|
||||||
|
chat
|
||||||
|
}).pipe(
|
||||||
|
map((updatedChat) => {
|
||||||
|
|
||||||
|
// Find the index of the updated chat
|
||||||
|
const index = chats.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Update the chat
|
||||||
|
chats[index] = updatedChat;
|
||||||
|
|
||||||
|
// Update the chats
|
||||||
|
this._chats.next(chats);
|
||||||
|
|
||||||
|
// Return the updated contact
|
||||||
|
return updatedChat;
|
||||||
|
}),
|
||||||
|
switchMap(updatedChat => this.chat$.pipe(
|
||||||
|
take(1),
|
||||||
|
filter(item => item && item.id === id),
|
||||||
|
tap(() => {
|
||||||
|
|
||||||
|
// Update the chat if it's selected
|
||||||
|
this._chat.next(updatedChat);
|
||||||
|
|
||||||
|
// Return the updated chat
|
||||||
|
return updatedChat;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the selected chat
|
||||||
|
*/
|
||||||
|
resetChat(): void
|
||||||
|
{
|
||||||
|
this._chat.next(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/modules/admin/apps/chat/chat.types.ts
Normal file
55
src/app/modules/admin/apps/chat/chat.types.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export interface Profile
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
about?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
avatar?: string;
|
||||||
|
name?: string;
|
||||||
|
about?: string;
|
||||||
|
details?: {
|
||||||
|
emails?: {
|
||||||
|
email?: string;
|
||||||
|
label?: string;
|
||||||
|
}[];
|
||||||
|
phoneNumbers?: {
|
||||||
|
country?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
label?: string;
|
||||||
|
}[];
|
||||||
|
title?: string;
|
||||||
|
company?: string;
|
||||||
|
birthday?: string;
|
||||||
|
address?: string;
|
||||||
|
};
|
||||||
|
attachments?: {
|
||||||
|
media?: any[];
|
||||||
|
docs?: any[];
|
||||||
|
links?: any[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chat
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
contactId?: string;
|
||||||
|
contact?: Contact;
|
||||||
|
unreadCount?: number;
|
||||||
|
muted?: boolean;
|
||||||
|
lastMessage?: string;
|
||||||
|
lastMessageAt?: string;
|
||||||
|
messages?: {
|
||||||
|
id?: string;
|
||||||
|
chatId?: string;
|
||||||
|
contactId?: string;
|
||||||
|
isMine?: boolean;
|
||||||
|
value?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
191
src/app/modules/admin/apps/chat/chats/chats.component.html
Normal file
191
src/app/modules/admin/apps/chat/chats/chats.component.html
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<div class="relative flex flex-auto w-full bg-card dark:bg-transparent">
|
||||||
|
|
||||||
|
<mat-drawer-container
|
||||||
|
class="flex-auto h-full"
|
||||||
|
[hasBackdrop]="false">
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<mat-drawer
|
||||||
|
class="w-full sm:w-100 lg:border-r lg:shadow-none dark:bg-gray-900"
|
||||||
|
[autoFocus]="false"
|
||||||
|
[(opened)]="drawerOpened"
|
||||||
|
#drawer>
|
||||||
|
|
||||||
|
<!-- New chat -->
|
||||||
|
<ng-container *ngIf="drawerComponent === 'new-chat'">
|
||||||
|
<chat-new-chat [drawer]="drawer"></chat-new-chat>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Profile -->
|
||||||
|
<ng-container *ngIf="drawerComponent === 'profile'">
|
||||||
|
<chat-profile [drawer]="drawer"></chat-profile>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</mat-drawer>
|
||||||
|
|
||||||
|
<!-- Drawer content -->
|
||||||
|
<mat-drawer-content class="flex overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Chats list -->
|
||||||
|
<ng-container *ngIf="chats && chats.length > 0; else noChats">
|
||||||
|
<div class="relative flex flex-auto flex-col w-full min-w-0 lg:min-w-100 lg:max-w-100 bg-card dark:bg-transparent">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col flex-0 py-4 px-8 border-b bg-gray-50 dark:bg-transparent">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="flex items-center mr-1 cursor-pointer"
|
||||||
|
(click)="openProfile()">
|
||||||
|
<div class="w-10 h-10">
|
||||||
|
<ng-container *ngIf="profile.avatar">
|
||||||
|
<img
|
||||||
|
class="object-cover w-full h-full rounded-full object-cover"
|
||||||
|
[src]="profile.avatar"
|
||||||
|
alt="Profile avatar"/>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!profile.avatar">
|
||||||
|
<div class="flex items-center justify-center w-full h-full rounded-full text-lg uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{profile.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 font-medium truncate">{{profile.name}}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="ml-auto"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="openNewChat()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:plus-circle'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ml-1 -mr-4"
|
||||||
|
mat-icon-button
|
||||||
|
[matMenuTriggerFor]="chatsHeaderMenu">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:dots-vertical'"></mat-icon>
|
||||||
|
<mat-menu #chatsHeaderMenu>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:user-group'"></mat-icon>
|
||||||
|
New group
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:chat-alt-2'"></mat-icon>
|
||||||
|
Create a room
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="openProfile()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:user-circle'"></mat-icon>
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:archive'"></mat-icon>
|
||||||
|
Archived
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:star'"></mat-icon>
|
||||||
|
Starred
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:cog'"></mat-icon>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<mat-form-field
|
||||||
|
class="fuse-mat-rounded fuse-mat-dense w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:search'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[autocomplete]="'off'"
|
||||||
|
[placeholder]="'Search or start new chat'"
|
||||||
|
(input)="filterChats(searchField.value)"
|
||||||
|
#searchField>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chats -->
|
||||||
|
<div class="flex-auto overflow-y-auto">
|
||||||
|
<ng-container *ngIf="filteredChats.length > 0; else noChats">
|
||||||
|
<ng-container *ngFor="let chat of filteredChats; trackBy: trackByFn">
|
||||||
|
<a
|
||||||
|
class="z-20 flex items-center py-5 px-8 cursor-pointer border-b"
|
||||||
|
[ngClass]="{'hover:bg-gray-100 dark:hover:bg-hover': !selectedChat || selectedChat.id !== chat.id,
|
||||||
|
'bg-primary-50 dark:bg-hover': selectedChat && selectedChat.id === chat.id}"
|
||||||
|
[routerLink]="[chat.id]">
|
||||||
|
<div class="relative flex flex-0 items-center justify-center w-10 h-10">
|
||||||
|
<ng-container *ngIf="chat.unreadCount > 0">
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 right-0 flex-0 w-2 h-2 -ml-0.5 rounded-full ring-2 ring-bg-card dark:ring-gray-900 bg-primary dark:bg-primary-500 text-on-primary"
|
||||||
|
[class.ring-primary-50]="selectedChat && selectedChat.id === chat.id"></div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="chat.contact.avatar">
|
||||||
|
<img
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
[src]="chat.contact.avatar"
|
||||||
|
alt="Contact avatar"/>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!chat.contact.avatar">
|
||||||
|
<div class="flex items-center justify-center w-full h-full rounded-full text-lg uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{chat.contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 ml-4">
|
||||||
|
<div class="font-medium leading-5 truncate">{{chat.contact.name}}</div>
|
||||||
|
<div
|
||||||
|
class="leading-5 truncate text-secondary"
|
||||||
|
[class.text-primary]="chat.unreadCount > 0"
|
||||||
|
[class.dark:text-primary-500]="chat.unreadCount > 0">
|
||||||
|
{{chat.lastMessage}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end self-start ml-auto pl-2">
|
||||||
|
<div class="text-sm leading-5 text-secondary">{{chat.lastMessageAt}}</div>
|
||||||
|
<ng-container *ngIf="chat.muted">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 text-hint"
|
||||||
|
[svgIcon]="'heroicons_solid:volume-off'"></mat-icon>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- No chats template -->
|
||||||
|
<ng-template #noChats>
|
||||||
|
<div class="flex flex-auto flex-col items-center justify-center h-full">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-24"
|
||||||
|
[svgIcon]="'heroicons_outline:chat'"></mat-icon>
|
||||||
|
<div class="mt-4 text-2xl font-semibold tracking-tight text-secondary">No chats</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- Conversation -->
|
||||||
|
<ng-container *ngIf="chats && chats.length > 0">
|
||||||
|
<div
|
||||||
|
class="flex-auto border-l"
|
||||||
|
[ngClass]="{'z-20 absolute inset-0 lg:static lg:inset-auto flex': selectedChat && selectedChat.id,
|
||||||
|
'hidden lg:flex': !selectedChat || !selectedChat.id}">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</mat-drawer-content>
|
||||||
|
|
||||||
|
</mat-drawer-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
137
src/app/modules/admin/apps/chat/chats/chats.component.ts
Normal file
137
src/app/modules/admin/apps/chat/chats/chats.component.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { Chat, Profile } from 'app/modules/admin/apps/chat/chat.types';
|
||||||
|
import { ChatService } from 'app/modules/admin/apps/chat/chat.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'chat-chats',
|
||||||
|
templateUrl : './chats.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ChatsComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
chats: Chat[];
|
||||||
|
drawerComponent: 'profile' | 'new-chat';
|
||||||
|
drawerOpened: boolean = false;
|
||||||
|
filteredChats: Chat[];
|
||||||
|
profile: Profile;
|
||||||
|
selectedChat: Chat;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _chatService: ChatService,
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Chats
|
||||||
|
this._chatService.chats$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((chats: Chat[]) => {
|
||||||
|
this.chats = this.filteredChats = chats;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
this._chatService.profile$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((profile: Profile) => {
|
||||||
|
this.profile = profile;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selected chat
|
||||||
|
this._chatService.chat$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((chat: Chat) => {
|
||||||
|
this.selectedChat = chat;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter the chats
|
||||||
|
*
|
||||||
|
* @param query
|
||||||
|
*/
|
||||||
|
filterChats(query: string): void
|
||||||
|
{
|
||||||
|
// Reset the filter
|
||||||
|
if ( !query )
|
||||||
|
{
|
||||||
|
this.filteredChats = this.chats;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filteredChats = this.chats.filter(chat => chat.contact.name.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the new chat sidebar
|
||||||
|
*/
|
||||||
|
openNewChat(): void
|
||||||
|
{
|
||||||
|
this.drawerComponent = 'new-chat';
|
||||||
|
this.drawerOpened = true;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the profile sidebar
|
||||||
|
*/
|
||||||
|
openProfile(): void
|
||||||
|
{
|
||||||
|
this.drawerComponent = 'profile';
|
||||||
|
this.drawerOpened = true;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<div class="flex flex-col flex-auto h-full bg-card dark:bg-default">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-0 items-center h-18 px-4 border-b bg-gray-50 dark:bg-transparent">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="drawer.close()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:x'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="ml-2 text-lg font-medium">Contact info</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<!-- Contact avatar & info -->
|
||||||
|
<div class="flex flex-col items-center mt-8">
|
||||||
|
<div class="w-40 h-40 rounded-full">
|
||||||
|
<ng-container *ngIf="chat.contact.avatar">
|
||||||
|
<img
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
[src]="chat.contact.avatar"
|
||||||
|
[alt]="'Contact avatar'">
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!chat.contact.avatar">
|
||||||
|
<div class="flex items-center justify-center w-full h-full rounded-full text-8xl font-semibold uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{chat.contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-lg font-medium">{{chat.contact.name}}</div>
|
||||||
|
<div class="mt-0.5 text-md text-secondary">{{chat.contact.about}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-10 px-7">
|
||||||
|
<!-- Media -->
|
||||||
|
<div class="text-lg font-medium">Media</div>
|
||||||
|
<div class="grid grid-cols-4 gap-1 mt-4">
|
||||||
|
<ng-container *ngFor="let media of chat.contact.attachments.media">
|
||||||
|
<img
|
||||||
|
class="h-20 rounded object-cover"
|
||||||
|
[src]="media"/>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="mt-10 space-y-4">
|
||||||
|
<div class="text-lg font-medium mb-3">Details</div>
|
||||||
|
<ng-container *ngIf="chat.contact.details.emails.length">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-secondary">Email</div>
|
||||||
|
<div class="">{{chat.contact.details.emails[0].email}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="chat.contact.details.phoneNumbers.length">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-secondary">Phone number</div>
|
||||||
|
<div class="">{{chat.contact.details.phoneNumbers[0].phoneNumber}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="chat.contact.details.title">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-secondary">Title</div>
|
||||||
|
<div class="">{{chat.contact.details.title}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="chat.contact.details.company">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-secondary">Company</div>
|
||||||
|
<div class="">{{chat.contact.details.company}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="chat.contact.details.birthday">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-secondary">Birthday</div>
|
||||||
|
<div class="">{{chat.contact.details.birthday}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="chat.contact.details.address">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-secondary">Address</div>
|
||||||
|
<div class="">{{chat.contact.details.address}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { MatDrawer } from '@angular/material/sidenav';
|
||||||
|
import { Chat } from 'app/modules/admin/apps/chat/chat.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'chat-contact-info',
|
||||||
|
templateUrl : './contact-info.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ContactInfoComponent
|
||||||
|
{
|
||||||
|
@Input() chat: Chat;
|
||||||
|
@Input() drawer: MatDrawer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<div class="flex flex-col flex-auto overflow-y-auto lg:overflow-hidden bg-card dark:bg-default">
|
||||||
|
|
||||||
|
<ng-container *ngIf="chat; else selectChatOrStartNew">
|
||||||
|
|
||||||
|
<mat-drawer-container
|
||||||
|
class="flex-auto h-full"
|
||||||
|
[hasBackdrop]="false">
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<mat-drawer
|
||||||
|
class="w-full sm:w-100 lg:border-l lg:shadow-none dark:bg-gray-900"
|
||||||
|
[autoFocus]="false"
|
||||||
|
[mode]="drawerMode"
|
||||||
|
[position]="'end'"
|
||||||
|
[(opened)]="drawerOpened"
|
||||||
|
#drawer>
|
||||||
|
|
||||||
|
<!-- Contact info -->
|
||||||
|
<chat-contact-info
|
||||||
|
[drawer]="drawer"
|
||||||
|
[chat]="chat"></chat-contact-info>
|
||||||
|
</mat-drawer>
|
||||||
|
|
||||||
|
<!-- Drawer content -->
|
||||||
|
<mat-drawer-content class="flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-0 items-center h-18 px-4 md:px-6 border-b bg-gray-50 dark:bg-transparent">
|
||||||
|
|
||||||
|
<!-- Back button -->
|
||||||
|
<a
|
||||||
|
class="lg:hidden md:-ml-2"
|
||||||
|
mat-icon-button
|
||||||
|
[routerLink]="['./']"
|
||||||
|
(click)="resetChat()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Contact info -->
|
||||||
|
<div
|
||||||
|
class="flex items-center ml-2 lg:ml-0 mr-2 cursor-pointer"
|
||||||
|
(click)="openContactInfo()">
|
||||||
|
<div class="relative flex flex-0 items-center justify-center w-10 h-10">
|
||||||
|
<ng-container *ngIf="chat.contact.avatar">
|
||||||
|
<img
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
[src]="chat.contact.avatar"
|
||||||
|
alt="Contact avatar"/>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!chat.contact.avatar">
|
||||||
|
<div class="flex items-center justify-center w-full h-full rounded-full text-lg uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{chat.contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 text-lg font-medium leading-5 truncate">{{chat.contact.name}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="ml-auto"
|
||||||
|
mat-icon-button
|
||||||
|
[matMenuTriggerFor]="conversationHeaderMenu">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:dots-vertical'"></mat-icon>
|
||||||
|
<mat-menu #conversationHeaderMenu>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="openContactInfo()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:user-circle'"></mat-icon>
|
||||||
|
Contact info
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:check-circle'"></mat-icon>
|
||||||
|
Select messages
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-menu-item
|
||||||
|
(click)="toggleMuteNotifications()">
|
||||||
|
<ng-container *ngIf="!chat.muted">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:volume-off'"></mat-icon>
|
||||||
|
Mute notifications
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="chat.muted">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:volume-up'"></mat-icon>
|
||||||
|
Unmute notifications
|
||||||
|
</ng-container>
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:backspace'"></mat-icon>
|
||||||
|
Clear messages
|
||||||
|
</button>
|
||||||
|
<button mat-menu-item>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||||
|
Delete chat
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation -->
|
||||||
|
<div class="flex overflow-y-auto flex-col-reverse">
|
||||||
|
<div class="flex flex-col flex-auto shrink p-6 bg-card dark:bg-transparent">
|
||||||
|
<ng-container *ngFor="let message of chat.messages; let i = index; let first = first; let last = last; trackBy: trackByFn">
|
||||||
|
<!-- Start of the day -->
|
||||||
|
<ng-container *ngIf="first || (chat.messages[i - 1].createdAt | date:'d') !== (message.createdAt | date:'d')">
|
||||||
|
<div class="flex items-center justify-center my-3 -mx-6">
|
||||||
|
<div class="flex-auto border-b"></div>
|
||||||
|
<div class="flex-0 mx-4 text-sm font-medium leading-5 text-secondary">
|
||||||
|
{{message.createdAt | date: 'longDate'}}
|
||||||
|
</div>
|
||||||
|
<div class="flex-auto border-b"></div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<div
|
||||||
|
class="flex flex-col"
|
||||||
|
[ngClass]="{'items-end': message.isMine,
|
||||||
|
'items-start': !message.isMine,
|
||||||
|
'mt-0.5': i > 0 && chat.messages[i - 1].isMine === message.isMine,
|
||||||
|
'mt-3': i > 0 && chat.messages[i - 1].isMine !== message.isMine}">
|
||||||
|
<!-- Bubble -->
|
||||||
|
<div
|
||||||
|
class="relative max-w-3/4 px-3 py-2 rounded-lg"
|
||||||
|
[ngClass]="{'bg-blue-500 text-blue-50': message.isMine,
|
||||||
|
'bg-gray-500 text-gray-50': !message.isMine}">
|
||||||
|
<!-- Speech bubble tail -->
|
||||||
|
<ng-container *ngIf="last || chat.messages[i + 1].isMine !== message.isMine">
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 w-3"
|
||||||
|
[ngClass]="{'text-blue-500 -right-1 -mr-px mb-px': message.isMine,
|
||||||
|
'text-gray-500 -left-1 -ml-px mb-px -scale-x-1': !message.isMine}">
|
||||||
|
<ng-container *ngTemplateOutlet="speechBubbleExtension"></ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Message -->
|
||||||
|
<div
|
||||||
|
class="min-w-4 leading-5"
|
||||||
|
[innerHTML]="message.value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Time -->
|
||||||
|
<ng-container
|
||||||
|
*ngIf="first
|
||||||
|
|| last
|
||||||
|
|| chat.messages[i + 1].isMine !== message.isMine
|
||||||
|
|| chat.messages[i + 1].createdAt !== message.createdAt">
|
||||||
|
<div
|
||||||
|
class="my-0.5 text-sm font-medium text-secondary"
|
||||||
|
[ngClass]="{'mr-3': message.isMine,
|
||||||
|
'ml-3': !message.isMine}">
|
||||||
|
{{message.createdAt | date:'HH:mm'}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message field -->
|
||||||
|
<div class="flex items-end p-4 border-t bg-gray-50 dark:bg-transparent">
|
||||||
|
<div class="flex items-center h-11 my-px">
|
||||||
|
<button mat-icon-button>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:emoji-happy'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ml-0.5"
|
||||||
|
mat-icon-button>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:paper-clip'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<mat-form-field
|
||||||
|
class="fuse-mat-dense fuse-mat-rounded fuse-mat-bold w-full ml-4"
|
||||||
|
subscriptSizing="dynamic">
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
cdkTextareaAutosize
|
||||||
|
#messageInput></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="flex items-center h-11 my-px ml-4">
|
||||||
|
<button
|
||||||
|
mat-icon-button>
|
||||||
|
<mat-icon
|
||||||
|
class="rotate-90"
|
||||||
|
[svgIcon]="'heroicons_outline:paper-airplane'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</mat-drawer-content>
|
||||||
|
|
||||||
|
</mat-drawer-container>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Select chat or start new template -->
|
||||||
|
<ng-template #selectChatOrStartNew>
|
||||||
|
<div class="flex flex-col flex-auto items-center justify-center bg-gray-100 dark:bg-transparent">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-24"
|
||||||
|
[svgIcon]="'heroicons_outline:chat'"></mat-icon>
|
||||||
|
<div class="mt-4 text-2xl font-semibold tracking-tight text-secondary">Select a conversation or start a new chat</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<!-- Speech bubble tail SVG -->
|
||||||
|
<!-- @formatter:off -->
|
||||||
|
<ng-template #speechBubbleExtension>
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 66 66" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M1.01522827,0.516204834 C-8.83532715,54.3062744 61.7609863,70.5215302 64.8009949,64.3061218 C68.8074951,54.8859711 30.1663208,52.9997559 37.5036011,0.516204834 L1.01522827,0.516204834 Z" fill="currentColor" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</ng-template>
|
||||||
|
<!-- @formatter:on -->
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, NgZone, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { FuseMediaWatcherService } from '@fuse/services/media-watcher';
|
||||||
|
import { Chat } from 'app/modules/admin/apps/chat/chat.types';
|
||||||
|
import { ChatService } from 'app/modules/admin/apps/chat/chat.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'chat-conversation',
|
||||||
|
templateUrl : './conversation.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ConversationComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
@ViewChild('messageInput') messageInput: ElementRef;
|
||||||
|
chat: Chat;
|
||||||
|
drawerMode: 'over' | 'side' = 'side';
|
||||||
|
drawerOpened: boolean = false;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _chatService: ChatService,
|
||||||
|
private _fuseMediaWatcherService: FuseMediaWatcherService,
|
||||||
|
private _ngZone: NgZone
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Decorated methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize on 'input' and 'ngModelChange' events
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
@HostListener('input')
|
||||||
|
@HostListener('ngModelChange')
|
||||||
|
private _resizeMessageInput(): void
|
||||||
|
{
|
||||||
|
// This doesn't need to trigger Angular's change detection by itself
|
||||||
|
this._ngZone.runOutsideAngular(() => {
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
|
||||||
|
// Set the height to 'auto' so we can correctly read the scrollHeight
|
||||||
|
this.messageInput.nativeElement.style.height = 'auto';
|
||||||
|
|
||||||
|
// Detect the changes so the height is applied
|
||||||
|
this._changeDetectorRef.detectChanges();
|
||||||
|
|
||||||
|
// Get the scrollHeight and subtract the vertical padding
|
||||||
|
this.messageInput.nativeElement.style.height = `${this.messageInput.nativeElement.scrollHeight}px`;
|
||||||
|
|
||||||
|
// Detect the changes one more time to apply the final height
|
||||||
|
this._changeDetectorRef.detectChanges();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Chat
|
||||||
|
this._chatService.chat$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((chat: Chat) => {
|
||||||
|
this.chat = chat;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to media changes
|
||||||
|
this._fuseMediaWatcherService.onMediaChange$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe(({matchingAliases}) => {
|
||||||
|
|
||||||
|
// Set the drawerMode if the given breakpoint is active
|
||||||
|
if ( matchingAliases.includes('lg') )
|
||||||
|
{
|
||||||
|
this.drawerMode = 'side';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.drawerMode = 'over';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the contact info
|
||||||
|
*/
|
||||||
|
openContactInfo(): void
|
||||||
|
{
|
||||||
|
// Open the drawer
|
||||||
|
this.drawerOpened = true;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the chat
|
||||||
|
*/
|
||||||
|
resetChat(): void
|
||||||
|
{
|
||||||
|
this._chatService.resetChat();
|
||||||
|
|
||||||
|
// Close the contact info in case it's opened
|
||||||
|
this.drawerOpened = false;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mute notifications
|
||||||
|
*/
|
||||||
|
toggleMuteNotifications(): void
|
||||||
|
{
|
||||||
|
// Toggle the muted
|
||||||
|
this.chat.muted = !this.chat.muted;
|
||||||
|
|
||||||
|
// Update the chat on the server
|
||||||
|
this._chatService.updateChat(this.chat.id, this.chat).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div class="flex flex-col flex-auto overflow-y-auto lg:overflow-hidden bg-card dark:bg-default">
|
||||||
|
|
||||||
|
<!-- Select chat or start new -->
|
||||||
|
<div class="flex flex-col flex-auto items-center justify-center bg-gray-100 dark:bg-transparent">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-24"
|
||||||
|
[svgIcon]="'heroicons_outline:chat'"></mat-icon>
|
||||||
|
<div class="mt-4 text-2xl font-semibold tracking-tight text-secondary">Select a conversation or start a new chat</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'chat-empty-conversation',
|
||||||
|
templateUrl : './empty-conversation.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class EmptyConversationComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<div class="flex flex-col flex-auto h-full overflow-hidden bg-card dark:bg-default">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-0 items-center h-18 -mb-px px-6 bg-gray-50 dark:bg-transparent">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="drawer.close()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="ml-2 text-2xl font-semibold">New chat</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative overflow-y-auto">
|
||||||
|
<ng-container *ngIf="contacts.length; else noContacts">
|
||||||
|
<ng-container *ngFor="let contact of contacts; let i = index; trackBy: trackByFn">
|
||||||
|
<!-- Group -->
|
||||||
|
<ng-container *ngIf="i === 0 || contact.name.charAt(0) !== contacts[i - 1].name.charAt(0)">
|
||||||
|
<div class="z-10 sticky top-0 -mt-px px-6 py-1 md:px-8 border-t border-b font-medium uppercase text-secondary bg-gray-100 dark:bg-gray-900">
|
||||||
|
{{contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Contact -->
|
||||||
|
<div class="z-20 flex items-center px-6 py-4 md:px-8 cursor-pointer border-b hover:bg-gray-100 dark:hover:bg-hover">
|
||||||
|
<div class="flex flex-0 items-center justify-center w-10 h-10 rounded-full overflow-hidden">
|
||||||
|
<ng-container *ngIf="contact.avatar">
|
||||||
|
<img
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
[src]="contact.avatar"
|
||||||
|
alt="Contact avatar"/>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!contact.avatar">
|
||||||
|
<div class="flex items-center justify-center w-full h-full rounded-full text-lg uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 ml-4">
|
||||||
|
<div class="font-medium leading-5 truncate">{{contact.name}}</div>
|
||||||
|
<div class="leading-5 truncate text-secondary">{{contact.about}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No contacts -->
|
||||||
|
<ng-template #noContacts>
|
||||||
|
<div class="p-8 sm:p-16 border-t text-4xl font-semibold tracking-tight text-center">There are no contacts!</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { MatDrawer } from '@angular/material/sidenav';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { Contact } from 'app/modules/admin/apps/chat/chat.types';
|
||||||
|
import { ChatService } from 'app/modules/admin/apps/chat/chat.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'chat-new-chat',
|
||||||
|
templateUrl : './new-chat.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class NewChatComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
@Input() drawer: MatDrawer;
|
||||||
|
contacts: Contact[] = [];
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _chatService: ChatService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Contacts
|
||||||
|
this._chatService.contacts$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((contacts: Contact[]) => {
|
||||||
|
this.contacts = contacts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<div class="flex flex-col flex-auto overflow-y-auto bg-card dark:bg-default">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-0 items-center h-18 px-6 border-b bg-gray-50 dark:bg-transparent">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="drawer.close()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<div class="ml-2 text-2xl font-semibold">Profile</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6">
|
||||||
|
<!-- Profile photo -->
|
||||||
|
<div class="group relative flex flex-0 mt-8 mx-auto w-40 h-40 rounded-full">
|
||||||
|
<div class="hidden group-hover:flex absolute inset-0 flex-col items-center justify-center backdrop-filter backdrop-blur bg-opacity-80 rounded-full cursor-pointer bg-gray-800">
|
||||||
|
<mat-icon
|
||||||
|
class="text-white"
|
||||||
|
[svgIcon]="'heroicons_outline:camera'"></mat-icon>
|
||||||
|
<div class="mt-2 mx-6 font-medium text-center text-white">Change Profile Photo</div>
|
||||||
|
</div>
|
||||||
|
<ng-container *ngIf="profile.avatar">
|
||||||
|
<img
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
[src]="profile.avatar"
|
||||||
|
[alt]="'Profile avatar'">
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!profile.avatar">
|
||||||
|
<div class="flex items-center justify-center w-full h-full rounded-full text-8xl font-semibold uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{profile.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile info -->
|
||||||
|
<div class="flex flex-col mt-8 mx-2">
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
matPrefix
|
||||||
|
[svgIcon]="'heroicons_solid:user-circle'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[ngModel]="profile.name">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>Email</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
matPrefix
|
||||||
|
[svgIcon]="'heroicons_solid:mail'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[ngModel]="profile.email">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field>
|
||||||
|
<mat-label>About</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
matPrefix
|
||||||
|
[svgIcon]="'heroicons_solid:identification'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[ngModel]="profile.about">
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="flex items-center justify-end mt-4">
|
||||||
|
<button
|
||||||
|
(click)="drawer.close()"
|
||||||
|
mat-button>Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ml-2"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'">Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
52
src/app/modules/admin/apps/chat/profile/profile.component.ts
Normal file
52
src/app/modules/admin/apps/chat/profile/profile.component.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { MatDrawer } from '@angular/material/sidenav';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { Profile } from 'app/modules/admin/apps/chat/chat.types';
|
||||||
|
import { ChatService } from 'app/modules/admin/apps/chat/chat.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'chat-profile',
|
||||||
|
templateUrl : './profile.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ProfileComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
@Input() drawer: MatDrawer;
|
||||||
|
profile: Profile;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _chatService: ChatService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Profile
|
||||||
|
this._chatService.profile$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((profile: Profile) => {
|
||||||
|
this.profile = profile;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
17
src/app/modules/admin/apps/contacts/contacts.component.ts
Normal file
17
src/app/modules/admin/apps/contacts/contacts.component.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'contacts',
|
||||||
|
templateUrl : './contacts.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ContactsComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/modules/admin/apps/contacts/contacts.guards.ts
Normal file
47
src/app/modules/admin/apps/contacts/contacts.guards.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, CanDeactivate, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ContactsDetailsComponent } from 'app/modules/admin/apps/contacts/details/details.component';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class CanDeactivateContactsDetails implements CanDeactivate<ContactsDetailsComponent>
|
||||||
|
{
|
||||||
|
canDeactivate(
|
||||||
|
component: ContactsDetailsComponent,
|
||||||
|
currentRoute: ActivatedRouteSnapshot,
|
||||||
|
currentState: RouterStateSnapshot,
|
||||||
|
nextState: RouterStateSnapshot
|
||||||
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
|
||||||
|
{
|
||||||
|
// Get the next route
|
||||||
|
let nextRoute: ActivatedRouteSnapshot = nextState.root;
|
||||||
|
while ( nextRoute.firstChild )
|
||||||
|
{
|
||||||
|
nextRoute = nextRoute.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the next state doesn't contain '/contacts'
|
||||||
|
// it means we are navigating away from the
|
||||||
|
// contacts app
|
||||||
|
if ( !nextState.url.includes('/contacts') )
|
||||||
|
{
|
||||||
|
// Let it navigate
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are navigating to another contact...
|
||||||
|
if ( nextRoute.paramMap.get('id') )
|
||||||
|
{
|
||||||
|
// Just navigate
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Otherwise...
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Close the drawer first, and then navigate
|
||||||
|
return component.closeDrawer().then(() => true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/modules/admin/apps/contacts/contacts.module.ts
Normal file
72
src/app/modules/admin/apps/contacts/contacts.module.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MAT_DATE_FORMATS, MatRippleModule } from '@angular/material/core';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatLuxonDateModule } from '@angular/material-luxon-adapter';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatRadioModule } from '@angular/material/radio';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { FuseFindByKeyPipeModule } from '@fuse/pipes/find-by-key';
|
||||||
|
import { SharedModule } from 'app/shared/shared.module';
|
||||||
|
import { contactsRoutes } from 'app/modules/admin/apps/contacts/contacts.routing';
|
||||||
|
import { ContactsComponent } from 'app/modules/admin/apps/contacts/contacts.component';
|
||||||
|
import { ContactsDetailsComponent } from 'app/modules/admin/apps/contacts/details/details.component';
|
||||||
|
import { ContactsListComponent } from 'app/modules/admin/apps/contacts/list/list.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
ContactsComponent,
|
||||||
|
ContactsListComponent,
|
||||||
|
ContactsDetailsComponent
|
||||||
|
],
|
||||||
|
imports : [
|
||||||
|
RouterModule.forChild(contactsRoutes),
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatLuxonDateModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatRadioModule,
|
||||||
|
MatRippleModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
FuseFindByKeyPipeModule,
|
||||||
|
SharedModule
|
||||||
|
],
|
||||||
|
providers : [
|
||||||
|
{
|
||||||
|
provide : MAT_DATE_FORMATS,
|
||||||
|
useValue: {
|
||||||
|
parse : {
|
||||||
|
dateInput: 'D'
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
dateInput : 'DDD',
|
||||||
|
monthYearLabel : 'LLL yyyy',
|
||||||
|
dateA11yLabel : 'DD',
|
||||||
|
monthYearA11yLabel: 'LLLL yyyy'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ContactsModule
|
||||||
|
{
|
||||||
|
}
|
||||||
137
src/app/modules/admin/apps/contacts/contacts.resolvers.ts
Normal file
137
src/app/modules/admin/apps/contacts/contacts.resolvers.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { catchError, Observable, throwError } from 'rxjs';
|
||||||
|
import { ContactsService } from 'app/modules/admin/apps/contacts/contacts.service';
|
||||||
|
import { Contact, Country, Tag } from 'app/modules/admin/apps/contacts/contacts.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ContactsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _contactsService: ContactsService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Contact[]>
|
||||||
|
{
|
||||||
|
return this._contactsService.getContacts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ContactsContactResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _contactsService: ContactsService,
|
||||||
|
private _router: Router
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this._contactsService.getContactById(route.paramMap.get('id'))
|
||||||
|
.pipe(
|
||||||
|
// Error here means the requested contact is not available
|
||||||
|
catchError((error) => {
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
// Get the parent url
|
||||||
|
const parentUrl = state.url.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Navigate to there
|
||||||
|
this._router.navigateByUrl(parentUrl);
|
||||||
|
|
||||||
|
// Throw an error
|
||||||
|
return throwError(error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ContactsCountriesResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _contactsService: ContactsService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Country[]>
|
||||||
|
{
|
||||||
|
return this._contactsService.getCountries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ContactsTagsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _contactsService: ContactsService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Tag[]>
|
||||||
|
{
|
||||||
|
return this._contactsService.getTags();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/modules/admin/apps/contacts/contacts.routing.ts
Normal file
37
src/app/modules/admin/apps/contacts/contacts.routing.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { CanDeactivateContactsDetails } from 'app/modules/admin/apps/contacts/contacts.guards';
|
||||||
|
import { ContactsContactResolver, ContactsCountriesResolver, ContactsResolver, ContactsTagsResolver } from 'app/modules/admin/apps/contacts/contacts.resolvers';
|
||||||
|
import { ContactsComponent } from 'app/modules/admin/apps/contacts/contacts.component';
|
||||||
|
import { ContactsListComponent } from 'app/modules/admin/apps/contacts/list/list.component';
|
||||||
|
import { ContactsDetailsComponent } from 'app/modules/admin/apps/contacts/details/details.component';
|
||||||
|
|
||||||
|
export const contactsRoutes: Route[] = [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: ContactsComponent,
|
||||||
|
resolve : {
|
||||||
|
tags: ContactsTagsResolver
|
||||||
|
},
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: ContactsListComponent,
|
||||||
|
resolve : {
|
||||||
|
contacts : ContactsResolver,
|
||||||
|
countries: ContactsCountriesResolver
|
||||||
|
},
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : ':id',
|
||||||
|
component : ContactsDetailsComponent,
|
||||||
|
resolve : {
|
||||||
|
contact : ContactsContactResolver,
|
||||||
|
countries: ContactsCountriesResolver
|
||||||
|
},
|
||||||
|
canDeactivate: [CanDeactivateContactsDetails]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
389
src/app/modules/admin/apps/contacts/contacts.service.ts
Normal file
389
src/app/modules/admin/apps/contacts/contacts.service.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, filter, map, Observable, of, switchMap, take, tap, throwError } from 'rxjs';
|
||||||
|
import { Contact, Country, Tag } from 'app/modules/admin/apps/contacts/contacts.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ContactsService
|
||||||
|
{
|
||||||
|
// Private
|
||||||
|
private _contact: BehaviorSubject<Contact | null> = new BehaviorSubject(null);
|
||||||
|
private _contacts: BehaviorSubject<Contact[] | null> = new BehaviorSubject(null);
|
||||||
|
private _countries: BehaviorSubject<Country[] | null> = new BehaviorSubject(null);
|
||||||
|
private _tags: BehaviorSubject<Tag[] | null> = new BehaviorSubject(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _httpClient: HttpClient)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Accessors
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for contact
|
||||||
|
*/
|
||||||
|
get contact$(): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this._contact.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for contacts
|
||||||
|
*/
|
||||||
|
get contacts$(): Observable<Contact[]>
|
||||||
|
{
|
||||||
|
return this._contacts.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for countries
|
||||||
|
*/
|
||||||
|
get countries$(): Observable<Country[]>
|
||||||
|
{
|
||||||
|
return this._countries.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for tags
|
||||||
|
*/
|
||||||
|
get tags$(): Observable<Tag[]>
|
||||||
|
{
|
||||||
|
return this._tags.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contacts
|
||||||
|
*/
|
||||||
|
getContacts(): Observable<Contact[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Contact[]>('api/apps/contacts/all').pipe(
|
||||||
|
tap((contacts) => {
|
||||||
|
this._contacts.next(contacts);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search contacts with given query
|
||||||
|
*
|
||||||
|
* @param query
|
||||||
|
*/
|
||||||
|
searchContacts(query: string): Observable<Contact[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Contact[]>('api/apps/contacts/search', {
|
||||||
|
params: {query}
|
||||||
|
}).pipe(
|
||||||
|
tap((contacts) => {
|
||||||
|
this._contacts.next(contacts);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contact by id
|
||||||
|
*/
|
||||||
|
getContactById(id: string): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this._contacts.pipe(
|
||||||
|
take(1),
|
||||||
|
map((contacts) => {
|
||||||
|
|
||||||
|
// Find the contact
|
||||||
|
const contact = contacts.find(item => item.id === id) || null;
|
||||||
|
|
||||||
|
// Update the contact
|
||||||
|
this._contact.next(contact);
|
||||||
|
|
||||||
|
// Return the contact
|
||||||
|
return contact;
|
||||||
|
}),
|
||||||
|
switchMap((contact) => {
|
||||||
|
|
||||||
|
if ( !contact )
|
||||||
|
{
|
||||||
|
return throwError('Could not found contact with id of ' + id + '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(contact);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create contact
|
||||||
|
*/
|
||||||
|
createContact(): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this.contacts$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(contacts => this._httpClient.post<Contact>('api/apps/contacts/contact', {}).pipe(
|
||||||
|
map((newContact) => {
|
||||||
|
|
||||||
|
// Update the contacts with the new contact
|
||||||
|
this._contacts.next([newContact, ...contacts]);
|
||||||
|
|
||||||
|
// Return the new contact
|
||||||
|
return newContact;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update contact
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* @param contact
|
||||||
|
*/
|
||||||
|
updateContact(id: string, contact: Contact): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this.contacts$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(contacts => this._httpClient.patch<Contact>('api/apps/contacts/contact', {
|
||||||
|
id,
|
||||||
|
contact
|
||||||
|
}).pipe(
|
||||||
|
map((updatedContact) => {
|
||||||
|
|
||||||
|
// Find the index of the updated contact
|
||||||
|
const index = contacts.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Update the contact
|
||||||
|
contacts[index] = updatedContact;
|
||||||
|
|
||||||
|
// Update the contacts
|
||||||
|
this._contacts.next(contacts);
|
||||||
|
|
||||||
|
// Return the updated contact
|
||||||
|
return updatedContact;
|
||||||
|
}),
|
||||||
|
switchMap(updatedContact => this.contact$.pipe(
|
||||||
|
take(1),
|
||||||
|
filter(item => item && item.id === id),
|
||||||
|
tap(() => {
|
||||||
|
|
||||||
|
// Update the contact if it's selected
|
||||||
|
this._contact.next(updatedContact);
|
||||||
|
|
||||||
|
// Return the updated contact
|
||||||
|
return updatedContact;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the contact
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
deleteContact(id: string): Observable<boolean>
|
||||||
|
{
|
||||||
|
return this.contacts$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(contacts => this._httpClient.delete('api/apps/contacts/contact', {params: {id}}).pipe(
|
||||||
|
map((isDeleted: boolean) => {
|
||||||
|
|
||||||
|
// Find the index of the deleted contact
|
||||||
|
const index = contacts.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Delete the contact
|
||||||
|
contacts.splice(index, 1);
|
||||||
|
|
||||||
|
// Update the contacts
|
||||||
|
this._contacts.next(contacts);
|
||||||
|
|
||||||
|
// Return the deleted status
|
||||||
|
return isDeleted;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get countries
|
||||||
|
*/
|
||||||
|
getCountries(): Observable<Country[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Country[]>('api/apps/contacts/countries').pipe(
|
||||||
|
tap((countries) => {
|
||||||
|
this._countries.next(countries);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tags
|
||||||
|
*/
|
||||||
|
getTags(): Observable<Tag[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Tag[]>('api/apps/contacts/tags').pipe(
|
||||||
|
tap((tags) => {
|
||||||
|
this._tags.next(tags);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tag
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
createTag(tag: Tag): Observable<Tag>
|
||||||
|
{
|
||||||
|
return this.tags$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(tags => this._httpClient.post<Tag>('api/apps/contacts/tag', {tag}).pipe(
|
||||||
|
map((newTag) => {
|
||||||
|
|
||||||
|
// Update the tags with the new tag
|
||||||
|
this._tags.next([...tags, newTag]);
|
||||||
|
|
||||||
|
// Return new tag from observable
|
||||||
|
return newTag;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the tag
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
updateTag(id: string, tag: Tag): Observable<Tag>
|
||||||
|
{
|
||||||
|
return this.tags$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(tags => this._httpClient.patch<Tag>('api/apps/contacts/tag', {
|
||||||
|
id,
|
||||||
|
tag
|
||||||
|
}).pipe(
|
||||||
|
map((updatedTag) => {
|
||||||
|
|
||||||
|
// Find the index of the updated tag
|
||||||
|
const index = tags.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Update the tag
|
||||||
|
tags[index] = updatedTag;
|
||||||
|
|
||||||
|
// Update the tags
|
||||||
|
this._tags.next(tags);
|
||||||
|
|
||||||
|
// Return the updated tag
|
||||||
|
return updatedTag;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the tag
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
deleteTag(id: string): Observable<boolean>
|
||||||
|
{
|
||||||
|
return this.tags$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(tags => this._httpClient.delete('api/apps/contacts/tag', {params: {id}}).pipe(
|
||||||
|
map((isDeleted: boolean) => {
|
||||||
|
|
||||||
|
// Find the index of the deleted tag
|
||||||
|
const index = tags.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Delete the tag
|
||||||
|
tags.splice(index, 1);
|
||||||
|
|
||||||
|
// Update the tags
|
||||||
|
this._tags.next(tags);
|
||||||
|
|
||||||
|
// Return the deleted status
|
||||||
|
return isDeleted;
|
||||||
|
}),
|
||||||
|
filter(isDeleted => isDeleted),
|
||||||
|
switchMap(isDeleted => this.contacts$.pipe(
|
||||||
|
take(1),
|
||||||
|
map((contacts) => {
|
||||||
|
|
||||||
|
// Iterate through the contacts
|
||||||
|
contacts.forEach((contact) => {
|
||||||
|
|
||||||
|
const tagIndex = contact.tags.findIndex(tag => tag === id);
|
||||||
|
|
||||||
|
// If the contact has the tag, remove it
|
||||||
|
if ( tagIndex > -1 )
|
||||||
|
{
|
||||||
|
contact.tags.splice(tagIndex, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the deleted status
|
||||||
|
return isDeleted;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the avatar of the given contact
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* @param avatar
|
||||||
|
*/
|
||||||
|
uploadAvatar(id: string, avatar: File): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this.contacts$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(contacts => this._httpClient.post<Contact>('api/apps/contacts/avatar', {
|
||||||
|
id,
|
||||||
|
avatar
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
'Content-Type': avatar.type
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
map((updatedContact) => {
|
||||||
|
|
||||||
|
// Find the index of the updated contact
|
||||||
|
const index = contacts.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Update the contact
|
||||||
|
contacts[index] = updatedContact;
|
||||||
|
|
||||||
|
// Update the contacts
|
||||||
|
this._contacts.next(contacts);
|
||||||
|
|
||||||
|
// Return the updated contact
|
||||||
|
return updatedContact;
|
||||||
|
}),
|
||||||
|
switchMap(updatedContact => this.contact$.pipe(
|
||||||
|
take(1),
|
||||||
|
filter(item => item && item.id === id),
|
||||||
|
tap(() => {
|
||||||
|
|
||||||
|
// Update the contact if it's selected
|
||||||
|
this._contact.next(updatedContact);
|
||||||
|
|
||||||
|
// Return the updated contact
|
||||||
|
return updatedContact;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/modules/admin/apps/contacts/contacts.types.ts
Normal file
37
src/app/modules/admin/apps/contacts/contacts.types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface Contact
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
background?: string | null;
|
||||||
|
name: string;
|
||||||
|
emails?: {
|
||||||
|
email: string;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
phoneNumbers?: {
|
||||||
|
country: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
title?: string;
|
||||||
|
company?: string;
|
||||||
|
birthday?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Country
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
iso: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
flagImagePos: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
<div class="flex flex-col w-full">
|
||||||
|
|
||||||
|
<!-- View mode -->
|
||||||
|
<ng-container *ngIf="!editMode">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="relative w-full h-40 sm:h-48 px-8 sm:px-12 bg-accent-100 dark:bg-accent-700">
|
||||||
|
<!-- Background -->
|
||||||
|
<ng-container *ngIf="contact.background">
|
||||||
|
<img
|
||||||
|
class="absolute inset-0 object-cover w-full h-full"
|
||||||
|
[src]="contact.background">
|
||||||
|
</ng-container>
|
||||||
|
<!-- Close button -->
|
||||||
|
<div class="flex items-center justify-end w-full max-w-3xl mx-auto pt-6">
|
||||||
|
<a
|
||||||
|
mat-icon-button
|
||||||
|
[matTooltip]="'Close'"
|
||||||
|
[routerLink]="['../']">
|
||||||
|
<mat-icon
|
||||||
|
class="text-white"
|
||||||
|
[svgIcon]="'heroicons_outline:x'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact -->
|
||||||
|
<div class="relative flex flex-col flex-auto items-center p-6 pt-0 sm:p-12 sm:pt-0">
|
||||||
|
<div class="w-full max-w-3xl">
|
||||||
|
|
||||||
|
<!-- Avatar and actions -->
|
||||||
|
<div class="flex flex-auto items-end -mt-16">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="flex items-center justify-center w-32 h-32 rounded-full overflow-hidden ring-4 ring-bg-card">
|
||||||
|
<img
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
*ngIf="contact.avatar"
|
||||||
|
[src]="contact.avatar">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-full h-full rounded overflow-hidden uppercase text-8xl font-bold leading-none bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
*ngIf="!contact.avatar">
|
||||||
|
{{contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center ml-auto mb-1">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="toggleEditMode(true)">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:pencil-alt'"></mat-icon>
|
||||||
|
<span class="ml-2">Edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="mt-3 text-4xl font-bold truncate">{{contact.name}}</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<ng-container *ngIf="contact.tags.length">
|
||||||
|
<div class="flex flex-wrap items-center mt-2">
|
||||||
|
<!-- Tag -->
|
||||||
|
<ng-container *ngFor="let tag of (contact.tags | fuseFindByKey:'id':tags); trackBy: trackByFn">
|
||||||
|
<div class="flex items-center justify-center py-1 px-3 mr-3 mb-3 rounded-full leading-normal text-gray-500 bg-gray-100 dark:text-gray-300 dark:bg-gray-700">
|
||||||
|
<span class="text-sm font-medium whitespace-nowrap">{{tag.title}}</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<div class="flex flex-col mt-4 pt-6 border-t space-y-8">
|
||||||
|
<!-- Title -->
|
||||||
|
<ng-container *ngIf="contact.title">
|
||||||
|
<div class="flex sm:items-center">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:briefcase'"></mat-icon>
|
||||||
|
<div class="ml-6 leading-6">{{contact.title}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Company -->
|
||||||
|
<ng-container *ngIf="contact.company">
|
||||||
|
<div class="flex sm:items-center">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:office-building'"></mat-icon>
|
||||||
|
<div class="ml-6 leading-6">{{contact.company}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Emails -->
|
||||||
|
<ng-container *ngIf="contact.emails.length">
|
||||||
|
<div class="flex">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:mail'"></mat-icon>
|
||||||
|
<div class="min-w-0 ml-6 space-y-1">
|
||||||
|
<ng-container *ngFor="let email of contact.emails; trackBy: trackByFn">
|
||||||
|
<div class="flex items-center leading-6">
|
||||||
|
<a
|
||||||
|
class="hover:underline text-primary-500"
|
||||||
|
[href]="'mailto:' + email.email"
|
||||||
|
target="_blank">
|
||||||
|
{{email.email}}
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="text-md truncate text-secondary"
|
||||||
|
*ngIf="email.label">
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span class="font-medium">{{email.label}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<ng-container *ngIf="contact.phoneNumbers.length">
|
||||||
|
<div class="flex">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:phone'"></mat-icon>
|
||||||
|
<div class="min-w-0 ml-6 space-y-1">
|
||||||
|
<ng-container *ngFor="let phoneNumber of contact.phoneNumbers; trackBy: trackByFn">
|
||||||
|
<div class="flex items-center leading-6">
|
||||||
|
<div
|
||||||
|
class="hidden sm:flex w-6 h-4 overflow-hidden"
|
||||||
|
[matTooltip]="getCountryByIso(phoneNumber.country).name"
|
||||||
|
[style.background]="'url(\'/assets/images/apps/contacts/flags.png\') no-repeat 0 0'"
|
||||||
|
[style.backgroundSize]="'24px 3876px'"
|
||||||
|
[style.backgroundPosition]="getCountryByIso(phoneNumber.country).flagImagePos"></div>
|
||||||
|
<div class="sm:ml-3 font-mono">{{getCountryByIso(phoneNumber.country).code}}</div>
|
||||||
|
<div class="ml-2.5 font-mono">{{phoneNumber.phoneNumber}}</div>
|
||||||
|
<div
|
||||||
|
class="text-md truncate text-secondary"
|
||||||
|
*ngIf="phoneNumber.label">
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span class="font-medium">{{phoneNumber.label}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<ng-container *ngIf="contact.address">
|
||||||
|
<div class="flex sm:items-center">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:location-marker'"></mat-icon>
|
||||||
|
<div class="ml-6 leading-6">{{contact.address}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Birthday -->
|
||||||
|
<ng-container *ngIf="contact.birthday">
|
||||||
|
<div class="flex sm:items-center">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:cake'"></mat-icon>
|
||||||
|
<div class="ml-6 leading-6">{{contact.birthday | date:'longDate'}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<ng-container *ngIf="contact.notes">
|
||||||
|
<div class="flex">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:menu-alt-2'"></mat-icon>
|
||||||
|
<div
|
||||||
|
class="max-w-none ml-6 prose prose-sm"
|
||||||
|
[innerHTML]="contact.notes"></div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<ng-container *ngIf="editMode">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="relative w-full h-40 sm:h-48 px-8 sm:px-12 bg-accent-100 dark:bg-accent-700">
|
||||||
|
<!-- Background -->
|
||||||
|
<ng-container *ngIf="contact.background">
|
||||||
|
<img
|
||||||
|
class="absolute inset-0 object-cover w-full h-full"
|
||||||
|
[src]="contact.background">
|
||||||
|
</ng-container>
|
||||||
|
<!-- Close button -->
|
||||||
|
<div class="flex items-center justify-end w-full max-w-3xl mx-auto pt-6">
|
||||||
|
<a
|
||||||
|
mat-icon-button
|
||||||
|
[matTooltip]="'Close'"
|
||||||
|
[routerLink]="['../']">
|
||||||
|
<mat-icon
|
||||||
|
class="text-white"
|
||||||
|
[svgIcon]="'heroicons_outline:x'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact form -->
|
||||||
|
<div class="relative flex flex-col flex-auto items-center px-6 sm:px-12">
|
||||||
|
<div class="w-full max-w-3xl">
|
||||||
|
<form [formGroup]="contactForm">
|
||||||
|
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="flex flex-auto items-end -mt-16">
|
||||||
|
<div class="relative flex items-center justify-center w-32 h-32 rounded-full overflow-hidden ring-4 ring-bg-card">
|
||||||
|
<!-- Upload / Remove avatar -->
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-50 z-10"></div>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center z-20">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="avatar-file-input"
|
||||||
|
class="absolute h-0 w-0 opacity-0 invisible pointer-events-none"
|
||||||
|
type="file"
|
||||||
|
[multiple]="false"
|
||||||
|
[accept]="'image/jpeg, image/png'"
|
||||||
|
(change)="uploadAvatar(avatarFileInput.files)"
|
||||||
|
#avatarFileInput>
|
||||||
|
<label
|
||||||
|
class="flex items-center justify-center w-10 h-10 rounded-full cursor-pointer hover:bg-hover"
|
||||||
|
for="avatar-file-input"
|
||||||
|
matRipple>
|
||||||
|
<mat-icon
|
||||||
|
class="text-white"
|
||||||
|
[svgIcon]="'heroicons_outline:camera'"></mat-icon>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="removeAvatar()">
|
||||||
|
<mat-icon
|
||||||
|
class="text-white"
|
||||||
|
[svgIcon]="'heroicons_outline:trash'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Image/Letter -->
|
||||||
|
<img
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
*ngIf="contact.avatar"
|
||||||
|
[src]="contact.avatar">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-full h-full rounded overflow-hidden uppercase text-8xl font-bold leading-none bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||||
|
*ngIf="!contact.avatar">
|
||||||
|
{{contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:user-circle'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'name'"
|
||||||
|
[placeholder]="'Name'"
|
||||||
|
[spellcheck]="false">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="flex flex-wrap items-center -m-1.5 mt-6">
|
||||||
|
<!-- Tags -->
|
||||||
|
<ng-container *ngIf="contact.tags.length">
|
||||||
|
<ng-container *ngFor="let tag of (contact.tags | fuseFindByKey:'id':tags); trackBy: trackByFn">
|
||||||
|
<div class="flex items-center justify-center px-4 m-1.5 rounded-full leading-9 text-gray-500 bg-gray-100 dark:text-gray-300 dark:bg-gray-700">
|
||||||
|
<span class="text-md font-medium whitespace-nowrap">{{tag.title}}</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Tags panel and its button -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center px-4 m-1.5 rounded-full leading-9 cursor-pointer text-gray-500 bg-gray-100 dark:text-gray-300 dark:bg-gray-700"
|
||||||
|
(click)="openTagsPanel()"
|
||||||
|
#tagsPanelOrigin>
|
||||||
|
|
||||||
|
<ng-container *ngIf="contact.tags.length">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:pencil-alt'"></mat-icon>
|
||||||
|
<span class="ml-1.5 text-md font-medium whitespace-nowrap">Edit</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container *ngIf="!contact.tags.length">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:plus-circle'"></mat-icon>
|
||||||
|
<span class="ml-1.5 text-md font-medium whitespace-nowrap">Add</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Tags panel -->
|
||||||
|
<ng-template #tagsPanel>
|
||||||
|
<div class="w-60 rounded border shadow-md bg-card">
|
||||||
|
<!-- Tags panel header -->
|
||||||
|
<div class="flex items-center m-3 mr-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:search'"></mat-icon>
|
||||||
|
<div class="ml-2">
|
||||||
|
<input
|
||||||
|
class="w-full min-w-0 py-1 border-0"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter tag name"
|
||||||
|
(input)="filterTags($event)"
|
||||||
|
(keydown)="filterTagsInputKeyDown($event)"
|
||||||
|
[maxLength]="30"
|
||||||
|
#newTagInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="ml-1"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="toggleTagsEditMode()">
|
||||||
|
<mat-icon
|
||||||
|
*ngIf="!tagsEditMode"
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:pencil-alt'"></mat-icon>
|
||||||
|
<mat-icon
|
||||||
|
*ngIf="tagsEditMode"
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:check'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col max-h-64 py-2 border-t overflow-y-auto">
|
||||||
|
<!-- Tags -->
|
||||||
|
<ng-container *ngIf="!tagsEditMode">
|
||||||
|
<ng-container *ngFor="let tag of filteredTags; trackBy: trackByFn">
|
||||||
|
<div
|
||||||
|
class="flex items-center h-10 min-h-10 pl-1 pr-4 cursor-pointer hover:bg-hover"
|
||||||
|
(click)="toggleContactTag(tag)"
|
||||||
|
matRipple>
|
||||||
|
<mat-checkbox
|
||||||
|
class="flex items-center h-10 min-h-10 pointer-events-none"
|
||||||
|
[checked]="contact.tags.includes(tag.id)"
|
||||||
|
[color]="'primary'"
|
||||||
|
[disableRipple]="true">
|
||||||
|
</mat-checkbox>
|
||||||
|
<div>{{tag.title}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Tags editing -->
|
||||||
|
<ng-container *ngIf="tagsEditMode">
|
||||||
|
<div class="py-2 space-y-2">
|
||||||
|
<ng-container *ngFor="let tag of filteredTags; trackBy: trackByFn">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<mat-form-field
|
||||||
|
class="fuse-mat-dense w-full mx-4"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="tag.title"
|
||||||
|
(input)="updateTagTitle(tag, $event)">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="deleteTag(tag)"
|
||||||
|
matSuffix>
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5 ml-2"
|
||||||
|
[svgIcon]="'heroicons_solid:trash'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Create tag -->
|
||||||
|
<div
|
||||||
|
class="flex items-center h-10 min-h-10 -ml-0.5 pl-4 pr-3 leading-none cursor-pointer hover:bg-hover"
|
||||||
|
*ngIf="shouldShowCreateTagButton(newTagInput.value)"
|
||||||
|
(click)="createTag(newTagInput.value); newTagInput.value = ''"
|
||||||
|
matRipple>
|
||||||
|
<mat-icon
|
||||||
|
class="mr-2 icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:plus-circle'"></mat-icon>
|
||||||
|
<div class="break-all">Create "<b>{{newTagInput.value}}</b>"</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label>Title</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:briefcase'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'title'"
|
||||||
|
[placeholder]="'Job title'">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Company -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label>Company</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:office-building'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'company'"
|
||||||
|
[placeholder]="'Company'">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emails -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<ng-container *ngFor="let email of contactForm.get('emails')['controls']; let i = index; let first = first; let last = last; trackBy: trackByFn">
|
||||||
|
<div class="flex">
|
||||||
|
<mat-form-field
|
||||||
|
class="flex-auto"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label *ngIf="first">Email</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:mail'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControl]="email.get('email')"
|
||||||
|
[placeholder]="'Email address'"
|
||||||
|
[spellcheck]="false">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field
|
||||||
|
class="flex-auto w-full max-w-24 sm:max-w-40 ml-2 sm:ml-4"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label *ngIf="first">Label</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:tag'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControl]="email.get('label')"
|
||||||
|
[placeholder]="'Label'">
|
||||||
|
</mat-form-field>
|
||||||
|
<!-- Remove email -->
|
||||||
|
<ng-container *ngIf="!(first && last)">
|
||||||
|
<div
|
||||||
|
class="flex items-center w-10 pl-2"
|
||||||
|
[ngClass]="{'mt-6': first}">
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 min-h-8"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="removeEmailField(i)"
|
||||||
|
matTooltip="Remove">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:trash'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="group inline-flex items-center mt-2 -ml-4 py-2 px-4 rounded cursor-pointer"
|
||||||
|
(click)="addEmailField()">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:plus-circle'"></mat-icon>
|
||||||
|
<span class="ml-2 font-medium text-secondary group-hover:underline">Add an email address</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone numbers -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<ng-container *ngFor="let phoneNumber of contactForm.get('phoneNumbers')['controls']; let i = index; let first = first; let last = last; trackBy: trackByFn">
|
||||||
|
<div class="relative flex">
|
||||||
|
<mat-form-field
|
||||||
|
class="flex-auto"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label *ngIf="first">Phone</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControl]="phoneNumber.get('phoneNumber')"
|
||||||
|
[placeholder]="'Phone'">
|
||||||
|
<mat-select
|
||||||
|
class="mr-1.5"
|
||||||
|
[formControl]="phoneNumber.get('country')"
|
||||||
|
matPrefix>
|
||||||
|
<mat-select-trigger>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="hidden sm:flex w-6 h-4 mr-1 overflow-hidden"
|
||||||
|
[style.background]="'url(\'/assets/images/apps/contacts/flags.png\') no-repeat 0 0'"
|
||||||
|
[style.backgroundSize]="'24px 3876px'"
|
||||||
|
[style.backgroundPosition]="getCountryByIso(phoneNumber.get('country').value).flagImagePos"></span>
|
||||||
|
<span class="sm:mx-0.5 font-medium text-default">{{getCountryByIso(phoneNumber.get('country').value).code}}</span>
|
||||||
|
</span>
|
||||||
|
</mat-select-trigger>
|
||||||
|
<ng-container *ngFor="let country of countries; trackBy: trackByFn">
|
||||||
|
<mat-option [value]="country.iso">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="w-6 h-4 overflow-hidden"
|
||||||
|
[style.background]="'url(\'/assets/images/apps/contacts/flags.png\') no-repeat 0 0'"
|
||||||
|
[style.backgroundSize]="'24px 3876px'"
|
||||||
|
[style.backgroundPosition]="country.flagImagePos"></span>
|
||||||
|
<span class="ml-2">{{country.name}}</span>
|
||||||
|
<span class="ml-2 font-medium">{{country.code}}</span>
|
||||||
|
</span>
|
||||||
|
</mat-option>
|
||||||
|
</ng-container>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field
|
||||||
|
class="flex-auto w-full max-w-24 sm:max-w-40 ml-2 sm:ml-4"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label *ngIf="first">Label</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:tag'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControl]="phoneNumber.get('label')"
|
||||||
|
[placeholder]="'Label'">
|
||||||
|
</mat-form-field>
|
||||||
|
<!-- Remove phone number -->
|
||||||
|
<ng-container *ngIf="!(first && last)">
|
||||||
|
<div
|
||||||
|
class="flex items-center w-10 pl-2"
|
||||||
|
[ngClass]="{'mt-6': first}">
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 min-h-8"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="removePhoneNumberField(i)"
|
||||||
|
matTooltip="Remove">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:trash'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="group inline-flex items-center mt-2 -ml-4 py-2 px-4 rounded cursor-pointer"
|
||||||
|
(click)="addPhoneNumberField()">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:plus-circle'"></mat-icon>
|
||||||
|
<span class="ml-2 font-medium text-secondary group-hover:underline">Add a phone number</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label>Address</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:location-marker'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'address'"
|
||||||
|
[placeholder]="'Address'">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Birthday -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label>Birthday</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:cake'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[matDatepicker]="birthdayDatepicker"
|
||||||
|
[formControlName]="'birthday'"
|
||||||
|
[placeholder]="'Birthday'">
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
matSuffix
|
||||||
|
[for]="birthdayDatepicker">
|
||||||
|
</mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #birthdayDatepicker></mat-datepicker>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<mat-form-field
|
||||||
|
class="w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-label>Notes</mat-label>
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
class="hidden sm:flex icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:menu-alt-2'"></mat-icon>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
[formControlName]="'notes'"
|
||||||
|
[placeholder]="'Notes'"
|
||||||
|
[rows]="5"
|
||||||
|
[spellcheck]="false"
|
||||||
|
cdkTextareaAutosize></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center mt-10 -mx-6 sm:-mx-12 py-4 pr-4 pl-1 sm:pr-12 sm:pl-7 border-t bg-gray-50 dark:bg-transparent">
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
[color]="'warn'"
|
||||||
|
[matTooltip]="'Delete'"
|
||||||
|
(click)="deleteContact()">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<!-- Cancel -->
|
||||||
|
<button
|
||||||
|
class="ml-auto"
|
||||||
|
mat-button
|
||||||
|
[matTooltip]="'Cancel'"
|
||||||
|
(click)="toggleEditMode(false)">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
class="ml-2"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'"
|
||||||
|
[disabled]="contactForm.invalid"
|
||||||
|
[matTooltip]="'Save'"
|
||||||
|
(click)="updateContact()">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
721
src/app/modules/admin/apps/contacts/details/details.component.ts
Normal file
721
src/app/modules/admin/apps/contacts/details/details.component.ts
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, Renderer2, TemplateRef, ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { TemplatePortal } from '@angular/cdk/portal';
|
||||||
|
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||||
|
import { MatDrawerToggleResult } from '@angular/material/sidenav';
|
||||||
|
import { debounceTime, Subject, takeUntil } from 'rxjs';
|
||||||
|
import { FuseConfirmationService } from '@fuse/services/confirmation';
|
||||||
|
import { Contact, Country, Tag } from 'app/modules/admin/apps/contacts/contacts.types';
|
||||||
|
import { ContactsListComponent } from 'app/modules/admin/apps/contacts/list/list.component';
|
||||||
|
import { ContactsService } from 'app/modules/admin/apps/contacts/contacts.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'contacts-details',
|
||||||
|
templateUrl : './details.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ContactsDetailsComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
@ViewChild('avatarFileInput') private _avatarFileInput: ElementRef;
|
||||||
|
@ViewChild('tagsPanel') private _tagsPanel: TemplateRef<any>;
|
||||||
|
@ViewChild('tagsPanelOrigin') private _tagsPanelOrigin: ElementRef;
|
||||||
|
|
||||||
|
editMode: boolean = false;
|
||||||
|
tags: Tag[];
|
||||||
|
tagsEditMode: boolean = false;
|
||||||
|
filteredTags: Tag[];
|
||||||
|
contact: Contact;
|
||||||
|
contactForm: UntypedFormGroup;
|
||||||
|
contacts: Contact[];
|
||||||
|
countries: Country[];
|
||||||
|
private _tagsPanelOverlayRef: OverlayRef;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _activatedRoute: ActivatedRoute,
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _contactsListComponent: ContactsListComponent,
|
||||||
|
private _contactsService: ContactsService,
|
||||||
|
private _formBuilder: UntypedFormBuilder,
|
||||||
|
private _fuseConfirmationService: FuseConfirmationService,
|
||||||
|
private _renderer2: Renderer2,
|
||||||
|
private _router: Router,
|
||||||
|
private _overlay: Overlay,
|
||||||
|
private _viewContainerRef: ViewContainerRef
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Open the drawer
|
||||||
|
this._contactsListComponent.matDrawer.open();
|
||||||
|
|
||||||
|
// Create the contact form
|
||||||
|
this.contactForm = this._formBuilder.group({
|
||||||
|
id : [''],
|
||||||
|
avatar : [null],
|
||||||
|
name : ['', [Validators.required]],
|
||||||
|
emails : this._formBuilder.array([]),
|
||||||
|
phoneNumbers: this._formBuilder.array([]),
|
||||||
|
title : [''],
|
||||||
|
company : [''],
|
||||||
|
birthday : [null],
|
||||||
|
address : [null],
|
||||||
|
notes : [null],
|
||||||
|
tags : [[]]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the contacts
|
||||||
|
this._contactsService.contacts$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((contacts: Contact[]) => {
|
||||||
|
this.contacts = contacts;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the contact
|
||||||
|
this._contactsService.contact$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((contact: Contact) => {
|
||||||
|
|
||||||
|
// Open the drawer in case it is closed
|
||||||
|
this._contactsListComponent.matDrawer.open();
|
||||||
|
|
||||||
|
// Get the contact
|
||||||
|
this.contact = contact;
|
||||||
|
|
||||||
|
// Clear the emails and phoneNumbers form arrays
|
||||||
|
(this.contactForm.get('emails') as UntypedFormArray).clear();
|
||||||
|
(this.contactForm.get('phoneNumbers') as UntypedFormArray).clear();
|
||||||
|
|
||||||
|
// Patch values to the form
|
||||||
|
this.contactForm.patchValue(contact);
|
||||||
|
|
||||||
|
// Setup the emails form array
|
||||||
|
const emailFormGroups = [];
|
||||||
|
|
||||||
|
if ( contact.emails.length > 0 )
|
||||||
|
{
|
||||||
|
// Iterate through them
|
||||||
|
contact.emails.forEach((email) => {
|
||||||
|
|
||||||
|
// Create an email form group
|
||||||
|
emailFormGroups.push(
|
||||||
|
this._formBuilder.group({
|
||||||
|
email: [email.email],
|
||||||
|
label: [email.label]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create an email form group
|
||||||
|
emailFormGroups.push(
|
||||||
|
this._formBuilder.group({
|
||||||
|
email: [''],
|
||||||
|
label: ['']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the email form groups to the emails form array
|
||||||
|
emailFormGroups.forEach((emailFormGroup) => {
|
||||||
|
(this.contactForm.get('emails') as UntypedFormArray).push(emailFormGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup the phone numbers form array
|
||||||
|
const phoneNumbersFormGroups = [];
|
||||||
|
|
||||||
|
if ( contact.phoneNumbers.length > 0 )
|
||||||
|
{
|
||||||
|
// Iterate through them
|
||||||
|
contact.phoneNumbers.forEach((phoneNumber) => {
|
||||||
|
|
||||||
|
// Create an email form group
|
||||||
|
phoneNumbersFormGroups.push(
|
||||||
|
this._formBuilder.group({
|
||||||
|
country : [phoneNumber.country],
|
||||||
|
phoneNumber: [phoneNumber.phoneNumber],
|
||||||
|
label : [phoneNumber.label]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create a phone number form group
|
||||||
|
phoneNumbersFormGroups.push(
|
||||||
|
this._formBuilder.group({
|
||||||
|
country : ['us'],
|
||||||
|
phoneNumber: [''],
|
||||||
|
label : ['']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the phone numbers form groups to the phone numbers form array
|
||||||
|
phoneNumbersFormGroups.forEach((phoneNumbersFormGroup) => {
|
||||||
|
(this.contactForm.get('phoneNumbers') as UntypedFormArray).push(phoneNumbersFormGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle the edit mode off
|
||||||
|
this.toggleEditMode(false);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the country telephone codes
|
||||||
|
this._contactsService.countries$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((codes: Country[]) => {
|
||||||
|
this.countries = codes;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the tags
|
||||||
|
this._contactsService.tags$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((tags: Tag[]) => {
|
||||||
|
this.tags = tags;
|
||||||
|
this.filteredTags = tags;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
|
||||||
|
// Dispose the overlays if they are still on the DOM
|
||||||
|
if ( this._tagsPanelOverlayRef )
|
||||||
|
{
|
||||||
|
this._tagsPanelOverlayRef.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the drawer
|
||||||
|
*/
|
||||||
|
closeDrawer(): Promise<MatDrawerToggleResult>
|
||||||
|
{
|
||||||
|
return this._contactsListComponent.matDrawer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle edit mode
|
||||||
|
*
|
||||||
|
* @param editMode
|
||||||
|
*/
|
||||||
|
toggleEditMode(editMode: boolean | null = null): void
|
||||||
|
{
|
||||||
|
if ( editMode === null )
|
||||||
|
{
|
||||||
|
this.editMode = !this.editMode;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.editMode = editMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the contact
|
||||||
|
*/
|
||||||
|
updateContact(): void
|
||||||
|
{
|
||||||
|
// Get the contact object
|
||||||
|
const contact = this.contactForm.getRawValue();
|
||||||
|
|
||||||
|
// Go through the contact object and clear empty values
|
||||||
|
contact.emails = contact.emails.filter(email => email.email);
|
||||||
|
|
||||||
|
contact.phoneNumbers = contact.phoneNumbers.filter(phoneNumber => phoneNumber.phoneNumber);
|
||||||
|
|
||||||
|
// Update the contact on the server
|
||||||
|
this._contactsService.updateContact(contact.id, contact).subscribe(() => {
|
||||||
|
|
||||||
|
// Toggle the edit mode off
|
||||||
|
this.toggleEditMode(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the contact
|
||||||
|
*/
|
||||||
|
deleteContact(): void
|
||||||
|
{
|
||||||
|
// Open the confirmation dialog
|
||||||
|
const confirmation = this._fuseConfirmationService.open({
|
||||||
|
title : 'Delete contact',
|
||||||
|
message: 'Are you sure you want to delete this contact? This action cannot be undone!',
|
||||||
|
actions: {
|
||||||
|
confirm: {
|
||||||
|
label: 'Delete'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to the confirmation dialog closed action
|
||||||
|
confirmation.afterClosed().subscribe((result) => {
|
||||||
|
|
||||||
|
// If the confirm button pressed...
|
||||||
|
if ( result === 'confirmed' )
|
||||||
|
{
|
||||||
|
// Get the current contact's id
|
||||||
|
const id = this.contact.id;
|
||||||
|
|
||||||
|
// Get the next/previous contact's id
|
||||||
|
const currentContactIndex = this.contacts.findIndex(item => item.id === id);
|
||||||
|
const nextContactIndex = currentContactIndex + ((currentContactIndex === (this.contacts.length - 1)) ? -1 : 1);
|
||||||
|
const nextContactId = (this.contacts.length === 1 && this.contacts[0].id === id) ? null : this.contacts[nextContactIndex].id;
|
||||||
|
|
||||||
|
// Delete the contact
|
||||||
|
this._contactsService.deleteContact(id)
|
||||||
|
.subscribe((isDeleted) => {
|
||||||
|
|
||||||
|
// Return if the contact wasn't deleted...
|
||||||
|
if ( !isDeleted )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the next contact if available
|
||||||
|
if ( nextContactId )
|
||||||
|
{
|
||||||
|
this._router.navigate(['../', nextContactId], {relativeTo: this._activatedRoute});
|
||||||
|
}
|
||||||
|
// Otherwise, navigate to the parent
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._router.navigate(['../'], {relativeTo: this._activatedRoute});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the edit mode off
|
||||||
|
this.toggleEditMode(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload avatar
|
||||||
|
*
|
||||||
|
* @param fileList
|
||||||
|
*/
|
||||||
|
uploadAvatar(fileList: FileList): void
|
||||||
|
{
|
||||||
|
// Return if canceled
|
||||||
|
if ( !fileList.length )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png'];
|
||||||
|
const file = fileList[0];
|
||||||
|
|
||||||
|
// Return if the file is not allowed
|
||||||
|
if ( !allowedTypes.includes(file.type) )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the avatar
|
||||||
|
this._contactsService.uploadAvatar(this.contact.id, file).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the avatar
|
||||||
|
*/
|
||||||
|
removeAvatar(): void
|
||||||
|
{
|
||||||
|
// Get the form control for 'avatar'
|
||||||
|
const avatarFormControl = this.contactForm.get('avatar');
|
||||||
|
|
||||||
|
// Set the avatar as null
|
||||||
|
avatarFormControl.setValue(null);
|
||||||
|
|
||||||
|
// Set the file input value as null
|
||||||
|
this._avatarFileInput.nativeElement.value = null;
|
||||||
|
|
||||||
|
// Update the contact
|
||||||
|
this.contact.avatar = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open tags panel
|
||||||
|
*/
|
||||||
|
openTagsPanel(): void
|
||||||
|
{
|
||||||
|
// Create the overlay
|
||||||
|
this._tagsPanelOverlayRef = this._overlay.create({
|
||||||
|
backdropClass : '',
|
||||||
|
hasBackdrop : true,
|
||||||
|
scrollStrategy : this._overlay.scrollStrategies.block(),
|
||||||
|
positionStrategy: this._overlay.position()
|
||||||
|
.flexibleConnectedTo(this._tagsPanelOrigin.nativeElement)
|
||||||
|
.withFlexibleDimensions(true)
|
||||||
|
.withViewportMargin(64)
|
||||||
|
.withLockedPosition(true)
|
||||||
|
.withPositions([
|
||||||
|
{
|
||||||
|
originX : 'start',
|
||||||
|
originY : 'bottom',
|
||||||
|
overlayX: 'start',
|
||||||
|
overlayY: 'top'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to the attachments observable
|
||||||
|
this._tagsPanelOverlayRef.attachments().subscribe(() => {
|
||||||
|
|
||||||
|
// Add a class to the origin
|
||||||
|
this._renderer2.addClass(this._tagsPanelOrigin.nativeElement, 'panel-opened');
|
||||||
|
|
||||||
|
// Focus to the search input once the overlay has been attached
|
||||||
|
this._tagsPanelOverlayRef.overlayElement.querySelector('input').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a portal from the template
|
||||||
|
const templatePortal = new TemplatePortal(this._tagsPanel, this._viewContainerRef);
|
||||||
|
|
||||||
|
// Attach the portal to the overlay
|
||||||
|
this._tagsPanelOverlayRef.attach(templatePortal);
|
||||||
|
|
||||||
|
// Subscribe to the backdrop click
|
||||||
|
this._tagsPanelOverlayRef.backdropClick().subscribe(() => {
|
||||||
|
|
||||||
|
// Remove the class from the origin
|
||||||
|
this._renderer2.removeClass(this._tagsPanelOrigin.nativeElement, 'panel-opened');
|
||||||
|
|
||||||
|
// If overlay exists and attached...
|
||||||
|
if ( this._tagsPanelOverlayRef && this._tagsPanelOverlayRef.hasAttached() )
|
||||||
|
{
|
||||||
|
// Detach it
|
||||||
|
this._tagsPanelOverlayRef.detach();
|
||||||
|
|
||||||
|
// Reset the tag filter
|
||||||
|
this.filteredTags = this.tags;
|
||||||
|
|
||||||
|
// Toggle the edit mode off
|
||||||
|
this.tagsEditMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If template portal exists and attached...
|
||||||
|
if ( templatePortal && templatePortal.isAttached )
|
||||||
|
{
|
||||||
|
// Detach it
|
||||||
|
templatePortal.detach();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the tags edit mode
|
||||||
|
*/
|
||||||
|
toggleTagsEditMode(): void
|
||||||
|
{
|
||||||
|
this.tagsEditMode = !this.tagsEditMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tags
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
filterTags(event): void
|
||||||
|
{
|
||||||
|
// Get the value
|
||||||
|
const value = event.target.value.toLowerCase();
|
||||||
|
|
||||||
|
// Filter the tags
|
||||||
|
this.filteredTags = this.tags.filter(tag => tag.title.toLowerCase().includes(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tags input key down event
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
filterTagsInputKeyDown(event): void
|
||||||
|
{
|
||||||
|
// Return if the pressed key is not 'Enter'
|
||||||
|
if ( event.key !== 'Enter' )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is no tag available...
|
||||||
|
if ( this.filteredTags.length === 0 )
|
||||||
|
{
|
||||||
|
// Create the tag
|
||||||
|
this.createTag(event.target.value);
|
||||||
|
|
||||||
|
// Clear the input
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
// Return
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is a tag...
|
||||||
|
const tag = this.filteredTags[0];
|
||||||
|
const isTagApplied = this.contact.tags.find(id => id === tag.id);
|
||||||
|
|
||||||
|
// If the found tag is already applied to the contact...
|
||||||
|
if ( isTagApplied )
|
||||||
|
{
|
||||||
|
// Remove the tag from the contact
|
||||||
|
this.removeTagFromContact(tag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Otherwise add the tag to the contact
|
||||||
|
this.addTagToContact(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tag
|
||||||
|
*
|
||||||
|
* @param title
|
||||||
|
*/
|
||||||
|
createTag(title: string): void
|
||||||
|
{
|
||||||
|
const tag = {
|
||||||
|
title
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create tag on the server
|
||||||
|
this._contactsService.createTag(tag)
|
||||||
|
.subscribe((response) => {
|
||||||
|
|
||||||
|
// Add the tag to the contact
|
||||||
|
this.addTagToContact(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the tag title
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
updateTagTitle(tag: Tag, event): void
|
||||||
|
{
|
||||||
|
// Update the title on the tag
|
||||||
|
tag.title = event.target.value;
|
||||||
|
|
||||||
|
// Update the tag on the server
|
||||||
|
this._contactsService.updateTag(tag.id, tag)
|
||||||
|
.pipe(debounceTime(300))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the tag
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
deleteTag(tag: Tag): void
|
||||||
|
{
|
||||||
|
// Delete the tag from the server
|
||||||
|
this._contactsService.deleteTag(tag.id).subscribe();
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add tag to the contact
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
addTagToContact(tag: Tag): void
|
||||||
|
{
|
||||||
|
// Add the tag
|
||||||
|
this.contact.tags.unshift(tag.id);
|
||||||
|
|
||||||
|
// Update the contact form
|
||||||
|
this.contactForm.get('tags').patchValue(this.contact.tags);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove tag from the contact
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
removeTagFromContact(tag: Tag): void
|
||||||
|
{
|
||||||
|
// Remove the tag
|
||||||
|
this.contact.tags.splice(this.contact.tags.findIndex(item => item === tag.id), 1);
|
||||||
|
|
||||||
|
// Update the contact form
|
||||||
|
this.contactForm.get('tags').patchValue(this.contact.tags);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle contact tag
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
toggleContactTag(tag: Tag): void
|
||||||
|
{
|
||||||
|
if ( this.contact.tags.includes(tag.id) )
|
||||||
|
{
|
||||||
|
this.removeTagFromContact(tag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.addTagToContact(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the create tag button be visible
|
||||||
|
*
|
||||||
|
* @param inputValue
|
||||||
|
*/
|
||||||
|
shouldShowCreateTagButton(inputValue: string): boolean
|
||||||
|
{
|
||||||
|
return !!!(inputValue === '' || this.tags.findIndex(tag => tag.title.toLowerCase() === inputValue.toLowerCase()) > -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the email field
|
||||||
|
*/
|
||||||
|
addEmailField(): void
|
||||||
|
{
|
||||||
|
// Create an empty email form group
|
||||||
|
const emailFormGroup = this._formBuilder.group({
|
||||||
|
email: [''],
|
||||||
|
label: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the email form group to the emails form array
|
||||||
|
(this.contactForm.get('emails') as UntypedFormArray).push(emailFormGroup);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the email field
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
*/
|
||||||
|
removeEmailField(index: number): void
|
||||||
|
{
|
||||||
|
// Get form array for emails
|
||||||
|
const emailsFormArray = this.contactForm.get('emails') as UntypedFormArray;
|
||||||
|
|
||||||
|
// Remove the email field
|
||||||
|
emailsFormArray.removeAt(index);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an empty phone number field
|
||||||
|
*/
|
||||||
|
addPhoneNumberField(): void
|
||||||
|
{
|
||||||
|
// Create an empty phone number form group
|
||||||
|
const phoneNumberFormGroup = this._formBuilder.group({
|
||||||
|
country : ['us'],
|
||||||
|
phoneNumber: [''],
|
||||||
|
label : ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the phone number form group to the phoneNumbers form array
|
||||||
|
(this.contactForm.get('phoneNumbers') as UntypedFormArray).push(phoneNumberFormGroup);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the phone number field
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
*/
|
||||||
|
removePhoneNumberField(index: number): void
|
||||||
|
{
|
||||||
|
// Get form array for phone numbers
|
||||||
|
const phoneNumbersFormArray = this.contactForm.get('phoneNumbers') as UntypedFormArray;
|
||||||
|
|
||||||
|
// Remove the phone number field
|
||||||
|
phoneNumbersFormArray.removeAt(index);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get country info by iso code
|
||||||
|
*
|
||||||
|
* @param iso
|
||||||
|
*/
|
||||||
|
getCountryByIso(iso: string): Country
|
||||||
|
{
|
||||||
|
return this.countries.find(country => country.iso === iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/app/modules/admin/apps/contacts/list/list.component.html
Normal file
123
src/app/modules/admin/apps/contacts/list/list.component.html
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<div class="absolute inset-0 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
|
||||||
|
<mat-drawer-container
|
||||||
|
class="flex-auto h-full bg-card dark:bg-transparent"
|
||||||
|
(backdropClick)="onBackdropClicked()">
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<mat-drawer
|
||||||
|
class="w-full md:w-160 dark:bg-gray-900"
|
||||||
|
[mode]="drawerMode"
|
||||||
|
[opened]="false"
|
||||||
|
[position]="'end'"
|
||||||
|
[disableClose]="true"
|
||||||
|
#matDrawer>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</mat-drawer>
|
||||||
|
|
||||||
|
<mat-drawer-content class="flex flex-col">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex-auto">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col sm:flex-row md:flex-col flex-auto justify-between py-8 px-6 md:px-8 border-b">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<div class="text-4xl font-extrabold tracking-tight leading-none">Contacts</div>
|
||||||
|
<div class="ml-0.5 font-medium text-secondary">
|
||||||
|
<ng-container *ngIf="contactsCount > 0">
|
||||||
|
{{contactsCount}}
|
||||||
|
</ng-container>
|
||||||
|
{{contactsCount | i18nPlural: {
|
||||||
|
'=0' : 'No contacts',
|
||||||
|
'=1' : 'contact',
|
||||||
|
'other': 'contacts'
|
||||||
|
} }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main actions -->
|
||||||
|
<div class="flex items-center mt-4 sm:mt-0 md:mt-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex-auto">
|
||||||
|
<mat-form-field
|
||||||
|
class="fuse-mat-dense fuse-mat-rounded w-full min-w-50"
|
||||||
|
subscriptSizing="dynamic">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
matPrefix
|
||||||
|
[svgIcon]="'heroicons_solid:search'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControl]="searchInputControl"
|
||||||
|
[autocomplete]="'off'"
|
||||||
|
[placeholder]="'Search contacts'">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<!-- Add contact button -->
|
||||||
|
<button
|
||||||
|
class="ml-4"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'"
|
||||||
|
(click)="createContact()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||||
|
<span class="ml-2 mr-1">Add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts list -->
|
||||||
|
<div class="relative">
|
||||||
|
<ng-container *ngIf="contacts$ | async as contacts">
|
||||||
|
<ng-container *ngIf="contacts.length; else noContacts">
|
||||||
|
<ng-container *ngFor="let contact of contacts; let i = index; trackBy: trackByFn">
|
||||||
|
<!-- Group -->
|
||||||
|
<ng-container *ngIf="i === 0 || contact.name.charAt(0) !== contacts[i - 1].name.charAt(0)">
|
||||||
|
<div class="z-10 sticky top-0 -mt-px px-6 py-1 md:px-8 border-t border-b font-medium uppercase text-secondary bg-gray-50 dark:bg-gray-900">
|
||||||
|
{{contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Contact -->
|
||||||
|
<a
|
||||||
|
class="z-20 flex items-center px-6 py-4 md:px-8 cursor-pointer border-b"
|
||||||
|
[ngClass]="{'hover:bg-gray-100 dark:hover:bg-hover': !selectedContact || selectedContact.id !== contact.id,
|
||||||
|
'bg-primary-50 dark:bg-hover': selectedContact && selectedContact.id === contact.id}"
|
||||||
|
[routerLink]="['./', contact.id]">
|
||||||
|
<div class="flex flex-0 items-center justify-center w-10 h-10 rounded-full overflow-hidden">
|
||||||
|
<ng-container *ngIf="contact.avatar">
|
||||||
|
<img
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
[src]="contact.avatar"
|
||||||
|
alt="Contact avatar"/>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!contact.avatar">
|
||||||
|
<div class="flex items-center justify-center w-full h-full rounded-full text-lg uppercase bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||||
|
{{contact.name.charAt(0)}}
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 ml-4">
|
||||||
|
<div class="font-medium leading-5 truncate">{{contact.name}}</div>
|
||||||
|
<div class="leading-5 truncate text-secondary">{{contact.title}}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- No contacts -->
|
||||||
|
<ng-template #noContacts>
|
||||||
|
<div class="p-8 sm:p-16 border-t text-4xl font-semibold tracking-tight text-center">There are no contacts!</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</mat-drawer-content>
|
||||||
|
|
||||||
|
</mat-drawer-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
200
src/app/modules/admin/apps/contacts/list/list.component.ts
Normal file
200
src/app/modules/admin/apps/contacts/list/list.component.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { UntypedFormControl } from '@angular/forms';
|
||||||
|
import { MatDrawer } from '@angular/material/sidenav';
|
||||||
|
import { filter, fromEvent, Observable, Subject, switchMap, takeUntil } from 'rxjs';
|
||||||
|
import { FuseMediaWatcherService } from '@fuse/services/media-watcher';
|
||||||
|
import { Contact, Country } from 'app/modules/admin/apps/contacts/contacts.types';
|
||||||
|
import { ContactsService } from 'app/modules/admin/apps/contacts/contacts.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'contacts-list',
|
||||||
|
templateUrl : './list.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ContactsListComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
@ViewChild('matDrawer', {static: true}) matDrawer: MatDrawer;
|
||||||
|
|
||||||
|
contacts$: Observable<Contact[]>;
|
||||||
|
|
||||||
|
contactsCount: number = 0;
|
||||||
|
contactsTableColumns: string[] = ['name', 'email', 'phoneNumber', 'job'];
|
||||||
|
countries: Country[];
|
||||||
|
drawerMode: 'side' | 'over';
|
||||||
|
searchInputControl: UntypedFormControl = new UntypedFormControl();
|
||||||
|
selectedContact: Contact;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _activatedRoute: ActivatedRoute,
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _contactsService: ContactsService,
|
||||||
|
@Inject(DOCUMENT) private _document: any,
|
||||||
|
private _router: Router,
|
||||||
|
private _fuseMediaWatcherService: FuseMediaWatcherService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the contacts
|
||||||
|
this.contacts$ = this._contactsService.contacts$;
|
||||||
|
this._contactsService.contacts$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((contacts: Contact[]) => {
|
||||||
|
|
||||||
|
// Update the counts
|
||||||
|
this.contactsCount = contacts.length;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the contact
|
||||||
|
this._contactsService.contact$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((contact: Contact) => {
|
||||||
|
|
||||||
|
// Update the selected contact
|
||||||
|
this.selectedContact = contact;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the countries
|
||||||
|
this._contactsService.countries$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((countries: Country[]) => {
|
||||||
|
|
||||||
|
// Update the countries
|
||||||
|
this.countries = countries;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to search input field value changes
|
||||||
|
this.searchInputControl.valueChanges
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this._unsubscribeAll),
|
||||||
|
switchMap(query =>
|
||||||
|
|
||||||
|
// Search
|
||||||
|
this._contactsService.searchContacts(query)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Subscribe to MatDrawer opened change
|
||||||
|
this.matDrawer.openedChange.subscribe((opened) => {
|
||||||
|
if ( !opened )
|
||||||
|
{
|
||||||
|
// Remove the selected contact when drawer closed
|
||||||
|
this.selectedContact = null;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to media changes
|
||||||
|
this._fuseMediaWatcherService.onMediaChange$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe(({matchingAliases}) => {
|
||||||
|
|
||||||
|
// Set the drawerMode if the given breakpoint is active
|
||||||
|
if ( matchingAliases.includes('lg') )
|
||||||
|
{
|
||||||
|
this.drawerMode = 'side';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.drawerMode = 'over';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for shortcuts
|
||||||
|
fromEvent(this._document, 'keydown')
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this._unsubscribeAll),
|
||||||
|
filter<KeyboardEvent>(event =>
|
||||||
|
(event.ctrlKey === true || event.metaKey) // Ctrl or Cmd
|
||||||
|
&& (event.key === '/') // '/'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.createContact();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On backdrop clicked
|
||||||
|
*/
|
||||||
|
onBackdropClicked(): void
|
||||||
|
{
|
||||||
|
// Go back to the list
|
||||||
|
this._router.navigate(['./'], {relativeTo: this._activatedRoute});
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create contact
|
||||||
|
*/
|
||||||
|
createContact(): void
|
||||||
|
{
|
||||||
|
// Create the contact
|
||||||
|
this._contactsService.createContact().subscribe((newContact) => {
|
||||||
|
|
||||||
|
// Go to the new contact
|
||||||
|
this._router.navigate(['./', newContact.id], {relativeTo: this._activatedRoute});
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/modules/admin/apps/ecommerce/ecommerce.module.ts
Normal file
46
src/app/modules/admin/apps/ecommerce/ecommerce.module.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatRippleModule } from '@angular/material/core';
|
||||||
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { SharedModule } from 'app/shared/shared.module';
|
||||||
|
import { InventoryComponent } from 'app/modules/admin/apps/ecommerce/inventory/inventory.component';
|
||||||
|
import { InventoryListComponent } from 'app/modules/admin/apps/ecommerce/inventory/list/inventory.component';
|
||||||
|
import { ecommerceRoutes } from 'app/modules/admin/apps/ecommerce/ecommerce.routing';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
InventoryComponent,
|
||||||
|
InventoryListComponent
|
||||||
|
],
|
||||||
|
imports : [
|
||||||
|
RouterModule.forChild(ecommerceRoutes),
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatRippleModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
SharedModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ECommerceModule
|
||||||
|
{
|
||||||
|
}
|
||||||
50
src/app/modules/admin/apps/ecommerce/ecommerce.routing.ts
Normal file
50
src/app/modules/admin/apps/ecommerce/ecommerce.routing.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { InventoryComponent } from 'app/modules/admin/apps/ecommerce/inventory/inventory.component';
|
||||||
|
import { InventoryListComponent } from 'app/modules/admin/apps/ecommerce/inventory/list/inventory.component';
|
||||||
|
import { InventoryBrandsResolver, InventoryCategoriesResolver, InventoryProductsResolver, InventoryTagsResolver, InventoryVendorsResolver } from 'app/modules/admin/apps/ecommerce/inventory/inventory.resolvers';
|
||||||
|
|
||||||
|
export const ecommerceRoutes: Route[] = [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
pathMatch : 'full',
|
||||||
|
redirectTo: 'inventory'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : 'inventory',
|
||||||
|
component: InventoryComponent,
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: InventoryListComponent,
|
||||||
|
resolve : {
|
||||||
|
brands : InventoryBrandsResolver,
|
||||||
|
categories: InventoryCategoriesResolver,
|
||||||
|
products : InventoryProductsResolver,
|
||||||
|
tags : InventoryTagsResolver,
|
||||||
|
vendors : InventoryVendorsResolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
/*children : [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: ContactsListComponent,
|
||||||
|
resolve : {
|
||||||
|
tasks : ContactsResolver,
|
||||||
|
countries: ContactsCountriesResolver
|
||||||
|
},
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : ':id',
|
||||||
|
component : ContactsDetailsComponent,
|
||||||
|
resolve : {
|
||||||
|
task : ContactsContactResolver,
|
||||||
|
countries: ContactsCountriesResolver
|
||||||
|
},
|
||||||
|
canDeactivate: [CanDeactivateContactsDetails]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]*/
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'inventory',
|
||||||
|
templateUrl : './inventory.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class InventoryComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { catchError, Observable, throwError } from 'rxjs';
|
||||||
|
import { InventoryService } from 'app/modules/admin/apps/ecommerce/inventory/inventory.service';
|
||||||
|
import { InventoryBrand, InventoryCategory, InventoryPagination, InventoryProduct, InventoryTag, InventoryVendor } from 'app/modules/admin/apps/ecommerce/inventory/inventory.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InventoryBrandsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _inventoryService: InventoryService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<InventoryBrand[]>
|
||||||
|
{
|
||||||
|
return this._inventoryService.getBrands();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InventoryCategoriesResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _inventoryService: InventoryService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<InventoryCategory[]>
|
||||||
|
{
|
||||||
|
return this._inventoryService.getCategories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InventoryProductResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _inventoryService: InventoryService,
|
||||||
|
private _router: Router
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<InventoryProduct>
|
||||||
|
{
|
||||||
|
return this._inventoryService.getProductById(route.paramMap.get('id'))
|
||||||
|
.pipe(
|
||||||
|
// Error here means the requested product is not available
|
||||||
|
catchError((error) => {
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
// Get the parent url
|
||||||
|
const parentUrl = state.url.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Navigate to there
|
||||||
|
this._router.navigateByUrl(parentUrl);
|
||||||
|
|
||||||
|
// Throw an error
|
||||||
|
return throwError(error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InventoryProductsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _inventoryService: InventoryService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<{ pagination: InventoryPagination; products: InventoryProduct[] }>
|
||||||
|
{
|
||||||
|
return this._inventoryService.getProducts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InventoryTagsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _inventoryService: InventoryService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<InventoryTag[]>
|
||||||
|
{
|
||||||
|
return this._inventoryService.getTags();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InventoryVendorsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _inventoryService: InventoryService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<InventoryVendor[]>
|
||||||
|
{
|
||||||
|
return this._inventoryService.getVendors();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, filter, map, Observable, of, switchMap, take, tap, throwError } from 'rxjs';
|
||||||
|
import { InventoryBrand, InventoryCategory, InventoryPagination, InventoryProduct, InventoryTag, InventoryVendor } from 'app/modules/admin/apps/ecommerce/inventory/inventory.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class InventoryService
|
||||||
|
{
|
||||||
|
// Private
|
||||||
|
private _brands: BehaviorSubject<InventoryBrand[] | null> = new BehaviorSubject(null);
|
||||||
|
private _categories: BehaviorSubject<InventoryCategory[] | null> = new BehaviorSubject(null);
|
||||||
|
private _pagination: BehaviorSubject<InventoryPagination | null> = new BehaviorSubject(null);
|
||||||
|
private _product: BehaviorSubject<InventoryProduct | null> = new BehaviorSubject(null);
|
||||||
|
private _products: BehaviorSubject<InventoryProduct[] | null> = new BehaviorSubject(null);
|
||||||
|
private _tags: BehaviorSubject<InventoryTag[] | null> = new BehaviorSubject(null);
|
||||||
|
private _vendors: BehaviorSubject<InventoryVendor[] | null> = new BehaviorSubject(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _httpClient: HttpClient)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Accessors
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for brands
|
||||||
|
*/
|
||||||
|
get brands$(): Observable<InventoryBrand[]>
|
||||||
|
{
|
||||||
|
return this._brands.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for categories
|
||||||
|
*/
|
||||||
|
get categories$(): Observable<InventoryCategory[]>
|
||||||
|
{
|
||||||
|
return this._categories.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for pagination
|
||||||
|
*/
|
||||||
|
get pagination$(): Observable<InventoryPagination>
|
||||||
|
{
|
||||||
|
return this._pagination.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for product
|
||||||
|
*/
|
||||||
|
get product$(): Observable<InventoryProduct>
|
||||||
|
{
|
||||||
|
return this._product.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for products
|
||||||
|
*/
|
||||||
|
get products$(): Observable<InventoryProduct[]>
|
||||||
|
{
|
||||||
|
return this._products.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for tags
|
||||||
|
*/
|
||||||
|
get tags$(): Observable<InventoryTag[]>
|
||||||
|
{
|
||||||
|
return this._tags.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for vendors
|
||||||
|
*/
|
||||||
|
get vendors$(): Observable<InventoryVendor[]>
|
||||||
|
{
|
||||||
|
return this._vendors.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brands
|
||||||
|
*/
|
||||||
|
getBrands(): Observable<InventoryBrand[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<InventoryBrand[]>('api/apps/ecommerce/inventory/brands').pipe(
|
||||||
|
tap((brands) => {
|
||||||
|
this._brands.next(brands);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories
|
||||||
|
*/
|
||||||
|
getCategories(): Observable<InventoryCategory[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<InventoryCategory[]>('api/apps/ecommerce/inventory/categories').pipe(
|
||||||
|
tap((categories) => {
|
||||||
|
this._categories.next(categories);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get products
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @param page
|
||||||
|
* @param size
|
||||||
|
* @param sort
|
||||||
|
* @param order
|
||||||
|
* @param search
|
||||||
|
*/
|
||||||
|
getProducts(page: number = 0, size: number = 10, sort: string = 'name', order: 'asc' | 'desc' | '' = 'asc', search: string = ''):
|
||||||
|
Observable<{ pagination: InventoryPagination; products: InventoryProduct[] }>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<{ pagination: InventoryPagination; products: InventoryProduct[] }>('api/apps/ecommerce/inventory/products', {
|
||||||
|
params: {
|
||||||
|
page: '' + page,
|
||||||
|
size: '' + size,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
search
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
tap((response) => {
|
||||||
|
this._pagination.next(response.pagination);
|
||||||
|
this._products.next(response.products);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product by id
|
||||||
|
*/
|
||||||
|
getProductById(id: string): Observable<InventoryProduct>
|
||||||
|
{
|
||||||
|
return this._products.pipe(
|
||||||
|
take(1),
|
||||||
|
map((products) => {
|
||||||
|
|
||||||
|
// Find the product
|
||||||
|
const product = products.find(item => item.id === id) || null;
|
||||||
|
|
||||||
|
// Update the product
|
||||||
|
this._product.next(product);
|
||||||
|
|
||||||
|
// Return the product
|
||||||
|
return product;
|
||||||
|
}),
|
||||||
|
switchMap((product) => {
|
||||||
|
|
||||||
|
if ( !product )
|
||||||
|
{
|
||||||
|
return throwError('Could not found product with id of ' + id + '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(product);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create product
|
||||||
|
*/
|
||||||
|
createProduct(): Observable<InventoryProduct>
|
||||||
|
{
|
||||||
|
return this.products$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(products => this._httpClient.post<InventoryProduct>('api/apps/ecommerce/inventory/product', {}).pipe(
|
||||||
|
map((newProduct) => {
|
||||||
|
|
||||||
|
// Update the products with the new product
|
||||||
|
this._products.next([newProduct, ...products]);
|
||||||
|
|
||||||
|
// Return the new product
|
||||||
|
return newProduct;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update product
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* @param product
|
||||||
|
*/
|
||||||
|
updateProduct(id: string, product: InventoryProduct): Observable<InventoryProduct>
|
||||||
|
{
|
||||||
|
return this.products$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(products => this._httpClient.patch<InventoryProduct>('api/apps/ecommerce/inventory/product', {
|
||||||
|
id,
|
||||||
|
product
|
||||||
|
}).pipe(
|
||||||
|
map((updatedProduct) => {
|
||||||
|
|
||||||
|
// Find the index of the updated product
|
||||||
|
const index = products.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Update the product
|
||||||
|
products[index] = updatedProduct;
|
||||||
|
|
||||||
|
// Update the products
|
||||||
|
this._products.next(products);
|
||||||
|
|
||||||
|
// Return the updated product
|
||||||
|
return updatedProduct;
|
||||||
|
}),
|
||||||
|
switchMap(updatedProduct => this.product$.pipe(
|
||||||
|
take(1),
|
||||||
|
filter(item => item && item.id === id),
|
||||||
|
tap(() => {
|
||||||
|
|
||||||
|
// Update the product if it's selected
|
||||||
|
this._product.next(updatedProduct);
|
||||||
|
|
||||||
|
// Return the updated product
|
||||||
|
return updatedProduct;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the product
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
deleteProduct(id: string): Observable<boolean>
|
||||||
|
{
|
||||||
|
return this.products$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(products => this._httpClient.delete('api/apps/ecommerce/inventory/product', {params: {id}}).pipe(
|
||||||
|
map((isDeleted: boolean) => {
|
||||||
|
|
||||||
|
// Find the index of the deleted product
|
||||||
|
const index = products.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Delete the product
|
||||||
|
products.splice(index, 1);
|
||||||
|
|
||||||
|
// Update the products
|
||||||
|
this._products.next(products);
|
||||||
|
|
||||||
|
// Return the deleted status
|
||||||
|
return isDeleted;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tags
|
||||||
|
*/
|
||||||
|
getTags(): Observable<InventoryTag[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<InventoryTag[]>('api/apps/ecommerce/inventory/tags').pipe(
|
||||||
|
tap((tags) => {
|
||||||
|
this._tags.next(tags);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create tag
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
createTag(tag: InventoryTag): Observable<InventoryTag>
|
||||||
|
{
|
||||||
|
return this.tags$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(tags => this._httpClient.post<InventoryTag>('api/apps/ecommerce/inventory/tag', {tag}).pipe(
|
||||||
|
map((newTag) => {
|
||||||
|
|
||||||
|
// Update the tags with the new tag
|
||||||
|
this._tags.next([...tags, newTag]);
|
||||||
|
|
||||||
|
// Return new tag from observable
|
||||||
|
return newTag;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the tag
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
updateTag(id: string, tag: InventoryTag): Observable<InventoryTag>
|
||||||
|
{
|
||||||
|
return this.tags$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(tags => this._httpClient.patch<InventoryTag>('api/apps/ecommerce/inventory/tag', {
|
||||||
|
id,
|
||||||
|
tag
|
||||||
|
}).pipe(
|
||||||
|
map((updatedTag) => {
|
||||||
|
|
||||||
|
// Find the index of the updated tag
|
||||||
|
const index = tags.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Update the tag
|
||||||
|
tags[index] = updatedTag;
|
||||||
|
|
||||||
|
// Update the tags
|
||||||
|
this._tags.next(tags);
|
||||||
|
|
||||||
|
// Return the updated tag
|
||||||
|
return updatedTag;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the tag
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
deleteTag(id: string): Observable<boolean>
|
||||||
|
{
|
||||||
|
return this.tags$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(tags => this._httpClient.delete('api/apps/ecommerce/inventory/tag', {params: {id}}).pipe(
|
||||||
|
map((isDeleted: boolean) => {
|
||||||
|
|
||||||
|
// Find the index of the deleted tag
|
||||||
|
const index = tags.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Delete the tag
|
||||||
|
tags.splice(index, 1);
|
||||||
|
|
||||||
|
// Update the tags
|
||||||
|
this._tags.next(tags);
|
||||||
|
|
||||||
|
// Return the deleted status
|
||||||
|
return isDeleted;
|
||||||
|
}),
|
||||||
|
filter(isDeleted => isDeleted),
|
||||||
|
switchMap(isDeleted => this.products$.pipe(
|
||||||
|
take(1),
|
||||||
|
map((products) => {
|
||||||
|
|
||||||
|
// Iterate through the contacts
|
||||||
|
products.forEach((product) => {
|
||||||
|
|
||||||
|
const tagIndex = product.tags.findIndex(tag => tag === id);
|
||||||
|
|
||||||
|
// If the contact has the tag, remove it
|
||||||
|
if ( tagIndex > -1 )
|
||||||
|
{
|
||||||
|
product.tags.splice(tagIndex, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the deleted status
|
||||||
|
return isDeleted;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vendors
|
||||||
|
*/
|
||||||
|
getVendors(): Observable<InventoryVendor[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<InventoryVendor[]>('api/apps/ecommerce/inventory/vendors').pipe(
|
||||||
|
tap((vendors) => {
|
||||||
|
this._vendors.next(vendors);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the avatar of the given contact
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* @param avatar
|
||||||
|
*/
|
||||||
|
/*uploadAvatar(id: string, avatar: File): Observable<Contact>
|
||||||
|
{
|
||||||
|
return this.contacts$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap(contacts => this._httpClient.post<Contact>('api/apps/contacts/avatar', {
|
||||||
|
id,
|
||||||
|
avatar
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': avatar.type
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
map((updatedContact) => {
|
||||||
|
|
||||||
|
// Find the index of the updated contact
|
||||||
|
const index = contacts.findIndex(item => item.id === id);
|
||||||
|
|
||||||
|
// Update the contact
|
||||||
|
contacts[index] = updatedContact;
|
||||||
|
|
||||||
|
// Update the contacts
|
||||||
|
this._contacts.next(contacts);
|
||||||
|
|
||||||
|
// Return the updated contact
|
||||||
|
return updatedContact;
|
||||||
|
}),
|
||||||
|
switchMap(updatedContact => this.contact$.pipe(
|
||||||
|
take(1),
|
||||||
|
filter(item => item && item.id === id),
|
||||||
|
tap(() => {
|
||||||
|
|
||||||
|
// Update the contact if it's selected
|
||||||
|
this._contact.next(updatedContact);
|
||||||
|
|
||||||
|
// Return the updated contact
|
||||||
|
return updatedContact;
|
||||||
|
})
|
||||||
|
))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}*/
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export interface InventoryProduct
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
category?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
sku?: string | null;
|
||||||
|
barcode?: string | null;
|
||||||
|
brand?: string | null;
|
||||||
|
vendor: string | null;
|
||||||
|
stock: number;
|
||||||
|
reserved: number;
|
||||||
|
cost: number;
|
||||||
|
basePrice: number;
|
||||||
|
taxPercent: number;
|
||||||
|
price: number;
|
||||||
|
weight: number;
|
||||||
|
thumbnail: string;
|
||||||
|
images: string[];
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryPagination
|
||||||
|
{
|
||||||
|
length: number;
|
||||||
|
size: number;
|
||||||
|
page: number;
|
||||||
|
lastPage: number;
|
||||||
|
startIndex: number;
|
||||||
|
endIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryCategory
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
parentId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryBrand
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryTag
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryVendor
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
<div class="sm:absolute sm:inset-0 flex flex-col flex-auto min-w-0 sm:overflow-hidden bg-card dark:bg-transparent">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="relative flex flex-col sm:flex-row flex-0 sm:items-center sm:justify-between py-8 px-6 md:px-8 border-b">
|
||||||
|
<!-- Loader -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0"
|
||||||
|
*ngIf="isLoading">
|
||||||
|
<mat-progress-bar [mode]="'indeterminate'"></mat-progress-bar>
|
||||||
|
</div>
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="text-4xl font-extrabold tracking-tight">Inventory</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex shrink-0 items-center mt-6 sm:mt-0 sm:ml-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<mat-form-field
|
||||||
|
class="fuse-mat-dense fuse-mat-rounded min-w-64"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
matPrefix
|
||||||
|
[svgIcon]="'heroicons_solid:search'"></mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControl]="searchInputControl"
|
||||||
|
[autocomplete]="'off'"
|
||||||
|
[placeholder]="'Search products'">
|
||||||
|
</mat-form-field>
|
||||||
|
<!-- Add product button -->
|
||||||
|
<button
|
||||||
|
class="ml-4"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'"
|
||||||
|
(click)="createProduct()">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||||
|
<span class="ml-2 mr-1">Add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-auto overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Products list -->
|
||||||
|
<div class="flex flex-col flex-auto sm:mb-18 overflow-hidden sm:overflow-y-auto">
|
||||||
|
<ng-container *ngIf="(products$ | async) as products">
|
||||||
|
<ng-container *ngIf="products.length > 0; else noProducts">
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
class="inventory-grid z-10 sticky top-0 grid gap-4 py-4 px-6 md:px-8 shadow text-md font-semibold text-secondary bg-gray-50 dark:bg-black dark:bg-opacity-5"
|
||||||
|
matSort
|
||||||
|
matSortDisableClear>
|
||||||
|
<div></div>
|
||||||
|
<div
|
||||||
|
class="hidden md:block"
|
||||||
|
[mat-sort-header]="'sku'">
|
||||||
|
SKU
|
||||||
|
</div>
|
||||||
|
<div [mat-sort-header]="'name'">Name</div>
|
||||||
|
<div
|
||||||
|
class="hidden sm:block"
|
||||||
|
[mat-sort-header]="'price'">
|
||||||
|
Price
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hidden lg:block"
|
||||||
|
[mat-sort-header]="'stock'">
|
||||||
|
Stock
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="hidden lg:block"
|
||||||
|
[mat-sort-header]="'active'">
|
||||||
|
Active
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:block">Details</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rows -->
|
||||||
|
<ng-container *ngIf="(products$ | async) as products">
|
||||||
|
<ng-container *ngFor="let product of products; trackBy: trackByFn">
|
||||||
|
<div class="inventory-grid grid items-center gap-4 py-3 px-6 md:px-8 border-b">
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="relative flex flex-0 items-center justify-center w-12 h-12 mr-6 rounded overflow-hidden border">
|
||||||
|
<img
|
||||||
|
class="w-8"
|
||||||
|
*ngIf="product.thumbnail"
|
||||||
|
[alt]="'Product thumbnail image'"
|
||||||
|
[src]="product.thumbnail">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-full h-full text-xs font-semibold leading-none text-center uppercase"
|
||||||
|
*ngIf="!product.thumbnail">
|
||||||
|
NO THUMB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SKU -->
|
||||||
|
<div class="hidden md:block truncate">
|
||||||
|
{{product.sku}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="truncate">
|
||||||
|
{{product.name}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<div class="hidden sm:block">
|
||||||
|
{{product.price | currency:'USD':'symbol':'1.2-2'}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock -->
|
||||||
|
<div class="hidden lg:flex items-center">
|
||||||
|
<div class="min-w-4">{{product.stock}}</div>
|
||||||
|
<!-- Low stock -->
|
||||||
|
<div
|
||||||
|
class="flex items-end ml-2 w-1 h-4 bg-red-200 rounded overflow-hidden"
|
||||||
|
*ngIf="product.stock < 20">
|
||||||
|
<div class="flex w-full h-1/3 bg-red-600"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Medium stock -->
|
||||||
|
<div
|
||||||
|
class="flex items-end ml-2 w-1 h-4 bg-orange-200 rounded overflow-hidden"
|
||||||
|
*ngIf="product.stock >= 20 && product.stock < 30">
|
||||||
|
<div class="flex w-full h-2/4 bg-orange-400"></div>
|
||||||
|
</div>
|
||||||
|
<!-- High stock -->
|
||||||
|
<div
|
||||||
|
class="flex items-end ml-2 w-1 h-4 bg-green-100 rounded overflow-hidden"
|
||||||
|
*ngIf="product.stock >= 30">
|
||||||
|
<div class="flex w-full h-full bg-green-400"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active -->
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<ng-container *ngIf="product.active">
|
||||||
|
<mat-icon
|
||||||
|
class="text-green-400 icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:check'"></mat-icon>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!product.active">
|
||||||
|
<mat-icon
|
||||||
|
class="text-gray-400 icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:x'"></mat-icon>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details button -->
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="min-w-10 min-h-7 h-7 px-2 leading-6"
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="toggleDetails(product.id)">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="selectedProduct?.id === product.id ? 'heroicons_solid:chevron-up' : 'heroicons_solid:chevron-down'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<ng-container *ngIf="selectedProduct?.id === product.id">
|
||||||
|
<ng-container *ngTemplateOutlet="rowDetailsTemplate; context: {$implicit: product}"></ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-paginator
|
||||||
|
class="sm:absolute sm:inset-x-0 sm:bottom-0 border-b sm:border-t sm:border-b-0 z-10 bg-gray-50 dark:bg-transparent"
|
||||||
|
[ngClass]="{'pointer-events-none': isLoading}"
|
||||||
|
[length]="pagination.length"
|
||||||
|
[pageIndex]="pagination.page"
|
||||||
|
[pageSize]="pagination.size"
|
||||||
|
[pageSizeOptions]="[5, 10, 25, 100]"
|
||||||
|
[showFirstLastButtons]="true"></mat-paginator>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template
|
||||||
|
#rowDetailsTemplate
|
||||||
|
let-product>
|
||||||
|
<div class="shadow-lg overflow-hidden">
|
||||||
|
<div class="flex border-b">
|
||||||
|
<!-- Selected product form -->
|
||||||
|
<form
|
||||||
|
class="flex flex-col w-full"
|
||||||
|
[formGroup]="selectedProductForm">
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row p-8">
|
||||||
|
|
||||||
|
<!-- Product images and status -->
|
||||||
|
<div class="flex flex-col items-center sm:items-start mb-8 sm:mb-0">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-32 h-44 border rounded overflow-hidden">
|
||||||
|
<ng-container *ngIf="selectedProductForm.get('images').value.length; else noImage">
|
||||||
|
<img
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
[src]="selectedProductForm.get('images').value[selectedProductForm.get('currentImageIndex').value]">
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noImage>
|
||||||
|
<span class="flex items-center min-h-20 text-lg font-semibold">NO IMAGE</span>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center mt-2 whitespace-nowrap"
|
||||||
|
*ngIf="selectedProductForm.get('images').value.length">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="cycleImages(false)">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-narrow-left'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<span class="font-sm mx-2">
|
||||||
|
{{selectedProductForm.get('currentImageIndex').value + 1}} of {{selectedProductForm.get('images').value.length}}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="cycleImages(true)">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-narrow-right'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col mt-8">
|
||||||
|
<span class="font-semibold mb-2">Product status</span>
|
||||||
|
<mat-slide-toggle
|
||||||
|
[formControlName]="'active'"
|
||||||
|
[color]="'primary'">
|
||||||
|
{{selectedProductForm.get('active').value === true ? 'Active' : 'Disabled'}}
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-auto flex-wrap">
|
||||||
|
<!-- Name, SKU & etc. -->
|
||||||
|
<div class="flex flex-col w-full lg:w-2/4 sm:pl-8">
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'name'">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- SKU and Barcode -->
|
||||||
|
<div class="flex">
|
||||||
|
<mat-form-field class="w-1/3 pr-2">
|
||||||
|
<mat-label>SKU</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'sku'">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-2/3 pl-2">
|
||||||
|
<mat-label>Barcode</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'barcode'">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category, Brand & Vendor -->
|
||||||
|
<div class="flex">
|
||||||
|
<mat-form-field class="w-1/3 pr-2">
|
||||||
|
<mat-label>Category</mat-label>
|
||||||
|
<mat-select [formControlName]="'category'">
|
||||||
|
<ng-container *ngFor="let category of categories">
|
||||||
|
<mat-option [value]="category.id">
|
||||||
|
{{category.name}}
|
||||||
|
</mat-option>
|
||||||
|
</ng-container>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-1/3 px-2">
|
||||||
|
<mat-label>Brand</mat-label>
|
||||||
|
<mat-select [formControlName]="'brand'">
|
||||||
|
<ng-container *ngFor="let brand of brands">
|
||||||
|
<mat-option [value]="brand.id">
|
||||||
|
{{brand.name}}
|
||||||
|
</mat-option>
|
||||||
|
</ng-container>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-1/3 pl-2">
|
||||||
|
<mat-label>Vendor</mat-label>
|
||||||
|
<mat-select [formControlName]="'vendor'">
|
||||||
|
<ng-container *ngFor="let vendor of vendors">
|
||||||
|
<mat-option [value]="vendor.id">
|
||||||
|
{{vendor.name}}
|
||||||
|
</mat-option>
|
||||||
|
</ng-container>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock and Reserved -->
|
||||||
|
<div class="flex">
|
||||||
|
<mat-form-field class="w-1/3 pr-2">
|
||||||
|
<mat-label>Stock</mat-label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
matInput
|
||||||
|
[formControlName]="'stock'">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-1/3 pl-2">
|
||||||
|
<mat-label>Reserved</mat-label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
matInput
|
||||||
|
[formControlName]="'reserved'">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cost, Base price, Tax & Price -->
|
||||||
|
<div class="flex flex-col w-full lg:w-1/4 sm:pl-8">
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<mat-label>Cost</mat-label>
|
||||||
|
<span matPrefix>$</span>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'cost'">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<mat-label>Base Price</mat-label>
|
||||||
|
<span matPrefix>$</span>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'basePrice'">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<mat-label>Tax</mat-label>
|
||||||
|
<span matSuffix>%</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
matInput
|
||||||
|
[formControlName]="'taxPercent'">
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<mat-label>Price</mat-label>
|
||||||
|
<span matSuffix>$</span>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'price'">
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weight & Tags -->
|
||||||
|
<div class="flex flex-col w-full lg:w-1/4 sm:pl-8">
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<mat-label>Weight</mat-label>
|
||||||
|
<span matSuffix>lbs.</span>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'weight'">
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<span class="mb-px font-medium leading-tight">Tags</span>
|
||||||
|
<div class="mt-1.5 rounded-md border border-gray-300 dark:border-gray-500 shadow-sm overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center -my-px py-2 px-3">
|
||||||
|
<div class="flex items-center flex-auto min-w-0">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:search'"></mat-icon>
|
||||||
|
<input
|
||||||
|
class="min-w-0 ml-2 py-1 border-0"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter tag name"
|
||||||
|
(input)="filterTags($event)"
|
||||||
|
(keydown)="filterTagsInputKeyDown($event)"
|
||||||
|
[maxLength]="50"
|
||||||
|
#newTagInput>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="ml-3 w-8 h-8 min-h-8"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="toggleTagsEditMode()">
|
||||||
|
<mat-icon
|
||||||
|
*ngIf="!tagsEditMode"
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:pencil-alt'"></mat-icon>
|
||||||
|
<mat-icon
|
||||||
|
*ngIf="tagsEditMode"
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:check'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Available tags -->
|
||||||
|
<div class="h-44 leading-none overflow-y-auto border-t border-gray-300 dark:border-gray-500">
|
||||||
|
<!-- Tags -->
|
||||||
|
<ng-container *ngIf="!tagsEditMode">
|
||||||
|
<ng-container *ngFor="let tag of filteredTags; trackBy: trackByFn">
|
||||||
|
<mat-checkbox
|
||||||
|
class="flex items-center h-10 min-h-10 pl-1 pr-4"
|
||||||
|
[color]="'primary'"
|
||||||
|
[checked]="selectedProduct.tags.includes(tag.id)"
|
||||||
|
(change)="toggleProductTag(tag, $event)">
|
||||||
|
{{tag.title}}
|
||||||
|
</mat-checkbox>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Tags editing -->
|
||||||
|
<ng-container *ngIf="tagsEditMode">
|
||||||
|
<div class="p-4 space-y-2">
|
||||||
|
<ng-container *ngFor="let tag of filteredTags; trackBy: trackByFn">
|
||||||
|
<mat-form-field
|
||||||
|
class="fuse-mat-dense w-full"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="tag.title"
|
||||||
|
(input)="updateTagTitle(tag, $event)">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="deleteTag(tag)"
|
||||||
|
matSuffix>
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:trash'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-form-field>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<div
|
||||||
|
class="flex items-center h-10 min-h-10 -ml-0.5 pl-4 pr-3 leading-none cursor-pointer border-t hover:bg-gray-50 dark:hover:bg-hover"
|
||||||
|
*ngIf="shouldShowCreateTagButton(newTagInput.value)"
|
||||||
|
(click)="createTag(newTagInput.value); newTagInput.value = ''"
|
||||||
|
matRipple>
|
||||||
|
<mat-icon
|
||||||
|
class="mr-2 icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:plus-circle'"></mat-icon>
|
||||||
|
<div class="break-all">Create "<b>{{newTagInput.value}}</b>"</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between w-full border-t px-8 py-4">
|
||||||
|
<button
|
||||||
|
class="-ml-4"
|
||||||
|
mat-button
|
||||||
|
[color]="'warn'"
|
||||||
|
(click)="deleteSelectedProduct()">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="flex items-center mr-4"
|
||||||
|
*ngIf="flashMessage">
|
||||||
|
<ng-container *ngIf="flashMessage === 'success'">
|
||||||
|
<mat-icon
|
||||||
|
class="text-green-500"
|
||||||
|
[svgIcon]="'heroicons_outline:check'"></mat-icon>
|
||||||
|
<span class="ml-2">Product updated</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="flashMessage === 'error'">
|
||||||
|
<mat-icon
|
||||||
|
class="text-red-500"
|
||||||
|
[svgIcon]="'heroicons_outline:x'"></mat-icon>
|
||||||
|
<span class="ml-2">An error occurred, try again!</span>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'"
|
||||||
|
(click)="updateSelectedProduct()">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #noProducts>
|
||||||
|
<div class="p-8 sm:p-16 border-t text-4xl font-semibold tracking-tight text-center">There are no products!</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
|
||||||
|
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
|
import { MatPaginator } from '@angular/material/paginator';
|
||||||
|
import { MatSort } from '@angular/material/sort';
|
||||||
|
import { debounceTime, map, merge, Observable, Subject, switchMap, takeUntil } from 'rxjs';
|
||||||
|
import { fuseAnimations } from '@fuse/animations';
|
||||||
|
import { FuseConfirmationService } from '@fuse/services/confirmation';
|
||||||
|
import { InventoryBrand, InventoryCategory, InventoryPagination, InventoryProduct, InventoryTag, InventoryVendor } from 'app/modules/admin/apps/ecommerce/inventory/inventory.types';
|
||||||
|
import { InventoryService } from 'app/modules/admin/apps/ecommerce/inventory/inventory.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'inventory-list',
|
||||||
|
templateUrl : './inventory.component.html',
|
||||||
|
styles : [
|
||||||
|
/* language=SCSS */
|
||||||
|
`
|
||||||
|
.inventory-grid {
|
||||||
|
grid-template-columns: 48px auto 40px;
|
||||||
|
|
||||||
|
@screen sm {
|
||||||
|
grid-template-columns: 48px auto 112px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen md {
|
||||||
|
grid-template-columns: 48px 112px auto 112px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen lg {
|
||||||
|
grid-template-columns: 48px 112px auto 112px 96px 96px 72px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
],
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
animations : fuseAnimations
|
||||||
|
})
|
||||||
|
export class InventoryListComponent implements OnInit, AfterViewInit, OnDestroy
|
||||||
|
{
|
||||||
|
@ViewChild(MatPaginator) private _paginator: MatPaginator;
|
||||||
|
@ViewChild(MatSort) private _sort: MatSort;
|
||||||
|
|
||||||
|
products$: Observable<InventoryProduct[]>;
|
||||||
|
|
||||||
|
brands: InventoryBrand[];
|
||||||
|
categories: InventoryCategory[];
|
||||||
|
filteredTags: InventoryTag[];
|
||||||
|
flashMessage: 'success' | 'error' | null = null;
|
||||||
|
isLoading: boolean = false;
|
||||||
|
pagination: InventoryPagination;
|
||||||
|
searchInputControl: UntypedFormControl = new UntypedFormControl();
|
||||||
|
selectedProduct: InventoryProduct | null = null;
|
||||||
|
selectedProductForm: UntypedFormGroup;
|
||||||
|
tags: InventoryTag[];
|
||||||
|
tagsEditMode: boolean = false;
|
||||||
|
vendors: InventoryVendor[];
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _fuseConfirmationService: FuseConfirmationService,
|
||||||
|
private _formBuilder: UntypedFormBuilder,
|
||||||
|
private _inventoryService: InventoryService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Create the selected product form
|
||||||
|
this.selectedProductForm = this._formBuilder.group({
|
||||||
|
id : [''],
|
||||||
|
category : [''],
|
||||||
|
name : ['', [Validators.required]],
|
||||||
|
description : [''],
|
||||||
|
tags : [[]],
|
||||||
|
sku : [''],
|
||||||
|
barcode : [''],
|
||||||
|
brand : [''],
|
||||||
|
vendor : [''],
|
||||||
|
stock : [''],
|
||||||
|
reserved : [''],
|
||||||
|
cost : [''],
|
||||||
|
basePrice : [''],
|
||||||
|
taxPercent : [''],
|
||||||
|
price : [''],
|
||||||
|
weight : [''],
|
||||||
|
thumbnail : [''],
|
||||||
|
images : [[]],
|
||||||
|
currentImageIndex: [0], // Image index that is currently being viewed
|
||||||
|
active : [false]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the brands
|
||||||
|
this._inventoryService.brands$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((brands: InventoryBrand[]) => {
|
||||||
|
|
||||||
|
// Update the brands
|
||||||
|
this.brands = brands;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the categories
|
||||||
|
this._inventoryService.categories$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((categories: InventoryCategory[]) => {
|
||||||
|
|
||||||
|
// Update the categories
|
||||||
|
this.categories = categories;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the pagination
|
||||||
|
this._inventoryService.pagination$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((pagination: InventoryPagination) => {
|
||||||
|
|
||||||
|
// Update the pagination
|
||||||
|
this.pagination = pagination;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the products
|
||||||
|
this.products$ = this._inventoryService.products$;
|
||||||
|
|
||||||
|
// Get the tags
|
||||||
|
this._inventoryService.tags$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((tags: InventoryTag[]) => {
|
||||||
|
|
||||||
|
// Update the tags
|
||||||
|
this.tags = tags;
|
||||||
|
this.filteredTags = tags;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the vendors
|
||||||
|
this._inventoryService.vendors$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((vendors: InventoryVendor[]) => {
|
||||||
|
|
||||||
|
// Update the vendors
|
||||||
|
this.vendors = vendors;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to search input field value changes
|
||||||
|
this.searchInputControl.valueChanges
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this._unsubscribeAll),
|
||||||
|
debounceTime(300),
|
||||||
|
switchMap((query) => {
|
||||||
|
this.closeDetails();
|
||||||
|
this.isLoading = true;
|
||||||
|
return this._inventoryService.getProducts(0, 10, 'name', 'asc', query);
|
||||||
|
}),
|
||||||
|
map(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After view init
|
||||||
|
*/
|
||||||
|
ngAfterViewInit(): void
|
||||||
|
{
|
||||||
|
if ( this._sort && this._paginator )
|
||||||
|
{
|
||||||
|
// Set the initial sort
|
||||||
|
this._sort.sort({
|
||||||
|
id : 'name',
|
||||||
|
start : 'asc',
|
||||||
|
disableClear: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
// If the user changes the sort order...
|
||||||
|
this._sort.sortChange
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe(() => {
|
||||||
|
// Reset back to the first page
|
||||||
|
this._paginator.pageIndex = 0;
|
||||||
|
|
||||||
|
// Close the details
|
||||||
|
this.closeDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get products if sort or page changes
|
||||||
|
merge(this._sort.sortChange, this._paginator.page).pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
this.closeDetails();
|
||||||
|
this.isLoading = true;
|
||||||
|
return this._inventoryService.getProducts(this._paginator.pageIndex, this._paginator.pageSize, this._sort.active, this._sort.direction);
|
||||||
|
}),
|
||||||
|
map(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
})
|
||||||
|
).subscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle product details
|
||||||
|
*
|
||||||
|
* @param productId
|
||||||
|
*/
|
||||||
|
toggleDetails(productId: string): void
|
||||||
|
{
|
||||||
|
// If the product is already selected...
|
||||||
|
if ( this.selectedProduct && this.selectedProduct.id === productId )
|
||||||
|
{
|
||||||
|
// Close the details
|
||||||
|
this.closeDetails();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the product by id
|
||||||
|
this._inventoryService.getProductById(productId)
|
||||||
|
.subscribe((product) => {
|
||||||
|
|
||||||
|
// Set the selected product
|
||||||
|
this.selectedProduct = product;
|
||||||
|
|
||||||
|
// Fill the form
|
||||||
|
this.selectedProductForm.patchValue(product);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the details
|
||||||
|
*/
|
||||||
|
closeDetails(): void
|
||||||
|
{
|
||||||
|
this.selectedProduct = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle through images of selected product
|
||||||
|
*/
|
||||||
|
cycleImages(forward: boolean = true): void
|
||||||
|
{
|
||||||
|
// Get the image count and current image index
|
||||||
|
const count = this.selectedProductForm.get('images').value.length;
|
||||||
|
const currentIndex = this.selectedProductForm.get('currentImageIndex').value;
|
||||||
|
|
||||||
|
// Calculate the next and previous index
|
||||||
|
const nextIndex = currentIndex + 1 === count ? 0 : currentIndex + 1;
|
||||||
|
const prevIndex = currentIndex - 1 < 0 ? count - 1 : currentIndex - 1;
|
||||||
|
|
||||||
|
// If cycling forward...
|
||||||
|
if ( forward )
|
||||||
|
{
|
||||||
|
this.selectedProductForm.get('currentImageIndex').setValue(nextIndex);
|
||||||
|
}
|
||||||
|
// If cycling backwards...
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.selectedProductForm.get('currentImageIndex').setValue(prevIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the tags edit mode
|
||||||
|
*/
|
||||||
|
toggleTagsEditMode(): void
|
||||||
|
{
|
||||||
|
this.tagsEditMode = !this.tagsEditMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tags
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
filterTags(event): void
|
||||||
|
{
|
||||||
|
// Get the value
|
||||||
|
const value = event.target.value.toLowerCase();
|
||||||
|
|
||||||
|
// Filter the tags
|
||||||
|
this.filteredTags = this.tags.filter(tag => tag.title.toLowerCase().includes(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tags input key down event
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
filterTagsInputKeyDown(event): void
|
||||||
|
{
|
||||||
|
// Return if the pressed key is not 'Enter'
|
||||||
|
if ( event.key !== 'Enter' )
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is no tag available...
|
||||||
|
if ( this.filteredTags.length === 0 )
|
||||||
|
{
|
||||||
|
// Create the tag
|
||||||
|
this.createTag(event.target.value);
|
||||||
|
|
||||||
|
// Clear the input
|
||||||
|
event.target.value = '';
|
||||||
|
|
||||||
|
// Return
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there is a tag...
|
||||||
|
const tag = this.filteredTags[0];
|
||||||
|
const isTagApplied = this.selectedProduct.tags.find(id => id === tag.id);
|
||||||
|
|
||||||
|
// If the found tag is already applied to the product...
|
||||||
|
if ( isTagApplied )
|
||||||
|
{
|
||||||
|
// Remove the tag from the product
|
||||||
|
this.removeTagFromProduct(tag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Otherwise add the tag to the product
|
||||||
|
this.addTagToProduct(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tag
|
||||||
|
*
|
||||||
|
* @param title
|
||||||
|
*/
|
||||||
|
createTag(title: string): void
|
||||||
|
{
|
||||||
|
const tag = {
|
||||||
|
title
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create tag on the server
|
||||||
|
this._inventoryService.createTag(tag)
|
||||||
|
.subscribe((response) => {
|
||||||
|
|
||||||
|
// Add the tag to the product
|
||||||
|
this.addTagToProduct(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the tag title
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
updateTagTitle(tag: InventoryTag, event): void
|
||||||
|
{
|
||||||
|
// Update the title on the tag
|
||||||
|
tag.title = event.target.value;
|
||||||
|
|
||||||
|
// Update the tag on the server
|
||||||
|
this._inventoryService.updateTag(tag.id, tag)
|
||||||
|
.pipe(debounceTime(300))
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the tag
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
deleteTag(tag: InventoryTag): void
|
||||||
|
{
|
||||||
|
// Delete the tag from the server
|
||||||
|
this._inventoryService.deleteTag(tag.id).subscribe();
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add tag to the product
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
addTagToProduct(tag: InventoryTag): void
|
||||||
|
{
|
||||||
|
// Add the tag
|
||||||
|
this.selectedProduct.tags.unshift(tag.id);
|
||||||
|
|
||||||
|
// Update the selected product form
|
||||||
|
this.selectedProductForm.get('tags').patchValue(this.selectedProduct.tags);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove tag from the product
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
*/
|
||||||
|
removeTagFromProduct(tag: InventoryTag): void
|
||||||
|
{
|
||||||
|
// Remove the tag
|
||||||
|
this.selectedProduct.tags.splice(this.selectedProduct.tags.findIndex(item => item === tag.id), 1);
|
||||||
|
|
||||||
|
// Update the selected product form
|
||||||
|
this.selectedProductForm.get('tags').patchValue(this.selectedProduct.tags);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle product tag
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
* @param change
|
||||||
|
*/
|
||||||
|
toggleProductTag(tag: InventoryTag, change: MatCheckboxChange): void
|
||||||
|
{
|
||||||
|
if ( change.checked )
|
||||||
|
{
|
||||||
|
this.addTagToProduct(tag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.removeTagFromProduct(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the create tag button be visible
|
||||||
|
*
|
||||||
|
* @param inputValue
|
||||||
|
*/
|
||||||
|
shouldShowCreateTagButton(inputValue: string): boolean
|
||||||
|
{
|
||||||
|
return !!!(inputValue === '' || this.tags.findIndex(tag => tag.title.toLowerCase() === inputValue.toLowerCase()) > -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create product
|
||||||
|
*/
|
||||||
|
createProduct(): void
|
||||||
|
{
|
||||||
|
// Create the product
|
||||||
|
this._inventoryService.createProduct().subscribe((newProduct) => {
|
||||||
|
|
||||||
|
// Go to new product
|
||||||
|
this.selectedProduct = newProduct;
|
||||||
|
|
||||||
|
// Fill the form
|
||||||
|
this.selectedProductForm.patchValue(newProduct);
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the selected product using the form data
|
||||||
|
*/
|
||||||
|
updateSelectedProduct(): void
|
||||||
|
{
|
||||||
|
// Get the product object
|
||||||
|
const product = this.selectedProductForm.getRawValue();
|
||||||
|
|
||||||
|
// Remove the currentImageIndex field
|
||||||
|
delete product.currentImageIndex;
|
||||||
|
|
||||||
|
// Update the product on the server
|
||||||
|
this._inventoryService.updateProduct(product.id, product).subscribe(() => {
|
||||||
|
|
||||||
|
// Show a success message
|
||||||
|
this.showFlashMessage('success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the selected product using the form data
|
||||||
|
*/
|
||||||
|
deleteSelectedProduct(): void
|
||||||
|
{
|
||||||
|
// Open the confirmation dialog
|
||||||
|
const confirmation = this._fuseConfirmationService.open({
|
||||||
|
title : 'Delete product',
|
||||||
|
message: 'Are you sure you want to remove this product? This action cannot be undone!',
|
||||||
|
actions: {
|
||||||
|
confirm: {
|
||||||
|
label: 'Delete'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to the confirmation dialog closed action
|
||||||
|
confirmation.afterClosed().subscribe((result) => {
|
||||||
|
|
||||||
|
// If the confirm button pressed...
|
||||||
|
if ( result === 'confirmed' )
|
||||||
|
{
|
||||||
|
|
||||||
|
// Get the product object
|
||||||
|
const product = this.selectedProductForm.getRawValue();
|
||||||
|
|
||||||
|
// Delete the product on the server
|
||||||
|
this._inventoryService.deleteProduct(product.id).subscribe(() => {
|
||||||
|
|
||||||
|
// Close the details
|
||||||
|
this.closeDetails();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show flash message
|
||||||
|
*/
|
||||||
|
showFlashMessage(type: 'success' | 'error'): void
|
||||||
|
{
|
||||||
|
// Show the message
|
||||||
|
this.flashMessage = type;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
// Hide it after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
|
||||||
|
this.flashMessage = null;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<div class="flex flex-col flex-auto p-6 md:p-8">
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<a
|
||||||
|
mat-icon-button
|
||||||
|
[routerLink]="['../../']">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:x'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="mt-8 aspect-[9/6]">
|
||||||
|
<div class="flex items-center justify-center h-full border rounded-lg bg-gray-50 dark:bg-card">
|
||||||
|
<ng-container *ngIf="item.type === 'folder'">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-24 text-hint"
|
||||||
|
[svgIcon]="'heroicons_outline:folder'"></mat-icon>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="item.type !== 'folder'">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-24 text-hint"
|
||||||
|
[svgIcon]="'heroicons_outline:document'"></mat-icon>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name & Type -->
|
||||||
|
<div class="flex flex-col items-start mt-8">
|
||||||
|
<div class="text-xl font-medium">{{item.name}}</div>
|
||||||
|
<div
|
||||||
|
class="mt-1 px-1.5 rounded text-sm font-semibold leading-5 text-white"
|
||||||
|
[class.bg-indigo-600]="item.type === 'folder'"
|
||||||
|
[class.bg-red-600]="item.type === 'PDF'"
|
||||||
|
[class.bg-blue-600]="item.type === 'DOC'"
|
||||||
|
[class.bg-green-600]="item.type === 'XLS'"
|
||||||
|
[class.bg-gray-600]="item.type === 'TXT'"
|
||||||
|
[class.bg-amber-600]="item.type === 'JPG'">
|
||||||
|
{{item.type.toUpperCase()}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Information -->
|
||||||
|
<div class="text-lg font-medium mt-8">Information</div>
|
||||||
|
<div class="flex flex-col mt-4 border-t border-b divide-y font-medium">
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div class="text-secondary">Created By</div>
|
||||||
|
<div>{{item.createdBy}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div class="text-secondary">Created At</div>
|
||||||
|
<div>{{item.createdAt}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div class="text-secondary">Modified At</div>
|
||||||
|
<div>{{item.modifiedAt}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div class="text-secondary">Size</div>
|
||||||
|
<div>{{item.size}}</div>
|
||||||
|
</div>
|
||||||
|
<ng-container *ngIf="item.contents">
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div class="text-secondary">Contents</div>
|
||||||
|
<div>{{item.contents}}</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="flex items-center justify-between mt-8">
|
||||||
|
<div class="text-lg font-medium">Description</div>
|
||||||
|
<button mat-icon-button>
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:pencil'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex mt-2 border-t">
|
||||||
|
<div class="py-3">
|
||||||
|
<ng-container *ngIf="item.description">
|
||||||
|
<div>{{item.description}}</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!item.description">
|
||||||
|
<div class="italic text-secondary">Click here to add a description</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 w-full mt-8">
|
||||||
|
<button
|
||||||
|
class="flex-auto"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-auto"
|
||||||
|
mat-stroked-button>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { MatDrawerToggleResult } from '@angular/material/sidenav';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { FileManagerListComponent } from 'app/modules/admin/apps/file-manager/list/list.component';
|
||||||
|
import { FileManagerService } from 'app/modules/admin/apps/file-manager/file-manager.service';
|
||||||
|
import { Item } from 'app/modules/admin/apps/file-manager/file-manager.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'file-manager-details',
|
||||||
|
templateUrl : './details.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class FileManagerDetailsComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
item: Item;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _fileManagerListComponent: FileManagerListComponent,
|
||||||
|
private _fileManagerService: FileManagerService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Open the drawer
|
||||||
|
this._fileManagerListComponent.matDrawer.open();
|
||||||
|
|
||||||
|
// Get the item
|
||||||
|
this._fileManagerService.item$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((item: Item) => {
|
||||||
|
|
||||||
|
// Open the drawer in case it is closed
|
||||||
|
this._fileManagerListComponent.matDrawer.open();
|
||||||
|
|
||||||
|
// Get the item
|
||||||
|
this.item = item;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the drawer
|
||||||
|
*/
|
||||||
|
closeDrawer(): Promise<MatDrawerToggleResult>
|
||||||
|
{
|
||||||
|
return this._fileManagerListComponent.matDrawer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'file-manager',
|
||||||
|
templateUrl : './file-manager.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class FileManagerComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, CanDeactivate, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { FileManagerDetailsComponent } from 'app/modules/admin/apps/file-manager/details/details.component';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class CanDeactivateFileManagerDetails implements CanDeactivate<FileManagerDetailsComponent>
|
||||||
|
{
|
||||||
|
canDeactivate(
|
||||||
|
component: FileManagerDetailsComponent,
|
||||||
|
currentRoute: ActivatedRouteSnapshot,
|
||||||
|
currentState: RouterStateSnapshot,
|
||||||
|
nextState: RouterStateSnapshot
|
||||||
|
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree
|
||||||
|
{
|
||||||
|
// Get the next route
|
||||||
|
let nextRoute: ActivatedRouteSnapshot = nextState.root;
|
||||||
|
while ( nextRoute.firstChild )
|
||||||
|
{
|
||||||
|
nextRoute = nextRoute.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the next state doesn't contain '/file-manager'
|
||||||
|
// it means we are navigating away from the
|
||||||
|
// file manager app
|
||||||
|
if ( !nextState.url.includes('/file-manager') )
|
||||||
|
{
|
||||||
|
// Let it navigate
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are navigating to another item...
|
||||||
|
if ( nextState.url.includes('/details') )
|
||||||
|
{
|
||||||
|
// Just navigate
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Otherwise...
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Close the drawer first, and then navigate
|
||||||
|
return component.closeDrawer().then(() => true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { SharedModule } from 'app/shared/shared.module';
|
||||||
|
import { fileManagerRoutes } from 'app/modules/admin/apps/file-manager/file-manager.routing';
|
||||||
|
import { FileManagerComponent } from 'app/modules/admin/apps/file-manager/file-manager.component';
|
||||||
|
import { FileManagerDetailsComponent } from 'app/modules/admin/apps/file-manager/details/details.component';
|
||||||
|
import { FileManagerListComponent } from 'app/modules/admin/apps/file-manager/list/list.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
FileManagerComponent,
|
||||||
|
FileManagerDetailsComponent,
|
||||||
|
FileManagerListComponent
|
||||||
|
],
|
||||||
|
imports : [
|
||||||
|
RouterModule.forChild(fileManagerRoutes),
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
SharedModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class FileManagerModule
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { catchError, Observable, throwError } from 'rxjs';
|
||||||
|
import { FileManagerService } from 'app/modules/admin/apps/file-manager/file-manager.service';
|
||||||
|
import { Item } from 'app/modules/admin/apps/file-manager/file-manager.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FileManagerItemsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _fileManagerService: FileManagerService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Item[]>
|
||||||
|
{
|
||||||
|
return this._fileManagerService.getItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FileManagerFolderResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _router: Router,
|
||||||
|
private _fileManagerService: FileManagerService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Item[]>
|
||||||
|
{
|
||||||
|
return this._fileManagerService.getItems(route.paramMap.get('folderId'))
|
||||||
|
.pipe(
|
||||||
|
// Error here means the requested task is not available
|
||||||
|
catchError((error) => {
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
// Get the parent url
|
||||||
|
const parentUrl = state.url.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Navigate to there
|
||||||
|
this._router.navigateByUrl(parentUrl);
|
||||||
|
|
||||||
|
// Throw an error
|
||||||
|
return throwError(error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FileManagerItemResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _router: Router,
|
||||||
|
private _fileManagerService: FileManagerService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Item>
|
||||||
|
{
|
||||||
|
return this._fileManagerService.getItemById(route.paramMap.get('id'))
|
||||||
|
.pipe(
|
||||||
|
// Error here means the requested task is not available
|
||||||
|
catchError((error) => {
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
// Get the parent url
|
||||||
|
const parentUrl = state.url.split('/').slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Navigate to there
|
||||||
|
this._router.navigateByUrl(parentUrl);
|
||||||
|
|
||||||
|
// Throw an error
|
||||||
|
return throwError(error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { CanDeactivateFileManagerDetails } from 'app/modules/admin/apps/file-manager/file-manager.guards';
|
||||||
|
import { FileManagerComponent } from 'app/modules/admin/apps/file-manager/file-manager.component';
|
||||||
|
import { FileManagerListComponent } from 'app/modules/admin/apps/file-manager/list/list.component';
|
||||||
|
import { FileManagerDetailsComponent } from 'app/modules/admin/apps/file-manager/details/details.component';
|
||||||
|
import { FileManagerFolderResolver, FileManagerItemResolver, FileManagerItemsResolver } from 'app/modules/admin/apps/file-manager/file-manager.resolvers';
|
||||||
|
|
||||||
|
export const fileManagerRoutes: Route[] = [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: FileManagerComponent,
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : 'folders/:folderId',
|
||||||
|
component: FileManagerListComponent,
|
||||||
|
resolve : {
|
||||||
|
item: FileManagerFolderResolver
|
||||||
|
},
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : 'details/:id',
|
||||||
|
component : FileManagerDetailsComponent,
|
||||||
|
resolve : {
|
||||||
|
item: FileManagerItemResolver
|
||||||
|
},
|
||||||
|
canDeactivate: [CanDeactivateFileManagerDetails]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: FileManagerListComponent,
|
||||||
|
resolve : {
|
||||||
|
items: FileManagerItemsResolver
|
||||||
|
},
|
||||||
|
children : [
|
||||||
|
{
|
||||||
|
path : 'details/:id',
|
||||||
|
component : FileManagerDetailsComponent,
|
||||||
|
resolve : {
|
||||||
|
item: FileManagerItemResolver
|
||||||
|
},
|
||||||
|
canDeactivate: [CanDeactivateFileManagerDetails]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, map, Observable, of, switchMap, take, tap, throwError } from 'rxjs';
|
||||||
|
import { Item, Items } from 'app/modules/admin/apps/file-manager/file-manager.types';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class FileManagerService
|
||||||
|
{
|
||||||
|
// Private
|
||||||
|
private _item: BehaviorSubject<Item | null> = new BehaviorSubject(null);
|
||||||
|
private _items: BehaviorSubject<Items | null> = new BehaviorSubject(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _httpClient: HttpClient)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Accessors
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for items
|
||||||
|
*/
|
||||||
|
get items$(): Observable<Items>
|
||||||
|
{
|
||||||
|
return this._items.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for item
|
||||||
|
*/
|
||||||
|
get item$(): Observable<Item>
|
||||||
|
{
|
||||||
|
return this._item.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get items
|
||||||
|
*/
|
||||||
|
getItems(folderId: string | null = null): Observable<Item[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<Items>('api/apps/file-manager', {params: {folderId}}).pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._items.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get item by id
|
||||||
|
*/
|
||||||
|
getItemById(id: string): Observable<Item>
|
||||||
|
{
|
||||||
|
return this._items.pipe(
|
||||||
|
take(1),
|
||||||
|
map((items) => {
|
||||||
|
|
||||||
|
// Find within the folders and files
|
||||||
|
const item = [...items.folders, ...items.files].find(value => value.id === id) || null;
|
||||||
|
|
||||||
|
// Update the item
|
||||||
|
this._item.next(item);
|
||||||
|
|
||||||
|
// Return the item
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
switchMap((item) => {
|
||||||
|
|
||||||
|
if ( !item )
|
||||||
|
{
|
||||||
|
return throwError('Could not found the item with id of ' + id + '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(item);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface Items
|
||||||
|
{
|
||||||
|
folders: Item[];
|
||||||
|
files: Item[];
|
||||||
|
path: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Item
|
||||||
|
{
|
||||||
|
id?: string;
|
||||||
|
folderId?: string;
|
||||||
|
name?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
modifiedAt?: string;
|
||||||
|
size?: string;
|
||||||
|
type?: string;
|
||||||
|
contents?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
176
src/app/modules/admin/apps/file-manager/list/list.component.html
Normal file
176
src/app/modules/admin/apps/file-manager/list/list.component.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<div class="absolute inset-0 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
|
||||||
|
<mat-drawer-container
|
||||||
|
class="flex-auto h-full bg-card dark:bg-transparent"
|
||||||
|
(backdropClick)="onBackdropClicked()">
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<mat-drawer
|
||||||
|
class="w-full sm:w-100 dark:bg-gray-900"
|
||||||
|
[mode]="drawerMode"
|
||||||
|
[opened]="false"
|
||||||
|
[position]="'end'"
|
||||||
|
[disableClose]="true"
|
||||||
|
#matDrawer>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</mat-drawer>
|
||||||
|
|
||||||
|
<mat-drawer-content class="flex flex-col bg-gray-100 dark:bg-transparent">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-col flex-auto">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center sm:justify-between p-6 sm:py-12 md:px-8 border-b bg-card dark:bg-transparent">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<div class="text-4xl font-extrabold tracking-tight leading-none">File Manager</div>
|
||||||
|
<div class="flex items-center mt-0.5 font-medium text-secondary">
|
||||||
|
<ng-container *ngIf="!items.path.length">
|
||||||
|
{{items.folders.length}} folders, {{items.files.length}} files
|
||||||
|
</ng-container>
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<ng-container *ngIf="items.path.length">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<a
|
||||||
|
class="text-primary cursor-pointer"
|
||||||
|
[routerLink]="['/apps/file-manager']">Home
|
||||||
|
</a>
|
||||||
|
<div class="">/</div>
|
||||||
|
<ng-container *ngFor="let path of items.path; let last = last; trackBy: trackByFn">
|
||||||
|
<ng-container *ngIf="!last">
|
||||||
|
<a
|
||||||
|
class="text-primary cursor-pointer"
|
||||||
|
[routerLink]="['/apps/file-manager/folders/', path.id]">{{path.name}}</a>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="last">
|
||||||
|
<div>{{path.name}}</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="!last">
|
||||||
|
<div class="">/</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="mt-4 sm:mt-0">
|
||||||
|
<!-- Upload button -->
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:plus'"></mat-icon>
|
||||||
|
<span class="ml-2 mr-1">Upload file</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items list -->
|
||||||
|
<ng-container *ngIf="items && (items.folders.length > 0 || items.files.length > 0); else noItems">
|
||||||
|
<div class="p-6 md:p-8 space-y-8">
|
||||||
|
<!-- Folders -->
|
||||||
|
<ng-container *ngIf="items.folders.length > 0">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Folders</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap -m-2 mt-2">
|
||||||
|
<ng-container *ngFor="let folder of items.folders; trackBy:trackByFn">
|
||||||
|
<div class="relative w-40 h-40 m-2 p-4 shadow rounded-2xl bg-card">
|
||||||
|
<a
|
||||||
|
class="absolute z-20 top-1.5 right-1.5 w-8 h-8 min-h-8"
|
||||||
|
(click)="$event.preventDefault()"
|
||||||
|
[routerLink]="['./details/', folder.id]"
|
||||||
|
mat-icon-button>
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:information-circle'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="z-10 absolute inset-0 flex flex-col p-4 cursor-pointer"
|
||||||
|
[routerLink]="['/apps/file-manager/folders/', folder.id]">
|
||||||
|
<div class="aspect-[9/6]">
|
||||||
|
<div class="flex items-center justify-center h-full">
|
||||||
|
<!-- Icon -->
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-14 text-hint opacity-50"
|
||||||
|
[svgIcon]="'heroicons_solid:folder'"></mat-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-auto justify-center text-center text-sm font-medium">
|
||||||
|
<div
|
||||||
|
class="truncate"
|
||||||
|
[matTooltip]="folder.name">{{folder.name}}</div>
|
||||||
|
<ng-container *ngIf="folder.contents">
|
||||||
|
<div class="text-secondary truncate">{{folder.contents}}</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Files -->
|
||||||
|
<ng-container *ngIf="items.files.length > 0">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">Files</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap -m-2 mt-2">
|
||||||
|
<ng-container *ngFor="let file of items.files; trackBy:trackByFn">
|
||||||
|
<a
|
||||||
|
class="flex flex-col w-40 h-40 m-2 p-4 shadow rounded-2xl cursor-pointer bg-card"
|
||||||
|
[routerLink]="['./details/', file.id]">
|
||||||
|
<div class="aspect-[9/6]">
|
||||||
|
<div class="flex items-center justify-center h-full">
|
||||||
|
<!-- Icons -->
|
||||||
|
<div class="relative">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-14 text-hint opacity-50"
|
||||||
|
[svgIcon]="'heroicons_solid:document'"></mat-icon>
|
||||||
|
<div
|
||||||
|
class="absolute left-0 bottom-0 px-1.5 rounded text-sm font-semibold leading-5 text-white"
|
||||||
|
[class.bg-red-600]="file.type === 'PDF'"
|
||||||
|
[class.bg-blue-600]="file.type === 'DOC'"
|
||||||
|
[class.bg-green-600]="file.type === 'XLS'"
|
||||||
|
[class.bg-gray-600]="file.type === 'TXT'"
|
||||||
|
[class.bg-amber-600]="file.type === 'JPG'">
|
||||||
|
{{file.type.toUpperCase()}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-auto justify-center text-center text-sm font-medium">
|
||||||
|
<div
|
||||||
|
class="truncate"
|
||||||
|
[matTooltip]="file.name">{{file.name}}</div>
|
||||||
|
<ng-container *ngIf="file.contents">
|
||||||
|
<div class="text-secondary truncate">{{file.contents}}</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- No items template -->
|
||||||
|
<ng-template #noItems>
|
||||||
|
<div class="flex flex-auto flex-col items-center justify-center bg-gray-100 dark:bg-transparent">
|
||||||
|
<mat-icon
|
||||||
|
class="icon-size-24"
|
||||||
|
[svgIcon]="'heroicons_outline:folder-open'"></mat-icon>
|
||||||
|
<div class="mt-4 text-2xl font-semibold tracking-tight text-secondary">There are no items!</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</mat-drawer-content>
|
||||||
|
|
||||||
|
</mat-drawer-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
114
src/app/modules/admin/apps/file-manager/list/list.component.ts
Normal file
114
src/app/modules/admin/apps/file-manager/list/list.component.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatDrawer } from '@angular/material/sidenav';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { FuseMediaWatcherService } from '@fuse/services/media-watcher';
|
||||||
|
import { FileManagerService } from 'app/modules/admin/apps/file-manager/file-manager.service';
|
||||||
|
import { Item, Items } from 'app/modules/admin/apps/file-manager/file-manager.types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'file-manager-list',
|
||||||
|
templateUrl : './list.component.html',
|
||||||
|
encapsulation : ViewEncapsulation.None,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class FileManagerListComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
@ViewChild('matDrawer', {static: true}) matDrawer: MatDrawer;
|
||||||
|
drawerMode: 'side' | 'over';
|
||||||
|
selectedItem: Item;
|
||||||
|
items: Items;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject<any>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _activatedRoute: ActivatedRoute,
|
||||||
|
private _changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private _router: Router,
|
||||||
|
private _fileManagerService: FileManagerService,
|
||||||
|
private _fuseMediaWatcherService: FuseMediaWatcherService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the items
|
||||||
|
this._fileManagerService.items$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((items: Items) => {
|
||||||
|
this.items = items;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the item
|
||||||
|
this._fileManagerService.item$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((item: Item) => {
|
||||||
|
this.selectedItem = item;
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to media query change
|
||||||
|
this._fuseMediaWatcherService.onMediaQueryChange$('(min-width: 1440px)')
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((state) => {
|
||||||
|
|
||||||
|
// Calculate the drawer mode
|
||||||
|
this.drawerMode = state.matches ? 'side' : 'over';
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On backdrop clicked
|
||||||
|
*/
|
||||||
|
onBackdropClicked(): void
|
||||||
|
{
|
||||||
|
// Go back to the list
|
||||||
|
this._router.navigate(['./'], {relativeTo: this._activatedRoute});
|
||||||
|
|
||||||
|
// Mark for check
|
||||||
|
this._changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<div class="flex flex-col flex-auto min-w-0">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-col items-center p-6 sm:p-10">
|
||||||
|
<div class="flex flex-col w-full max-w-4xl">
|
||||||
|
<div class="-ml-4 sm:mt-8">
|
||||||
|
<a
|
||||||
|
mat-button
|
||||||
|
[routerLink]="['../']"
|
||||||
|
[color]="'primary'">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
<span class="ml-2">Back to Help Center</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-4xl sm:text-7xl font-extrabold tracking-tight leading-tight">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</div>
|
||||||
|
<ng-container *ngFor="let faqCategory of faqCategories; trackBy: trackByFn">
|
||||||
|
<div class="mt-12 sm:mt-16 text-3xl font-bold leading-tight tracking-tight">{{faqCategory.title}}</div>
|
||||||
|
<mat-accordion class="max-w-4xl mt-8">
|
||||||
|
<ng-container *ngFor="let faq of faqCategory.faqs; trackBy: trackByFn">
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header [collapsedHeight]="'56px'">
|
||||||
|
<mat-panel-title class="font-medium leading-tight">{{faq.question}}</mat-panel-title>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
{{faq.answer}}
|
||||||
|
</mat-expansion-panel>
|
||||||
|
</ng-container>
|
||||||
|
</mat-accordion>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { HelpCenterService } from 'app/modules/admin/apps/help-center/help-center.service';
|
||||||
|
import { FaqCategory } from 'app/modules/admin/apps/help-center/help-center.type';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'help-center-faqs',
|
||||||
|
templateUrl : './faqs.component.html',
|
||||||
|
encapsulation: ViewEncapsulation.None
|
||||||
|
})
|
||||||
|
export class HelpCenterFaqsComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
faqCategories: FaqCategory[];
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the FAQs
|
||||||
|
this._helpCenterService.faqs$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((faqCategories) => {
|
||||||
|
this.faqCategories = faqCategories;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="flex flex-col flex-auto min-w-0">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-col items-center p-6 sm:p-10">
|
||||||
|
<div class="flex flex-col w-full max-w-4xl">
|
||||||
|
<div class="-ml-4 sm:mt-8">
|
||||||
|
<a
|
||||||
|
mat-button
|
||||||
|
[routerLink]="['../']"
|
||||||
|
[color]="'primary'">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
<span class="ml-2">Back to Guides & Resources</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-4xl sm:text-7xl font-extrabold tracking-tight leading-tight">
|
||||||
|
{{guideCategory.title}}
|
||||||
|
</div>
|
||||||
|
<!-- Guides -->
|
||||||
|
<div class="flex flex-col items-start mt-8 sm:mt-12 space-y-2">
|
||||||
|
<ng-container *ngFor="let guide of guideCategory.guides; trackBy: trackByFn">
|
||||||
|
<a
|
||||||
|
class="font-medium hover:underline text-primary-500"
|
||||||
|
[routerLink]="[guide.slug]">
|
||||||
|
{{guide.title}}
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { HelpCenterService } from 'app/modules/admin/apps/help-center/help-center.service';
|
||||||
|
import { GuideCategory } from 'app/modules/admin/apps/help-center/help-center.type';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'help-center-guides-category',
|
||||||
|
templateUrl : './category.component.html',
|
||||||
|
encapsulation: ViewEncapsulation.None
|
||||||
|
})
|
||||||
|
export class HelpCenterGuidesCategoryComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
guideCategory: GuideCategory;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _activatedRoute: ActivatedRoute,
|
||||||
|
private _helpCenterService: HelpCenterService,
|
||||||
|
private _router: Router
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the Guides
|
||||||
|
this._helpCenterService.guides$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((guideCategories) => {
|
||||||
|
this.guideCategory = guideCategories[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<div class="flex flex-col flex-auto min-w-0">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-col items-center p-6 sm:p-10">
|
||||||
|
<div class="flex flex-col w-full max-w-4xl">
|
||||||
|
<div class="-ml-4 sm:mt-8">
|
||||||
|
<a
|
||||||
|
mat-button
|
||||||
|
[routerLink]="['../']"
|
||||||
|
[color]="'primary'">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
<span class="ml-2">Back to {{guideCategory.title}}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-4xl sm:text-7xl font-extrabold tracking-tight leading-tight">{{guideCategory.guides[0].title}}</div>
|
||||||
|
<div class="mt-1 sm:text-2xl tracking-tight text-secondary">{{guideCategory.guides[0].subtitle}}</div>
|
||||||
|
|
||||||
|
<!-- Guide -->
|
||||||
|
<div
|
||||||
|
class="mt-8 sm:mt-12 max-w-none prose prose-sm"
|
||||||
|
[innerHTML]="guideCategory.guides[0].content"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-10 pt-8 border-t">
|
||||||
|
<div class="text-sm font-medium text-secondary">Last updated 2 months ago</div>
|
||||||
|
<div class="flex items-center mt-2 sm:mt-0">
|
||||||
|
<div class="font-medium text-secondary">Was this page helpful?</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<button mat-icon-button>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:thumb-up'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
<button mat-icon-button>
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:thumb-down'"></mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next -->
|
||||||
|
<a
|
||||||
|
class="mt-8 flex items-center justify-between p-6 sm:px-10 rounded-2xl shadow hover:shadow-lg bg-card transition-shadow ease-in-out duration-150"
|
||||||
|
[routerLink]="'.'">
|
||||||
|
<div>
|
||||||
|
<div class="text-secondary">Next</div>
|
||||||
|
<div class="text-lg font-semibold">Removing a media from a project</div>
|
||||||
|
</div>
|
||||||
|
<mat-icon
|
||||||
|
class="ml-3"
|
||||||
|
[svgIcon]="'heroicons_outline:arrow-right'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { HelpCenterService } from 'app/modules/admin/apps/help-center/help-center.service';
|
||||||
|
import { GuideCategory } from 'app/modules/admin/apps/help-center/help-center.type';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'help-center-guides-guide',
|
||||||
|
templateUrl : './guide.component.html',
|
||||||
|
encapsulation: ViewEncapsulation.None
|
||||||
|
})
|
||||||
|
export class HelpCenterGuidesGuideComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
guideCategory: GuideCategory;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the Guides
|
||||||
|
this._helpCenterService.guide$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((guideCategory) => {
|
||||||
|
this.guideCategory = guideCategory;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<div class="flex flex-col flex-auto min-w-0">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-col items-center p-6 sm:p-10">
|
||||||
|
<div class="flex flex-col w-full max-w-4xl">
|
||||||
|
<div class="-ml-4 sm:mt-8">
|
||||||
|
<a
|
||||||
|
mat-button
|
||||||
|
[routerLink]="['../']"
|
||||||
|
[color]="'primary'">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
<span class="ml-2">Back to Help Center</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-4xl sm:text-7xl font-extrabold tracking-tight leading-tight">
|
||||||
|
Guides & Resources
|
||||||
|
</div>
|
||||||
|
<!-- Guides -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 grid-flow-row gap-y-12 sm:gap-x-4 mt-8 sm:mt-12">
|
||||||
|
<ng-container *ngFor="let guideCategory of guideCategories; trackBy: trackByFn">
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<a
|
||||||
|
class="flex items-center mb-1 text-2xl font-semibold"
|
||||||
|
[routerLink]="[guideCategory.slug]">
|
||||||
|
{{guideCategory.title}}
|
||||||
|
</a>
|
||||||
|
<ng-container *ngFor="let guide of guideCategory.guides; trackBy: trackByFn">
|
||||||
|
<a
|
||||||
|
class="mt-3 font-medium hover:underline text-primary-500"
|
||||||
|
[routerLink]="[guideCategory.slug, guide.slug]">
|
||||||
|
{{guide.title}}
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
<a
|
||||||
|
class="flex items-center mt-5 pl-4 pr-3 py-0.5 rounded-full cursor-pointer bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||||
|
*ngIf="guideCategory.totalGuides > guideCategory.visibleGuides"
|
||||||
|
[routerLink]="guideCategory.slug">
|
||||||
|
<span class="text-sm font-medium text-secondary">View All</span>
|
||||||
|
<mat-icon
|
||||||
|
class="ml-2 icon-size-5"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-narrow-right'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { HelpCenterService } from 'app/modules/admin/apps/help-center/help-center.service';
|
||||||
|
import { GuideCategory } from 'app/modules/admin/apps/help-center/help-center.type';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'help-center-guides',
|
||||||
|
templateUrl : './guides.component.html',
|
||||||
|
encapsulation: ViewEncapsulation.None
|
||||||
|
})
|
||||||
|
export class HelpCenterGuidesComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
guideCategories: GuideCategory[];
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the Guide categories
|
||||||
|
this._helpCenterService.guides$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((guideCategories) => {
|
||||||
|
this.guideCategories = guideCategories;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<div class="flex flex-col flex-auto min-w-0">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="relative pt-8 pb-28 px-4 sm:pt-20 sm:pb-48 sm:px-16 overflow-hidden bg-gray-800 dark:bg-gray-900 dark">
|
||||||
|
<!-- Background - @formatter:off -->
|
||||||
|
<!-- Rings -->
|
||||||
|
<svg class="absolute inset-0 pointer-events-none"
|
||||||
|
viewBox="0 0 960 540" width="100%" height="100%" preserveAspectRatio="xMidYMax slice" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g class="text-gray-700 opacity-25" fill="none" stroke="currentColor" stroke-width="100">
|
||||||
|
<circle r="234" cx="196" cy="23"></circle>
|
||||||
|
<circle r="234" cx="790" cy="491"></circle>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<!-- @formatter:on -->
|
||||||
|
<div class="z-10 relative flex flex-col items-center">
|
||||||
|
<h2 class="text-xl font-semibold">HELP CENTER</h2>
|
||||||
|
<div class="mt-1 text-4xl sm:text-7xl font-extrabold tracking-tight leading-tight text-center">
|
||||||
|
How can we help you today?
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 sm:text-2xl text-center tracking-tight text-secondary">
|
||||||
|
Search for a topic or question, check out our FAQs and guides, contact us for detailed support
|
||||||
|
</div>
|
||||||
|
<mat-form-field
|
||||||
|
class="fuse-mat-rounded fuse-mat-bold w-full max-w-80 sm:max-w-120 mt-10 sm:mt-20"
|
||||||
|
[subscriptSizing]="'dynamic'">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[placeholder]="'Enter a question, topic or keyword'">
|
||||||
|
<mat-icon
|
||||||
|
matPrefix
|
||||||
|
[svgIcon]="'heroicons_outline:search'"></mat-icon>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center pb-6 px-6 sm:pb-10 sm:px-10">
|
||||||
|
<!-- Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-y-8 md:gap-y-0 md:gap-x-6 w-full max-w-sm md:max-w-4xl -mt-16 sm:-mt-24">
|
||||||
|
<!-- FAQs card -->
|
||||||
|
<div class="relative flex flex-col rounded-2xl shadow hover:shadow-lg overflow-hidden bg-card transition-shadow ease-in-out duration-150">
|
||||||
|
<div class="flex flex-col flex-auto items-center p-8 text-center">
|
||||||
|
<div class="text-2xl font-semibold">FAQs</div>
|
||||||
|
<div class="md:max-w-40 mt-1 text-secondary">Frequently asked questions and answers</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center py-4 px-8 text-primary-500 dark:text-primary-400 bg-gray-50 dark:bg-transparent dark:border-t">
|
||||||
|
<a
|
||||||
|
class="flex items-center"
|
||||||
|
[routerLink]="['faqs']">
|
||||||
|
<span class="absolute inset-0"></span>
|
||||||
|
<span class="font-medium">Go to FAQs</span>
|
||||||
|
<mat-icon
|
||||||
|
class="ml-2 icon-size-5 text-current"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-narrow-right'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Guides card -->
|
||||||
|
<div class="relative flex flex-col rounded-2xl shadow hover:shadow-lg overflow-hidden bg-card transition-shadow ease-in-out duration-150">
|
||||||
|
<div class="flex flex-col flex-auto items-center p-8 text-center">
|
||||||
|
<div class="text-2xl font-semibold">Guides</div>
|
||||||
|
<div class="md:max-w-40 mt-1 text-secondary">Articles and resources to guide you</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center py-4 px-8 text-primary-500 dark:text-primary-400 bg-gray-50 dark:bg-transparent dark:border-t">
|
||||||
|
<a
|
||||||
|
class="flex items-center"
|
||||||
|
[routerLink]="['guides']">
|
||||||
|
<span class="absolute inset-0"></span>
|
||||||
|
<span class="font-medium">Check guides</span>
|
||||||
|
<mat-icon
|
||||||
|
class="ml-2 icon-size-5 text-current"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-narrow-right'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Support card -->
|
||||||
|
<div class="relative flex flex-col rounded-2xl shadow hover:shadow-lg overflow-hidden bg-card transition-shadow ease-in-out duration-150">
|
||||||
|
<div class="flex flex-col flex-auto items-center p-8 text-center">
|
||||||
|
<div class="text-2xl font-semibold">Support</div>
|
||||||
|
<div class="md:max-w-40 mt-1 text-secondary">Contact us for more detailed support</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center py-4 px-8 text-primary-500 dark:text-primary-400 bg-gray-50 dark:bg-transparent dark:border-t">
|
||||||
|
<a
|
||||||
|
class="flex items-center"
|
||||||
|
[routerLink]="['support']">
|
||||||
|
<span class="absolute inset-0"></span>
|
||||||
|
<span class="font-medium">Contact us</span>
|
||||||
|
<mat-icon
|
||||||
|
class="ml-2 icon-size-5 text-current"
|
||||||
|
[svgIcon]="'heroicons_solid:arrow-narrow-right'"></mat-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- FAQs -->
|
||||||
|
<div class="mt-24 text-3xl sm:text-5xl font-extrabold leading-tight tracking-tight text-center">Most frequently asked questions</div>
|
||||||
|
<div class="mt-2 text-xl text-center text-secondary">Here are the most frequently asked questions you may check before getting started</div>
|
||||||
|
<mat-accordion class="max-w-4xl mt-12">
|
||||||
|
<ng-container *ngFor="let faq of faqCategory.faqs; trackBy: trackByFn">
|
||||||
|
<mat-expansion-panel>
|
||||||
|
<mat-expansion-panel-header [collapsedHeight]="'56px'">
|
||||||
|
<mat-panel-title>{{faq.question}}</mat-panel-title>
|
||||||
|
</mat-expansion-panel-header>
|
||||||
|
{{faq.answer}}
|
||||||
|
</mat-expansion-panel>
|
||||||
|
</ng-container>
|
||||||
|
</mat-accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { HelpCenterService } from 'app/modules/admin/apps/help-center/help-center.service';
|
||||||
|
import { FaqCategory } from 'app/modules/admin/apps/help-center/help-center.type';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'help-center',
|
||||||
|
templateUrl : './help-center.component.html',
|
||||||
|
encapsulation: ViewEncapsulation.None
|
||||||
|
})
|
||||||
|
export class HelpCenterComponent implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
faqCategory: FaqCategory;
|
||||||
|
private _unsubscribeAll: Subject<any> = new Subject();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Get the FAQs
|
||||||
|
this._helpCenterService.faqs$
|
||||||
|
.pipe(takeUntil(this._unsubscribeAll))
|
||||||
|
.subscribe((faqCategories) => {
|
||||||
|
this.faqCategory = faqCategories[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On destroy
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void
|
||||||
|
{
|
||||||
|
// Unsubscribe from all subscriptions
|
||||||
|
this._unsubscribeAll.next(null);
|
||||||
|
this._unsubscribeAll.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track by function for ngFor loops
|
||||||
|
*
|
||||||
|
* @param index
|
||||||
|
* @param item
|
||||||
|
*/
|
||||||
|
trackByFn(index: number, item: any): any
|
||||||
|
{
|
||||||
|
return item.id || index;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/app/modules/admin/apps/help-center/help-center.module.ts
Normal file
40
src/app/modules/admin/apps/help-center/help-center.module.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { FuseAlertModule } from '@fuse/components/alert';
|
||||||
|
import { SharedModule } from 'app/shared/shared.module';
|
||||||
|
import { HelpCenterComponent } from 'app/modules/admin/apps/help-center/help-center.component';
|
||||||
|
import { HelpCenterFaqsComponent } from 'app/modules/admin/apps/help-center/faqs/faqs.component';
|
||||||
|
import { HelpCenterGuidesComponent } from 'app/modules/admin/apps/help-center/guides/guides.component';
|
||||||
|
import { HelpCenterGuidesCategoryComponent } from 'app/modules/admin/apps/help-center/guides/category/category.component';
|
||||||
|
import { HelpCenterGuidesGuideComponent } from 'app/modules/admin/apps/help-center/guides/guide/guide.component';
|
||||||
|
import { HelpCenterSupportComponent } from 'app/modules/admin/apps/help-center/support/support.component';
|
||||||
|
import { helpCenterRoutes } from 'app/modules/admin/apps/help-center/help-center.routing';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [
|
||||||
|
HelpCenterComponent,
|
||||||
|
HelpCenterFaqsComponent,
|
||||||
|
HelpCenterGuidesComponent,
|
||||||
|
HelpCenterGuidesCategoryComponent,
|
||||||
|
HelpCenterGuidesGuideComponent,
|
||||||
|
HelpCenterSupportComponent
|
||||||
|
],
|
||||||
|
imports : [
|
||||||
|
RouterModule.forChild(helpCenterRoutes),
|
||||||
|
MatButtonModule,
|
||||||
|
MatExpansionModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
FuseAlertModule,
|
||||||
|
SharedModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class HelpCenterModule
|
||||||
|
{
|
||||||
|
}
|
||||||
145
src/app/modules/admin/apps/help-center/help-center.resolvers.ts
Normal file
145
src/app/modules/admin/apps/help-center/help-center.resolvers.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { HelpCenterService } from 'app/modules/admin/apps/help-center/help-center.service';
|
||||||
|
import { FaqCategory, GuideCategory } from 'app/modules/admin/apps/help-center/help-center.type';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class HelpCenterMostAskedFaqsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FaqCategory[]>
|
||||||
|
{
|
||||||
|
return this._helpCenterService.getFaqsByCategory('most-asked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class HelpCenterFaqsResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FaqCategory[]>
|
||||||
|
{
|
||||||
|
return this._helpCenterService.getAllFaqs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class HelpCenterGuidesResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<GuideCategory[]>
|
||||||
|
{
|
||||||
|
return this._helpCenterService.getAllGuides();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class HelpCenterGuidesCategoryResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<GuideCategory[]>
|
||||||
|
{
|
||||||
|
return this._helpCenterService.getGuidesByCategory(route.paramMap.get('categorySlug'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class HelpCenterGuidesGuideResolver implements Resolve<any>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _helpCenterService: HelpCenterService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolver
|
||||||
|
*
|
||||||
|
* @param route
|
||||||
|
* @param state
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<GuideCategory>
|
||||||
|
{
|
||||||
|
return this._helpCenterService.getGuide(route.parent.paramMap.get('categorySlug'), route.paramMap.get('guideSlug'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { HelpCenterComponent } from 'app/modules/admin/apps/help-center/help-center.component';
|
||||||
|
import { HelpCenterFaqsComponent } from 'app/modules/admin/apps/help-center/faqs/faqs.component';
|
||||||
|
import { HelpCenterGuidesComponent } from 'app/modules/admin/apps/help-center/guides/guides.component';
|
||||||
|
import { HelpCenterGuidesCategoryComponent } from 'app/modules/admin/apps/help-center/guides/category/category.component';
|
||||||
|
import { HelpCenterGuidesGuideComponent } from 'app/modules/admin/apps/help-center/guides/guide/guide.component';
|
||||||
|
import { HelpCenterSupportComponent } from 'app/modules/admin/apps/help-center/support/support.component';
|
||||||
|
import { HelpCenterFaqsResolver, HelpCenterGuidesCategoryResolver, HelpCenterGuidesGuideResolver, HelpCenterGuidesResolver, HelpCenterMostAskedFaqsResolver } from 'app/modules/admin/apps/help-center/help-center.resolvers';
|
||||||
|
|
||||||
|
export const helpCenterRoutes: Route[] = [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: HelpCenterComponent,
|
||||||
|
resolve : {
|
||||||
|
faqs: HelpCenterMostAskedFaqsResolver
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : 'faqs',
|
||||||
|
component: HelpCenterFaqsComponent,
|
||||||
|
resolve : {
|
||||||
|
faqs: HelpCenterFaqsResolver
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : 'guides',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: HelpCenterGuidesComponent,
|
||||||
|
resolve : {
|
||||||
|
guides: HelpCenterGuidesResolver
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : ':categorySlug',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path : '',
|
||||||
|
component: HelpCenterGuidesCategoryComponent,
|
||||||
|
resolve : {
|
||||||
|
guides: HelpCenterGuidesCategoryResolver
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : ':guideSlug',
|
||||||
|
component: HelpCenterGuidesGuideComponent,
|
||||||
|
resolve : {
|
||||||
|
guide: HelpCenterGuidesGuideResolver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path : 'support',
|
||||||
|
component: HelpCenterSupportComponent
|
||||||
|
}
|
||||||
|
];
|
||||||
133
src/app/modules/admin/apps/help-center/help-center.service.ts
Normal file
133
src/app/modules/admin/apps/help-center/help-center.service.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable, ReplaySubject, tap } from 'rxjs';
|
||||||
|
import { FaqCategory, Guide, GuideCategory } from 'app/modules/admin/apps/help-center/help-center.type';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class HelpCenterService
|
||||||
|
{
|
||||||
|
private _faqs: ReplaySubject<FaqCategory[]> = new ReplaySubject<FaqCategory[]>(1);
|
||||||
|
private _guides: ReplaySubject<GuideCategory[]> = new ReplaySubject<GuideCategory[]>(1);
|
||||||
|
private _guide: ReplaySubject<Guide> = new ReplaySubject<Guide>(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(private _httpClient: HttpClient)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Accessors
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for FAQs
|
||||||
|
*/
|
||||||
|
get faqs$(): Observable<FaqCategory[]>
|
||||||
|
{
|
||||||
|
return this._faqs.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for guides
|
||||||
|
*/
|
||||||
|
get guides$(): Observable<GuideCategory[]>
|
||||||
|
{
|
||||||
|
return this._guides.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter for guide
|
||||||
|
*/
|
||||||
|
get guide$(): Observable<GuideCategory>
|
||||||
|
{
|
||||||
|
return this._guide.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all FAQs
|
||||||
|
*/
|
||||||
|
getAllFaqs(): Observable<FaqCategory[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<FaqCategory[]>('api/apps/help-center/faqs').pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._faqs.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FAQs by category using category slug
|
||||||
|
*
|
||||||
|
* @param slug
|
||||||
|
*/
|
||||||
|
getFaqsByCategory(slug: string): Observable<FaqCategory[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<FaqCategory[]>('api/apps/help-center/faqs', {
|
||||||
|
params: {slug}
|
||||||
|
}).pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._faqs.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all guides limited per category by the given number
|
||||||
|
*
|
||||||
|
* @param limit
|
||||||
|
*/
|
||||||
|
getAllGuides(limit = '4'): Observable<GuideCategory[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<GuideCategory[]>('api/apps/help-center/guides', {
|
||||||
|
params: {limit}
|
||||||
|
}).pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._guides.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get guides by category using category slug
|
||||||
|
*
|
||||||
|
* @param slug
|
||||||
|
*/
|
||||||
|
getGuidesByCategory(slug: string): Observable<GuideCategory[]>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<GuideCategory[]>('api/apps/help-center/guides', {
|
||||||
|
params: {slug}
|
||||||
|
}).pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._guides.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get guide by category and guide slug
|
||||||
|
*
|
||||||
|
* @param categorySlug
|
||||||
|
* @param guideSlug
|
||||||
|
*/
|
||||||
|
getGuide(categorySlug: string, guideSlug: string): Observable<GuideCategory>
|
||||||
|
{
|
||||||
|
return this._httpClient.get<GuideCategory>('api/apps/help-center/guide', {
|
||||||
|
params: {
|
||||||
|
categorySlug,
|
||||||
|
guideSlug
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
tap((response: any) => {
|
||||||
|
this._guide.next(response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/modules/admin/apps/help-center/help-center.type.ts
Normal file
35
src/app/modules/admin/apps/help-center/help-center.type.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface FaqCategory
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
faqs?: Faq[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Faq
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuideCategory
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
totalGuides?: number;
|
||||||
|
visibleGuides?: number;
|
||||||
|
guides?: Guide[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guide
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<div class="flex flex-col flex-auto min-w-0">
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex flex-col flex-auto items-center p-6 sm:p-10">
|
||||||
|
<div class="flex flex-col w-full max-w-4xl">
|
||||||
|
<div class="-ml-4 sm:mt-8">
|
||||||
|
<a
|
||||||
|
mat-button
|
||||||
|
[routerLink]="['../']"
|
||||||
|
[color]="'primary'">
|
||||||
|
<mat-icon [svgIcon]="'heroicons_outline:arrow-narrow-left'"></mat-icon>
|
||||||
|
<span class="ml-2">Back to Help Center</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-4xl sm:text-7xl font-extrabold tracking-tight leading-tight">
|
||||||
|
Contact support
|
||||||
|
</div>
|
||||||
|
<!-- Form -->
|
||||||
|
<div class="mt-8 sm:mt-12 p-6 pb-7 sm:p-10 sm:pb-7 shadow rounded-2xl bg-card">
|
||||||
|
<!-- Alert -->
|
||||||
|
<fuse-alert
|
||||||
|
class="mb-8"
|
||||||
|
*ngIf="alert"
|
||||||
|
[type]="alert.type"
|
||||||
|
[showIcon]="false">
|
||||||
|
{{alert.message}}
|
||||||
|
</fuse-alert>
|
||||||
|
<form
|
||||||
|
class="space-y-3"
|
||||||
|
[formGroup]="supportForm"
|
||||||
|
#supportNgForm="ngForm">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="text-2xl font-bold tracking-tight">Submit your request</div>
|
||||||
|
<div class="text-secondary">Your request will be processed and our support staff will get back to you in 24 hours.</div>
|
||||||
|
</div>
|
||||||
|
<!-- Name -->
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'name'"
|
||||||
|
[required]="true">
|
||||||
|
<mat-label>Name</mat-label>
|
||||||
|
<mat-error *ngIf="supportForm.get('name').hasError('required')">
|
||||||
|
Required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
<!-- Email -->
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
matInput
|
||||||
|
[formControlName]="'email'"
|
||||||
|
[required]="true">
|
||||||
|
<mat-label>Email</mat-label>
|
||||||
|
<mat-error *ngIf="supportForm.get('email').hasError('required')">
|
||||||
|
Required
|
||||||
|
</mat-error>
|
||||||
|
<mat-error *ngIf="supportForm.get('email').hasError('email')">
|
||||||
|
Enter a valid email address
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
<!-- Subject -->
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[formControlName]="'subject'"
|
||||||
|
[required]="true">
|
||||||
|
<mat-label>Subject</mat-label>
|
||||||
|
<mat-error *ngIf="supportForm.get('subject').hasError('required')">
|
||||||
|
Required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
<!-- Message -->
|
||||||
|
<mat-form-field class="w-full">
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
[formControlName]="'message'"
|
||||||
|
[required]="true"
|
||||||
|
[rows]="5"
|
||||||
|
cdkTextareaAutosize></textarea>
|
||||||
|
<mat-label>Message</mat-label>
|
||||||
|
<mat-error *ngIf="supportForm.get('message').hasError('required')">
|
||||||
|
Required
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
[color]="'accent'"
|
||||||
|
[disabled]="supportForm.pristine || supportForm.untouched"
|
||||||
|
(click)="clearForm()">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ml-2"
|
||||||
|
mat-flat-button
|
||||||
|
[color]="'primary'"
|
||||||
|
[disabled]="supportForm.pristine || supportForm.invalid"
|
||||||
|
(click)="sendForm()">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
|
||||||
|
import { UntypedFormBuilder, UntypedFormGroup, NgForm, Validators } from '@angular/forms';
|
||||||
|
import { fuseAnimations } from '@fuse/animations';
|
||||||
|
import { HelpCenterService } from 'app/modules/admin/apps/help-center/help-center.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector : 'help-center-support',
|
||||||
|
templateUrl : './support.component.html',
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
animations : fuseAnimations
|
||||||
|
})
|
||||||
|
export class HelpCenterSupportComponent implements OnInit
|
||||||
|
{
|
||||||
|
@ViewChild('supportNgForm') supportNgForm: NgForm;
|
||||||
|
|
||||||
|
alert: any;
|
||||||
|
supportForm: UntypedFormGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private _formBuilder: UntypedFormBuilder,
|
||||||
|
private _helpCenterService: HelpCenterService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Lifecycle hooks
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On init
|
||||||
|
*/
|
||||||
|
ngOnInit(): void
|
||||||
|
{
|
||||||
|
// Create the support form
|
||||||
|
this.supportForm = this._formBuilder.group({
|
||||||
|
name : ['', Validators.required],
|
||||||
|
email : ['', [Validators.required, Validators.email]],
|
||||||
|
subject: ['', Validators.required],
|
||||||
|
message: ['', Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
// @ Public methods
|
||||||
|
// -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the form
|
||||||
|
*/
|
||||||
|
clearForm(): void
|
||||||
|
{
|
||||||
|
// Reset the form
|
||||||
|
this.supportNgForm.resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the form
|
||||||
|
*/
|
||||||
|
sendForm(): void
|
||||||
|
{
|
||||||
|
// Send your form here using an http request
|
||||||
|
console.log('Your message has been sent!');
|
||||||
|
|
||||||
|
// Show a success message (it can also be an error message)
|
||||||
|
// and remove it after 5 seconds
|
||||||
|
this.alert = {
|
||||||
|
type : 'success',
|
||||||
|
message: 'Your request has been delivered! A member of our support staff will respond as soon as possible.'
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.alert = null;
|
||||||
|
}, 7000);
|
||||||
|
|
||||||
|
// Clear the form
|
||||||
|
this.clearForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user