Skip to content

Commit 65fc430

Browse files
Add tag selection feature to post creation and update policies for admin role
1 parent 7a02a05 commit 65fc430

File tree

7 files changed

+313
-10
lines changed

7 files changed

+313
-10
lines changed

src/app/admin/_components/add-post/add-post.component.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88
<div>Title is required.</div>
99
}
1010
}
11+
12+
<!-- Tags Selection -->
13+
<div class="w-full">
14+
<label class="label">
15+
<span class="label-text">Tags</span>
16+
</label>
17+
<blog-tag-multi-select formControlName="tags"></blog-tag-multi-select>
18+
</div>
19+
1120
<button class="btn btn--secondary" type="button" (click)="insertImage()">Insert Image</button>
1221
<quill-editor #quill formControlName="content" class="h-screen w-full"></quill-editor>
1322
<div class="mt-12 btn--group">

src/app/admin/_components/add-post/add-post.component.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import { DynamicDialogService } from '../../../shared/dynamic-dialog/dynamic-dia
2828
import { ModalConfig } from '../../../shared/_models/modal-config.intreface';
2929
import { AddImageComponent } from './add-image/add-image.component';
3030
import { AddImageForm } from './add-image/add-image-controls.interface';
31-
import { Post } from '../../../types/supabase';
31+
import { PostInsert, PostUpdate, Tag } from '../../../types/supabase';
32+
import { TagMultiSelectComponent } from '../../../shared/tag-multi-select/tag-multi-select.component';
3233

3334
@Component({
3435
selector: 'blog-add-post',
@@ -39,6 +40,7 @@ import { Post } from '../../../types/supabase';
3940
QuillEditorComponent,
4041
HighlightModule,
4142
RouterModule,
43+
TagMultiSelectComponent,
4244
],
4345
providers: [AdminApiService, NgModel],
4446
templateUrl: './add-post.component.html',
@@ -65,6 +67,7 @@ export class AddPostComponent implements OnInit {
6567
created_at: new FormControl<Date | null>(null),
6668
description: new FormControl<string | null>(null),
6769
is_draft: new FormControl(false, { nonNullable: true }),
70+
tags: new FormControl<Tag[]>([], { nonNullable: true }),
6871
});
6972
range: Range | null = null;
7073

@@ -108,9 +111,12 @@ export class AddPostComponent implements OnInit {
108111
this.blogForm.controls.created_at.setValue(null);
109112
}
110113
if (this.postId) {
111-
this.apiService.updatePost(this.postId, this.blogForm.value as Post);
114+
this.apiService.updatePost(
115+
this.postId,
116+
this.blogForm.value as PostUpdate,
117+
);
112118
} else {
113-
this.apiService.addPost(this.blogForm.value as Post);
119+
this.apiService.addPost(this.blogForm.value as PostInsert);
114120
}
115121
}
116122
}
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { FormControl } from '@angular/forms';
22
import { SafeHtml } from '@angular/platform-browser';
3+
import { Tag } from '../../types/supabase';
34

45
export interface PostForm {
56
title: FormControl<string>;
6-
content: FormControl<string | SafeHtml>;
7+
content: FormControl<string>;
78
is_draft: FormControl<boolean>;
89
created_at: FormControl<Date | null>;
9-
description?: FormControl<string | null>;
10-
tags: FormControl<string[] | null>;
10+
description: FormControl<string | null>;
11+
tags: FormControl<Tag[]>;
1112
}

