improvements are done

This commit is contained in:
sdarbinyan
2026-01-22 00:41:13 +04:00
parent a1a2a69fd0
commit 0f3d0ae3ef
27 changed files with 2115 additions and 107 deletions

View File

@@ -235,7 +235,10 @@
placeholder="e.g. Black">
</mat-form-field>
<button mat-raised-button color="primary" (click)="addDescriptionField()">
<button
mat-raised-button
color="primary"
(click)="addDescriptionField()">
<mat-icon>add</mat-icon>
Add Field
</button>
@@ -289,7 +292,18 @@
@for (comment of item()!.comments; track comment.id) {
<div class="comment-card">
<div class="comment-header">
<strong>{{ comment.author || 'Anonymous' }}</strong>
<div>
<strong>{{ comment.author || 'Anonymous' }}</strong>
@if (comment.stars !== undefined && comment.stars !== null) {
<span class="comment-stars">
@for (star of [1,2,3,4,5]; track star) {
<mat-icon class="star-icon" [class.filled]="star <= comment.stars!">
{{ star <= comment.stars! ? 'star' : 'star_border' }}
</mat-icon>
}
</span>
}
</div>
<span class="comment-date">{{ comment.createdAt | date:'short' }}</span>
</div>
<p>{{ comment.text }}</p>

View File

@@ -279,10 +279,32 @@
justify-content: space-between;
margin-bottom: 0.5rem;
> div {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
strong {
color: #333;
}
.comment-stars {
display: flex;
gap: 2px;
.star-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #ffa726;
&.filled {
color: #ff9800;
}
}
}
.comment-date {
color: #999;
font-size: 0.875rem;

View File

@@ -1,5 +1,6 @@
import { Component, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { environment } from '../../../environments/environment';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -15,7 +16,8 @@ import { MatTabsModule } from '@angular/material/tabs';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ApiService } from '../../services';
import { Item, ItemDescriptionField } from '../../models';
import { ValidationService } from '../../services/validation.service';
import { Item, ItemDescriptionField, Subcategory } from '../../models';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
@Component({
@@ -42,10 +44,12 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
})
export class ItemEditorComponent implements OnInit {
item = signal<Item | null>(null);
subcategory = signal<Subcategory | null>(null);
loading = signal(true);
saving = signal(false);
itemId = signal<string>('');
projectId = signal<string>('');
validationErrors = signal<Record<string, string>>({});
newTag = '';
newDescKey = '';
@@ -59,7 +63,8 @@ export class ItemEditorComponent implements OnInit {
private router: Router,
private apiService: ApiService,
private snackBar: MatSnackBar,
private dialog: MatDialog
private dialog: MatDialog,
private validationService: ValidationService
) {}
ngOnInit() {
@@ -80,7 +85,8 @@ export class ItemEditorComponent implements OnInit {
this.apiService.getItem(this.itemId()).subscribe({
next: (item) => {
this.item.set(item);
this.loading.set(false);
// Load subcategory to get allowed description fields
this.loadSubcategory(item.subcategoryId);
},
error: (err) => {
console.error('Failed to load item', err);
@@ -90,7 +96,75 @@ export class ItemEditorComponent implements OnInit {
});
}
loadSubcategory(subcategoryId: string) {
this.apiService.getSubcategory(subcategoryId).subscribe({
next: (subcategory) => {
this.subcategory.set(subcategory);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load subcategory', err);
this.loading.set(false);
}
});
}
async buildCategoryPath(): Promise<string> {
// Build path like: /category/subcategory/subsubcategory/item
const item = this.item();
if (!item) return '';
const pathSegments: string[] = [];
let currentSubcategoryId = item.subcategoryId;
// Traverse up the subcategory hierarchy
while (currentSubcategoryId) {
try {
const subcategory = await this.apiService.getSubcategory(currentSubcategoryId).toPromise();
if (!subcategory) break;
pathSegments.unshift(subcategory.id); // Add to beginning
// Check if this subcategory has a parent subcategory or belongs to a category
if (subcategory.categoryId && subcategory.categoryId.startsWith('cat')) {
// This is directly under a category, add category and stop
const category = await this.apiService.getCategory(subcategory.categoryId).toPromise();
if (category) {
pathSegments.unshift(category.id);
}
break;
} else {
// This is under another subcategory, continue traversing
currentSubcategoryId = subcategory.categoryId;
}
} catch (err) {
console.error('Error building path:', err);
break;
}
}
pathSegments.push(this.itemId());
return '/' + pathSegments.join('/');
}
onFieldChange(field: keyof Item, value: any) {
const currentItem = this.item();
if (!currentItem) return;
// Validate the specific field
const errors = this.validationService.validateItem({ [field]: value } as any);
const currentErrors = { ...this.validationErrors() };
if (errors[field]) {
currentErrors[field] = errors[field];
this.validationErrors.set(currentErrors);
this.snackBar.open(`Validation error: ${errors[field]}`, 'Close', { duration: 3000 });
return;
} else {
delete currentErrors[field];
this.validationErrors.set(currentErrors);
}
this.saving.set(true);
this.apiService.queueSave('item', this.itemId(), field, value);
@@ -203,14 +277,32 @@ export class ItemEditorComponent implements OnInit {
if (item && item.subcategoryId) {
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
} else {
this.router.navigate(['/']);
this.router.navigate(['/project', this.projectId()]);
}
}
previewInMarketplace() {
async previewInMarketplace() {
// Open marketplace in new tab with this item
const marketplaceUrl = `http://localhost:4200/item/${this.itemId()}`;
window.open(marketplaceUrl, '_blank');
const item = this.item();
const subcategory = this.subcategory();
if (!item || !subcategory) {
this.snackBar.open('Item data not loaded', 'Close', { duration: 2000 });
return;
}
try {
// Build the full category hierarchy path
const path = await this.buildCategoryPath();
if (!path) {
this.snackBar.open('Unable to build preview URL', 'Close', { duration: 2000 });
return;
}
const marketplaceUrl = `${environment.marketplaceUrl}${path}`;
window.open(marketplaceUrl, '_blank');
} catch (err) {
console.error('Preview failed:', err);
this.snackBar.open('Failed to generate preview URL', 'Close', { duration: 3000 });
}
}
onImageDrop(event: CdkDragDrop<string[]>) {

View File

@@ -177,13 +177,8 @@ export class ItemsListComponent implements OnInit {
}
goBack() {
const subcategoryId = this.subcategoryId();
if (subcategoryId) {
// Navigate back to subcategory editor
this.router.navigate(['/subcategory', subcategoryId]);
} else {
this.router.navigate(['/']);
}
// Navigate back to the project view
this.router.navigate(['/project', this.projectId()]);
}
addItem() {

View File

@@ -16,14 +16,12 @@
</div>
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
</div>
<app-loading-skeleton type="tree"></app-loading-skeleton>
} @else {
<div class="tree-container">
@for (node of treeData(); track node.id) {
<div class="tree-node">
<div class="node-content category-node">
<div class="node-content category-node" [class.selected]="selectedNodeId() === node.id">
<button
mat-icon-button
(click)="toggleNode(node)"
@@ -38,6 +36,14 @@
</span>
<div class="node-actions">
<button
mat-icon-button
(click)="addSubcategory(node, $event)"
matTooltip="Add Subcategory"
color="accent">
<mat-icon>add</mat-icon>
</button>
<mat-slide-toggle
[checked]="node.visible"
(change)="toggleVisibility(node, $event)"
@@ -56,39 +62,7 @@
</div>
@if (node.expanded && node.children?.length) {
<div class="subcategories">
@for (subNode of node.children; track subNode.id) {
<div class="node-content subcategory-node">
<span class="node-name" (click)="editNode(subNode, $event)">
{{ subNode.name }}
</span>
<div class="node-actions">
<button
mat-icon-button
(click)="viewItems(subNode, $event)"
matTooltip="View Items">
<mat-icon>list</mat-icon>
</button>
<mat-slide-toggle
[checked]="subNode.visible"
(change)="toggleVisibility(subNode, $event)"
color="primary"
matTooltip="Toggle Visibility">
</mat-slide-toggle>
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" matTooltip="Edit">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" matTooltip="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
}
</div>
<ng-container [ngTemplateOutlet]="subcategoryTree" [ngTemplateOutletContext]="{nodes: node.children, level: 1}"></ng-container>
}
</div>
}
@@ -111,3 +85,67 @@
</mat-sidenav-content>
</mat-sidenav-container>
</div>
<ng-template #subcategoryTree let-nodes="nodes" let-level="level">
<div class="subcategories" [style.padding-left.rem]="level * 1.5">
@for (subNode of nodes; track subNode.id) {
<div>
<div class="node-content subcategory-node" [class.selected]="selectedNodeId() === subNode.id">
<button
mat-icon-button
(click)="toggleNode(subNode)"
[disabled]="!subNode.children?.length">
<mat-icon>
{{ subNode.children?.length ? (subNode.expanded ? 'expand_more' : 'chevron_right') : '' }}
</mat-icon>
</button>
<span class="node-name" (click)="editNode(subNode, $event)">
{{ subNode.name }}
</span>
<div class="node-actions">
@if (!subNode.hasItems) {
<button
mat-icon-button
(click)="addSubcategory(subNode, $event)"
matTooltip="Add Subcategory"
color="accent">
<mat-icon>add</mat-icon>
</button>
}
@if (subNode.hasItems) {
<button
mat-icon-button
(click)="viewItems(subNode, $event)"
matTooltip="View Items">
<mat-icon>list</mat-icon>
</button>
}
<mat-slide-toggle
[checked]="subNode.visible"
(change)="toggleVisibility(subNode, $event)"
color="primary"
matTooltip="Toggle Visibility">
</mat-slide-toggle>
<button mat-icon-button (click)="editNode(subNode, $event)" color="primary" matTooltip="Edit">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button (click)="deleteSubcategory(subNode, $event)" color="warn" matTooltip="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
@if (subNode.expanded && subNode.children?.length) {
<ng-container [ngTemplateOutlet]="subcategoryTree" [ngTemplateOutletContext]="{nodes: subNode.children, level: level + 1}"></ng-container>
}
</div>
}
</div>
</ng-template>

View File

@@ -64,16 +64,35 @@ mat-toolbar {
}
}
&.selected {
background-color: #bbdefb;
border-left: 4px solid #1976d2;
padding-left: calc(0.5rem - 4px);
&:hover {
background-color: #90caf9;
}
}
&.category-node {
font-weight: 600;
font-size: 1rem;
background-color: #fafafa;
&.selected {
background-color: #bbdefb;
}
}
&.subcategory-node {
padding-left: 3rem;
font-size: 0.95rem;
background-color: #fff;
&.selected {
background-color: #bbdefb;
padding-left: calc(3rem - 4px);
}
}
.node-name {

View File

@@ -11,9 +11,12 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ApiService } from '../../services';
import { ValidationService } from '../../services/validation.service';
import { Category, Subcategory } from '../../models';
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { MatTooltipModule } from '@angular/material/tooltip';
interface CategoryNode {
id: string;
@@ -23,6 +26,8 @@ interface CategoryNode {
expanded?: boolean;
children?: CategoryNode[];
categoryId?: string;
parentId?: string;
hasItems?: boolean;
}
@Component({
@@ -39,7 +44,9 @@ interface CategoryNode {
MatToolbarModule,
MatProgressSpinnerModule,
MatDialogModule,
MatSnackBarModule
MatSnackBarModule,
MatTooltipModule,
LoadingSkeletonComponent
],
templateUrl: './project-view.component.html',
styleUrls: ['./project-view.component.scss']
@@ -50,13 +57,15 @@ export class ProjectViewComponent implements OnInit {
categories = signal<Category[]>([]);
loading = signal(true);
treeData = signal<CategoryNode[]>([]);
selectedNodeId = signal<string | null>(null);
constructor(
private route: ActivatedRoute,
private router: Router,
private apiService: ApiService,
private dialog: MatDialog,
private snackBar: MatSnackBar
private snackBar: MatSnackBar,
private validationService: ValidationService
) {}
ngOnInit() {
@@ -65,6 +74,13 @@ export class ProjectViewComponent implements OnInit {
this.loadProject();
this.loadCategories();
});
// Track selected route
this.router.events.subscribe(() => {
const categoryId = this.route.children[0]?.snapshot.params['categoryId'];
const subcategoryId = this.route.children[0]?.snapshot.params['subcategoryId'];
this.selectedNodeId.set(subcategoryId || categoryId || null);
});
}
hasActiveRoute(): boolean {
@@ -100,23 +116,45 @@ export class ProjectViewComponent implements OnInit {
}
buildTree() {
// Save current expanded state
const expandedState = new Map<string, boolean>();
const saveExpandedState = (nodes: CategoryNode[]) => {
for (const node of nodes) {
if (node.expanded) {
expandedState.set(node.id, true);
}
if (node.children) {
saveExpandedState(node.children);
}
}
};
saveExpandedState(this.treeData());
// Build new tree
const tree: CategoryNode[] = this.categories().map(cat => ({
id: cat.id,
name: cat.name,
type: 'category' as const,
visible: cat.visible,
expanded: false,
children: (cat.subcategories || []).map(sub => ({
id: sub.id,
name: sub.name,
type: 'subcategory' as const,
visible: sub.visible,
categoryId: cat.id
}))
expanded: expandedState.has(cat.id),
children: this.buildSubcategoryTree(cat.subcategories || [], cat.id, expandedState)
}));
this.treeData.set(tree);
}
buildSubcategoryTree(subcategories: Subcategory[], parentId: string, expandedState?: Map<string, boolean>): CategoryNode[] {
return subcategories.map(sub => ({
id: sub.id,
name: sub.name,
type: 'subcategory' as const,
visible: sub.visible,
expanded: expandedState?.has(sub.id) || false,
parentId: parentId,
hasItems: sub.hasItems,
children: this.buildSubcategoryTree(sub.subcategories || [], sub.id, expandedState)
}));
}
toggleNode(node: CategoryNode) {
node.expanded = !node.expanded;
}
@@ -168,13 +206,61 @@ export class ProjectViewComponent implements OnInit {
dialogRef.afterClosed().subscribe(result => {
if (result) {
// Validate before creating
const errors = this.validationService.validateCategoryOrSubcategory(result);
if (Object.keys(errors).length > 0) {
const errorMsg = Object.values(errors).join(', ');
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
return;
}
this.apiService.createCategory(this.projectId(), result).subscribe({
next: () => {
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
this.loadCategories();
},
error: (err) => {
this.snackBar.open('Failed to create category', 'Close', { duration: 3000 });
this.snackBar.open(err.message || 'Failed to create category', 'Close', { duration: 3000 });
}
});
}
});
}
addSubcategory(parentNode: CategoryNode, event: Event) {
event.stopPropagation();
const dialogRef = this.dialog.open(CreateDialogComponent, {
data: {
title: 'Create New Subcategory',
type: 'subcategory',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'id', label: 'ID', type: 'text', required: true, hint: 'Used for routing' },
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
]
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
// Validate before creating
const errors = this.validationService.validateCategoryOrSubcategory(result);
if (Object.keys(errors).length > 0) {
const errorMsg = Object.values(errors).join(', ');
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
return;
}
const parentId = parentNode.type === 'category' ? parentNode.id : parentNode.id;
this.apiService.createSubcategory(parentId, result).subscribe({
next: () => {
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
this.loadCategories();
},
error: (err) => {
this.snackBar.open(err.message || 'Failed to create subcategory', 'Close', { duration: 3000 });
}
});
}
@@ -184,12 +270,18 @@ export class ProjectViewComponent implements OnInit {
deleteCategory(node: CategoryNode, event: Event) {
event.stopPropagation();
const subCount = node.children?.length || 0;
const message = subCount > 0
? `Are you sure you want to delete "${node.name}"? This will also delete ${subCount} subcategory(ies) and all their items. This action cannot be undone.`
: `Are you sure you want to delete "${node.name}"? This action cannot be undone.`;
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Category',
message: `Are you sure you want to delete "${node.name}"? This will also delete all subcategories and items.`,
message: message,
confirmText: 'Delete',
warning: true
cancelText: 'Cancel',
dangerous: true
}
});
@@ -211,10 +303,23 @@ export class ProjectViewComponent implements OnInit {
deleteSubcategory(node: CategoryNode, event: Event) {
event.stopPropagation();
const childCount = node.children?.length || 0;
const hasChildren = childCount > 0;
const hasItems = node.hasItems;
let message = `Are you sure you want to delete "${node.name}"?`;
if (hasChildren) {
message += ` This will also delete ${childCount} nested subcategory(ies).`;
}
if (hasItems) {
message += ' This will also delete all items.';
}
message += ' This action cannot be undone.';
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Subcategory',
message: `Are you sure you want to delete "${node.name}"? This will also delete all items.`,
message: message,
confirmText: 'Delete',
cancelText: 'Cancel',
dangerous: true

View File

@@ -16,7 +16,7 @@
@if (!loading() && !error()) {
<div class="projects-grid">
@for (project of projects(); track project.id) {
<mat-card class="project-card" (click)="openProject(project.id)">
<mat-card class="project-card" [class.selected]="currentProjectId() === project.id" (click)="openProject(project.id)">
<mat-card-header>
@if (project.logoUrl) {
<img [src]="project.logoUrl" [alt]="project.displayName" class="project-logo">

View File

@@ -39,6 +39,15 @@
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}
&.selected {
border: 2px solid #1976d2;
box-shadow: 0 4px 12px rgba(25, 118, 210, 0.3);
&:hover {
box-shadow: 0 8px 16px rgba(25, 118, 210, 0.4);
}
}
mat-card-header {
display: flex;
align-items: center;

View File

@@ -17,6 +17,7 @@ export class ProjectsDashboardComponent implements OnInit {
projects = signal<Project[]>([]);
loading = signal(true);
error = signal<string | null>(null);
currentProjectId = signal<string | null>(null);
constructor(
private apiService: ApiService,
@@ -25,6 +26,22 @@ export class ProjectsDashboardComponent implements OnInit {
ngOnInit() {
this.loadProjects();
// Check if we're currently viewing a project
const urlSegments = this.router.url.split('/');
if (urlSegments[1] === 'project' && urlSegments[2]) {
this.currentProjectId.set(urlSegments[2]);
}
// Listen to route changes
this.router.events.subscribe(() => {
const segments = this.router.url.split('/');
if (segments[1] === 'project' && segments[2]) {
this.currentProjectId.set(segments[2]);
} else {
this.currentProjectId.set(null);
}
});
}
loadProjects() {

View File

@@ -40,7 +40,15 @@
<mat-form-field appearance="outline" class="full-width">
<mat-label>ID</mat-label>
<input matInput [value]="subcategory()!.id" disabled>
<input
matInput
[(ngModel)]="subcategory()!.id"
(blur)="onFieldChange('id', subcategory()!.id)"
required>
<mat-hint>Used for routing - update carefully</mat-hint>
@if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
<mat-error>ID is required</mat-error>
}
</mat-form-field>
<div class="form-row">

View File

@@ -119,12 +119,8 @@ export class SubcategoryEditorComponent implements OnInit {
}
goBack() {
const sub = this.subcategory();
if (sub && this.projectId()) {
this.router.navigate(['/project', this.projectId(), 'category', sub.categoryId]);
} else {
this.router.navigate(['/']);
}
// Navigate back to the project view (close the editor)
this.router.navigate(['/project', this.projectId()]);
}
deleteSubcategory() {