From c99e3c3678f239871a9c1baf8039db1d7236a524 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 25 Mar 2026 18:53:49 -0400 Subject: [PATCH 1/3] feat(search): implement search for signals and decisions (ref Sentinent-AI/Sentinent#17) --- src/app/components/dashboard/dashboard.css | 20 +- src/app/components/dashboard/dashboard.html | 7 +- src/app/components/dashboard/dashboard.ts | 3 +- src/app/components/search-bar/search-bar.css | 212 ++++++++++++++++++ src/app/components/search-bar/search-bar.html | 69 ++++++ src/app/components/search-bar/search-bar.ts | 126 +++++++++++ .../components/signal-board/signal-board.css | 12 + .../components/signal-board/signal-board.html | 2 +- src/app/services/search.service.ts | 113 ++++++++++ 9 files changed, 558 insertions(+), 6 deletions(-) create mode 100644 src/app/components/search-bar/search-bar.css create mode 100644 src/app/components/search-bar/search-bar.html create mode 100644 src/app/components/search-bar/search-bar.ts create mode 100644 src/app/services/search.service.ts diff --git a/src/app/components/dashboard/dashboard.css b/src/app/components/dashboard/dashboard.css index ca05606..9288d76 100644 --- a/src/app/components/dashboard/dashboard.css +++ b/src/app/components/dashboard/dashboard.css @@ -109,8 +109,26 @@ font-size: 28px; } +.signals-header { + flex-direction: column; + align-items: flex-start; + gap: 20px; +} + +.title-group { + display: flex; + align-items: center; + gap: 24px; + width: 100%; +} + +.signals-search { + flex: 1; + max-width: 400px; +} + .section-copy { - margin: 6px 0 0; + margin: -10px 0 18px; color: #4a5568; } diff --git a/src/app/components/dashboard/dashboard.html b/src/app/components/dashboard/dashboard.html index d113e98..0813b0b 100644 --- a/src/app/components/dashboard/dashboard.html +++ b/src/app/components/dashboard/dashboard.html @@ -52,10 +52,10 @@

No workspaces yet

-
-
+
+

Signals

-

Slack messages and GitHub updates appear here as action-ready signals.

+
@@ -65,6 +65,7 @@

Signals

+

Slack messages and GitHub updates appear here as action-ready signals.

+
+
+ + + + +
+ +
+
+
+
+ +
+ +
+ + + + +
+ + +
+
+ No matches found for "{{query}}" +
+ +
+
+ # + GH + D +
+
+
+
+
+
+ {{ res.date | date:'shortDate' }} +
+
+
+ + +
+
+ Recent Searches + +
+
+ + + + + {{ s }} +
+
+
+
diff --git a/src/app/components/search-bar/search-bar.ts b/src/app/components/search-bar/search-bar.ts new file mode 100644 index 0000000..26153ef --- /dev/null +++ b/src/app/components/search-bar/search-bar.ts @@ -0,0 +1,126 @@ +import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterModule } from '@angular/router'; +import { Subject, Subscription, of } from 'rxjs'; +import { Decision } from '../../models/decision.model'; +import { Signal } from '../../models/signal.model'; +import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'; +import { SearchService, SearchResult } from '../../services/search.service'; + +@Component({ + selector: 'app-search-bar', + standalone: true, + imports: [CommonModule, FormsModule, RouterModule], + templateUrl: './search-bar.html', + styleUrls: ['./search-bar.css'] +}) +export class SearchBarComponent implements OnInit, OnDestroy { + query: string = ''; + sourceFilter: string = 'all'; + results: SearchResult[] = []; + showResults: boolean = false; + recentSearches: string[] = []; + isSearching: boolean = false; + + private searchSubject = new Subject<{q: string, f: string}>(); + private searchSubscription?: Subscription; + + @ViewChild('searchInput') searchInput!: ElementRef; + + @HostListener('document:click', ['$event']) + handleClickOutside(event: Event): void { + if (!this.elRef.nativeElement.contains(event.target)) { + this.showResults = false; + } + } + + constructor( + private searchService: SearchService, + private router: Router, + private elRef: ElementRef, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.recentSearches = this.searchService.getRecentSearches(); + + this.searchSubscription = this.searchSubject.pipe( + debounceTime(150), + distinctUntilChanged((prev, curr) => prev.q === curr.q && prev.f === curr.f), + switchMap(({q, f}) => { + return this.searchService.search(q, f); + }) + ).subscribe({ + next: (results) => { + this.results = results; + this.isSearching = false; + this.cdr.detectChanges(); + }, + error: (err) => { + console.error('Search error:', err); + this.isSearching = false; + this.cdr.detectChanges(); + } + }); + } + + ngOnDestroy(): void { + this.searchSubscription?.unsubscribe(); + } + + onSearchChange(): void { + if (this.query.trim().length < 2) { + this.results = []; + this.isSearching = false; + this.showResults = true; + } else { + this.isSearching = true; + this.showResults = true; + // Do not clear this.results here to provide a smoother dynamic feel while typing + } + this.searchSubject.next({ q: this.query, f: this.sourceFilter }); + } + + onFilterChange(filter: string): void { + this.sourceFilter = filter; + this.onSearchChange(); + } + + selectResult(result: SearchResult): void { + this.searchService.addRecentSearch(this.query); + this.recentSearches = this.searchService.getRecentSearches(); + this.showResults = false; + this.query = ''; + + if (result.type === 'signal') { + const element = document.getElementById('signal-' + result.id); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.classList.add('search-highlight'); + setTimeout(() => element.classList.remove('search-highlight'), 3000); + } + } else if (result.type === 'decision') { + const decision = result.original as Decision; + this.router.navigate(['/workspaces', decision.workspaceId, 'decisions']); + // After navigation, we might also want to scroll to it, but that requires more coordination. + // For now, jumping to the list is a good start. + } + } + + useRecentSearch(search: string): void { + this.query = search; + this.onSearchChange(); + } + + clearRecent(): void { + this.searchService.clearRecentSearches(); + this.recentSearches = []; + } + + highlightMatch(text: string): string { + if (!this.query || !text) return text; + const regex = new RegExp(`(${this.query})`, 'gi'); + return text.replace(regex, '$1'); + } +} diff --git a/src/app/components/signal-board/signal-board.css b/src/app/components/signal-board/signal-board.css index 67a960b..8394950 100644 --- a/src/app/components/signal-board/signal-board.css +++ b/src/app/components/signal-board/signal-board.css @@ -1,3 +1,15 @@ +@keyframes highlight-pulse { + 0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4); border-color: #6366f1; } + 70% { box-shadow: 0 0 0 10px rgba(99, 102, 241, 0); } + 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); } +} + +.search-highlight { + animation: highlight-pulse 2s ease-out; + border-color: #6366f1 !important; + scroll-margin-top: 100px; +} + .signals-board { display: grid; gap: 1rem; diff --git a/src/app/components/signal-board/signal-board.html b/src/app/components/signal-board/signal-board.html index aaf570d..2608f1f 100644 --- a/src/app/components/signal-board/signal-board.html +++ b/src/app/components/signal-board/signal-board.html @@ -1,5 +1,5 @@
-
+
{{ getSourceLabel(signal) }} diff --git a/src/app/services/search.service.ts b/src/app/services/search.service.ts new file mode 100644 index 0000000..28b91f3 --- /dev/null +++ b/src/app/services/search.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@angular/core'; +import { Observable, combineLatest, map, of } from 'rxjs'; +import { SignalService } from './signal.service'; +import { DecisionService } from './decision.service'; +import { Signal } from '../models/signal.model'; +import { Decision } from '../models/decision.model'; + +export type SearchResultType = 'signal' | 'decision'; + +export interface SearchResult { + id: string; + type: SearchResultType; + title: string; + content: string; + source: string; + date: Date; + url?: string; + original: Signal | Decision; +} + +@Injectable({ + providedIn: 'root' +}) +export class SearchService { + private readonly RECENT_SEARCHES_KEY = 'sentinent_recent_searches'; + private readonly MAX_RECENT_SEARCHES = 5; + + constructor( + private signalService: SignalService, + private decisionService: DecisionService + ) {} + + search(query: string, sourceFilter: string = 'all'): Observable { + const q = query.toLowerCase().trim(); + if (q.length < 2) return of([]); + + // Signal filtering + const signals$ = this.signalService.getSignals({ source: 'all', status: 'all' }).pipe( + map(signals => signals.filter(s => { + const matchesQuery = (s.title + ' ' + s.content).toLowerCase().includes(q); + const matchesSource = sourceFilter === 'all' || sourceFilter === s.sourceType; + return matchesQuery && matchesSource; + })), + map(signals => signals.map(s => this.mapSignalToResult(s))) + ); + + // Decision filtering + const decisions$ = this.decisionService.getDecisions('ws-1').pipe( + map(decisions => decisions.filter(d => { + const matchesQuery = (d.title + ' ' + (d.description || '')).toLowerCase().includes(q); + const matchesSource = sourceFilter === 'all' || sourceFilter === 'decision'; + return matchesQuery && matchesSource; + })), + map(decisions => decisions.map(d => this.mapDecisionToResult(d))) + ); + + return combineLatest([signals$, decisions$]).pipe( + map(([signals, decisions]) => { + const combined = [...signals, ...decisions]; + // Sort by date descending + return combined.sort((a, b) => b.date.getTime() - a.date.getTime()); + }) + ); + } + + private mapSignalToResult(s: Signal): SearchResult { + return { + id: s.id, + type: 'signal', + title: s.title, + content: s.content, + source: s.sourceType, + date: s.receivedAt, + url: s.url, + original: s + }; + } + + private mapDecisionToResult(d: Decision): SearchResult { + return { + id: d.id, + type: 'decision', + title: d.title, + content: d.description || '', + source: 'decision', + date: d.createdAt, + original: d + }; + } + + getRecentSearches(): string[] { + const stored = localStorage.getItem(this.RECENT_SEARCHES_KEY); + return stored ? JSON.parse(stored) : []; + } + + addRecentSearch(query: string): void { + if (!query || query.trim().length < 2) return; + + let searches = this.getRecentSearches(); + // Remove if already exists (to move to top) + searches = searches.filter(s => s.toLowerCase() !== query.toLowerCase()); + // Add to front + searches.unshift(query); + // Limit size + searches = searches.slice(0, this.MAX_RECENT_SEARCHES); + + localStorage.setItem(this.RECENT_SEARCHES_KEY, JSON.stringify(searches)); + } + + clearRecentSearches(): void { + localStorage.removeItem(this.RECENT_SEARCHES_KEY); + } +} From 0b67bc4fc966466ecb6c261e3472f82fcbdd337c Mon Sep 17 00:00:00 2001 From: = Date: Wed, 25 Mar 2026 19:06:19 -0400 Subject: [PATCH 2/3] test(search): add unit tests and fix dynamic search rendering (ref Sentinent-AI/Sentinent#17) --- src/app/services/search.service.spec.ts | 184 ++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/app/services/search.service.spec.ts diff --git a/src/app/services/search.service.spec.ts b/src/app/services/search.service.spec.ts new file mode 100644 index 0000000..7708aa3 --- /dev/null +++ b/src/app/services/search.service.spec.ts @@ -0,0 +1,184 @@ +import { TestBed } from '@angular/core/testing'; +import { SearchService } from './search.service'; +import { SignalService } from './signal.service'; +import { DecisionService } from './decision.service'; +import { of } from 'rxjs'; +import { Signal } from '../models/signal.model'; +import { Decision } from '../models/decision.model'; + +describe('SearchService', () => { + let service: SearchService; + let mockSignalService: any; + let mockDecisionService: any; + + beforeEach(() => { + mockSignalService = { + getSignals: jasmine.createSpy('getSignals').and.returnValue(of([])) + }; + + mockDecisionService = { + getDecisions: jasmine.createSpy('getDecisions').and.returnValue(of([])) + }; + + TestBed.configureTestingModule({ + providers: [ + SearchService, + { provide: SignalService, useValue: mockSignalService }, + { provide: DecisionService, useValue: mockDecisionService } + ] + }); + + service = TestBed.inject(SearchService); + + // Clear local storage for recent searches testing + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('search', () => { + const mockSignals: Signal[] = [ + { + id: '1', + title: 'Fix login bug', + content: 'Users cannot log in via SSO', + sourceType: 'github', + sourceId: 'src-1', + externalId: 'ext-1', + author: 'UserA', + receivedAt: new Date('2023-01-01T10:00:00Z'), + status: 'unread', + metadata: {} + }, + { + id: '2', + title: 'Deployment issue', + content: 'Server is down on production', + sourceType: 'slack', + sourceId: 'src-2', + externalId: 'ext-2', + author: 'UserB', + receivedAt: new Date('2023-01-02T10:00:00Z'), + status: 'unread', + metadata: {} + } + ]; + + const mockDecisions: Decision[] = [ + { + id: '1', + title: 'Use OAuth2 for SSO', + description: 'Decided to use OAuth2 to fix login bug', + createdAt: new Date('2023-01-03T10:00:00Z'), + updatedAt: new Date('2023-01-03T10:00:00Z'), + workspaceId: 'ws-1', + status: 'CLOSED', + userId: 'user-1', + isDeleted: false + } + ]; + + beforeEach(() => { + mockSignalService.getSignals.and.returnValue(of(mockSignals)); + mockDecisionService.getDecisions.and.returnValue(of(mockDecisions)); + }); + + it('should return empty array if query is less than 2 characters', (done) => { + service.search('a').subscribe(results => { + expect(results.length).toBe(0); + expect(mockSignalService.getSignals).not.toHaveBeenCalled(); + expect(mockDecisionService.getDecisions).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should find results across signals and decisions with matching text', (done) => { + service.search('login').subscribe(results => { + expect(results.length).toBe(2); + // Should sort by date descending (Decision is 01-03, Signal is 01-01) + expect(results[0].type).toBe('decision'); + expect(results[0].title).toBe('Use OAuth2 for SSO'); + expect(results[1].type).toBe('signal'); + expect(results[1].title).toBe('Fix login bug'); + done(); + }); + }); + + it('should filter by slack source', (done) => { + service.search('issue', 'slack').subscribe(results => { + expect(results.length).toBe(1); + expect(results[0].type).toBe('signal'); + expect(results[0].source).toBe('slack'); + expect(results[0].title).toBe('Deployment issue'); + done(); + }); + }); + + it('should filter by github source', (done) => { + service.search('login', 'github').subscribe(results => { + expect(results.length).toBe(1); + expect(results[0].type).toBe('signal'); + expect(results[0].source).toBe('github'); + done(); + }); + }); + + it('should filter by decision source', (done) => { + service.search('oauth', 'decision').subscribe(results => { + expect(results.length).toBe(1); + expect(results[0].type).toBe('decision'); + expect(results[0].title).toBe('Use OAuth2 for SSO'); + done(); + }); + }); + }); + + describe('Recent Searches', () => { + it('should add valid searches to localStorage and move them to front', () => { + service.addRecentSearch('first'); + service.addRecentSearch('second'); + service.addRecentSearch('third'); + + let recent = service.getRecentSearches(); + expect(recent).toEqual(['third', 'second', 'first']); + + // Adding duplicate should move it to front + service.addRecentSearch('first'); + recent = service.getRecentSearches(); + expect(recent).toEqual(['first', 'third', 'second']); + }); + + it('should not add queries less than 2 characters', () => { + service.addRecentSearch('a'); + expect(service.getRecentSearches().length).toBe(0); + }); + + it('should limit recent searches to maximum 5', () => { + service.addRecentSearch('q1'); + service.addRecentSearch('q2'); + service.addRecentSearch('q3'); + service.addRecentSearch('q4'); + service.addRecentSearch('q5'); + service.addRecentSearch('q6'); // Should push out q1 + + const recent = service.getRecentSearches(); + expect(recent.length).toBe(5); + expect(recent[0]).toBe('q6'); + expect(recent.includes('q1')).toBeFalse(); + }); + + it('should clear recent searches', () => { + service.addRecentSearch('query'); + expect(service.getRecentSearches().length).toBe(1); + + service.clearRecentSearches(); + expect(service.getRecentSearches().length).toBe(0); + }); + }); +}); From ad0587e6420af7c3900a957814e7ec1a215ed103 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 25 Mar 2026 20:17:43 -0400 Subject: [PATCH 3/3] feat(ui): refine search rendering and edit workspace UI --- src/app/app.routes.ts | 5 + src/app/components/dashboard/dashboard.html | 2 +- src/app/components/dashboard/dashboard.ts | 21 -- .../components/workspace/create-workspace.css | 211 +++++++++++++++--- .../workspace/create-workspace.html | 86 ++++--- .../components/workspace/edit-workspace.css | 208 +++++++++++++++++ .../components/workspace/edit-workspace.html | 65 ++++++ .../components/workspace/edit-workspace.ts | 67 ++++++ 8 files changed, 576 insertions(+), 89 deletions(-) create mode 100644 src/app/components/workspace/edit-workspace.css create mode 100644 src/app/components/workspace/edit-workspace.html create mode 100644 src/app/components/workspace/edit-workspace.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index e5fb9e1..ee8f7f7 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -22,6 +22,11 @@ export const routes: Routes = [ component: CreateWorkspace, canActivate: [authGuard] }, + { + path: 'workspaces/:id/edit', + loadComponent: () => import('./components/workspace/edit-workspace').then(m => m.EditWorkspaceComponent), + canActivate: [authGuard] + }, { path: 'workspaces/:id/settings/members', component: WorkspaceMembersComponent, diff --git a/src/app/components/dashboard/dashboard.html b/src/app/components/dashboard/dashboard.html index 0813b0b..f39c299 100644 --- a/src/app/components/dashboard/dashboard.html +++ b/src/app/components/dashboard/dashboard.html @@ -37,7 +37,7 @@

{{ ws.name }}

Created: {{ ws.createdDate | date: 'mediumDate' }}
Open - + Edit
diff --git a/src/app/components/dashboard/dashboard.ts b/src/app/components/dashboard/dashboard.ts index e682731..549a233 100644 --- a/src/app/components/dashboard/dashboard.ts +++ b/src/app/components/dashboard/dashboard.ts @@ -52,27 +52,6 @@ export class Dashboard implements OnInit { this.loadSignals(); } - editWorkspace(workspace: Workspace) { - const updatedName = window.prompt('Edit workspace name', workspace.name)?.trim(); - if (!updatedName) { - return; - } - - const updatedDescriptionInput = window.prompt('Edit workspace description', workspace.description ?? ''); - if (updatedDescriptionInput === null) { - return; - } - - const updatedDescription = updatedDescriptionInput.trim(); - - this.workspaceService.updateWorkspace(workspace.id, updatedName, updatedDescription).subscribe(updatedWorkspace => { - if (!updatedWorkspace) { - return; - } - this.workspaces = this.workspaces.map(ws => ws.id === updatedWorkspace.id ? updatedWorkspace : ws); - }); - } - deleteWorkspace(workspace: Workspace) { const confirmed = window.confirm(`Delete workspace "${workspace.name}"?`); if (!confirmed) { diff --git a/src/app/components/workspace/create-workspace.css b/src/app/components/workspace/create-workspace.css index 4de2a73..81da6cf 100644 --- a/src/app/components/workspace/create-workspace.css +++ b/src/app/components/workspace/create-workspace.css @@ -1,69 +1,208 @@ -.create-workspace-container { - max-width: 480px; - margin: 40px auto; - padding: 24px; - border: 1px solid #d8dee4; +.page-container { + min-height: 100vh; + background-color: #ffffff; + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.top-nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 32px; + background: #ffffff; + border-bottom: 1px solid #e5e5e5; + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + color: #000000; +} + +.brand-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: #000000; + color: #ffffff; border-radius: 8px; - background: #fff; +} + +.brand-name { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.back-nav-btn { + color: #000000; + text-decoration: none; + font-size: 14px; + font-weight: 500; + padding: 8px 16px; + border-radius: 6px; + transition: all 0.2s ease; + background: #ffffff; + border: 1px solid #e5e5e5; +} + +.back-nav-btn:hover { + background: #f3f3f3; + border-color: #cccccc; +} + +.form-wrapper { + flex: 1; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 64px 20px; +} + +.form-card { + width: 100%; + max-width: 480px; + background: #ffffff; + border-radius: 12px; + border: 1px solid #e5e5e5; + padding: 40px; +} + +.form-header { + margin-bottom: 32px; + text-align: center; } h2 { - margin-top: 0; - margin-bottom: 8px; + margin: 0 0 8px 0; + color: #000000; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.5px; } -p { - margin-top: 0; - margin-bottom: 20px; - color: #57606a; +.form-header p { + margin: 0; + color: #666666; + font-size: 15px; } form { display: flex; flex-direction: column; - gap: 10px; + gap: 24px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; } label { + font-size: 14px; font-weight: 600; + color: #000000; } -input, -textarea { - padding: 10px 12px; - border: 1px solid #d0d7de; - border-radius: 6px; - font: inherit; +.premium-input { + width: 100%; + padding: 12px 16px; + border: 1px solid #e5e5e5; + border-radius: 8px; + font-size: 15px; + color: #000000; + transition: all 0.2s ease; + box-sizing: border-box; + background: #ffffff; + font-family: inherit; +} + +.premium-input:hover { + border-color: #cccccc; +} + +.premium-input:focus { + outline: none; + border-color: #000000; + border-width: 2px; + padding: 11px 15px; +} + +.premium-input::placeholder { + color: #999999; +} + +textarea.premium-input { resize: vertical; + min-height: 100px; } -.actions { - display: flex; - align-items: center; - gap: 12px; - margin-top: 10px; +.form-actions { + margin-top: 8px; } -button { - padding: 10px 14px; +.premium-btn { + width: 100%; + background: #000000; + color: #ffffff; border: none; - border-radius: 6px; - background: #1f6feb; - color: #fff; + padding: 14px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: 600; cursor: pointer; + transition: background 0.2s ease; +} + +.premium-btn:hover:not(:disabled) { + background: #333333; } -button:disabled { +.premium-btn:disabled { opacity: 0.6; cursor: not-allowed; } -a { - color: #0969da; - text-decoration: none; +.error-msg { + margin-top: 24px; + padding: 12px 16px; + background: #ffffff; + border-radius: 8px; + color: #cf222e; + font-size: 14px; + font-weight: 500; + text-align: center; + border: 1px solid #cf222e; } -.error { - margin-top: 16px; - color: #cf222e; +.loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px 0; + color: #000000; + font-weight: 500; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #e5e5e5; + border-top-color: #000000; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } } diff --git a/src/app/components/workspace/create-workspace.html b/src/app/components/workspace/create-workspace.html index b7725db..760c351 100644 --- a/src/app/components/workspace/create-workspace.html +++ b/src/app/components/workspace/create-workspace.html @@ -1,36 +1,60 @@ -
-

Create Workspace

-

Set up a workspace to organize your decisions.

+
+
+
+
+ +
+ Sentinent +
+ Back to Dashboard +
-
- - +
+
+
+

Create Workspace

+

Set up a workspace to organize your decisions and signals.

+
- - + +
+ + +
-
- - Cancel -
- +
+ + +
-

{{ error }}

+
+ +
+ + +

{{ error }}

+
+
diff --git a/src/app/components/workspace/edit-workspace.css b/src/app/components/workspace/edit-workspace.css new file mode 100644 index 0000000..81da6cf --- /dev/null +++ b/src/app/components/workspace/edit-workspace.css @@ -0,0 +1,208 @@ +.page-container { + min-height: 100vh; + background-color: #ffffff; + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.top-nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 32px; + background: #ffffff; + border-bottom: 1px solid #e5e5e5; + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + color: #000000; +} + +.brand-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: #000000; + color: #ffffff; + border-radius: 8px; +} + +.brand-name { + font-size: 18px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.back-nav-btn { + color: #000000; + text-decoration: none; + font-size: 14px; + font-weight: 500; + padding: 8px 16px; + border-radius: 6px; + transition: all 0.2s ease; + background: #ffffff; + border: 1px solid #e5e5e5; +} + +.back-nav-btn:hover { + background: #f3f3f3; + border-color: #cccccc; +} + +.form-wrapper { + flex: 1; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 64px 20px; +} + +.form-card { + width: 100%; + max-width: 480px; + background: #ffffff; + border-radius: 12px; + border: 1px solid #e5e5e5; + padding: 40px; +} + +.form-header { + margin-bottom: 32px; + text-align: center; +} + +h2 { + margin: 0 0 8px 0; + color: #000000; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.form-header p { + margin: 0; + color: #666666; + font-size: 15px; +} + +form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +label { + font-size: 14px; + font-weight: 600; + color: #000000; +} + +.premium-input { + width: 100%; + padding: 12px 16px; + border: 1px solid #e5e5e5; + border-radius: 8px; + font-size: 15px; + color: #000000; + transition: all 0.2s ease; + box-sizing: border-box; + background: #ffffff; + font-family: inherit; +} + +.premium-input:hover { + border-color: #cccccc; +} + +.premium-input:focus { + outline: none; + border-color: #000000; + border-width: 2px; + padding: 11px 15px; +} + +.premium-input::placeholder { + color: #999999; +} + +textarea.premium-input { + resize: vertical; + min-height: 100px; +} + +.form-actions { + margin-top: 8px; +} + +.premium-btn { + width: 100%; + background: #000000; + color: #ffffff; + border: none; + padding: 14px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; +} + +.premium-btn:hover:not(:disabled) { + background: #333333; +} + +.premium-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.error-msg { + margin-top: 24px; + padding: 12px 16px; + background: #ffffff; + border-radius: 8px; + color: #cf222e; + font-size: 14px; + font-weight: 500; + text-align: center; + border: 1px solid #cf222e; +} + +.loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px 0; + color: #000000; + font-weight: 500; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid #e5e5e5; + border-top-color: #000000; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/app/components/workspace/edit-workspace.html b/src/app/components/workspace/edit-workspace.html new file mode 100644 index 0000000..fc328b8 --- /dev/null +++ b/src/app/components/workspace/edit-workspace.html @@ -0,0 +1,65 @@ +
+
+
+
+ +
+ Sentinent +
+ Back to Dashboard +
+ +
+
+
+

Edit Workspace

+

Update the details of your workspace below.

+
+ +
+
+ Loading workspace... +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+
+ +

{{ error }}

+
+
+
diff --git a/src/app/components/workspace/edit-workspace.ts b/src/app/components/workspace/edit-workspace.ts new file mode 100644 index 0000000..0ab4689 --- /dev/null +++ b/src/app/components/workspace/edit-workspace.ts @@ -0,0 +1,67 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { WorkspaceService } from '../../services/workspace'; + +@Component({ + selector: 'app-edit-workspace', + standalone: true, + imports: [CommonModule, FormsModule, RouterLink], + templateUrl: './edit-workspace.html', + styleUrls: ['./edit-workspace.css'] +}) +export class EditWorkspaceComponent implements OnInit { + workspaceId: string | null = null; + name = ''; + description = ''; + error = ''; + isSubmitting = false; + isLoading = true; + + private workspaceService = inject(WorkspaceService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + ngOnInit(): void { + this.route.paramMap.subscribe(params => { + this.workspaceId = params.get('id'); + if (this.workspaceId) { + this.loadWorkspace(this.workspaceId); + } else { + this.router.navigate(['/dashboard']); + } + }); + } + + loadWorkspace(id: string): void { + this.workspaceService.getWorkspace(id).subscribe(workspace => { + this.isLoading = false; + if (workspace) { + this.name = workspace.name; + this.description = workspace.description || ''; + } else { + this.error = 'Workspace not found'; + } + }); + } + + onSubmit() { + const trimmedName = this.name.trim(); + if (!trimmedName || !this.workspaceId) { + this.error = 'Workspace name is required.'; + return; + } + + this.isSubmitting = true; + this.error = ''; + + this.workspaceService.updateWorkspace(this.workspaceId, trimmedName, this.description.trim()).subscribe({ + next: () => this.router.navigate(['/dashboard']), + error: () => { + this.error = 'Unable to update workspace. Please try again.'; + this.isSubmitting = false; + } + }); + } +}