Skip to content

Commit d36860a

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/import-color
2 parents 74be931 + 9df0fa5 commit d36860a

File tree

13 files changed

+175
-10
lines changed

13 files changed

+175
-10
lines changed

ngsw-config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,6 @@
3232
}
3333
],
3434
"appData": {
35-
"version": "1.4.0"
35+
"version": "1.5.0-staging.1"
3636
}
3737
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rainbow-palette",
3-
"version": "1.4.0",
3+
"version": "1.5.0-staging.1",
44
"description": "This app generates your own custom color palette from just a single color. Preview your palette live or export it for usage with CSS or TailwindCSS.",
55
"homepage": "https://rainbow-palette.app/",
66
"bugs": {

src/app/shared/data-access/palette.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Value } from '../model';
55
import { Color } from '../model/color.model';
66
import { Palette } from '../model/palette.model';
77
import { Shade } from '../model/shade.model';
8+
import { deduplicateName } from '../utils/deduplicate-name';
89
import { ColorNameService } from './color-name.service';
910
import { ColorService } from './color.service';
1011
import { ListService } from './list.service';
@@ -139,7 +140,11 @@ export class PaletteService {
139140

140141
for (const color of palette.colors) {
141142
// Get the color name
142-
color.name = await this._colorNameService.getColorName(color.shades[0]);
143+
const generatedName = await this._colorNameService.getColorName(color.shades[0]);
144+
145+
// Deduplicate the name
146+
const existingNames = palette.colors.map((c) => c.name);
147+
color.name = deduplicateName(generatedName, existingNames);
143148

144149
// Regenerate the shades
145150
await this._colorService.regenerateShades(color);

src/app/shared/types/dialog-config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ValidatorFn } from '@angular/forms';
2+
13
export type AlertConfig = {
24
type: 'alert';
35
title: string;
@@ -19,6 +21,10 @@ export type PromptConfig = {
1921
label?: string;
2022
placeholder?: string;
2123
initialValue?: string;
24+
validation?: {
25+
validators: Array<ValidatorFn>;
26+
errorMessageKeys: Record<string, string>;
27+
};
2228
};
2329

2430
export type DialogConfig = AlertConfig | ConfirmConfig | PromptConfig;

src/app/shared/ui/dialog/dialog.component.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,19 @@ <h1 class="text-xl font-semibold">
2323
}
2424

2525
<input
26+
[class]="invalid() ? 'border-red-700 dark:border-red-400' : ''"
2627
[formControl]="input"
2728
[placeholder]="config.placeholder ?? '' | translate"
28-
class="w-full min-w-0 grow rounded-md border-transparent bg-neutral-200 px-4 py-2 invalid:border-red-600 focus:outline-none focus:ring-0 dark:bg-neutral-600"
29+
class="w-full min-w-0 grow rounded-md border-transparent bg-neutral-200 px-4 py-2 focus:outline-none focus:ring-0 dark:bg-neutral-600"
2930
id="dialog-input"
3031
type="text"
3132
/>
33+
34+
@if (validationError(); as validationError) {
35+
<p class="mt-2 text-sm text-red-700 dark:text-red-400">
36+
{{ validationError }}
37+
</p>
38+
}
3239
</section>
3340
}
3441

src/app/shared/ui/dialog/dialog.component.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
22
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
3+
import { toSignal } from '@angular/core/rxjs-interop';
34
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
4-
import { TranslateModule } from '@ngx-translate/core';
5+
import { TranslateModule, TranslateService } from '@ngx-translate/core';
6+
import { map, skip } from 'rxjs';
57
import { DialogConfig } from '../../types/dialog-config';
68

