init commit

This commit is contained in:
2026-03-18 06:40:53 -05:00
commit dd227be9b0
53 changed files with 18575 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
<div class="app-shell">
<nav class="top-nav">
<div class="nav-brand">
<span class="brand-icon">R</span>
<span class="brand-text">ReqPlanner</span>
</div>
<div class="nav-links">
<a routerLink="/graph" routerLinkActive="active">Graph</a>
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
</div>
<div class="nav-spacer"></div>
</nav>
<main class="main-content">
<router-outlet />
</main>
</div>

View File

@@ -0,0 +1,76 @@
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.top-nav {
display: flex;
align-items: center;
height: 48px;
padding: 0 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 24px;
}
.nav-brand {
display: flex;
align-items: center;
gap: 8px;
}
.brand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--accent);
color: var(--bg-primary);
border-radius: 6px;
font-weight: 700;
font-size: 16px;
}
.brand-text {
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
}
.nav-links {
display: flex;
gap: 4px;
a {
padding: 6px 12px;
border-radius: 6px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
transition: all 0.15s ease;
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
&.active {
color: var(--text-primary);
background: var(--bg-tertiary);
}
}
}
.nav-spacer {
flex: 1;
}
.main-content {
flex: 1;
overflow: hidden;
position: relative;
}

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'frontend' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('frontend');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
});
});

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {}

View File

@@ -0,0 +1,13 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
]
};

View File

@@ -0,0 +1,23 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [
{ path: '', redirectTo: 'graph', pathMatch: 'full' },
{
path: 'login',
loadComponent: () =>
import('./features/login/login.component').then(m => m.LoginComponent),
},
{
path: 'graph',
canActivate: [authGuard],
loadComponent: () =>
import('./features/graph/graph-view.component').then(m => m.GraphViewComponent),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () =>
import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent),
},
];

View File

@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
router.navigate(['/login']);
return false;
};

View File

@@ -0,0 +1,29 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
// Don't add token to login requests
if (req.url.includes('/auth/login')) {
return next(req);
}
const token = auth.getToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
}
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401 || error.status === 403) {
auth.logout();
}
return throwError(() => error);
})
);
};

View File

@@ -0,0 +1,154 @@
export type Priority = 'critical' | 'high' | 'medium' | 'low';
export type Status = 'idea' | 'draft' | 'todo' | 'in_progress' | 'done';
export type NodeType = 'pillar' | 'requirement' | 'sub_requirement' | 'leaf' | 'planning';
export type CodeLanguage = 'python' | 'cpp' | 'typescript';
export type TestStatus = 'not_written' | 'written' | 'passing' | 'failing';
export interface QualityReport {
traceable: boolean;
measurable: boolean;
consistent: boolean;
single_purpose: boolean;
errors: string[];
warnings: string[];
valid: boolean;
}
export interface RequirementNode {
id: string;
title: string;
description: string | null;
priority: Priority;
phase: number | null;
status: Status;
node_type: NodeType;
parent_id: string | null;
sort_order: number;
project_id: number | null;
gitea_issue_number: number | null;
gitea_repo: string | null;
traceable: boolean;
measurable: boolean;
consistent: boolean;
single_purpose: boolean;
acceptance_criteria: string | null;
test_spec: string | null;
test_code: string | null;
impl_code: string | null;
code_language: CodeLanguage | null;
target_test_file: string | null;
target_impl_file: string | null;
test_status: TestStatus;
created_at: string;
updated_at: string;
quality: QualityReport | null;
children: RequirementNode[];
}
export interface RequirementTreeNode {
id: string;
title: string;
priority: Priority;
phase: number | null;
status: Status;
node_type: NodeType;
parent_id: string | null;
child_count: number;
traceable: boolean;
measurable: boolean;
consistent: boolean;
single_purpose: boolean;
gitea_issue_number: number | null;
children: RequirementTreeNode[];
}
export interface CrossPillarLink {
source_id: string;
target_id: string;
relationship_type: string;
}
export interface Project {
id: number;
name: string;
description: string | null;
repo_path: string | null;
repo_url: string | null;
requirements_yaml_path: string | null;
created_at: string;
}
export interface Stats {
total_nodes: number;
pillars: number;
requirements: number;
sub_requirements: number;
leaves: number;
by_status: Record<string, number>;
by_priority: Record<string, number>;
quality_passing: number;
quality_failing: number;
coverage_percent: number;
}
// ─── History ──────────────────────────────────────────────────────────
export interface RequirementHistorySummary {
id: number;
requirement_id: string;
version: number;
title: string;
status: string;
created_at: string;
change_summary: string | null;
}
export interface RequirementHistoryDetail extends RequirementHistorySummary {
description: string | null;
priority: string;
phase: number | null;
node_type: string;
acceptance_criteria: string | null;
test_spec: string | null;
test_code: string | null;
impl_code: string | null;
code_language: string | null;
target_test_file: string | null;
target_impl_file: string | null;
measurable: boolean;
consistent: boolean;
single_purpose: boolean;
gitea_issue_number: number | null;
gitea_repo: string | null;
}
// ─── Graph Layouts ────────────────────────────────────────────────────
export interface GraphLayoutResponse {
id: number;
name: string;
description: string | null;
project_id: number | null;
is_default: boolean;
created_at: string;
updated_at: string;
}
export interface GraphLayoutDetailResponse extends GraphLayoutResponse {
node_positions: Record<string, { x: number; y: number }>;
viewport: { zoom: number; pan: { x: number; y: number } };
}
export interface GraphLayoutCreate {
name: string;
description?: string | null;
project_id?: number | null;
node_positions: Record<string, { x: number; y: number }>;
viewport: { zoom: number; pan: { x: number; y: number } };
is_default?: boolean;
}
export const PRIORITY_COLORS: Record<Priority, string> = {
critical: '#da3633',
high: '#d29922',
medium: '#e3b341',
low: '#3fb950',
};

View File

