Remove SCSS files related to components, layouts, and Bootstrap variables no longer in use

This commit is contained in:
liosha84
2025-08-11 14:24:19 +03:00
parent 4c1c545229
commit 8cdfd2a6f5
75 changed files with 811 additions and 11333 deletions
@@ -0,0 +1,24 @@
<!-- html -->
<nav aria-label="Breadcrumb" class="breadcrumb">
@let breadcrumbs = breadcrumbs$ | async;
<a mat-button [routerLink]="['/']">
<mat-icon>home</mat-icon>
</a>
@if (breadcrumbs) {
@for (bc of breadcrumbs; track bc.url; let last = $last) {
@if (!last) {
<a mat-button [routerLink]="bc.url">
<mat-icon class="sep">chevron_right</mat-icon>
{{ bc.label }}</a>
} @else {
<a mat-button >
<mat-icon class="sep">chevron_right</mat-icon>
{{ bc.label }}</a>
}
}
}
</nav>
<mat-divider></mat-divider>
@@ -0,0 +1,15 @@
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: inherit;
}
.breadcrumb .sep {
font-size: 16px;
opacity: 0.6;
}
.breadcrumb a[mat-button] {
min-width: auto;
padding: 0 4px;
text-transform: none;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BreadcrumbsComponent } from './breadcrumbs.component';
describe('BreadcrumbsComponent', () => {
let component: BreadcrumbsComponent;
let fixture: ComponentFixture<BreadcrumbsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BreadcrumbsComponent]
})
.compileComponents();
fixture = TestBed.createComponent(BreadcrumbsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,58 @@
import {Component, CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA} from '@angular/core';
import {ActivatedRoute, NavigationEnd, Router, RouterLink} from '@angular/router';
import {MatButton} from '@angular/material/button';
import {AsyncPipe, NgForOf, NgIf} from '@angular/common';
import {MatIcon} from '@angular/material/icon';
import {Observable, startWith} from 'rxjs';
import {filter, map} from 'rxjs/operators';
import {MatDivider} from '@angular/material/divider';
type Breadcrumb = { label: string; url: string };
@Component({
selector: 'app-breadcrumbs',
imports: [
RouterLink,
MatIcon,
MatButton,
NgIf,
AsyncPipe,
NgForOf,
MatDivider
],
templateUrl: './breadcrumbs.component.html',
styleUrl: './breadcrumbs.component.scss',
schemas:[CUSTOM_ELEMENTS_SCHEMA,NO_ERRORS_SCHEMA]
})
export class BreadcrumbsComponent {
breadcrumbs$: Observable<Breadcrumb[]>;
constructor(private router: Router, private route: ActivatedRoute) {
this.breadcrumbs$ = this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
startWith(null),
map(() => this.build(this.route.root))
);
}
private build(route: ActivatedRoute, url = '', crumbs: Breadcrumb[] = []): Breadcrumb[] {
const children = route.children;
if (!children || children.length === 0) return crumbs;
for (const child of children) {
if (child.outlet !== 'primary') continue;
const routeURL = child.snapshot.url.map(s => s.path).join('/');
if (routeURL) url += `/${routeURL}`;
const label = child.snapshot.data['breadcrumb'] as string | undefined;
if (label) crumbs.push({ label, url });
return this.build(child, url, crumbs);
}
return crumbs;
}
}
@@ -13,6 +13,7 @@
</button>
</mat-toolbar>
<input
id="fileInput"
type="file"
fileName="fileInput"
(change)="selectFile($event)"
+18
View File
@@ -21,4 +21,22 @@ export class GlobalConstants {
}
})();
public static readonly MARKDOWN_EXAMPLE =`## Markdown __rulez__!
---
### Syntax highlight
\`\`\`typescript
const language = 'typescript';
\`\`\`
### Lists
1. Ordered list
2. Another bullet point
- Unordered list
- Another unordered bullet
### Blockquote
> Blockquote to the max`;
}
@@ -3,6 +3,7 @@
>
</app-side-bar-admin>
<div class="pc-container">
<app-breadcrumbs></app-breadcrumbs>
<router-outlet />
</div>
@@ -2,11 +2,13 @@ import {Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit} from '@angular/core';
import {Router, RouterOutlet} from "@angular/router";
import {DOCUMENT} from '@angular/common';
import {SideBarAdminComponent} from '../side-bar-admin.component/side-bar-admin.component';
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
@Component({
selector: 'app-admin.component',
imports: [
RouterOutlet,
SideBarAdminComponent
SideBarAdminComponent,
BreadcrumbsComponent
],
schemas:[CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './admin.component.html',
@@ -5,23 +5,28 @@ const ADMIN_ROUTES: Routes = [
{
path: '',
component: AdminComponent,
data:{breadcrumb: 'Admin'},
children: [
{ path: '', pathMatch: 'full', redirectTo: 'admin-welcome' },
{
path: 'admin-welcome',
loadComponent: () => import('../admin-module/admin-welcome.component/admin-welcome.component').then((c) => c.AdminWelcomeComponent),
data:{breadcrumb: 'Welcome'}
},
{
path: 'settings',
loadComponent: () => import('../admin-module/settings.component/settings.component').then((c) => c.SettingsComponent)
loadComponent: () => import('../admin-module/settings.component/settings.component').then((c) => c.SettingsComponent),
data:{breadcrumb: 'Settings'}
},
{
path: 'system',
loadComponent: () => import('../admin-module/system.component/system.component').then((c) => c.SystemComponent)
loadComponent: () => import('../admin-module/system.component/system.component').then((c) => c.SystemComponent),
data:{breadcrumb: 'System'}
},
{
path: 'users',
loadComponent: () => import('../admin-module/users.component/users.component').then((c) => c.UsersComponent)
loadComponent: () => import('../admin-module/users.component/users.component').then((c) => c.UsersComponent),
data:{breadcrumb: 'Users'}
}
]
}
@@ -28,10 +28,11 @@
alt="" width="100%" height="100%">
<mat-toolbar class="image-toolbar">
<mat-toolbar-row>
<span class="menu-spacer"></span>
<button matMiniFab matTooltip="Dawnload image" aria-label="Download" (click)="downloadImage(image.url)">
<mat-icon>download</mat-icon>
</button>
<span class="menu-spacer"></span>
@if (isLoggedIn) {
<button matMiniFab matTooltip="Save to server" aria-label="Save" (click)="save(image.url)" >
<mat-icon>upload_file</mat-icon>
@@ -29,5 +29,9 @@ mat-toolbar{
.router_outlet{
margin-top: 65px;
height: 100%;
min-height: fit-content;
}
.component{
height: 100%;
}
@@ -10,6 +10,7 @@ const MAIN_ROUTES: Routes =[
path: 'tutorials',
loadChildren: () =>
import('../tutorials-module/tutorials.module').then((m) => m.TutorialsModule),
},
{
path: 'ai',
@@ -25,6 +26,8 @@ const MAIN_ROUTES: Routes =[
path: 'user',
loadChildren: () =>
import('../user-module/user.module').then((m) => m.UserModule),
},
{
path: 'moderator',
@@ -1,4 +1,5 @@
<app-side-bar-moderator class="pc-sidebar" ></app-side-bar-moderator>
<div class="pc-container">
<app-breadcrumbs></app-breadcrumbs>
<router-outlet></router-outlet>
</div>
@@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {SideBarModeratorComponent} from '../side-bar-moderator.component/side-bar-moderator.component';
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
@@ -9,6 +10,7 @@ import {SideBarModeratorComponent} from '../side-bar-moderator.component/side-ba
imports: [
RouterOutlet,
SideBarModeratorComponent,
BreadcrumbsComponent,
],
templateUrl: './moderator.component.html',
@@ -6,19 +6,23 @@ const MODERATOR_ROUTES: Routes = [
{
path: '',
component: ModeratorComponent,
data:{breadcrumb: 'Moderator'},
children: [
{ path: '', pathMatch: 'full', redirectTo: 'moderator-welcome' }, // default redirect
{
path: 'moderator-welcome',
loadComponent: () => import('../moderator-module/moderator-welcome.component/moderator-welcome.component').then((c) => c.ModeratorWelcomeComponent),
data:{breadcrumb: 'Welcome'}
},
{
path: 'tutorials-list',
loadComponent: () => import('../moderator-module/tutorials-list.component/tutorials-list.component').then((c) => c.TutorialsListComponent)
loadComponent: () => import('../moderator-module/tutorials-list.component/tutorials-list.component').then((c) => c.TutorialsListComponent),
data:{breadcrumb: 'Tutorials List'}
},
{
path: 'tutorial-preview',
loadComponent: () => import('../moderator-module/tutorial-preview.component/tutorial-preview.component').then((c) => c.TutorialPreviewComponent)
loadComponent: () => import('../moderator-module/tutorial-preview.component/tutorial-preview.component').then((c) => c.TutorialPreviewComponent),
data:{breadcrumb: 'Tutorial Preview'}
}
]
}
@@ -1,28 +1,75 @@
<mat-card appearance="outlined">
<mat-card-header>
<mat-card-title>Edit tutorial</mat-card-title>
</mat-card-header>
<mat-card-content >
<mat-form-field class="example-full-width">
<mat-label>Title</mat-label>
<input matInput required
[(ngModel)]="tutorial.title">
</mat-form-field>
<mat-tab-group>
<mat-tab label="Preview">
<markdown class="preview" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Edit">
<textarea class="variable-textarea" [(ngModel)]="tutorial.body"></textarea>
<markdown class="variable-binding" [data]="tutorial.body"></markdown>
</mat-tab>
<form [formGroup]="form" novalidate>
<mat-form-field class="example-full-width">
<mat-label class="label-style">Title</mat-label>
<input matInput
formControlName="title"
>
</mat-form-field>
<mat-tab label="Example">
<textarea class="variable-textarea" [(ngModel)]="markdown"></textarea>
<markdown class="variable-binding" [data]="markdown"></markdown>
</mat-tab>
</mat-tab-group>
<mat-label class="label-style">Title image</mat-label>
<mat-divider></mat-divider>
<table class="example-full-width">
<tr>
<td>
<mat-form-field class="example-full-width">
<mat-label>Title image</mat-label>
<input matInput
formControlName="titleimage"
[disabled]="true"
>
</mat-form-field>
<div>
<button mat-raised-button color="primary" (click)="openSelectImageDialog('100ms', '5ms')">Select image</button>
<button mat-raised-button color="primary" (click)="openUploadImageDialog('100ms', '5ms')">Upload image</button>
</div>
</td>
</tr>
</table>
@if(tutorial.titleimage){
<div class="example-full-width content-center">
<img style="width: 716px; height: 400px" src="{{tutorial.titleimage}}" alt="">
</div>
}
<div class="example-full-width">
<mat-label class="label-style">Body</mat-label>
<mat-divider></mat-divider>
<mat-tab-group>
<mat-tab label="Result">
<markdown class="preview" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Markdown text | Result">
<textarea class="variable-textarea"
formControlName = "body"
(input)="onBodyChange($event)"
>
</textarea>
<markdown class="variable-binding" [data]="tutorial.body"></markdown>
</mat-tab>
<!--<mat-tab label="Editor">
<form novalidate>
<angular-markdown-editor style="color: #1a1a1a"
textareaId="editor2"
[options]="editorOptions"
name="markdownText"
[(ngModel)]="tutorial.body"
(onFullscreenExit)="hidePreview()"
>
</angular-markdown-editor>
</form>
</mat-tab>-->
<mat-tab label="Example">
<textarea class="variable-textarea" [value]="markdown"></textarea>
<markdown class="variable-binding" [data]="markdown"></markdown>
</mat-tab>
</mat-tab-group>
</div>
</form>
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-actions>
<button matButton (click)="publishTutorial()">Publicate</button>
@@ -19,6 +19,11 @@ mat-card-title{
.example-full-width {
width: 100%;
margin-bottom: inherit;
}
.content-center{
text-align: center;
}
@@ -57,3 +62,9 @@ mat-card-title{
border-color: var(--mat-card-outlined-outline-color, var(--mat-sys-outline-variant));
box-shadow: var(--mat-card-outlined-container-elevation, var(--mat-sys-level0));
}
.label-style{
font-size: xx-large;
font-weight: bold;
}
@@ -1,5 +1,13 @@
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {FormsModule} from "@angular/forms";
import {Component, CUSTOM_ELEMENTS_SCHEMA, inject} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators
} from "@angular/forms";
import {MarkdownComponent} from "ngx-markdown";
import {MatButton} from "@angular/material/button";
import {MatCard, MatCardActions, MatCardContent, MatCardHeader} from "@angular/material/card";
@@ -8,6 +16,27 @@ import {MatTab, MatTabGroup} from "@angular/material/tabs";
import {ActivatedRoute, RouterLink} from "@angular/router";
import {Tutorial} from '../../../models/tutorial.model';
import {ModeratorApiService} from '../moderator-api.service';
import {GlobalConstants} from '../../../global-constants';
import {MatDivider} from '@angular/material/divider';
import {MatDialog} from '@angular/material/dialog';
import {
DialogSelectImageComponent
} from '../../user-module/dialog-select-image.component/dialog-select-image.component';
import {
DialogUploadImageComponent
} from '../../user-module/dialog-upload-image.component/dialog-upload-image.component';
const nonBlank = (): ValidatorFn => (c: AbstractControl): ValidationErrors | null =>
c.value && /\S/.test(c.value) ? null : { nonBlank: true };
type TutorialFormValue = {
title: string;
titleimage: string;
body: string;
};
@Component({
selector: 'app-tutorial-preview.component',
@@ -27,7 +56,9 @@ import {ModeratorApiService} from '../moderator-api.service';
MatTabGroup,
RouterLink,
MatError,
MatFormField
MatFormField,
MatDivider,
ReactiveFormsModule
],
templateUrl: './tutorial-preview.component.html',
styleUrl: './tutorial-preview.component.scss',
@@ -38,22 +69,23 @@ export class TutorialPreviewComponent {
submitted = false;
hasError = false;
errorMessage = '';
markdown = `## Markdown __rulez__!
---
markdown = GlobalConstants.MARKDOWN_EXAMPLE;
### Syntax highlight
\`\`\`typescript
const language = 'typescript';
\`\`\`
readonly dialog = inject(MatDialog);
### Lists
1. Ordered list
2. Another bullet point
- Unordered list
- Another unordered bullet
### Blockquote
> Blockquote to the max`;
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
title: ['', [Validators.required, nonBlank()] ],
body: ['', [Validators.required, nonBlank()] ],
titleimage: this.fb.nonNullable.control({ value: '', disabled: true }),
});
private id: string | null | undefined;
@@ -69,11 +101,43 @@ const language = 'typescript';
this.moderatorApiService.getTutorial(this.id).subscribe(
data=>{
this.tutorial = data;
console.log(data);
this.form.patchValue(this.fromModel(data));
}
);
}
// Map Model -> Form value
private fromModel(m: Tutorial): Partial<TutorialFormValue> {
return {
title: m.title ?? '',
body: m.body ?? '',
titleimage: m.titleimage ?? '',
};
}
// Map Form value -> Model (compose with existing model if you need to keep id, etc.)
private toModel(): Tutorial {
const v = this.form.getRawValue(); // TutorialFormValue
return {
...this.tutorial, // keep immutable fields like id
title: v.title.trim(),
body: v.body.trim(),
//
titleimage: v.titleimage,
modified: new Date(),
};
}
publishTutorial(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.tutorial = this.toModel();
// send `updated` to your API here
this.tutorial.published = true;
this.moderatorApiService.publish(this.id, this.tutorial)
.subscribe(
@@ -90,4 +154,59 @@ const language = 'typescript';
this.hasError = true;
});
}
onBodyChange($event: Event) {
this.tutorial.body = this.form.get('body')?.value;
}
openSelectImageDialog(enterAnimationDuration: string, exitAnimationDuration: string) {
let dialogSelectRef = this.dialog.open(DialogSelectImageComponent, {
height: '500px',
width: '600px',
enterAnimationDuration,
exitAnimationDuration,
// data: {username: this.dialogLoginData.username, password: this.dialogLoginData.password}
});
dialogSelectRef.componentInstance.uploadClicked.subscribe(result => {
dialogSelectRef.close();
this.openUploadImageDialog(enterAnimationDuration, exitAnimationDuration);
})
const dialogSelectSubscription = dialogSelectRef.componentInstance.selectClicked
.subscribe(result => {
console.log('Got the data!', result);
if (result == null) {
return;
}
this.tutorial.titleimage = result.url;
this.form.patchValue({ titleimage: result.url });
});
}
openUploadImageDialog(enterAnimationDuration: string, exitAnimationDuration: string) {
let dialogSelectRef = this.dialog.open(DialogUploadImageComponent, {
height: '500px',
width: '600px',
enterAnimationDuration,
exitAnimationDuration,
// data: {username: this.dialogLoginData.username, password: this.dialogLoginData.password}
});
dialogSelectRef.componentInstance.openSelectDialogClicked.subscribe(result => {
dialogSelectRef.close();
this.openSelectImageDialog(enterAnimationDuration, exitAnimationDuration);
})
const dialogUploadSubscription = dialogSelectRef.componentInstance.selectUploadedImageClicked
.subscribe(result => {
console.log('Got the data!', result);
if (result == null) {
return;
}
this.tutorial.titleimage = result.url;
this.form.patchValue({ titleimage: result.url });
});
}
}
@@ -1,10 +1,18 @@
<div class="container-card-view">
@for(tutorial of tutorials; track $index){
<mat-card>
<img mat-card-image [src]="tutorial.titleimage" alt="Photo of a" />
<mat-card
routerLink="../tutorial-view" [queryParams]="{id:tutorial.id}"
style="cursor: grab"
>
<mat-card-content>
<img mat-card-image [src]="tutorial.titleimage" alt="Photo of a" />
<!--<a routerLink="../tutorial-view" [queryParams]="{id:tutorial.id}">
</a>-->
</mat-card-content>
<mat-card-actions>
<button mat-button routerLink="../tutorial-view" [queryParams]="{id:tutorial.id}" target="_blank">{{tutorial.title}}</button>
<a class="app-link" routerLink="../tutorial-view" [queryParams]="{id:tutorial.id}">{{tutorial.title}}</a>
</mat-card-actions>
</mat-card>
@@ -11,15 +11,7 @@
object-fit: cover;
padding: 24px;
justify-content: center;
//width: 100%;
//height: 100%;
//padding-top: 20px;
//display: flex;
//flex-wrap: wrap;
//flex-direction: row;
//align-content: flex-start;
//justify-content: space-around;
//align-items: center;
min-height: fit-content;
}
.container {
@@ -38,3 +30,38 @@ img {
gap: 24px;
}
.app-link{
font-weight: bold;
color:cyan;
}
.app-link:link{
text-decoration:none;
}
.app-link:visited{
//color:#ff0000;
text-decoration:none;
}
.app-link:hover{
//color:#ff0000;
text-decoration:underline;
}
.mat-mdc-card{
background-color: rgba(153,153,153,0.16);
backdrop-filter: blur(8px);
border-radius: 0;
}
.mat-mdc-card-content {
padding: 0;
}
.mat-mdc-card-content:first-child{
padding-top: 0;
}
.mat-mdc-card-actions{
min-height: 64px;
backdrop-filter: blur(8px);
background-color: #1d284a85;
}
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import {Tutorial} from '../../../models/tutorial.model';
import {MatCard, MatCardActions, MatCardImage} from '@angular/material/card';
import {MatCard, MatCardActions, MatCardContent, MatCardImage} from '@angular/material/card';
import {RouterLink} from '@angular/router';
import {MatButton} from '@angular/material/button';
import {TutorialsApiService} from '../tutorials-api.service';
@@ -12,7 +12,8 @@ import {TutorialsApiService} from '../tutorials-api.service';
MatCardActions,
RouterLink,
MatButton,
MatCardImage
MatCardImage,
MatCardContent
],
templateUrl: './tutorials-card-view.component.html',
styleUrl: './tutorials-card-view.component.scss'
@@ -1 +1,4 @@
<router-outlet></router-outlet>
<router-outlet></router-outlet>
@@ -6,4 +6,6 @@
}
.component{
height: 100%;
}
@@ -4,7 +4,7 @@
<!-- <mat-nav-list>-->
@for (fileInfo of fileInfos; track fileInfo){
<a mat-list-item (click)="select(fileInfo)">
<img style="width: 120px; height: 120px" src="{{fileInfo.url}}" alt="">
<img style="width: 179px; height: 100px" src="{{fileInfo.url}}" alt="">
<span class="entry">
<!-- @if (!isCollapsed) {-->
@@ -1,26 +1,26 @@
<mat-card appearance="outlined">
<mat-card-header>
<mat-card-title>Add tutorial</mat-card-title>
</mat-card-header>
<mat-card-content >
<mat-card-content>
<form [formGroup]="form" novalidate>
<mat-form-field class="example-full-width">
<mat-label class="label-style">Title</mat-label>
<input matInput required
[(ngModel)]="tutorial.title">
<input matInput
formControlName="title"
>
</mat-form-field>
<mat-label class="label-style">Title image</mat-label>
<mat-divider></mat-divider>
<table class="example-full-width">
<tr>
<td style="width: 120px;">
<img style="width: 120px; height: 120px" src="{{tutorial.titleimage}}" alt="">
</td>
<td>
<mat-form-field class="example-full-width">
<mat-label>Title image</mat-label>
<input matInput disabled
[(ngModel)]="tutorial.titleimage">
<input matInput
formControlName="titleimage"
[disabled]="true"
>
</mat-form-field>
<div>
<button mat-raised-button color="primary" (click)="openSelectImageDialog('100ms', '5ms')">Select image</button>
@@ -30,50 +30,27 @@
</td>
</tr>
</table>
<div class="example-full-width">
<mat-label class="label-style">Description</mat-label>
<mat-divider></mat-divider>
<mat-tab-group>
<mat-tab label="Markdown text | Result">
<textarea class="variable-textarea" [(ngModel)]="tutorial.description"></textarea>
<markdown class="variable-binding" [data]="tutorial.description"></markdown>
</mat-tab>
<mat-tab label="Editor">
<div class="markdown-editor-container">
<div class="markdown-editor">
<form novalidate>
<angular-markdown-editor
textareaId="editor1"
[options]="editorOptions"
name="markdownText"
[(ngModel)]="tutorial.description"
(onFullscreenExit)="hidePreview()"
>
</angular-markdown-editor>
</form>
</div>
</div>
</mat-tab>
<mat-tab label="Result">
<markdown class="preview" [data]="tutorial.description"></markdown>
</mat-tab>
<mat-tab label="Example">
<textarea class="variable-textarea" [(ngModel)]="markdown"></textarea>
<markdown class="variable-binding" [data]="markdown"></markdown>
</mat-tab>
</mat-tab-group>
</div>
@if(tutorial.titleimage){
<div class="example-full-width content-center">
<img style="width: 716px; height: 400px" src="{{tutorial.titleimage}}" alt="">
</div>
}
<div class="example-full-width">
<mat-label class="label-style">Body</mat-label>
<mat-divider></mat-divider>
<mat-tab-group>
<mat-tab label="Markdown text | Result">
<textarea class="variable-textarea" [(ngModel)]="tutorial.body"></textarea>
<textarea class="variable-textarea"
formControlName = "body"
(input)="onBodyChange($event)"
>
</textarea>
<markdown class="variable-binding" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Editor">
<!--<mat-tab label="Editor">
<form novalidate>
<angular-markdown-editor style="color: #1a1a1a"
textareaId="editor2"
@@ -84,26 +61,38 @@
>
</angular-markdown-editor>
</form>
</mat-tab>
</mat-tab>-->
<mat-tab label="Result">
<markdown class="preview" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Example">
<textarea class="variable-textarea" [(ngModel)]="markdown"></textarea>
<textarea class="variable-textarea" [value]="markdown"></textarea>
<markdown class="variable-binding" [data]="markdown"></markdown>
</mat-tab>
</mat-tab-group>
</div>
</form>
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-actions>
@if (hasError){
<mat-error>
{{errorMessage}}
</mat-error>
}
@if (submitted){
<div >
<h4>Tutorial was submitted successfully!</h4>
<button matButton (click)="newTutorial()">Add new tutorial</button>
<button matButton routerLink="../tutorials-list">Back to list</button>
</div>
} @else {
<button matButton (click)="saveTutorial()" >Save</button>
<button matButton (click)="saveTutorial()"
[disabled]="form.invalid"
>
Save
</button>
}
</mat-card-actions>
</mat-card>
@@ -24,6 +24,9 @@ mat-card-title{
.example-full-width {
width: 100%;
}
.content-center{
text-align: center;
}
.variable-binding,
@@ -55,7 +58,7 @@ mat-card-title{
}
.label-style{
font-size: -webkit-xxx-large;
font-size: xx-large;
font-weight: bold;
}
@@ -2,21 +2,44 @@ import {Component, CUSTOM_ELEMENTS_SCHEMA, inject, OnInit} from '@angular/core';
import {UserApiService} from '../user-api.service';
import {TokenStorageService} from '../../../services/token-storage.service';
import {EventBusService} from '../../../_shared/event-bus.service';
import {Router} from '@angular/router';
import {Router, RouterLink} from '@angular/router';
import {AuthService} from '../../../services/auth.service';
import {Tutorial} from '../../../models/tutorial.model';
import {MarkdownComponent, MarkdownService} from 'ngx-markdown';
import {MatButton} from '@angular/material/button';
import {MatCard, MatCardActions, MatCardContent, MatCardHeader} from '@angular/material/card';
import {MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {MatError, MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {MatTab, MatTabGroup} from '@angular/material/tabs';
import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule, ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import {AngularMarkdownEditorModule, EditorInstance, EditorOption} from 'angular-markdown-editor';
import {MatDialog} from '@angular/material/dialog';
import {DialogSelectImageComponent} from '../dialog-select-image.component/dialog-select-image.component';
import {DialogUploadImageComponent} from '../dialog-upload-image.component/dialog-upload-image.component';
import {FileUploadComponent} from '../../../components/file-upload.component/file-upload.component';
import {MatDivider} from '@angular/material/divider';
import {GlobalConstants} from '../../../global-constants';
const nonBlank = (): ValidatorFn => (c: AbstractControl): ValidationErrors | null =>
c.value && /\S/.test(c.value) ? null : { nonBlank: true };
type TutorialFormValue = {
title: string;
titleimage: string;
body: string;
};
@Component({
selector: 'app-tutorial-add.component',
@@ -35,140 +58,122 @@ import {MatDivider} from '@angular/material/divider';
ReactiveFormsModule,
FormsModule,
AngularMarkdownEditorModule,
MatDivider
MatDivider,
MatError,
RouterLink
],
templateUrl: './tutorial-add.component.html',
styleUrl: './tutorial-add.component.scss',
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class TutorialAddComponent implements OnInit{
export class TutorialAddComponent{
tutorial: Tutorial = new Tutorial();
submitted = false;
markdownText = '';
showEditor = true;
bsEditorInstance!: EditorInstance;
tutorialForm!: FormGroup;
editorOptions!: EditorOption;
readonly dialog = inject(MatDialog);
markdown = GlobalConstants.MARKDOWN_EXAMPLE;
private fb = inject(FormBuilder);
markdown = `## Markdown __rulez__!
---
form = this.fb.nonNullable.group({
### Syntax highlight
\`\`\`typescript
const language = 'typescript';
\`\`\`
title: ['', [Validators.required, nonBlank()] ],
body: ['', [Validators.required, nonBlank()] ],
### Lists
1. Ordered list
2. Another bullet point
- Unordered list
- Another unordered bullet
titleimage: this.fb.nonNullable.control({ value: '', disabled: true }),
### Blockquote
> Blockquote to the max`;
});
hasError = false;
errorMessage = '';
constructor(private fb: FormBuilder,
constructor(
private markdownService: MarkdownService,
private userApiService: UserApiService,
) {
this.tutorial.description = this.markdown;
this.tutorial.title="";
this.tutorial.titleimage="";
this.tutorial.body = this.markdown;
this.form.patchValue(this.fromModel(this.tutorial));
}
ngOnInit(): void {
this.editorOptions = {
autofocus: false,
iconlibrary: 'fa',
height: 300,
savable: false,
onFullscreenExit: (e) => this.hidePreview(),
onShow: (e) => this.bsEditorInstance = e,
parser: (val) => this.parse(val)
// Map Model -> Form value
private fromModel(m: Tutorial): Partial<TutorialFormValue> {
return {
title: m.title ?? '',
body: m.body ?? '',
titleimage: m.titleimage ?? '',
};
this.buildForm(this.tutorial.description);
}
buildForm(markdownText: string | undefined) {
this.tutorialForm = this.fb.group({
body: [markdownText],
isPreview: [true]
});
}
/** highlight all code found, needs to be wrapped in timer to work properly */
highlight() {
setTimeout(() => {
this.markdownService.highlight();
});
}
hidePreview() {
if (this.bsEditorInstance && this.bsEditorInstance.hidePreview) {
this.bsEditorInstance.hidePreview();
}
}
showFullScreen(isFullScreen: boolean) {
if (this.bsEditorInstance && this.bsEditorInstance.setFullscreen) {
this.bsEditorInstance.showPreview();
this.bsEditorInstance.setFullscreen(isFullScreen);
}
}
parse(inputValue: string) {
const markedOutput = this.markdownService.parse(inputValue.trim());
this.highlight();
return markedOutput;
}
onFormChanges(): void {
this.tutorialForm.valueChanges.subscribe(formData => {
if (formData) {
this.markdownText = formData.body;
}
});
// Map Form value -> Model (compose with existing model if you need to keep id, etc.)
private toModel(): Tutorial {
const v = this.form.getRawValue(); // TutorialFormValue
return {
...this.tutorial, // keep immutable fields like id
title: v.title.trim(),
body: v.body.trim(),
//
titleimage: v.titleimage,
created: new Date(),
};
}
saveTutorial(): void {
const data = {
title: this.tutorial.title,
description: this.tutorial.description,
body: this.tutorial.body,
published: this.tutorial.published,
titleimage: this.tutorial.titleimage,
created: new Date(),
modified: new Date(),
tobepublished: false,
isEdit: false,
isAdmin: false,
isSuperAdmin: false,
isUser: false,
};
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.tutorial = this.toModel();
// send `updated` to your API here
this.userApiService.create(this.tutorial)
.subscribe(
response => {
console.log(response);
this.submitted = true;
},
)
const data = {
title: this.tutorial.title,
description: this.tutorial.description,
body: this.tutorial.body,
published: this.tutorial.published,
titleimage: this.tutorial.titleimage,
created: new Date(),
modified: new Date(),
tobepublished: false,
isEdit: false,
isAdmin: false,
isSuperAdmin: false,
isUser: false,
}
this.userApiService.create(data)
.subscribe(
response => {
console.log(response);
this.submitted = true;
this.hasError = false;
},
error => {
console.log(error);
this.errorMessage = error.error.message;
this.hasError = true;
});
this.userApiService.create(data)
.subscribe(
response => {
console.log(response);
this.submitted = true;
},
error => {
console.log(error);
});
}
newTutorial(): void {
this.submitted = false;
this.tutorial = {
title: '',
description: '',
body:"",
published: false
};
}
@@ -195,6 +200,7 @@ const language = 'typescript';
return;
}
this.tutorial.titleimage = result.url;
this.form.patchValue({ titleimage: result.url });
});
}
@@ -220,7 +226,12 @@ const language = 'typescript';
return;
}
this.tutorial.titleimage = result.url;
this.form.patchValue({ titleimage: result.url });
});
}
onBodyChange($event: Event) {
this.tutorial.body = this.form.get('body')?.value;
}
}
@@ -1,104 +1,78 @@
<mat-card appearance="outlined">
<mat-card-header>
<mat-card-title>Edit tutorial</mat-card-title>
</mat-card-header>
<mat-card-content >
<mat-form-field class="example-full-width">
<mat-label class="label-style">Title</mat-label>
<input matInput required
[(ngModel)]="tutorial.title">
</mat-form-field>
<form [formGroup]="form" novalidate>
<mat-form-field class="example-full-width">
<mat-label class="label-style">Title</mat-label>
<input matInput
formControlName="title"
>
</mat-form-field>
<mat-label class="label-style">Title image</mat-label>
<mat-divider></mat-divider>
<table class="example-full-width">
<tr>
<td style="width: 120px;">
<img style="width: 120px; height: 120px" src="{{tutorial.titleimage}}" alt="">
</td>
<td>
<mat-form-field class="example-full-width">
<mat-label>Title image</mat-label>
<input matInput disabled
[(ngModel)]="tutorial.titleimage">
</mat-form-field>
<div>
<button mat-raised-button color="primary" (click)="openSelectImageDialog('100ms', '5ms')">Select image</button>
<button mat-raised-button color="primary" (click)="openUploadImageDialog('100ms', '5ms')">Upload image</button>
</div>
</td>
</tr>
</table>
<div class="example-full-width">
<mat-label class="label-style">Description</mat-label>
<mat-label class="label-style">Title image</mat-label>
<mat-divider></mat-divider>
<mat-tab-group>
<mat-tab label="Markdown text | Result">
<textarea class="variable-textarea" [(ngModel)]="tutorial.description"></textarea>
<markdown class="variable-binding" [data]="tutorial.description"></markdown>
</mat-tab>
<mat-tab label="Editor">
<div class="markdown-editor-container">
<div class="markdown-editor">
<form novalidate>
<angular-markdown-editor
textareaId="editor1"
[options]="editorOptions"
name="markdownText"
[(ngModel)]="tutorial.description"
(onFullscreenExit)="hidePreview()"
>
</angular-markdown-editor>
</form>
<table class="example-full-width">
<tr>
<td>
<mat-form-field class="example-full-width">
<mat-label>Title image</mat-label>
<input matInput
formControlName="titleimage"
[disabled]="true"
>
</mat-form-field>
<div>
<button mat-raised-button color="primary" (click)="openSelectImageDialog('100ms', '5ms')">Select image</button>
<button mat-raised-button color="primary" (click)="openUploadImageDialog('100ms', '5ms')">Upload image</button>
</div>
</div>
</mat-tab>
<mat-tab label="Result">
<markdown class="preview" [data]="tutorial.description"></markdown>
</mat-tab>
<mat-tab label="Example">
<textarea class="variable-textarea" [(ngModel)]="markdown"></textarea>
<markdown class="variable-binding" [data]="markdown"></markdown>
</mat-tab>
</mat-tab-group>
</div>
</td>
</tr>
</table>
@if(tutorial.titleimage){
<div class="example-full-width content-center">
<img style="width: 716px; height: 400px" src="{{tutorial.titleimage}}" alt="">
</div>
}
<div class="example-full-width">
<mat-label class="label-style">Body</mat-label>
<mat-divider></mat-divider>
<mat-tab-group>
<mat-tab label="Markdown text | Result">
<textarea class="variable-textarea"
<div class="example-full-width">
<mat-label class="label-style">Body</mat-label>
<mat-divider></mat-divider>
<mat-tab-group>
<mat-tab label="Markdown text | Result">
<textarea class="variable-textarea" [(ngModel)]="tutorial.body"></textarea>
<markdown class="variable-binding" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Editor">
<form novalidate>
<angular-markdown-editor style="color: #1a1a1a"
textareaId="editor2"
[options]="editorOptions"
name="markdownText"
[(ngModel)]="tutorial.body"
(onFullscreenExit)="hidePreview()"
formControlName = "body"
(input)="onBodyChange($event)"
>
</angular-markdown-editor>
</form>
</mat-tab>
<mat-tab label="Result">
<markdown class="preview" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Example">
<textarea class="variable-textarea" [(ngModel)]="markdown"></textarea>
<markdown class="variable-binding" [data]="markdown"></markdown>
</mat-tab>
</mat-tab-group>
</div>
</textarea>
<markdown class="variable-binding" [data]="tutorial.body"></markdown>
</mat-tab>
<!--<mat-tab label="Editor">
<form novalidate>
<angular-markdown-editor style="color: #1a1a1a"
textareaId="editor2"
[options]="editorOptions"
name="markdownText"
[(ngModel)]="tutorial.body"
(onFullscreenExit)="hidePreview()"
>
</angular-markdown-editor>
</form>
</mat-tab>-->
<mat-tab label="Result">
<markdown class="preview" [data]="tutorial.body"></markdown>
</mat-tab>
<mat-tab label="Example">
<textarea class="variable-textarea" [value]="markdown"></textarea>
<markdown class="variable-binding" [data]="markdown"></markdown>
</mat-tab>
</mat-tab-group>
</div>
</form>
</mat-card-content>
<mat-divider></mat-divider>
<mat-card-actions>
<button matButton (click)="updateTutorial()">Save</button>
<button matButton (click)="updateTutorial()" [disabled]="form.invalid">Save</button>
@if (hasError){
<mat-error>
{{errorMessage}}
@@ -19,6 +19,11 @@ mat-card-title{
.example-full-width {
width: 100%;
margin-bottom: inherit;
}
.content-center{
text-align: center;
}
@@ -57,3 +62,9 @@ mat-card-title{
border-color: var(--mat-card-outlined-outline-color, var(--mat-sys-outline-variant));
box-shadow: var(--mat-card-outlined-container-elevation, var(--mat-sys-level0));
}
.label-style{
font-size: xx-large;
font-weight: bold;
}
@@ -4,7 +4,15 @@ import {MatButton} from '@angular/material/button';
import {MatCard, MatCardActions, MatCardContent, MatCardHeader} from '@angular/material/card';
import {MatError, MatFormField, MatInput, MatLabel} from '@angular/material/input';
import {MatTab, MatTabGroup} from '@angular/material/tabs';
import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {
AbstractControl,
FormBuilder,
FormGroup,
FormsModule,
ReactiveFormsModule, ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import {Tutorial} from '../../../models/tutorial.model';
import {UserApiService} from '../user-api.service';
import {ActivatedRoute, RouterLink} from '@angular/router';
@@ -13,6 +21,18 @@ import {MatDivider} from '@angular/material/divider';
import {MatDialog} from '@angular/material/dialog';
import {DialogSelectImageComponent} from '../dialog-select-image.component/dialog-select-image.component';
import {DialogUploadImageComponent} from '../dialog-upload-image.component/dialog-upload-image.component';
import {GlobalConstants} from '../../../global-constants';
const nonBlank = (): ValidatorFn => (c: AbstractControl): ValidationErrors | null =>
c.value && /\S/.test(c.value) ? null : { nonBlank: true };
type TutorialFormValue = {
title: string;
titleimage: string;
body: string;
};
@Component({
selector: 'app-tutorial-edit.component',
@@ -40,41 +60,34 @@ import {DialogUploadImageComponent} from '../dialog-upload-image.component/dialo
styleUrl: './tutorial-edit.component.scss',
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class TutorialEditComponent implements OnInit{
export class TutorialEditComponent{
tutorial: Tutorial = new Tutorial();
submitted = false;
hasError = false;
errorMessage = '';
markdownText="";
bsEditorInstance!: EditorInstance;
tutorialForm!: FormGroup;
editorOptions!: EditorOption;
readonly dialog = inject(MatDialog);
markdown = `## Markdown __rulez__!
---
markdown = GlobalConstants.MARKDOWN_EXAMPLE;
### Syntax highlight
\`\`\`typescript
const language = 'typescript';
\`\`\`
private fb = inject(FormBuilder);
### Lists
1. Ordered list
2. Another bullet point
- Unordered list
- Another unordered bullet
form = this.fb.nonNullable.group({
title: ['', [Validators.required, nonBlank()] ],
body: ['', [Validators.required, nonBlank()] ],
titleimage: this.fb.nonNullable.control({ value: '', disabled: true }),
});
### Blockquote
> Blockquote to the max`;
private id: string | null | undefined;
constructor(private userApiService: UserApiService,
private route: ActivatedRoute,
private fb: FormBuilder,
private markdownService: MarkdownService
) {
@@ -88,66 +101,42 @@ const language = 'typescript';
this.userApiService.getTutorial(this.id).subscribe(
data=>{
this.tutorial = data;
this.form.patchValue(this.fromModel(this.tutorial));
}
);
}
ngOnInit(): void {
this.editorOptions = {
autofocus: false,
iconlibrary: 'fa',
height: 300,
savable: false,
onFullscreenExit: (e) => this.hidePreview(),
onShow: (e) => this.bsEditorInstance = e,
parser: (val) => this.parse(val)
// Map Model -> Form value
private fromModel(m: Tutorial): Partial<TutorialFormValue> {
return {
title: m.title ?? '',
body: m.body ?? '',
titleimage: m.titleimage ?? '',
};
this.buildForm(this.tutorial.description);
}
buildForm(markdownText: string | undefined) {
this.tutorialForm = this.fb.group({
body: [markdownText],
isPreview: [true]
});
}
/** highlight all code found, needs to be wrapped in timer to work properly */
highlight() {
setTimeout(() => {
this.markdownService.highlight();
});
}
hidePreview() {
if (this.bsEditorInstance && this.bsEditorInstance.hidePreview) {
this.bsEditorInstance.hidePreview();
}
}
showFullScreen(isFullScreen: boolean) {
if (this.bsEditorInstance && this.bsEditorInstance.setFullscreen) {
this.bsEditorInstance.showPreview();
this.bsEditorInstance.setFullscreen(isFullScreen);
}
}
parse(inputValue: string) {
const markedOutput = this.markdownService.parse(inputValue.trim());
this.highlight();
return markedOutput;
}
onFormChanges(): void {
this.tutorialForm.valueChanges.subscribe(formData => {
if (formData) {
this.markdownText = formData.body;
}
});
// Map Form value -> Model (compose with existing model if you need to keep id, etc.)
private toModel(): Tutorial {
const v = this.form.getRawValue(); // TutorialFormValue
return {
...this.tutorial, // keep immutable fields like id
title: v.title.trim(),
body: v.body.trim(),
//
titleimage: v.titleimage,
modified: new Date(),
};
}
updateTutorial(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.tutorial = this.toModel();
// send `updated` to your API here
this.userApiService.update(this.id, this.tutorial)
.subscribe(
response => {
@@ -186,7 +175,7 @@ const language = 'typescript';
return;
}
this.tutorial.titleimage = result.url;
this.form.patchValue({ titleimage: result.url });
});
}
@@ -211,7 +200,11 @@ const language = 'typescript';
return;
}
this.tutorial.titleimage = result.url;
this.form.patchValue({ titleimage: result.url });
});
}
onBodyChange($event: Event) {
this.tutorial.body = this.form.get('body')?.value;
}
}
@@ -1,4 +1,6 @@
<article class="table-header">
<button mat-raised-button routerLink="../tutorial-add">Add tutorial</button>
<button
class="button-remove-rows"
mat-button
@@ -6,7 +8,7 @@
>
Remove Rows
</button>
<button mat-raised-button routerLink="../tutorial-add">Add tutorial</button>
</article>
<table mat-table [dataSource]="dataSource">
@@ -22,6 +22,7 @@ import {
import {DatePipe} from '@angular/common';
import {MatCheckbox} from '@angular/material/checkbox';
import {MatSlideToggle} from '@angular/material/slide-toggle';
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
@Component({
@@ -43,7 +44,8 @@ import {MatSlideToggle} from '@angular/material/slide-toggle';
MatCheckbox,
DatePipe,
MatCell,
MatSlideToggle
MatSlideToggle,
BreadcrumbsComponent
],
schemas:[CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './tutorials-list.component.html',
@@ -4,6 +4,7 @@
</app-side-bar-user>
<div class="pc-container">
<app-breadcrumbs></app-breadcrumbs>
<router-outlet></router-outlet>
</div>
@@ -1,13 +0,0 @@
//---------------
.pc-sidebar{
top: 65px;
overflow-y: auto;
background-color: rgba(153, 153, 153, 0.16);
backdrop-filter: blur(8px);
}
.pc-container{
top: 0px;
padding-left: 5px;
padding-right: 5px;
}
@@ -3,12 +3,14 @@ import {RouterOutlet} from '@angular/router';
import {MatSidenav} from '@angular/material/sidenav';
import {BreakpointObserver} from '@angular/cdk/layout';
import {SideBarUserComponent} from '../side-bar-user.component/side-bar-user.component';
import {BreadcrumbsComponent} from '../../../components/breadcrumbs.component/breadcrumbs.component';
@Component({
selector: 'app-user.component',
imports: [
RouterOutlet,
SideBarUserComponent
SideBarUserComponent,
BreadcrumbsComponent
],
templateUrl: './user.component.html',
styleUrl: './user.component.scss',
@@ -7,30 +7,39 @@ const USER_ROUTES: Routes = [
path: '',
component: UserComponent,
data:{breadcrumb: 'User'},
children: [
{ path: '', pathMatch: 'full', redirectTo: 'user-welcome' }, // default redirect
{
path: 'user-welcome',
loadComponent: () => import('../user-module/user-welcome.component/user-welcome.component').then((c) => c.UserWelcomeComponent),
data:{breadcrumb: 'Welcome'}
},
{
path: 'tutorials-list',
loadComponent: () => import('../user-module/tutorials-list.component/tutorials-list.component').then((c) => c.TutorialsListComponent)
loadComponent: () => import('../user-module/tutorials-list.component/tutorials-list.component').then((c) => c.TutorialsListComponent),
data:{breadcrumb: 'Tutorials List'}
},
{
path: 'tutorial-add',
loadComponent: () => import('../user-module/tutorial-add.component/tutorial-add.component').then((c) => c.TutorialAddComponent)
loadComponent: () => import('../user-module/tutorial-add.component/tutorial-add.component').then((c) => c.TutorialAddComponent),
data:{breadcrumb: 'Add Tutorial'}
},
{
path: 'tutorial-edit',
loadComponent: () => import('../user-module/tutorial-edit.component/tutorial-edit.component').then((c) => c.TutorialEditComponent)
loadComponent: () => import('../user-module/tutorial-edit.component/tutorial-edit.component').then((c) => c.TutorialEditComponent),
data:{breadcrumb: 'Edit Tutorial'}
},
{
path: 'ai-models',
loadComponent: () => import('../user-module/ai-models.component/ai-models.component').then((c) => c.AiModelsComponent)
loadComponent: () => import('../user-module/ai-models.component/ai-models.component').then((c) => c.AiModelsComponent),
data:{breadcrumb: 'AI'}
},
{
path: 'images',
loadComponent: () => import('../user-module/images.component/images.component').then((c) => c.ImagesComponent)
loadComponent: () => import('../user-module/images.component/images.component').then((c) => c.ImagesComponent),
data:{breadcrumb: 'Images'}
}
]
}