79
@Component({
@@ -14,7 +16,11 @@ import { DialogConfig } from '../../types/dialog-config';
1416
export class DialogComponent {
1517
protected readonly config = inject<DialogConfig>(DIALOG_DATA);
1618
private readonly _dialogRef = inject(DialogRef);
19+
private readonly _translate = inject(TranslateService);
1720

21+
/**
22+
* Label for confirm button.
23+
*/
1824
protected get confirmLabel(): string {
1925
if (this.config.type === 'alert') {
2026
return 'common.ok';
@@ -23,13 +29,69 @@ export class DialogComponent {
2329
return this.config.confirmLabel;
2430
}
2531

32+
/**
33+
* Input control for prompt dialog.
34+
*/
2635
protected readonly input = new FormControl('', {
2736
nonNullable: true,
2837
validators: [Validators.required]
2938
});
3039

40+
/**
41+
* Subject that emits true if input is invalid after the first change.
42+
*/
43+
readonly #invalid$ = this.input.valueChanges.pipe(
44+
// Skip the first value since it's the initial value
45+
skip(1),
46+
// Check if input is invalid
47+
map(() => this.input.invalid)
48+
);
49+
50+
/**
51+
* Signal containing the invalid state of the input.
52+
*/
53+
protected readonly invalid = toSignal(this.#invalid$, { initialValue: false });
54+
55+
/**
56+
* Signal containing the validation error message.
57+
*/
58+
protected readonly validationError = toSignal<string, string>(
59+
this.#invalid$.pipe(
60+
map((invalid) => {
61+
// Check if input is valid or dialog is not prompt
62+
if (!invalid || this.config.type !== 'prompt') {
63+
return '';
64+
}
65+
66+
// Use default error message if no custom validation is set
67+
if (!this.config.validation) {
68+
return this._translate.instant('common.required');
69+
}
70+
71+
// Find the first error key
72+
const errorKey = Object.keys(this.config.validation.errorMessageKeys).find((key) => this.input.hasError(key));
73+
if (!errorKey) {
74+
return '';
75+
}
76+
77+
// Get the error message
78+
const error = this.input.getError(errorKey);
79+
return this._translate.instant(this.config.validation.errorMessageKeys[errorKey], { value: error.value });
80+
})
81+
),
82+
{
83+
initialValue: ''
84+
}
85+
);
86+
3187
public constructor() {
3288
if (this.config.type === 'prompt') {
89+
// Set validators to input
90+
if (this.config.validation) {
91+
this.input.setValidators(this.config.validation.validators);
92+
}
93+
94+
// Set initial value to input
3395
this.input.setValue(this.config.initialValue ?? '');
3496
}
3597
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { normalizeName } from './normalize-name';
2+
3+
/**
4+
* Deduplicate a name by adding a number to the end if it already exists.
5+
*/
6+
export function deduplicateName(name: string, existingNames: Array<string>): string {
7+
const normalizedNames = existingNames.map((n) => normalizeName(n));
8+
9+
// Check if name already exists
10+
while (normalizedNames.includes(normalizeName(name))) {
11+
// Check if the name already has a number
12+
const lastNumber = name.match(/\d+$/);
13+
if (lastNumber) {
14+
// Increment the number
15+
const number = parseInt(lastNumber[0], 10);
16+
name = name.replace(/\d+$/, '') + (number + 1);
17+
} else {
18+
// Add a number to the color name
19+
name += ' 2';
20+
}
21+
}
22+
23+
// Return the deduplicated name
24+
return name;
25+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Normalize a string by removing whitespace and converting to lowercase.
3+
*/
4+
export function normalizeName(value: string): string {
5+
return value.trim().replace(/\s+/g, '').toLowerCase();
6+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { ValidatorFn } from '@angular/forms';
2+
import { normalizeName } from '../../shared/utils/normalize-name';
3+
4+
/**
5+
* Validator that checks if the value is already in the array.
6+
*/
7+
export function duplicateValidator(values: Array<string>): ValidatorFn {
8+
// Normalize the values
9+
const normalizedValues = values.map((value) => normalizeName(value));
10+
11+
return (control) => {
12+
const normalizedValue = normalizeName(control.value);
13+
14+
// Check if the value is already in the array
15+
const duplicate = normalizedValues.findIndex((value) => value === normalizedValue);
16+
if (duplicate === -1) {
17+
// No duplicate found
18+
return null;
19+
}
20+
21+
// Return duplicate
22+
return { duplicate: { value: values.at(duplicate) } };
23+
};
24+
}

src/app/view/view.component.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Dialog } from '@angular/cdk/dialog';
22
import { Component, HostListener, OnInit, computed, inject, input, signal } from '@angular/core';
33
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
4+
import { Validators } from '@angular/forms';
45
import { Router, RouterLink } from '@angular/router';
56
import { NgIconComponent } from '@ng-icons/core';
67
import {
@@ -26,10 +27,12 @@ import { ToastService } from '../shared/data-access/toast.service';
2627
import { TrackingEventAction, TrackingEventCategory } from '../shared/enums/tracking-event';
2728
import { Color, Shade } from '../shared/model';
2829
import { NoPaletteComponent } from '../shared/ui/no-palette/no-palette.component';
30+
import { deduplicateName } from '../shared/utils/deduplicate-name';
2931
import { IS_RUNNING_TEST } from '../shared/utils/is-running-test';
3032
import { sleep } from '../shared/utils/sleep';
3133
import { ImportColorData } from './ui/import-color/import-color.component';
3234
import { ViewPaletteComponent } from './ui/view-palette/view-palette.component';
35+
import { duplicateValidator } from './utils/duplicate.validator';
3336
import { UnsavedChangesComponent } from './utils/unsaved-changes.guard';
3437

3538
@Component({
@@ -183,13 +186,26 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent {
183186
}
184187

185188
public async renameColor(color: Color): Promise<void> {
189+
// Get all color names except the current one
190+
const colorNames =
191+
this.palette()
192+
?.colors.filter((c) => c !== color)
193+
.map((c) => c.name) ?? [];
194+
186195
const newName = await this._dialogService.prompt({
187196
title: 'common.rename',
188197
message: 'view.color.rename',
189198
confirmLabel: 'common.rename',
190199
initialValue: color.name,
191200
label: 'common.name',
192-
placeholder: 'common.name'
201+
placeholder: 'common.name',
202+
validation: {
203+
validators: [Validators.required, duplicateValidator(colorNames)],
204+
errorMessageKeys: {
205+
required: 'common.required',
206+
duplicate: 'view.color.duplicate-name'
207+
}
208+
}
193209
});
194210

195211
if (!newName || newName === color.name) {
@@ -242,13 +258,23 @@ export default class ViewComponent implements OnInit, UnsavedChangesComponent {
242258
}
243259

244260
public async addColor(): Promise<void> {
261+
// Check if a palette exists
245262
const palette = this.palette();
246263
if (!palette) {
247264
return;
248265
}
249266

267+
// Create a new random color
250268
const color = await this._colorService.randomColor();
269+
270+
// Check if color name already exists
271+
const colorNames = palette.colors.map((c) => c.name);
272+
color.name = deduplicateName(color.name, colorNames);
273+
274+
// Add the color to the palette
251275
palette.addColor(color);
276+
277+
// Set unsaved changes
252278
this._hasUnsavedChanges.set(true);
253279
}
254280

0 commit comments

Comments
 (0)