@@ -0,0 +1,39 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, tap } from 'rxjs';
const TOKEN_KEY = 'req-planner-token';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
private baseUrl = '/api';
login(password: string): Observable<{ token: string }> {
return this.http
.post<{ token: string }>(`${this.baseUrl}/auth/login`, { password })
.pipe(tap((res) => localStorage.setItem(TOKEN_KEY, res.token)));
}
logout(): void {
localStorage.removeItem(TOKEN_KEY);
this.router.navigate(['/login']);
}
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
isLoggedIn(): boolean {
const token = this.getToken();
if (!token) return false;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 > Date.now();
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,125 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import {
RequirementNode,
RequirementTreeNode,
CrossPillarLink,
Project,
Stats,
RequirementHistorySummary,
RequirementHistoryDetail,
GraphLayoutResponse,
GraphLayoutDetailResponse,
GraphLayoutCreate,
} from '../models/requirement.model';
@Injectable({ providedIn: 'root' })
export class RequirementService {
private http = inject(HttpClient);
private baseUrl = '/api';
// ─── Requirements ─────────────────────────────────────────────
getRequirements(projectId?: number, parentId?: string): Observable<RequirementNode[]> {
const params: Record<string, string> = {};
if (projectId != null) params['project_id'] = String(projectId);
if (parentId != null) params['parent_id'] = parentId;
return this.http.get<RequirementNode[]>(`${this.baseUrl}/requirements`, { params });
}
getTree(projectId?: number): Observable<RequirementTreeNode[]> {
const params: Record<string, string> = {};
if (projectId != null) params['project_id'] = String(projectId);
return this.http.get<RequirementTreeNode[]>(`${this.baseUrl}/requirements/tree`, { params });
}
getRequirement(id: string): Observable<RequirementNode> {
return this.http.get<RequirementNode>(`${this.baseUrl}/requirements/${id}`);
}
getChildren(id: string): Observable<RequirementNode[]> {
return this.http.get<RequirementNode[]>(`${this.baseUrl}/requirements/${id}/children`);
}
createRequirement(data: Partial<RequirementNode>): Observable<RequirementNode> {
return this.http.post<RequirementNode>(`${this.baseUrl}/requirements`, data);
}
updateRequirement(id: string, data: Partial<RequirementNode>): Observable<RequirementNode> {
return this.http.put<RequirementNode>(`${this.baseUrl}/requirements/${id}`, data);
}
deleteRequirement(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/requirements/${id}`);
}
// ─── Links ────────────────────────────────────────────────────
getLinks(): Observable<CrossPillarLink[]> {
return this.http.get<CrossPillarLink[]>(`${this.baseUrl}/links`);
}
getLinksForNode(nodeId: string): Observable<CrossPillarLink[]> {
return this.http.get<CrossPillarLink[]>(`${this.baseUrl}/links`, { params: { node_id: nodeId } });
}
createLink(data: CrossPillarLink): Observable<CrossPillarLink> {
return this.http.post<CrossPillarLink>(`${this.baseUrl}/links`, data);
}
deleteLink(sourceId: string, targetId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/links/${sourceId}/${targetId}`);
}
// ─── Projects ─────────────────────────────────────────────────
getProjects(): Observable<Project[]> {
return this.http.get<Project[]>(`${this.baseUrl}/projects`);
}
createProject(data: Partial<Project>): Observable<Project> {
return this.http.post<Project>(`${this.baseUrl}/projects`, data);
}
// ─── History ──────────────────────────────────────────────────
getHistory(id: string): Observable<RequirementHistorySummary[]> {
return this.http.get<RequirementHistorySummary[]>(`${this.baseUrl}/requirements/${id}/history`);
}
getHistoryVersion(id: string, version: number): Observable<RequirementHistoryDetail> {
return this.http.get<RequirementHistoryDetail>(`${this.baseUrl}/requirements/${id}/history/${version}`);
}
restoreVersion(id: string, version: number): Observable<RequirementNode> {
return this.http.post<RequirementNode>(`${this.baseUrl}/requirements/${id}/history/${version}/restore`, {});
}
// ─── Layouts ──────────────────────────────────────────────────
getLayouts(projectId?: number): Observable<GraphLayoutResponse[]> {
const params: Record<string, string> = {};
if (projectId != null) params['project_id'] = String(projectId);
return this.http.get<GraphLayoutResponse[]>(`${this.baseUrl}/layouts`, { params });
}
getLayout(id: number): Observable<GraphLayoutDetailResponse> {
return this.http.get<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/${id}`);
}
saveLayout(data: GraphLayoutCreate): Observable<GraphLayoutDetailResponse> {
return this.http.post<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts`, data);
}
updateLayout(id: number, data: Partial<GraphLayoutCreate>): Observable<GraphLayoutDetailResponse> {
return this.http.put<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/${id}`, data);
}
deleteLayout(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/layouts/${id}`);
}
// ─── Stats ────────────────────────────────────────────────────
getStats(projectId?: number): Observable<Stats> {
const params: Record<string, string> = {};
if (projectId != null) params['project_id'] = String(projectId);
return this.http.get<Stats>(`${this.baseUrl}/stats`, { params });
}
}

View File

@@ -0,0 +1,74 @@
<div class="dashboard">
@if (stats) {
<h1 class="page-title">Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_nodes }}</div>
<div class="stat-label">Total Nodes</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.pillars }}</div>
<div class="stat-label">Pillars</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.requirements }}</div>
<div class="stat-label">Requirements</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.sub_requirements }}</div>
<div class="stat-label">Sub-Requirements</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.leaves }}</div>
<div class="stat-label">Leaves</div>
</div>
<div class="stat-card accent">
<div class="stat-value">{{ stats.coverage_percent }}%</div>
<div class="stat-label">Test Coverage</div>
</div>
</div>
<div class="charts-row">
<div class="chart-card">
<h2 class="chart-title">Quality Gates</h2>
<div class="quality-bar">
<div class="bar-segment pass" [style.width.%]="stats.total_nodes ? (stats.quality_passing / stats.total_nodes * 100) : 0"></div>
<div class="bar-segment fail" [style.width.%]="stats.total_nodes ? (stats.quality_failing / stats.total_nodes * 100) : 0"></div>
</div>
<div class="quality-legend">
<span class="legend-item"><span class="dot pass"></span> Passing: {{ stats.quality_passing }}</span>
<span class="legend-item"><span class="dot fail"></span> Failing: {{ stats.quality_failing }}</span>
</div>
</div>
<div class="chart-card">
<h2 class="chart-title">By Status</h2>
<div class="breakdown-list">
@for (entry of statusEntries; track entry[0]) {
<div class="breakdown-row">
<span class="breakdown-dot" [style.background]="getStatusColor(entry[0])"></span>
<span class="breakdown-label">{{ entry[0] }}</span>
<span class="breakdown-value">{{ entry[1] }}</span>
</div>
}
</div>
</div>
<div class="chart-card">
<h2 class="chart-title">By Priority</h2>
<div class="breakdown-list">
@for (entry of priorityEntries; track entry[0]) {
<div class="breakdown-row">
<span class="breakdown-dot" [style.background]="getPriorityColor(entry[0])"></span>
<span class="breakdown-label">{{ entry[0] }}</span>
<span class="breakdown-value">{{ entry[1] }}</span>
</div>
}
</div>
</div>
</div>
} @else {
<div class="loading">Loading dashboard...</div>
}
</div>

View File

@@ -0,0 +1,146 @@
.dashboard {
padding: 24px 32px;
max-width: 1200px;
margin: 0 auto;
overflow-y: auto;
height: 100%;
}
.page-title {
font-size: 22px;
font-weight: 600;
margin-bottom: 24px;
color: var(--text-primary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
text-align: center;
&.accent {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.05);
}
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.charts-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.chart-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 14px;
}
.quality-bar {
display: flex;
height: 24px;
border-radius: 6px;
overflow: hidden;
background: var(--bg-tertiary);
margin-bottom: 10px;
}
.bar-segment {
transition: width 0.3s ease;
&.pass { background: var(--green); }
&.fail { background: var(--red); }
}
.quality-legend {
display: flex;
gap: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.pass { background: var(--green); }
&.fail { background: var(--red); }
}
.breakdown-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.breakdown-row {
display: flex;
align-items: center;
gap: 8px;
}
.breakdown-dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.breakdown-label {
flex: 1;
font-size: 13px;
color: var(--text-secondary);
text-transform: capitalize;
}
.breakdown-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, inject, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RequirementService } from '../../core/services/requirement.service';
import { Stats, PRIORITY_COLORS, Priority } from '../../core/models/requirement.model';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent implements OnInit {
private reqService = inject(RequirementService);
private cdr = inject(ChangeDetectorRef);
stats: Stats | null = null;
ngOnInit(): void {
this.reqService.getStats().subscribe({
next: (stats) => {
this.stats = stats;
this.cdr.markForCheck();
},
});
}
getPriorityColor(priority: string): string {
return PRIORITY_COLORS[priority as Priority] || '#8b949e';
}
getStatusColor(status: string): string {
const colors: Record<string, string> = {
draft: '#8b949e',
todo: '#58a6ff',
in_progress: '#d29922',
done: '#3fb950',
};
return colors[status] || '#8b949e';
}
get statusEntries(): [string, number][] {
return this.stats ? Object.entries(this.stats.by_status) : [];
}
get priorityEntries(): [string, number][] {
return this.stats ? Object.entries(this.stats.by_priority) : [];
}
}

View File