src/app/admin/_services/admin-api.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { inject, Injectable } from '@angular/core';
22
import { map, Observable } from 'rxjs';
3-
import { Post } from '../../supabase-types';
3+
import { Post, PostInsert, PostUpdate } from '../../supabase-types';
44
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
55
import { SupabaseService } from '../../services/supabase.service';
66
import { environment } from '../../../environments/environment.local';
@@ -12,7 +12,7 @@ export class AdminApiService {
1212
private readonly baseUrl = `${environment.supabaseUrl}/rest/v1/`;
1313
private readonly apiKey = environment.supabaseKey;
1414

15-
async addPost(post: Post): Promise<void> {
15+
async addPost(post: PostInsert): Promise<void> {
1616
const { error } = await this.supabaseService.getClient
1717
.from('posts')
1818
.insert({ ...post });
@@ -43,7 +43,7 @@ export class AdminApiService {
4343
.pipe(map((results) => results[0] ?? null));
4444
}
4545

46-
async updatePost(id: string, post: Post): Promise<void> {
46+
async updatePost(id: string, post: PostUpdate): Promise<void> {
4747
await this.supabaseService.getClient
4848
.from('posts')
4949
.update({ ...post })
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<div class="relative w-full max-w-md">
2+
<!-- Selected Tags Display -->
3+
@if (selectedTags().length > 0) {
4+
<div class="flex flex-wrap gap-2 mb-2">
5+
@for (tag of selectedTags(); track tag.id) {
6+
<div
7+
class="badge gap-2 cursor-pointer transition-all duration-200 hover:opacity-80 hover:scale-95 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
8+
[style.background-color]="tag.color || '#12372A'"
9+
[style.color]="'#FBFADA'"
10+
(click)="removeTag(tag)"
11+
[attr.aria-label]="'Remove ' + tag.name"
12+
role="button"
13+
tabindex="0"
14+
(keydown.enter)="removeTag(tag)"
15+
(keydown.space)="removeTag(tag)"
16+
>
17+
{{ tag.name }}
18+
<svg
19+
xmlns="http://www.w3.org/2000/svg"
20+
fill="none"
21+
viewBox="0 0 24 24"
22+
class="inline-block w-4 h-4 stroke-current"
23+
>
24+
<path
25+
stroke-linecap="round"
26+
stroke-linejoin="round"
27+
stroke-width="2"
28+
d="M6 18L18 6M6 6l12 12"
29+
></path>
30+
</svg>
31+
</div>
32+
}
33+
</div>
34+
}
35+
36+
<!-- Search Input -->
37+
<div class="relative">
38+
<input
39+
#searchInput
40+
type="text"
41+
class="input input-bordered w-full focus:border-green-600 focus:ring-2 focus:ring-green-200 disabled:opacity-50 disabled:cursor-not-allowed"
42+
placeholder="Search and select tags..."
43+
[value]="searchTerm()"
44+
(input)="onSearchChange($event)"
45+
(focus)="onInputFocus()"
46+
(blur)="onInputBlur()"
47+
[disabled]="disabled()"
48+
[attr.aria-expanded]="isOpen()"
49+
[attr.aria-haspopup]="'listbox'"
50+
role="textbox"
51+
aria-label="Search tags"
52+
/>
53+
54+
<!-- Dropdown -->
55+
@if (isOpen() && !disabled()) {
56+
<div
57+
class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-y-auto"
58+
role="listbox"
59+
[attr.aria-label]="'Available tags'"
60+
>
61+
@for (tag of filteredTags(); track tag.id) {
62+
<div
63+
class="px-4 py-2 cursor-pointer hover:bg-base-200 flex items-center justify-between transition-colors duration-150"
64+
[class.bg-primary]="tag.id === focusedTagId()"
65+
[class.text-quaternary]="tag.id === focusedTagId()"
66+
(click)="selectTag(tag)"
67+
[attr.aria-selected]="isTagSelected(tag)"
68+
role="option"
69+
tabindex="-1"
70+
>
71+
<span>{{ tag.name }}</span>
72+
@if (tag.color) {
73+
<div
74+
class="w-4 h-4 rounded-full border border-gray-200"
75+
[style.background-color]="tag.color"
76+
></div>
77+
}
78+
</div>
79+
}
80+
@empty {
81+
<div class="px-4 py-2 text-base-content/60 text-sm italic">
82+
No tags found
83+
</div>
84+
}
85+
</div>
86+
}
87+
</div>
88+
</div>
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
forwardRef,
5+
inject,
6+
OnInit,
7+
signal,
8+
computed,
9+
HostListener,
10+
ElementRef,
11+
viewChild,
12+
} from '@angular/core';
13+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
14+
import { Subject } from 'rxjs';
15+
import { ReaderApiService } from '../../reader/_services/reader-api.service';
16+
import { Tag } from '../../types/supabase';
17+
18+
@Component({
19+
selector: 'blog-tag-multi-select',
20+
standalone: true,
21+
imports: [],
22+
providers: [
23+
{
24+
provide: NG_VALUE_ACCESSOR,
25+
useExisting: forwardRef(() => TagMultiSelectComponent),
26+
multi: true,
27+
},
28+
],
29+
templateUrl: './tag-multi-select.component.html',
30+
changeDetection: ChangeDetectionStrategy.OnPush,
31+
})
32+
export class TagMultiSelectComponent implements ControlValueAccessor, OnInit {
33+
private elementRef = inject(ElementRef);
34+
private readerApi = inject(ReaderApiService);
35+
36+
searchInput = viewChild.required<ElementRef<HTMLInputElement>>('searchInput');
37+
38+
// Signals
39+
allTags = signal<Tag[]>([]);
40+
selectedTags = signal<Tag[]>([]);
41+
searchTerm = signal('');
42+
isOpen = signal(false);
43+
disabled = signal(false);
44+
focusedTagId = signal<number | null>(null);
45+
isTouched = signal(false);
46+
47+
// Search subject for debouncing
48+
private searchSubject = new Subject<string>();
49+
50+
// Computed values
51+
filteredTags = computed(() => {
52+
const search = this.searchTerm().toLowerCase();
53+
const selected = this.selectedTags();
54+
return this.allTags().filter(
55+
(tag) =>
56+
tag.name.toLowerCase().includes(search) &&
57+
!selected.some((s) => s.id === tag.id),
58+
);
59+
});
60+
61+
// ControlValueAccessor
62+
private onChange = (value: Tag[]) => {};
63+
private onTouched = () => {};
64+
65+
ngOnInit() {
66+
this.loadTags();
67+
}
68+
69+
private loadTags() {
70+
this.readerApi.getTags().subscribe({
71+
next: (tags) => this.allTags.set(tags!),
72+
error: (error) => console.error('Failed to load tags:', error),
73+
});
74+
}
75+
76+
onSearchChange(event: Event) {
77+
const target = event.target as HTMLInputElement;
78+
const value = target.value;
79+
this.searchTerm.set(value);
80+
this.searchSubject.next(value);
81+
}
82+
83+
onInputFocus() {
84+
this.isOpen.set(true);
85+
this.focusedTagId.set(null);
86+
}
87+
88+
onInputBlur() {
89+
setTimeout(() => {
90+
this.isOpen.set(false);
91+
this.markAsTouched();
92+
}, 150);
93+
}
94+
95+
selectTag(tag: Tag): void {
96+
if (!this.selectedTags().find((t) => t.id === tag.id)) {
97+
this.selectedTags.update((tags) => [...tags, tag]);
98+
this.onChange(this.selectedTags());
99+
this.onTouched();
100+
101+
this.searchTerm.set('');
102+
this.searchInput().nativeElement.value = '';
103+
104+
this.isOpen.set(true);
105+
106+
setTimeout(() => {
107+
this.searchInput().nativeElement.focus();
108+
}, 0);
109+
}
110+
}
111+
112+
removeTag(tag: Tag) {
113+
const current = this.selectedTags();
114+
const updated = current.filter((t) => t.id !== tag.id);
115+
this.selectedTags.set(updated);
116+
this.onChange(updated);
117+
this.markAsTouched();
118+
}
119+
120+
isTagSelected(tag: Tag): boolean {
121+
return this.selectedTags().some((t) => t.id === tag.id);
122+
}
123+
124+
trackByTagId(index: number, tag: Tag): number {
125+
return tag.id;
126+
}
127+
128+
private markAsTouched() {
129+
if (!this.isTouched()) {
130+
this.isTouched.set(true);
131+
this.onTouched();
132+
}
133+
}
134+
135+
@HostListener('document:keydown', ['$event'])
136+
onKeyDown(event: KeyboardEvent) {
137+
if (!this.isOpen() || this.disabled()) return;
138+
139+
const filtered = this.filteredTags();
140+
const currentIndex = filtered.findIndex(
141+
(tag) => tag.id === this.focusedTagId(),
142+
);
143+
144+
switch (event.key) {
145+
case 'ArrowDown':
146+
event.preventDefault();
147+
const nextIndex =
148+
currentIndex < filtered.length - 1 ? currentIndex + 1 : 0;
149+
this.focusedTagId.set(filtered[nextIndex]?.id || null);
150+
break;
151+
152+
case 'ArrowUp':
153+
event.preventDefault();
154+
const prevIndex =
155+
currentIndex > 0 ? currentIndex - 1 : filtered.length - 1;
156+
this.focusedTagId.set(filtered[prevIndex]?.id || null);
157+
break;
158+
159+
case 'Enter':
160+
event.preventDefault();
161+
const focusedTag = filtered.find(
162+
(tag) => tag.id === this.focusedTagId(),
163+
);
164+
if (focusedTag) {
165+
this.selectTag(focusedTag);
166+
}
167+
break;
168+
169+
case 'Escape':
170+
event.preventDefault();
171+
this.isOpen.set(false);
172+
this.searchInput().nativeElement.blur();
173+
break;
174+
}
175+
}
176+
177+
@HostListener('document:click', ['$event'])
178+
onDocumentClick(event: Event) {
179+
if (!this.elementRef.nativeElement.contains(event.target as Node)) {
180+
this.isOpen.set(false);
181+
}
182+
}
183+
184+
writeValue(value: Tag[]): void {
185+
this.selectedTags.set(value || []);
186+
}
187+
188+
registerOnChange(fn: (value: Tag[]) => void): void {
189+
this.onChange = fn;
190+
}
191+
192+
registerOnTouched(fn: () => void): void {
193+
this.onTouched = fn;
194+
}
195+
196+
setDisabledState(isDisabled: boolean): void {
197+
this.disabled.set(isDisabled);
198+
}
199+
}

supabase/migrations/20250525091356_remote_schema.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ CREATE POLICY "Authenticated users can create profiles" ON "public"."profiles" F
210210

211211

212212

213-
CREATE POLICY "Only Admin can create post_tags" ON "public"."post_tags" FOR INSERT WITH CHECK (("auth"."role"() = 'Admin'::"text"));
213+
CREATE POLICY "Only Admin can create post_tags" ON "public"."post_tags" FOR INSERT WITH CHECK (((("auth"."jwt"() -> 'app_metadata'::"text") ->> 'role'::"text") = 'Admin'::"text"));
214214

215215

216216

0 commit comments

Comments
 (0)