@@ -0,0 +1,411 @@
<div class="detail-panel">
<div class="panel-header">
<div class="header-top">
<span class="node-id">{{ node.id }}</span>
<span class="node-type" [style.color]="node.node_type === 'pillar' ? '#58a6ff' : '#8b949e'">
{{ node.node_type }}
</span>
<button class="close-btn" (click)="close.emit()">x</button>
</div>
@if (!editing) {
<h2 class="node-title">{{ node.title }}</h2>
} @else {
<input class="edit-input title-input" [(ngModel)]="editData.title" placeholder="Title" />
}
<div class="badges-row">
<span class="badge priority-badge" [style.background]="getPriorityColor(node.priority)">
{{ node.priority }}
</span>
<span class="badge status-badge" [style.background]="getStatusColor(node.status)">
{{ node.status }}
</span>
@if (node.phase != null) {
<span class="badge phase-badge">Phase {{ node.phase }}</span>
}
@if (node.gitea_issue_number && node.gitea_repo) {
<a class="badge issue-badge" [href]="getGiteaIssueUrl()" target="_blank" title="Open in Gitea">
#{{ node.gitea_issue_number }}
</a>
}
</div>
</div>
<div class="panel-body">
<!-- Quality Gates -->
<div class="section quality-section">
<h3 class="section-title">Quality Gates</h3>
<div class="quality-badges">
<span class="quality-badge auto" [class.pass]="node.traceable" [class.fail]="!node.traceable" title="Traceable (auto-calculated)">T</span>
<span class="quality-badge toggle" [class.pass]="effectiveMeasurable" [class.fail]="!effectiveMeasurable" [title]="measurableForced ? 'Measurable (forced off missing test spec)' : 'Measurable (click to toggle)'" (click)="toggleGate('measurable')">M</span>
<span class="quality-badge toggle" [class.pass]="node.consistent" [class.fail]="!node.consistent" title="Consistent (click to toggle)" (click)="toggleGate('consistent')">C</span>
<span class="quality-badge toggle" [class.pass]="node.single_purpose" [class.fail]="!node.single_purpose" title="Single Purpose (click to toggle)" (click)="toggleGate('single_purpose')">S</span>
</div>
@if (validationReport) {
@if (validationReport.errors.length > 0) {
<div class="validation-list errors">
@for (err of validationReport.errors; track err) {
<div class="validation-item error-item">{{ err }}</div>
}
</div>
}
@if (validationReport.warnings.length > 0) {
<div class="validation-list warnings">
@for (warn of validationReport.warnings; track warn) {
<div class="validation-item warning-item">{{ warn }}</div>
}
</div>
}
@if (validationReport.valid && node.traceable && node.measurable && node.consistent && node.single_purpose) {
<div class="validation-pass">All quality gates passed</div>
} @else if (validationReport.valid && validationReport.warnings.length === 0) {
<div class="validation-warn">No errors, but not all gates satisfied</div>
}
}
</div>
<!-- Description -->
<div class="section">
<h3 class="section-title">Description</h3>
@if (!editing) {
<p class="description">{{ node.description || 'No description' }}</p>
} @else {
<textarea class="edit-textarea" [(ngModel)]="editData.description" placeholder="Description..." rows="4"></textarea>
}
</div>
<!-- Editing fields -->
@if (editing) {
<div class="section">
<h3 class="section-title">Properties</h3>
<div class="edit-row">
<label>Priority</label>
<select class="edit-select" [(ngModel)]="editData.priority">
@for (p of priorities; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</div>
<div class="edit-row">
<label>Status</label>
<select class="edit-select" [(ngModel)]="editData.status">
@for (s of statuses; track s) {
<option [value]="s">{{ s }}</option>
}
</select>
</div>
<div class="edit-row">
<label>Phase</label>
<input class="edit-input" type="number" [(ngModel)]="editData.phase" placeholder="Phase" />
</div>
<div class="edit-row">
<label>Gitea Repo</label>
<input class="edit-input" [(ngModel)]="editData.gitea_repo" placeholder="owner/repo" />
</div>
<div class="edit-row">
<label>Issue #</label>
<input class="edit-input" type="number" [(ngModel)]="editData.gitea_issue_number" placeholder="Issue number" />
</div>
</div>
}
<!-- Acceptance Criteria -->
<div class="section">
<h3 class="section-title">Acceptance Criteria</h3>
@if (!editing) {
<pre class="code-block">{{ node.acceptance_criteria || 'No acceptance criteria defined' }}</pre>
} @else {
<textarea class="edit-textarea" [(ngModel)]="editData.acceptance_criteria" placeholder="Define what must be true for this requirement to be satisfied..." rows="4"></textarea>
}
</div>
<!-- Test Specification -->
<div class="section">
<h3 class="section-title">Test Specification</h3>
@if (!editing) {
<pre class="code-block">{{ node.test_spec || 'No test specification defined' }}</pre>
} @else {
<textarea class="edit-textarea mono" [(ngModel)]="editData.test_spec" placeholder="GIVEN...\nWHEN...\nTHEN..." rows="6"></textarea>
}
</div>
<!-- Test Code -->
<div class="section">
<h3 class="section-title">Test Code</h3>
@if (!editing) {
<pre class="code-block mono">{{ node.test_code || 'No test code' }}</pre>
} @else {
<textarea class="edit-textarea mono" [(ngModel)]="editData.test_code" placeholder="Test implementation..." rows="8"></textarea>
}
</div>
<!-- Implementation Code -->
<div class="section">
<h3 class="section-title">Implementation Code</h3>
@if (!editing) {
<pre class="code-block mono">{{ node.impl_code || 'No implementation code' }}</pre>
} @else {
<textarea class="edit-textarea mono" [(ngModel)]="editData.impl_code" placeholder="Implementation code..." rows="8"></textarea>
}
</div>
<!-- Parent Requirement -->
@if (parentNode) {
<div class="section">
<h3 class="section-title">Parent</h3>
<div class="child-card clickable" (click)="goToNode(parentNode.id)">
<div class="child-header">
<span class="child-id">{{ parentNode.id }}</span>
<span class="child-priority" [style.background]="getPriorityColor(parentNode.priority)">
{{ parentNode.priority }}
</span>
<div class="child-quality">
<span [class.pass]="parentNode.traceable" [class.fail]="!parentNode.traceable">T</span>
<span [class.pass]="parentNode.measurable" [class.fail]="!parentNode.measurable">M</span>
<span [class.pass]="parentNode.consistent" [class.fail]="!parentNode.consistent">C</span>
<span [class.pass]="parentNode.single_purpose" [class.fail]="!parentNode.single_purpose">S</span>
</div>
</div>
<div class="child-title">{{ parentNode.title }}</div>
<span class="child-status" [style.color]="getStatusColor(parentNode.status)">
{{ parentNode.status }}
</span>
</div>
</div>
}
<!-- Child Requirements -->
<div class="section">
<h3 class="section-title">
Child Requirements
<span class="child-count">({{ children.length }})</span>
</h3>
<div class="children-list">
@for (child of children; track child.id) {
<div class="child-card clickable" (click)="goToNode(child.id)">
<div class="child-header">
<span class="child-id">{{ child.id }}</span>
<span class="child-priority" [style.background]="getPriorityColor(child.priority)">
{{ child.priority }}
</span>
<div class="child-quality">
<span [class.pass]="child.traceable" [class.fail]="!child.traceable">T</span>
<span [class.pass]="child.measurable" [class.fail]="!child.measurable">M</span>
<span [class.pass]="child.consistent" [class.fail]="!child.consistent">C</span>
<span [class.pass]="child.single_purpose" [class.fail]="!child.single_purpose">S</span>
</div>
</div>
<div class="child-title">{{ child.title }}</div>
@if (child.description) {
<div class="child-desc">{{ child.description }}</div>
}
<span class="child-status" [style.color]="getStatusColor(child.status)">
{{ child.status }}
</span>
</div>
}
</div>
</div>
<!-- Add Child Form -->
@if (showAddChild) {
<div class="section add-child-form">
<h3 class="section-title">New Child Requirement</h3>
<input class="edit-input" [(ngModel)]="$any(newChild).id" placeholder="ID (e.g. {{ node.id }}.1)" />
<input class="edit-input" [(ngModel)]="newChild.title" placeholder="Title" />
<textarea class="edit-textarea" [(ngModel)]="newChild.description" placeholder="Description..." rows="3"></textarea>
<div class="edit-row">
<label>Priority</label>
<select class="edit-select" [(ngModel)]="newChild.priority">
@for (p of priorities; track p) {
<option [value]="p">{{ p }}</option>
}
</select>
</div>
@if (createError) {
<div class="create-error">{{ createError }}</div>
}
<div class="form-actions">
<button class="action-btn primary" (click)="createChild()">Create</button>
<button class="action-btn" (click)="toggleAddChild()">Cancel</button>
</div>
</div>
}
<!-- Also Satisfies (outgoing links) -->
<div class="section">
<h3 class="section-title">
Also Satisfies
<span class="child-count">({{ outgoingLinks.length }})</span>
</h3>
<div class="links-list">
@for (link of outgoingLinks; track link.target_id) {
<div class="link-card">
<span class="link-id" (click)="goToNode(link.target_id)">{{ link.target_id }}</span>
<button class="link-remove" (click)="deleteLink(link.source_id, link.target_id)" title="Remove link">x</button>
</div>
}
@if (outgoingLinks.length === 0 && !showAddLink) {
<div class="no-children">No satisfaction links</div>
}
</div>
@if (showAddLink) {
<div class="add-link-form">
<div class="typeahead-container">
<input
class="edit-input"
[(ngModel)]="newLinkTargetId"
placeholder="Search by ID or title..."
(input)="onLinkSearchInput()"
(focus)="onLinkSearchFocus()"
(keydown)="onLinkSearchKeydown($event)"
(keydown.enter)="highlightedIndex < 0 && createLink()"
(blur)="showLinkDropdown = false"
autocomplete="off"
/>
@if (showLinkDropdown) {
<div class="typeahead-dropdown">
@for (req of filteredRequirements; track req.id; let i = $index) {
<div
class="typeahead-option"
[class.highlighted]="i === highlightedIndex"
(mousedown)="selectLinkTarget(req)"
>
<span class="typeahead-id">{{ req.id }}</span>
<span class="typeahead-title">{{ req.title }}</span>
</div>
}
</div>
}
</div>
@if (linkError) {
<div class="create-error">{{ linkError }}</div>
}
<div class="form-actions">
<button class="action-btn primary small" (click)="createLink()">Link</button>
<button class="action-btn small" (click)="toggleAddLink()">Cancel</button>
</div>
</div>
} @else {
<button class="action-btn small" (click)="toggleAddLink()">+ Add Link</button>
}
</div>
<!-- Satisfied By (incoming links) -->
@if (incomingLinks.length > 0) {
<div class="section">
<h3 class="section-title">
Satisfied By
<span class="child-count">({{ incomingLinks.length }})</span>
</h3>
<div class="links-list">
@for (link of incomingLinks; track link.source_id) {
<div class="link-card clickable" (click)="goToNode(link.source_id)">
<span class="link-id">{{ link.source_id }}</span>
</div>
}
</div>
</div>
}
<!-- Metadata -->
<div class="section meta-section">
<div class="meta-row">
<span class="meta-label">Created</span>
<span class="meta-value">{{ node.created_at | date:'short' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">Updated</span>
<span class="meta-value">{{ node.updated_at | date:'short' }}</span>
</div>
</div>
<!-- History -->
<div class="section">
<h3 class="section-title clickable-title" (click)="toggleHistory()">
<span>History</span>
@if (historyList.length > 0) {
<span class="child-count">({{ historyList.length }})</span>
}
<span class="toggle-arrow">{{ showHistory ? '\u25BC' : '\u25B6' }}</span>
</h3>
@if (showHistory) {
<div class="history-list">
@for (entry of historyList; track entry.id) {
<div class="history-item" [class.active]="selectedVersion?.version === entry.version" (click)="viewVersion(entry.version)">
<div class="history-header">
<span class="history-version">v{{ entry.version }}</span>
<span class="history-time">{{ entry.created_at | date:'short' }}</span>
</div>
<div class="history-summary">{{ entry.change_summary || 'No summary' }}</div>
</div>
} @empty {
<div class="no-children">No history yet</div>
}
</div>
@if (selectedVersion) {
<div class="history-detail">
<h4 class="history-detail-title">Version {{ selectedVersion.version }} Details</h4>
<div class="history-fields">
<div class="history-field" [class.changed]="isFieldChanged('title')">
<span class="history-field-label">Title</span>
<span class="history-field-value">{{ selectedVersion.title }}</span>
</div>
<div class="history-field" [class.changed]="isFieldChanged('description')">
<span class="history-field-label">Description</span>
<span class="history-field-value">{{ selectedVersion.description || '(empty)' }}</span>
</div>
<div class="history-field" [class.changed]="isFieldChanged('priority')">
<span class="history-field-label">Priority</span>
<span class="history-field-value">{{ selectedVersion.priority }}</span>
</div>
<div class="history-field" [class.changed]="isFieldChanged('status')">
<span class="history-field-label">Status</span>
<span class="history-field-value">{{ selectedVersion.status }}</span>
</div>
@if (isFieldChanged('acceptance_criteria')) {
<div class="history-field changed">
<span class="history-field-label">Acceptance Criteria</span>
<pre class="history-field-pre">{{ selectedVersion.acceptance_criteria || '(empty)' }}</pre>
</div>
}
@if (isFieldChanged('test_spec')) {
<div class="history-field changed">
<span class="history-field-label">Test Spec</span>
<pre class="history-field-pre">{{ selectedVersion.test_spec || '(empty)' }}</pre>
</div>
}
@if (isFieldChanged('test_code')) {
<div class="history-field changed">
<span class="history-field-label">Test Code</span>
<pre class="history-field-pre">{{ selectedVersion.test_code || '(empty)' }}</pre>
</div>
}
@if (isFieldChanged('impl_code')) {
<div class="history-field changed">
<span class="history-field-label">Impl Code</span>
<pre class="history-field-pre">{{ selectedVersion.impl_code || '(empty)' }}</pre>
</div>
}
</div>
<button class="action-btn primary small" (click)="restoreVersion(selectedVersion.version)">Restore this version</button>
</div>
}
}
</div>
</div>
<div class="panel-actions">
@if (!editing) {
<button class="action-btn primary" (click)="startEdit()">Edit</button>
<button class="action-btn" (click)="toggleAddChild()">Add Child</button>
<button class="action-btn danger" (click)="deleteNode()">Delete</button>
} @else {
<button class="action-btn primary" (click)="saveEdit()">Save</button>
<button class="action-btn" (click)="cancelEdit()">Cancel</button>
}
</div>
</div>

View File

@@ -0,0 +1,668 @@
.detail-panel {
position: fixed;
right: 0;
top: 48px;
bottom: 0;
width: 420px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 20;
overflow: hidden;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.header-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.node-id {
font-weight: 600;
font-size: 13px;
color: var(--accent);
}
.node-type {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.close-btn {
margin-left: auto;
background: none;
border: none;
color: var(--text-secondary);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
}
.node-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
line-height: 1.3;
}
.badges-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
color: #fff;
font-weight: 500;
text-transform: capitalize;
}
.phase-badge {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.issue-badge {
background: #1f6feb;
color: #fff;
text-decoration: none;
cursor: pointer;
&:hover {
background: #388bfd;
}
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.section {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.child-count {
color: var(--text-secondary);
font-weight: 400;
}
.quality-badges {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.quality-badge {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
font-weight: 700;
font-size: 13px;
&.pass {
background: rgba(63, 185, 80, 0.15);
color: var(--green);
border: 1px solid rgba(63, 185, 80, 0.3);
}
&.fail {
background: rgba(218, 54, 51, 0.15);
color: var(--red);
border: 1px solid rgba(218, 54, 51, 0.3);
}
&.toggle {
cursor: pointer;
transition: all 0.15s ease;
&:hover {
transform: scale(1.15);
box-shadow: 0 0 6px rgba(255, 255, 255, 0.15);
}
}
&.auto {
opacity: 0.7;
cursor: default;
}
}
.validation-list {
margin: 6px 0;
}
.validation-item {
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 4px;
}
.error-item {
background: rgba(218, 54, 51, 0.1);
color: var(--red);
}
.warning-item {
background: rgba(210, 153, 34, 0.1);
color: var(--yellow);
}
.validation-pass {
font-size: 12px;
color: var(--green);
margin: 6px 0;
}
.validation-warn {
font-size: 12px;
color: var(--yellow);
margin: 6px 0;
}
.create-error {
font-size: 12px;
color: var(--red);
background: rgba(218, 54, 51, 0.1);
padding: 6px 10px;
border-radius: 4px;
}
.description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
white-space: pre-wrap;
}
.code-block {
font-size: 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
color: var(--text-secondary);
white-space: pre-wrap;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
&.mono {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
}
.children-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.child-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
&.clickable {
cursor: pointer;
&:hover {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.05);
}
}
}
.child-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.child-id {
font-size: 11px;
color: var(--accent);
font-weight: 600;
}
.child-priority {
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
color: #fff;
text-transform: capitalize;
}
.child-quality {
margin-left: auto;
display: flex;
gap: 3px;
font-size: 10px;
font-weight: 700;
span.pass { color: var(--green); }
span.fail { color: var(--red); opacity: 0.5; }
}
.child-title {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
margin-bottom: 2px;
}
.child-desc {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.child-status {
font-size: 11px;
text-transform: capitalize;
}
.no-children {
font-size: 13px;
color: var(--text-muted);
padding: 8px 0;
}
.meta-section {
border-bottom: none;
}
.meta-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
}
.meta-label {
font-size: 12px;
color: var(--text-muted);
}
.meta-value {
font-size: 12px;
color: var(--text-secondary);
}
// Edit styles
.edit-input,
.edit-textarea,
.edit-select {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
outline: none;
margin-bottom: 8px;
&:focus {
border-color: var(--accent);
}
}
.title-input {
font-size: 16px;
font-weight: 600;
}
.edit-textarea {
resize: vertical;
min-height: 60px;
&.mono {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
}
}
.edit-select {
cursor: pointer;
}
.edit-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
label {
font-size: 12px;
color: var(--text-muted);
min-width: 60px;
}
.edit-select, .edit-input {
flex: 1;
margin-bottom: 0;
}
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
// Actions bar
.panel-actions {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
}
.action-btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
transition: all 0.15s ease;
&:hover {
color: var(--text-primary);
border-color: var(--border-light);
}
&.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
&:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
}
&.danger {
color: var(--red);
&:hover {
background: rgba(218, 54, 51, 0.15);
border-color: var(--red);
}
}
&.small {
padding: 4px 10px;
font-size: 12px;
}
}
.add-child-form {
background: var(--bg-card);
}
// Link management styles
.links-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.link-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
border-left: 3px solid #bc8cff;
&.clickable {
cursor: pointer;
&:hover {
border-color: #bc8cff;
background: rgba(188, 140, 255, 0.08);
}
}
}
.link-id {
color: #bc8cff;
font-weight: 500;
font-size: 13px;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.link-remove {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
&:hover {
color: var(--red);
background: rgba(218, 54, 51, 0.15);
}
}
.add-link-form {
margin-top: 8px;
}
// Typeahead dropdown
.typeahead-container {
position: relative;
}
.typeahead-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 200px;
overflow-y: auto;
z-index: 20;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.typeahead-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border);
&:last-child {
border-bottom: none;
}
&:hover, &.highlighted {
background: rgba(188, 140, 255, 0.1);
}
}
.typeahead-id {
color: #bc8cff;
font-weight: 600;
font-size: 12px;
white-space: nowrap;
}
.typeahead-title {
color: var(--text-secondary);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// History styles
.clickable-title {
cursor: pointer;
user-select: none;
&:hover {
color: var(--text-primary);
}
}
.toggle-arrow {
margin-left: auto;
font-size: 10px;
color: var(--text-secondary);
}
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
}
.history-item {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.05);
}
&.active {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.1);
}
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2px;
}
.history-version {
font-weight: 600;
font-size: 12px;
color: var(--accent);
}
.history-time {
font-size: 11px;
color: var(--text-muted);
}
.history-summary {
font-size: 12px;
color: var(--text-secondary);
}
.history-detail {
margin-top: 8px;
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
}
.history-detail-title {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.history-fields {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.history-field {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
&.changed {
background: rgba(210, 153, 34, 0.1);
border-left: 3px solid var(--yellow);
}
}
.history-field-label {
color: var(--text-muted);
font-size: 11px;
display: block;
margin-bottom: 2px;
}
.history-field-value {
color: var(--text-primary);
}
.history-field-pre {
color: var(--text-primary);
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
white-space: pre-wrap;
margin: 4px 0 0;
max-height: 150px;
overflow-y: auto;
}

View File

@@ -0,0 +1,387 @@
import {
Component,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
ChangeDetectorRef,
inject,
OnChanges,
SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RequirementService } from '../../core/services/requirement.service';
import {
RequirementNode,
CrossPillarLink,
Priority,
Status,
NodeType,
PRIORITY_COLORS,
QualityReport,
RequirementHistorySummary,
RequirementHistoryDetail,
} from '../../core/models/requirement.model';
@Component({
selector: 'app-detail-panel',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './detail-panel.component.html',
styleUrl: './detail-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DetailPanelComponent implements OnChanges {
@Input({ required: true }) node!: RequirementNode;
@Output() close = new EventEmitter<void>();
@Output() nodeUpdated = new EventEmitter<RequirementNode>();
@Output() nodeCreated = new EventEmitter<void>();
@Output() nodeDeleted = new EventEmitter<string>();
@Output() linksChanged = new EventEmitter<void>();
@Output() navigateToNode = new EventEmitter<string>();
private reqService = inject(RequirementService);
private cdr = inject(ChangeDetectorRef);
editing = false;
editData: Partial<RequirementNode> = {};
showAddChild = false;
newChild: Partial<RequirementNode> = {};
validationReport: QualityReport | null = null;
children: RequirementNode[] = [];
parentNode: RequirementNode | null = null;
createError: string | null = null;
// History
historyList: RequirementHistorySummary[] = [];
showHistory = false;
selectedVersion: RequirementHistoryDetail | null = null;
// Link management
outgoingLinks: CrossPillarLink[] = []; // this node "also satisfies" these
incomingLinks: CrossPillarLink[] = []; // these nodes satisfy this one
showAddLink = false;
newLinkTargetId = '';
linkError: string | null = null;
allRequirements: { id: string; title: string }[] = [];
filteredRequirements: { id: string; title: string }[] = [];
showLinkDropdown = false;
highlightedIndex = -1;
get effectiveMeasurable(): boolean {
return this.validationReport ? this.validationReport.measurable : this.node.measurable;
}
get measurableForced(): boolean {
return this.validationReport != null && !this.validationReport.measurable && this.node.measurable;
}
priorities: Priority[] = ['critical', 'high', 'medium', 'low'];
statuses: Status[] = ['idea', 'draft', 'todo', 'in_progress', 'done'];
nodeTypes: NodeType[] = ['pillar', 'planning', 'requirement', 'sub_requirement', 'leaf'];
ngOnChanges(changes: SimpleChanges): void {
if (changes['node']) {
this.editing = false;
this.showAddChild = false;
this.showAddLink = false;
this.showHistory = false;
this.historyList = [];
this.selectedVersion = null;
this.validationReport = this.node.quality || null;
this.loadParent();
this.loadChildren();
this.loadLinks();
}
}
private loadParent(): void {
if (!this.node.parent_id) {
this.parentNode = null;
return;
}
this.reqService.getRequirement(this.node.parent_id).subscribe({
next: (parent) => {
this.parentNode = parent;
this.cdr.markForCheck();
},
});
}
private loadChildren(): void {
this.reqService.getChildren(this.node.id).subscribe({
next: (children) => {
this.children = children;
this.cdr.markForCheck();
},
});
}
getPriorityColor(priority: Priority): string {
return PRIORITY_COLORS[priority] || '#30363d';
}
getStatusColor(status: Status): string {
const colors: Record<Status, string> = {
idea: '#6e40c9',
draft: '#8b949e',
todo: '#58a6ff',
in_progress: '#d29922',
done: '#3fb950',
};
return colors[status] || '#8b949e';
}
getGiteaIssueUrl(): string {
return `https://theres-a-git-in-this-tea.andrewawesomo.net/${this.node.gitea_repo}/issues/${this.node.gitea_issue_number}`;
}
startEdit(): void {
this.editing = true;
this.editData = {
title: this.node.title,
description: this.node.description,
priority: this.node.priority,
status: this.node.status,
phase: this.node.phase,
gitea_issue_number: this.node.gitea_issue_number,
gitea_repo: this.node.gitea_repo,
acceptance_criteria: this.node.acceptance_criteria,
test_spec: this.node.test_spec,
test_code: this.node.test_code,
impl_code: this.node.impl_code,
};
}
cancelEdit(): void {
this.editing = false;
this.editData = {};
}
saveEdit(): void {
this.reqService.updateRequirement(this.node.id, this.editData).subscribe({
next: (updated) => {
this.editing = false;
this.nodeUpdated.emit(updated);
Object.assign(this.node, updated);
this.validationReport = updated.quality || null;
},
});
}
toggleAddChild(): void {
this.showAddChild = !this.showAddChild;
this.createError = null;
if (this.showAddChild) {
const childType = this.node.node_type === 'pillar' ? 'requirement' : this.node.node_type === 'requirement' ? 'sub_requirement' : 'leaf';
const nextIndex = this.children.length + 1;
this.newChild = {
id: `${this.node.id}.${nextIndex}`,
title: '',
description: '',
priority: 'medium',
status: 'draft',
node_type: childType,
parent_id: this.node.id,
project_id: this.node.project_id,
} as any;
}
}
createChild(): void {
if (!(this.newChild as any).id?.trim() || !this.newChild.title?.trim()) return;
this.reqService.createRequirement(this.newChild).subscribe({
next: () => {
this.showAddChild = false;
this.loadChildren();
this.nodeCreated.emit();
},
error: (err) => {
this.createError = err?.error?.detail || 'Failed to create requirement';
},
});
}
deleteNode(): void {
if (!confirm(`Delete "${this.node.title}" and all its children?`)) return;
this.reqService.deleteRequirement(this.node.id).subscribe({
next: () => this.nodeDeleted.emit(this.node.id),
});
}
// ─── Link Management ──────────────────────────────────────────
private loadLinks(): void {
this.reqService.getLinksForNode(this.node.id).subscribe({
next: (links) => {
this.outgoingLinks = links.filter(l => l.source_id === this.node.id);
this.incomingLinks = links.filter(l => l.target_id === this.node.id);
this.cdr.markForCheck();
},
});
}
toggleAddLink(): void {
this.showAddLink = !this.showAddLink;
this.newLinkTargetId = '';
this.linkError = null;
this.showLinkDropdown = false;
this.highlightedIndex = -1;
if (this.showAddLink) {
this.reqService.getRequirements().subscribe({
next: (reqs) => {
// Exclude self and already-linked targets
const linkedIds = new Set(this.outgoingLinks.map(l => l.target_id));
linkedIds.add(this.node.id);
this.allRequirements = reqs
.filter(r => !linkedIds.has(r.id))
.map(r => ({ id: r.id, title: r.title }));
this.filteredRequirements = [];
this.cdr.markForCheck();
},
});
}
}
onLinkSearchInput(): void {
this.filterLinkOptions();
}
onLinkSearchFocus(): void {
this.filterLinkOptions();
}
private filterLinkOptions(): void {
const q = this.newLinkTargetId.toLowerCase().trim();
if (!q) {
this.filteredRequirements = this.allRequirements.slice(0, 10);
} else {
this.filteredRequirements = this.allRequirements
.filter(r => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q))
.slice(0, 10);
}
this.showLinkDropdown = this.filteredRequirements.length > 0;
this.highlightedIndex = -1;
this.cdr.markForCheck();
}
onLinkSearchKeydown(event: KeyboardEvent): void {
if (!this.showLinkDropdown) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filteredRequirements.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
} else if (event.key === 'Enter' && this.highlightedIndex >= 0) {
event.preventDefault();
this.selectLinkTarget(this.filteredRequirements[this.highlightedIndex]);
}
}
selectLinkTarget(req: { id: string; title: string }): void {
this.newLinkTargetId = req.id;
this.showLinkDropdown = false;
this.highlightedIndex = -1;
this.cdr.markForCheck();
}
createLink(): void {
const targetId = this.newLinkTargetId.trim();
if (!targetId) return;
this.reqService.createLink({
source_id: this.node.id,
target_id: targetId,
relationship_type: 'also_satisfies',
}).subscribe({
next: () => {
this.showAddLink = false;
this.newLinkTargetId = '';
this.linkError = null;
this.loadLinks();
this.linksChanged.emit();
},
error: (err) => {
this.linkError = err?.error?.detail || 'Failed to create link';
},
});
}
deleteLink(sourceId: string, targetId: string): void {
this.reqService.deleteLink(sourceId, targetId).subscribe({
next: () => {
this.loadLinks();
this.linksChanged.emit();
},
});
}
toggleGate(gate: 'measurable' | 'consistent' | 'single_purpose'): void {
const newValue = !this.node[gate];
this.reqService.updateRequirement(this.node.id, { [gate]: newValue }).subscribe({
next: (updated) => {
Object.assign(this.node, updated);
this.validationReport = updated.quality || null;
this.nodeUpdated.emit(updated);
this.cdr.markForCheck();
},
});
}
// ─── History ─────────────────────────────────────────────────
toggleHistory(): void {
this.showHistory = !this.showHistory;
this.selectedVersion = null;
if (this.showHistory) {
this.loadHistory();
}
}
private loadHistory(): void {
this.reqService.getHistory(this.node.id).subscribe({
next: (list) => {
this.historyList = list;
this.cdr.markForCheck();
},
});
}
viewVersion(version: number): void {
if (this.selectedVersion?.version === version) {
this.selectedVersion = null;
return;
}
this.reqService.getHistoryVersion(this.node.id, version).subscribe({
next: (detail) => {
this.selectedVersion = detail;
this.cdr.markForCheck();
},
});
}
restoreVersion(version: number): void {
if (!confirm(`Restore to version ${version}? Current values will be saved as a new version first.`)) return;
this.reqService.restoreVersion(this.node.id, version).subscribe({
next: (restored) => {
Object.assign(this.node, restored);
this.validationReport = restored.quality || null;
this.nodeUpdated.emit(restored);
this.selectedVersion = null;
this.loadHistory();
this.cdr.markForCheck();
},
});
}
isFieldChanged(field: string): boolean {
if (!this.selectedVersion) return false;
return (this.selectedVersion as any)[field] !== (this.node as any)[field];
}
goToNode(nodeId: string): void {
this.navigateToNode.emit(nodeId);
}
}

View File

@@ -0,0 +1,203 @@
<div class="graph-container" [class.detail-open]="detailOpen">
<div class="graph-toolbar">
<div class="toolbar-left">
<button class="toolbar-btn primary" (click)="toggleCreatePillar()">+ Pillar</button>
<div class="search-box">
<input
type="text"
[(ngModel)]="searchQuery"
(input)="onSearchInput()"
(focus)="onSearchFocus()"
(blur)="onSearchBlur()"
(keydown)="onSearchKeydown($event)"
placeholder="Search by ID or title..."
class="search-input"
autocomplete="off"
/>
@if (searchQuery) {
<button class="search-clear" (click)="clearSearch()">x</button>
}
@if (showSearchDropdown) {
<div class="search-dropdown">
@for (item of filteredSearchItems; track item.id; let i = $index) {
<div
class="search-option"
[class.highlighted]="i === searchHighlightedIndex"
(mousedown)="selectSearchResult(item)"
>
<span class="search-option-id">{{ item.id }}</span>
<span class="search-option-title">{{ item.title }}</span>
</div>
}
</div>
}
</div>
</div>
<div class="toolbar-center">
<span class="zoom-label">{{ currentZoomLevel }}</span>
</div>
<div class="toolbar-right">
<button class="toolbar-btn" (click)="zoomIn()" title="Zoom In">+</button>
<button class="toolbar-btn" (click)="zoomOut()" title="Zoom Out">-</button>
<button class="toolbar-btn" (click)="fitGraph()" title="Fit to Screen">Fit</button>
<button class="toolbar-btn" (click)="relayout()" title="Re-layout">Layout</button>
<div class="layout-controls">
<button class="toolbar-btn" (click)="showSaveLayout = true" title="Save Layout">Save</button>
@if (savedLayouts.length > 0) {
<select class="layout-select" (change)="onLayoutSelect($event)" [value]="''">
<option value="" disabled selected>Load...</option>
@for (layout of savedLayouts; track layout.id) {
<option [value]="layout.id">{{ layout.name }}{{ layout.is_default ? ' *' : '' }}</option>
}
</select>
}
@if (savedLayouts.length > 0) {
<button class="toolbar-btn" (click)="showManageLayouts = true" title="Manage Layouts">Layouts</button>
}
</div>
<button class="toolbar-btn help-btn" (click)="showHelp = !showHelp" title="Help">?</button>
</div>
</div>
@if (showHelp) {
<div class="help-overlay" (click)="showHelp = false">
<div class="help-dialog" (click)="$event.stopPropagation()">
<div class="help-header">
<span>Keyboard & Mouse Shortcuts</span>
<button class="help-close" (click)="showHelp = false">x</button>
</div>
<div class="help-body">
<div class="help-section">Mouse</div>
<div class="help-row"><span class="help-key">Click</span><span>Select node, open detail panel</span></div>
<div class="help-row"><span class="help-key">Right-click</span><span>Highlight entire subtree downward</span></div>
<div class="help-row"><span class="help-key">{{ isMac ? '⌘' : 'Ctrl' }}+Click</span><span>Focus mode: show node, direct children, and paths to roots</span></div>
<div class="help-row"><span class="help-key">{{ isMac ? '⌘⇧' : 'Ctrl+Shift' }}+Click</span><span>Deep focus: show node, all descendants to leaves, and paths to roots</span></div>
<div class="help-row"><span class="help-key">Shift+Click</span><span>Highlight parent path only (no "also satisfies")</span></div>
<div class="help-row"><span class="help-key">{{ isMac ? '⌥' : 'Alt' }}+Click</span><span>Highlight "also satisfies" links for a node</span></div>
<div class="help-row"><span class="help-key">Click background</span><span>Exit focus mode or close detail panel</span></div>
<div class="help-row"><span class="help-key">Drag node</span><span>Reposition (saved across sessions)</span></div>
<div class="help-section">Keyboard</div>
<div class="help-row"><span class="help-key">+ =</span><span>Zoom in</span></div>
<div class="help-row"><span class="help-key">-</span><span>Zoom out</span></div>
<div class="help-row"><span class="help-key">0</span><span>Fit graph to screen</span></div>
<div class="help-row"><span class="help-key">{{ isMac ? '⌘Z' : 'Ctrl+Z' }}</span><span>Undo last canvas action</span></div>
<div class="help-row"><span class="help-key">Esc</span><span>Exit focus mode</span></div>
<div class="help-section">Graph</div>
<div class="help-row"><span class="help-key">Solid lines</span><span>Parent-child relationships</span></div>
<div class="help-row"><span class="help-key">Dashed purple</span><span>"Also satisfies" links</span></div>
<div class="help-row"><span class="help-key">Red dot</span><span>Fewer than 2 quality gates pass</span></div>
<div class="help-row"><span class="help-key">Orange dot</span><span>2 of 3 quality gates pass</span></div>
<div class="help-row"><span class="help-key">Green dot</span><span>All quality gates pass</span></div>
</div>
</div>
</div>
}
@if (showCreatePillar) {
<div class="create-pillar-bar">
<input
class="create-input"
[(ngModel)]="newPillar.id"
placeholder="ID (e.g. P-01)"
/>
<input
class="create-input wide"
[(ngModel)]="newPillar.title"
placeholder="Pillar title"
/>
<input
class="create-input wide"
[(ngModel)]="newPillar.description"
placeholder="Description (optional)"
/>
<button class="toolbar-btn primary" (click)="createPillar()">Create</button>
<button class="toolbar-btn" (click)="toggleCreatePillar()">Cancel</button>
</div>
}
<div class="graph-canvas-wrapper">
<div class="graph-canvas" #cyContainer></div>
@if (loading) {
<div class="loading-overlay">
<span>Loading requirements graph...</span>
</div>
}
@if (!loading && treeData.length === 0 && !showCreatePillar) {
<div class="empty-state">
<div class="empty-title">No requirements yet</div>
<div class="empty-desc">Create a pillar to get started</div>
<button class="toolbar-btn primary" (click)="toggleCreatePillar()">+ Create First Pillar</button>
</div>
}
</div>
</div>
@if (showSaveLayout) {
<div class="help-overlay" (click)="showSaveLayout = false">
<div class="help-dialog" (click)="$event.stopPropagation()">
<div class="help-header">
<span>Save Current Layout</span>
<button class="help-close" (click)="showSaveLayout = false">x</button>
</div>
<div class="help-body">
<input class="layout-input" [(ngModel)]="newLayoutName" placeholder="Layout name" />
<input class="layout-input" [(ngModel)]="newLayoutDescription" placeholder="Description (optional)" />
<label class="layout-checkbox">
<input type="checkbox" [(ngModel)]="newLayoutDefault" />
Set as default (auto-load on startup)
</label>
<div class="layout-modal-actions">
<button class="toolbar-btn primary" (click)="saveCurrentLayout()" [disabled]="!newLayoutName.trim()">Save</button>
<button class="toolbar-btn" (click)="showSaveLayout = false">Cancel</button>
</div>
</div>
</div>
</div>
}
@if (showManageLayouts) {
<div class="help-overlay" (click)="showManageLayouts = false">
<div class="help-dialog" (click)="$event.stopPropagation()">
<div class="help-header">
<span>Manage Layouts</span>
<button class="help-close" (click)="showManageLayouts = false">x</button>
</div>
<div class="help-body">
@for (layout of savedLayouts; track layout.id) {
<div class="layout-manage-row">
<div class="layout-manage-info">
<span class="layout-manage-name">{{ layout.name }}{{ layout.is_default ? ' (default)' : '' }}</span>
<span class="layout-manage-date">{{ layout.updated_at | date:'short' }}</span>
@if (layout.description) {
<span class="layout-manage-desc">{{ layout.description }}</span>
}
</div>
<div class="layout-manage-actions">
<button class="toolbar-btn small" (click)="loadLayout(layout.id); showManageLayouts = false">Load</button>
@if (!layout.is_default) {
<button class="toolbar-btn small" (click)="setDefaultLayout(layout.id)">Default</button>
}
<button class="toolbar-btn small danger" (click)="deleteLayout(layout.id)">Delete</button>
</div>
</div>
} @empty {
<div class="no-layouts">No saved layouts</div>
}
</div>
</div>
</div>
}
@if (detailOpen && selectedNode) {
<app-detail-panel
[node]="selectedNode"
(close)="closeDetail()"
(nodeUpdated)="onNodeUpdated($event)"
(nodeCreated)="onNodeCreated()"
(nodeDeleted)="onNodeDeleted($event)"
(linksChanged)="onLinksChanged()"
(navigateToNode)="loadNodeDetail($event)"
/>
}

View File

@@ -0,0 +1,442 @@
:host {
display: flex;
width: 100%;
height: 100%;
}
.graph-container {
flex: 1;
display: flex;
flex-direction: column;
transition: margin-right 0.2s ease;
&.detail-open {
margin-right: 420px;
}
}
.graph-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
gap: 12px;
}
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-box {
position: relative;
}
.search-input {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
padding: 6px 30px 6px 10px;
font-size: 13px;
width: 280px;
outline: none;
&:focus {
border-color: var(--accent);
}
&::placeholder {
color: var(--text-muted);
}
}
.search-clear {
position: absolute;
right: 6px;
top: 6px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
z-index: 2;
&:hover {
color: var(--text-primary);
}
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 300px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.search-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border);
&:last-child {
border-bottom: none;
}
&:hover, &.highlighted {
background: rgba(88, 166, 255, 0.1);
}
}
.search-option-id {
color: var(--accent);
font-weight: 600;
font-size: 12px;
white-space: nowrap;
min-width: 70px;
}
.search-option-title {
color: var(--text-secondary);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zoom-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.toolbar-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 5px 10px;
border-radius: 6px;
font-size: 13px;
transition: all 0.15s ease;
&:hover {
color: var(--text-primary);
border-color: var(--border-light);
background: var(--bg-card);
}
&.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
&:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
}
}
.create-pillar-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.create-input {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
outline: none;
width: 120px;
&.wide {
flex: 1;
}
&:focus {
border-color: var(--accent);
}
&::placeholder {
color: var(--text-muted);
}
}
.empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
z-index: 10;
pointer-events: auto;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.empty-desc {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 8px;
}
.graph-canvas-wrapper {
flex: 1;
position: relative;
overflow: hidden;
}
.graph-canvas {
position: absolute;
inset: 0;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 15px;
z-index: 10;
}
.help-btn {
font-weight: bold;
font-size: 14px;
width: 28px;
text-align: center;
}
.help-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.help-dialog {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
width: 460px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.help-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
}
.help-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
&:hover {
color: var(--text-primary);
}
}
.help-body {
padding: 12px 16px 16px;
}
.help-section {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 14px;
margin-bottom: 6px;
&:first-child {
margin-top: 0;
}
}
.help-row {
display: flex;
align-items: baseline;
gap: 12px;
padding: 4px 0;
font-size: 13px;
color: var(--text-secondary);
}
.help-key {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 8px;
font-size: 12px;
font-family: monospace;
color: var(--text-primary);
white-space: nowrap;
min-width: 120px;
text-align: center;
}
// Layout controls
.layout-controls {
display: flex;
align-items: center;
gap: 4px;
}
.layout-select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
outline: none;
max-width: 130px;
&:focus {
border-color: var(--accent);
}
}
.layout-input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
padding: 8px 10px;
font-size: 13px;
outline: none;
margin-bottom: 8px;
&:focus {
border-color: var(--accent);
}
}
.layout-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 10px;
cursor: pointer;
input[type="checkbox"] {
cursor: pointer;
}
}
.layout-modal-actions {
display: flex;
gap: 8px;
}
.layout-manage-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
}
.layout-manage-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.layout-manage-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.layout-manage-date {
font-size: 11px;
color: var(--text-muted);
}
.layout-manage-desc {
font-size: 11px;
color: var(--text-secondary);
}
.layout-manage-actions {
display: flex;
gap: 4px;
}
.no-layouts {
font-size: 13px;
color: var(--text-muted);
padding: 12px 0;
text-align: center;
}
.toolbar-btn.small {
padding: 3px 8px;
font-size: 11px;
}
.toolbar-btn.danger {
color: var(--red);
&:hover {
background: rgba(218, 54, 51, 0.15);
border-color: var(--red);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
template: `
<div class="login-wrapper">
<div class="login-card">
<h1 class="login-title">req-planner</h1>
<form (ngSubmit)="submit()">
<input
type="password"
[(ngModel)]="password"
name="password"
placeholder="Password"
class="login-input"
autocomplete="current-password"
autofocus
/>
@if (error) {
<div class="login-error">{{ error }}</div>
}
<button type="submit" class="login-btn" [disabled]="!password">
Sign In
</button>
</form>
</div>
</div>
`,
styles: [`
.login-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--bg-primary, #0d1117);
}
.login-card {
background: var(--bg-secondary, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 40px;
width: 320px;
text-align: center;
}
.login-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary, #e6edf3);
margin-bottom: 24px;
}
.login-input {
width: 100%;
padding: 10px 12px;
background: var(--bg-tertiary, #21262d);
border: 1px solid var(--border, #30363d);
border-radius: 6px;
color: var(--text-primary, #e6edf3);
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.login-input:focus {
border-color: var(--accent, #58a6ff);
}
.login-input::placeholder {
color: var(--text-muted, #484f58);
}
.login-error {
color: #f85149;
font-size: 13px;
margin-top: 8px;
}
.login-btn {
width: 100%;
margin-top: 16px;
padding: 10px;
background: var(--accent, #58a6ff);
border: none;
border-radius: 6px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.login-btn:hover:not(:disabled) {
background: var(--accent-hover, #79c0ff);
}
.login-btn:disabled {
opacity: 0.5;
cursor: default;
}
`],
})
export class LoginComponent {
private auth = inject(AuthService);
private router = inject(Router);
password = '';
error = '';
submit(): void {
this.error = '';
this.auth.login(this.password).subscribe({
next: () => this.router.navigate(['/graph']),
error: () => (this.error = 'Invalid password'),
});
}
}

View File

BIN
frontend/src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

13
frontend/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Frontend</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

70
frontend/src/styles.scss Normal file
View File

@@ -0,0 +1,70 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-card: #1c2128;
--border: #30363d;
--border-light: #484f58;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--green: #3fb950;
--red: #da3633;
--yellow: #d29922;
--orange: #e3b341;
--purple: #bc8cff;
--priority-critical: #da3633;
--priority-high: #d29922;
--priority-medium: #e3b341;
--priority-low: #3fb950;
--status-draft: #8b949e;
--status-todo: #58a6ff;
--status-in-progress: #d29922;
--status-done: #3fb950;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent);
text-decoration: none;
&:hover { color: var(--accent-hover); }
}
button {
font-family: inherit;
cursor: pointer;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
&:hover { background: var(--border-light); }
}

1
frontend/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module 'cytoscape-dagre';