diff --git a/backend/Controllers/AdminController.cs b/backend/Controllers/AdminController.cs new file mode 100644 index 0000000..7363bff --- /dev/null +++ b/backend/Controllers/AdminController.cs @@ -0,0 +1,144 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Nexus.Api.Data; +using Nexus.Api.DTOs; +using Nexus.Api.Repositories; +using Nexus.Api.Services; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/v1/admin")] +[Authorize(Roles = "owner")] +public class AdminController( + IUserRepository userRepository, + ILogger logger) : ControllerBase +{ + /// + /// List all registered users. + /// + [HttpGet("users")] + public async Task GetUsers(CancellationToken ct) + { + var users = await userRepository.GetAllAsync(ct); + var result = users.Select(u => new AdminUserInfo + { + Id = u.Id, + Email = u.Email, + DisplayName = u.DisplayName, + Role = u.Role, + CreatedAt = u.CreatedAt, + LastLoginAt = u.LastLoginAt, + }).ToList(); + return Results.Ok(result); + } + + /// + /// Create a new user account (admin only). + /// Email muss eindeutig sein, Passwort mindestens 10 Zeichen. + /// + [HttpPost("users")] + public async Task CreateUser([FromBody] AdminCreateUserRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + return Results.ValidationProblem(new Dictionary + { + ["request"] = ["Email and password are required."] + }); + + if (request.Password.Length < 10) + return Results.ValidationProblem(new Dictionary + { + ["password"] = ["Password must be at least 10 characters."] + }); + + var normalizedEmail = AuthService.NormalizeEmail(request.Email); + var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct); + if (existing is not null) + return Results.Conflict(new { error = "A user with this email already exists." }); + + var user = new NexusUser + { + Email = request.Email.Trim(), + NormalizedEmail = normalizedEmail, + DisplayName = string.IsNullOrWhiteSpace(request.DisplayName) + ? request.Email.Split('@')[0] + : request.DisplayName.Trim(), + PasswordHash = PasswordSecurity.Hash(request.Password), + Role = string.IsNullOrWhiteSpace(request.Role) ? "user" : request.Role.Trim().ToLowerInvariant(), + }; + + await userRepository.AddAsync(user, ct); + logger.LogInformation("Admin created user {Email} with role {Role}", user.Email, user.Role); + + return Results.Created($"/api/v1/admin/users/{user.Id}", new AdminUserInfo + { + Id = user.Id, + Email = user.Email, + DisplayName = user.DisplayName, + Role = user.Role, + CreatedAt = user.CreatedAt, + }); + } + + /// + /// Delete a user account (admin only, cannot delete owner). + /// + [HttpDelete("users/{id:guid}")] + public async Task DeleteUser(Guid id, CancellationToken ct) + { + var user = await userRepository.GetByIdAsync(id, ct); + if (user is null) + return Results.NotFound(new { error = "User not found." }); + + if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase)) + return Results.Forbid(); + + await userRepository.DeleteAsync(user, ct); + logger.LogInformation("Admin deleted user {Email}", user.Email); + return Results.NoContent(); + } + + /// + /// Update a user's role (admin only, cannot change owner role). + /// + [HttpPatch("users/{id:guid}/role")] + public async Task UpdateUserRole(Guid id, [FromBody] AdminUpdateRoleRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Role)) + return Results.ValidationProblem(new Dictionary + { + ["role"] = ["Role is required."] + }); + + var validRoles = new[] { "owner", "admin", "user", "viewer" }; + if (!validRoles.Contains(request.Role.ToLowerInvariant())) + return Results.ValidationProblem(new Dictionary + { + ["role"] = ["Invalid role. Valid roles: owner, admin, user, viewer."] + }); + + var user = await userRepository.GetByIdAsync(id, ct); + if (user is null) + return Results.NotFound(new { error = "User not found." }); + + if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase)) + return Results.Forbid(); + + user.Role = request.Role.Trim().ToLowerInvariant(); + user.UpdatedAt = DateTimeOffset.UtcNow; + await userRepository.UpdateAsync(user, ct); + logger.LogInformation("Admin updated role for {Email} to {Role}", user.Email, user.Role); + + return Results.Ok(new AdminUserInfo + { + Id = user.Id, + Email = user.Email, + DisplayName = user.DisplayName, + Role = user.Role, + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt, + }); + } +} diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 199c8c4..4654a24 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Nexus.Api.Data; using Nexus.Api.Models; +using Nexus.Api.Repositories; using Nexus.Api.Services; namespace Nexus.Api.Controllers; @@ -13,6 +14,7 @@ namespace Nexus.Api.Controllers; public class DashboardController( IDashboardService dashboardService, ITaskService taskService, + IActivityRepository activityService, IHttpContextAccessor httpContextAccessor) : ControllerBase { [HttpGet("status")] @@ -239,6 +241,14 @@ public class DashboardController( return Ok(children.Select(MapToDto).ToList()); } + [HttpGet("tasks/{id:guid}")] + public async Task> GetTask(Guid id, CancellationToken ct) + { + var task = await taskService.GetByIdAsync(id, ct); + if (task is null) return NotFound(new { error = "Task not found." }); + return Ok(MapToDto(task)); + } + [HttpGet("tasks/{id:guid}/activity")] public async Task>> GetTaskActivity(Guid id, CancellationToken ct) { @@ -246,14 +256,25 @@ public class DashboardController( return Ok(events); } - // ── Import ── - - [HttpPost("tasks/import-from-iris-todo")] - public async Task> ImportFromIrisTodo( - [FromQuery] bool delete = false, CancellationToken ct = default) + [HttpPost("tasks/{id:guid}/activity")] + public async Task> PostTaskActivity( + Guid id, [FromBody] PostActivityRequest request, CancellationToken ct) { - var result = await taskService.ImportFromIrisTodoAsync(delete, ct); - return Ok(new ImportResultDto(result)); + var task = await taskService.GetByIdAsync(id, ct); + if (task is null) return NotFound(new { error = "Task not found." }); + + if (string.IsNullOrWhiteSpace(request.Message)) + return BadRequest(new { error = "Message is required." }); + + var ev = new ActivityEvent + { + Type = request.Type ?? "comment", + Message = request.Message.Trim(), + TaskId = id + }; + + await activityService.AddAsync(ev, ct); + return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev); } private static DashboardTaskDto MapToDto(WorkTask t) => new( diff --git a/backend/DTOs/AuthRequests.cs b/backend/DTOs/AuthRequests.cs index cb4df37..19f0119 100644 --- a/backend/DTOs/AuthRequests.cs +++ b/backend/DTOs/AuthRequests.cs @@ -26,6 +26,29 @@ public sealed record UserInfo public string Role { get; init; } = string.Empty; } +public sealed record AdminUserInfo +{ + public Guid Id { get; init; } + public string Email { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; + public string Role { get; init; } = string.Empty; + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastLoginAt { get; init; } +} + +public sealed record AdminCreateUserRequest +{ + public string Email { get; init; } = string.Empty; + public string Password { get; init; } = string.Empty; + public string? DisplayName { get; init; } + public string? Role { get; init; } +} + +public sealed record AdminUpdateRoleRequest +{ + public string Role { get; init; } = string.Empty; +} + public sealed record UpdateProfileRequest { [MaxLength(100)] diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 03654ae..430b975 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -134,10 +134,6 @@ public sealed record MoveTaskRequest( string State ); -public sealed record ImportResultDto( - int Imported -); - public sealed record ResetStaleRequest( int StaleHours = 2 ); @@ -146,6 +142,11 @@ public sealed record ResetStaleResponse( int ResetCount ); +public sealed record PostActivityRequest( + string Message, + string? Type = null +); + // ── Notification DTOs ── public sealed record NotificationDto( diff --git a/backend/Repositories/IUserRepository.cs b/backend/Repositories/IUserRepository.cs index 0f8cb6e..12e92d7 100644 --- a/backend/Repositories/IUserRepository.cs +++ b/backend/Repositories/IUserRepository.cs @@ -7,8 +7,10 @@ public interface IUserRepository ValueTask GetByIdAsync(Guid userId, CancellationToken ct = default); Task GetByEmailAsync(string normalizedEmail, CancellationToken ct = default); Task AnyUsersAsync(CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); Task AddAsync(NexusUser user, CancellationToken ct = default); Task UpdateAsync(NexusUser user, CancellationToken ct = default); + Task DeleteAsync(NexusUser user, CancellationToken ct = default); Task GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default); Task> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default); diff --git a/backend/Repositories/UserRepository.cs b/backend/Repositories/UserRepository.cs index f599695..6f11afd 100644 --- a/backend/Repositories/UserRepository.cs +++ b/backend/Repositories/UserRepository.cs @@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository public Task GetByEmailAsync(string normalizedEmail, CancellationToken ct = default) => db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); + public Task> GetAllAsync(CancellationToken ct = default) + => db.Users.OrderBy(u => u.CreatedAt).ToListAsync(ct); + public Task AnyUsersAsync(CancellationToken ct = default) => db.Users.AnyAsync(ct); @@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository public Task UpdateAsync(NexusUser user, CancellationToken ct = default) => db.SaveChangesAsync(ct); + public async Task DeleteAsync(NexusUser user, CancellationToken ct = default) + { + // Remove refresh tokens first + var tokens = await db.RefreshTokens + .Where(r => r.UserId == user.Id) + .ToListAsync(ct); + db.RefreshTokens.RemoveRange(tokens); + db.Users.Remove(user); + await db.SaveChangesAsync(ct); + } + public Task GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default) => db.RefreshTokens .Include(r => r.User) diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs index c6c46fd..7779a42 100644 --- a/backend/Services/ITaskService.cs +++ b/backend/Services/ITaskService.cs @@ -34,5 +34,4 @@ public interface ITaskService Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task> GetChildTasksAsync(Guid parentId, CancellationToken ct = default); Task> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default); - Task ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default); } diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index 376a9cd..aec14f5 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using Nexus.Api.Data; using Nexus.Api.DTOs; using Nexus.Api.Models; @@ -350,74 +349,6 @@ public sealed class TaskService( return all.Where(e => e.TaskId == taskId).ToList(); } - public async Task ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default) - { - var todoPath = "/mnt/workspace-iris/TODO.md"; - if (!File.Exists(todoPath)) return 0; - - var content = await File.ReadAllTextAsync(todoPath, ct); - var lines = content.Split('\n'); - - var imported = 0; - string? currentPriority = null; - - // Parse sections and extract tasks with assignee info - // Pattern: ### N. Title 👤 Person - var taskPattern = new Regex(@"^###\s+\d+\.\s+(.+?)(?:\s+👤\s+(.+?))?$", RegexOptions.Compiled); - foreach (var line in lines) - { - var trimmed = line.Trim(); - - // Detect priority section - if (trimmed.StartsWith("## ")) - { - var sectionLower = trimmed.ToLowerInvariant(); - if (sectionLower.Contains("high")) currentPriority = "High"; - else if (sectionLower.Contains("medium")) currentPriority = "Medium"; - else if (sectionLower.Contains("low")) currentPriority = "Low"; - else currentPriority = "Medium"; - continue; - } - - var match = taskPattern.Match(trimmed); - if (!match.Success) continue; - - var title = match.Groups[1].Value.Trim(); - var assigneeRaw = match.Groups[2].Success ? match.Groups[2].Value.Trim() : null; - - // Resolve assignee: "Iris" → "iris", "Bao" → "bao", "Iris+Bao" → "iris" - string? assignedTo = null; - if (!string.IsNullOrWhiteSpace(assigneeRaw)) - { - var lower = assigneeRaw.ToLowerInvariant(); - if (lower.Contains("iris")) assignedTo = "iris"; - else if (lower.Contains("bao")) assignedTo = "bao"; - } - - var task = new WorkTask - { - Title = title, - Source = "iris", - Priority = currentPriority ?? "Medium", - AssignedTo = assignedTo - }; - await taskRepo.AddAsync(task, ct); - imported++; - } - - if (deleteAfterImport) - { - File.Delete(todoPath); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md and deleted the file" }, ct); - } - else - { - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md" }, ct); - } - - return imported; - } - private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 68e14e6..d800a9b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -32,7 +32,7 @@ const navigate = (label: string) => { } const mobileNavOpen = ref(false) -const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'Notifications'].includes(activeView.value)) +const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value)) onMounted(() => { if (auth.isAuthenticated) store.refresh() diff --git a/frontend/src/router.ts b/frontend/src/router.ts index ef628e1..dd5cfea 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -12,6 +12,7 @@ import CalendarView from './views/CalendarView.vue' import NexusLayout from './layouts/NexusLayout.vue' import FlowBoard from './views/Dashboard/FlowBoard.vue' import TaskBoardView from './views/TaskBoardView.vue' +import TaskDetailView from './views/TaskDetailView.vue' import NotificationsView from './views/NotificationsView.vue' const routes = [ @@ -36,6 +37,7 @@ const routes = [ { path: '/projects', name: 'Projects', component: { template: '' } }, { path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView }, { path: '/tasks', name: 'Task Board', component: TaskBoardView }, + { path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView }, { path: '/agents', name: 'Agents', component: AgentsIndexView }, { path: '/models', name: 'Models', component: { template: '' } }, { path: '/activity', name: 'Activity', component: { template: '' } }, diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index ddb1bad..873ea43 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -4,7 +4,7 @@ * Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board * and maps them into TaskItem[] format for the TaskStrip component. * - * Board state: grouped by column (offen, inProgress, review, done, blocked) + * Board state: grouped by column (offen, inProgress, delegated, review, done, blocked) * Auto-refresh: every 30 seconds. */ import { defineStore } from 'pinia' @@ -13,7 +13,7 @@ import type { TaskItem } from '../components/dashboard/v2/types' /* ── API Response Shapes ──────────────────────────── */ -interface DashboardTaskDto { +export interface DashboardTaskDto { id: string title: string detail: string | null @@ -21,6 +21,8 @@ interface DashboardTaskDto { state: string priority: string assignedTo: string | null + parentTaskId?: string | null + dueDate?: string | null createdAt: string updatedAt: string } @@ -228,7 +230,7 @@ export const useTaskStore = defineStore('tasks', { }, /* ── API: Update task ─────────────────────────── */ - async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) { + async updateTask(id: string, updates: { title?: string; detail?: string | null; priority?: string; assignedTo?: string | null; dueDate?: string | null }) { try { const res = await apiFetch(`/api/dashboard/tasks/${id}`, { method: 'PUT', @@ -239,6 +241,29 @@ export const useTaskStore = defineStore('tasks', { await this.fetchBoard() } catch (err) { console.warn('[TaskStore] updateTask failed', err) + throw err + } + }, + + async fetchTaskChildren(id: string) { + try { + const res = await apiFetch(`/api/dashboard/tasks/${id}/children`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return await res.json() as DashboardTaskDto[] + } catch (err) { + console.warn('[TaskStore] fetchTaskChildren failed', err) + throw err + } + }, + + async fetchTaskActivity(id: string) { + try { + const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return await res.json() as Array<{ id?: string; message?: string; type?: string; createdAt?: string; timestamp?: string }> + } catch (err) { + console.warn('[TaskStore] fetchTaskActivity failed', err) + throw err } }, diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 65fd945..58cbf04 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -1,15 +1,24 @@ + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 79481c6..7086ca3 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -1,70 +1,86 @@ diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index 8e4e236..40e237d 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -1,17 +1,39 @@ @@ -446,382 +767,112 @@ onUnmounted(() => { display: flex; flex-direction: column; gap: 20px; + animation: board-fade-in 0.35s ease-out; } - -.board-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.board-header h1 { - margin: 0; - font-size: 20px; - font-weight: 700; - color: var(--nx-text); -} - -.board-subtitle { - margin: 4px 0 0; - font-size: 11px; - color: var(--nx-text-dim); -} - -.create-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 14px; - border: none; - border-radius: 8px; - background: var(--nx-accent); - color: #fff; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: opacity .15s; - flex-shrink: 0; -} - -.create-btn:hover { - opacity: .85; -} - -.board-loading { - display: flex; - align-items: center; - gap: 10px; - padding: 40px; - color: var(--nx-text-dim); - font-size: 13px; -} - -.spinner { - width: 20px; - height: 20px; - border: 2px solid var(--nx-line); - border-top-color: var(--nx-accent); - border-radius: 50%; - animation: spin .6s linear infinite; -} - +@keyframes board-fade-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +.board-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.board-header h1 { margin: 0; font-size: 22px; font-weight: 700; font-family: 'Space Grotesk', sans-serif; letter-spacing: -0.02em; } +.grad-text { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.board-subtitle { margin: 4px 0 0; font-size: 11px; color: var(--tx-3); font-family: 'Manrope', sans-serif; } +.create-btn { display: flex; align-items: center; gap: 6px; padding: 8px 16px; border: none; border-radius: var(--r-sm, 10px); background: var(--grad); color: #fff; font-size: 12.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: opacity .15s, transform .15s; flex-shrink: 0; box-shadow: var(--glow-purple); } +.create-btn:hover { opacity: .85; transform: translateY(-1px); } +.create-btn:active { transform: translateY(0); } +.board-loading { display: flex; align-items: center; gap: 10px; padding: 40px; color: var(--tx-3); font-size: 13px; font-family: 'Manrope', sans-serif; } +.spinner { width: 20px; height: 20px; border: 2px solid var(--line-2); border-top-color: var(--a-mid); border-radius: 50%; animation: spin .6s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } - -.board-columns { - display: flex; - gap: 14px; - flex: 1; - overflow-x: auto; - padding-bottom: 20px; - min-height: calc(100vh - 200px); -} - -.col { - flex: 1; - min-width: 240px; - max-width: 320px; - display: flex; - flex-direction: column; - background: var(--glass); - border: 1px solid var(--nx-line); - border-radius: 12px; - padding: 12px; - transition: border-color .15s, background .15s; -} - -.col.drag-over { - border-color: var(--nx-accent); - background: color-mix(in srgb, var(--nx-accent) 8%, transparent); -} - -.col-blocked { - max-width: 240px; -} - -.col-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; - padding-bottom: 10px; - border-bottom: 1px solid var(--nx-line); -} - -.col-name { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .06em; - color: var(--col-accent); -} - -.col-count { - margin-left: auto; - font-size: 10px; - font-weight: 700; - padding: 1px 7px; - border-radius: 10px; - background: var(--glass); - color: var(--col-accent); - border: 1px solid var(--nx-line); -} - -.col-cards { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; -} - -/* ── Cards ── */ -.card { - padding: 10px 12px; - border-radius: 8px; - background: var(--glass); - border: 1px solid var(--nx-line); - cursor: grab; - transition: transform .12s, box-shadow .12s, opacity .15s; -} - -.card:hover { - transform: scale(1.02); - box-shadow: 0 4px 12px rgba(0,0,0,.15); -} - -.card.dragging { - opacity: .4; -} - -.card-blocked { - border-left: 3px solid #f87171; -} - -.card-top { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 6px; -} - -.prio-badge { - font-family: 'JetBrains Mono', monospace; - font-size: 9px; - font-weight: 700; - padding: 1px 5px; - border-radius: 4px; - background: color-mix(in srgb, currentColor 12%, transparent); -} - -.assignee { - font-size: 10px; - font-weight: 600; - padding: 1px 6px; - border-radius: 4px; -} - -.assignee-iris { - background: rgba(147, 51, 234, .12); - color: #c084fc; -} - -.assignee-bao { - background: rgba(59, 130, 246, .12); - color: #60a5fa; -} - -.card-title { - font-size: 12.5px; - font-weight: 600; - color: var(--nx-text); - line-height: 1.4; - word-break: break-word; -} - -.card-meta { - font-size: 10px; - color: var(--nx-text-dim); - margin-top: 5px; -} - -.empty-col { - display: flex; - align-items: center; - justify-content: center; - padding: 24px 12px; - font-size: 11px; - color: var(--nx-text-dim); - font-style: italic; -} - -/* ── Modal ── */ -.modal-overlay { - position: fixed; - inset: 0; - z-index: 100; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0,0,0,.55); - backdrop-filter: blur(4px); -} - -.modal-content { - width: 100%; - max-width: 460px; - background: var(--space-1); - border: 1px solid var(--nx-line); - border-radius: 14px; - padding: 24px; - box-shadow: 0 12px 40px rgba(0,0,0,.3); -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 18px; -} - -.modal-header h2 { - margin: 0; - font-size: 16px; - font-weight: 700; - color: var(--nx-text); -} - -.modal-close { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--nx-text-dim); - font-size: 20px; - cursor: pointer; - border-radius: 6px; - transition: background .15s; -} - -.modal-close:hover { - background: var(--glass); -} - -.modal-form { - display: flex; - flex-direction: column; - gap: 14px; -} - -.field { - display: flex; - flex-direction: column; - gap: 5px; - flex: 1; -} - -.field label { - font-size: 10.5px; - font-weight: 600; - color: var(--nx-text-dim); - text-transform: uppercase; - letter-spacing: .04em; -} - -.field-input { - width: 100%; - padding: 8px 12px; - border: 1px solid var(--nx-line); - border-radius: 6px; - background: var(--glass); - color: var(--nx-text); - font-size: 13px; - outline: none; - transition: border-color .15s; - box-sizing: border-box; -} - -.field-input:focus { - border-color: var(--nx-accent); -} - -.field-textarea { - resize: vertical; - min-height: 60px; - font-family: inherit; -} - -.field-select { - cursor: pointer; -} - -.field-row { - display: flex; - gap: 12px; -} - -.form-error { - color: #f87171; - font-size: 11px; - margin: 0; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 4px; -} - -.btn-cancel { - padding: 7px 14px; - border: 1px solid var(--nx-line); - border-radius: 6px; - background: transparent; - color: var(--nx-text-dim); - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: background .15s; -} - -.btn-cancel:hover { - background: var(--glass); -} - -.btn-submit { - padding: 7px 16px; - border: none; - border-radius: 6px; - background: var(--nx-accent); - color: #fff; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: opacity .15s; -} - -.btn-submit:disabled { - opacity: .5; - cursor: not-allowed; -} - -.btn-submit:not(:disabled):hover { - opacity: .85; -} - -/* ── Responsive ── */ -@media (max-width: 860px) { - .board-columns { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: thin; - } - - .col { - min-width: 260px; - } -} +.board-columns { display: flex; gap: 14px; flex: 1; overflow-x: auto; padding-bottom: 20px; min-height: calc(100vh - 200px); scrollbar-width: thin; scrollbar-color: rgba(124,108,255,.22) transparent; } +.col { flex: 1; min-width: 240px; max-width: 320px; display: flex; flex-direction: column; background: var(--glass); border: 1px solid var(--line); border-radius: var(--r, 14px); padding: 12px; transition: border-color .2s, background .2s; backdrop-filter: blur(12px); } +.col.drag-over { border-color: var(--a-mid); background: linear-gradient(160deg, rgba(124,108,255,.10), rgba(20,17,48,.55)); box-shadow: 0 0 0 1px rgba(124,108,255,.15); } +.col-blocked { max-width: 240px; } +.col-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); } +.col-icon { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; } +.col-name { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--tx-2); font-family: 'Space Grotesk', sans-serif; } +.col-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; font-variant-numeric: tabular-nums; padding: 2px 8px; border-radius: 10px; background: var(--glass-2); color: var(--tx-2); border: 1px solid var(--line); } +.col-cards { display: flex; flex-direction: column; gap: 8px; flex: 1; } +.card { padding: 10px 12px; border-radius: var(--r-sm, 10px); background: linear-gradient(160deg, rgba(28,24,64,.45), rgba(20,17,48,.35)); border: 1px solid var(--line); cursor: pointer; transition: transform .15s, box-shadow .2s, border-color .15s, opacity .15s; text-align: left; width: 100%; } +.card:hover { transform: scale(1.02); border-color: var(--line-2); box-shadow: 0 0 0 1px rgba(124,108,255,.10), 0 8px 24px -6px rgba(0,0,0,.4); } +.card:active { cursor: grabbing; } +.card.dragging { opacity: .4; cursor: grabbing; } +.card-blocked { border-left: 3px solid var(--st-block); } +.card-top { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; } +.prio-badge { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; } +.assignee { font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; } +.assignee-iris { background: rgba(147, 51, 234, .12); color: #c084fc; } +.assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; } +.card-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; } +.card-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.card-meta { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--tx-3); margin-top: 5px; font-variant-numeric: tabular-nums; } +.empty-col, .detail-empty { display: flex; align-items: center; justify-content: center; padding: 24px 12px; font-size: 11px; color: var(--tx-3); font-style: italic; font-family: 'Manrope', sans-serif; } +.modal-overlay, .detail-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(5,4,16,.75); backdrop-filter: blur(16px); } +.modal-card { width: 100%; max-width: 460px; background: linear-gradient(160deg, rgba(20,17,48,.85), rgba(14,12,32,.85)); border: 1px solid var(--line-2); border-radius: var(--r, 14px); padding: 24px; box-shadow: 0 0 0 1px rgba(124,108,255,.12), 0 20px 60px -12px rgba(0,0,0,.5); backdrop-filter: blur(12px); } +.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--line); } +.modal-header h2 { margin: 0; font-size: 16px; font-weight: 700; color: var(--tx); font-family: 'Space Grotesk', sans-serif; } +.modal-close, .detail-close { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--tx-3); cursor: pointer; border-radius: 6px; transition: background .15s, color .15s; } +.modal-close { font-size: 20px; } +.modal-close:hover, .detail-close:hover { background: rgba(124,108,255,.10); color: var(--tx); } +.modal-form { display: flex; flex-direction: column; gap: 14px; } +.field { display: flex; flex-direction: column; gap: 5px; flex: 1; } +.field label, .sidebar-field span { font-size: 10.5px; font-weight: 600; color: var(--tx-2); text-transform: uppercase; letter-spacing: .04em; font-family: 'Manrope', sans-serif; } +.req { color: var(--st-block); } +.field-input { width: 100%; padding: 8px 12px; border: 1px solid var(--line); border-radius: var(--r-sm, 10px); background: rgba(14,12,32,.5); color: var(--tx); font-size: 13px; font-family: 'Manrope', sans-serif; outline: none; transition: border-color .15s, box-shadow .15s; box-sizing: border-box; } +.field-input:focus, .detail-title-input:focus, .detail-textarea:focus { border-color: var(--a-mid); box-shadow: 0 0 0 2px rgba(124,108,255,.15); } +.field-textarea, .detail-textarea { resize: vertical; min-height: 60px; font-family: inherit; } +.field-select { cursor: pointer; } +.field-row { display: flex; gap: 12px; } +.form-error, .detail-flash.error { color: var(--st-block); font-size: 11px; margin: 0; font-family: 'Manrope', sans-serif; } +.detail-flash.success { color: #86efac; font-size: 11px; margin: 0; } +.modal-actions, .detail-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } +.btn-cancel { padding: 7px 14px; border: 1px solid var(--line); border-radius: var(--r-sm, 10px); background: transparent; color: var(--tx-2); font-size: 12px; font-weight: 500; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, color .15s; } +.btn-cancel:hover { background: rgba(124,108,255,.08); color: var(--tx); } +.btn-ghost { padding: 7px 14px; border: 1px solid var(--line); border-radius: var(--r-sm, 10px); background: transparent; color: var(--tx-2); font-size: 12px; font-weight: 500; font-family: 'Manrope', sans-serif; cursor: pointer; transition: background .15s, color .15s; display: inline-flex; align-items: center; gap: 6px; } +.btn-ghost:hover { background: rgba(124,108,255,.08); color: var(--tx); } +.btn-submit { padding: 7px 18px; border: none; border-radius: var(--r-sm, 10px); background: var(--grad); color: #fff; font-size: 12px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: opacity .15s, transform .15s; box-shadow: var(--glow-purple); display: inline-flex; align-items: center; gap: 6px; } +.btn-submit:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; } +.btn-submit:not(:disabled):hover { opacity: .85; transform: translateY(-1px); } +.detail-panel { width: min(1120px, calc(100vw - 48px)); height: min(88vh, 860px); display: flex; flex-direction: column; background: linear-gradient(180deg, rgba(13,11,28,.97), rgba(10,9,24,.96)); border: 1px solid rgba(124,108,255,.18); border-radius: 22px; box-shadow: 0 28px 90px rgba(0,0,0,.45); overflow: hidden; } +.detail-topbar { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--line); } +.detail-breadcrumb { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--tx-3); } +.detail-content { display: grid; grid-template-columns: minmax(0, 1fr) 320px; min-height: 0; flex: 1; } +.detail-main { padding: 24px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; } +.detail-sidebar { padding: 24px; border-left: 1px solid var(--line); background: rgba(255,255,255,.02); display: flex; flex-direction: column; gap: 14px; } +.detail-title-block { display: flex; flex-direction: column; gap: 12px; } +.detail-state-pill { width: fit-content; border-radius: 999px; padding: 5px 10px; font-size: 11px; font-weight: 700; letter-spacing: .03em; border: 1px solid transparent; } +.detail-state-pill.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); } +.detail-state-pill.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); } +.detail-state-pill.is-delegated { color: #d8b4fe; background: rgba(168,85,247,.12); border-color: rgba(168,85,247,.25); } +.detail-state-pill.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); } +.detail-state-pill.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); } +.detail-state-pill.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); } +.detail-title-input { background: transparent; border: 1px solid transparent; border-radius: 12px; padding: 0; color: var(--tx); font-family: 'Space Grotesk', sans-serif; font-size: 31px; font-weight: 700; letter-spacing: -0.03em; outline: none; } +.detail-meta-row { display: flex; flex-wrap: wrap; gap: 14px; color: var(--tx-3); font-size: 11.5px; } +.detail-meta-row span { display: inline-flex; align-items: center; gap: 5px; } +.detail-section { background: rgba(255,255,255,.02); border: 1px solid var(--line); border-radius: 16px; padding: 16px; } +.detail-section-header, .sidebar-heading { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: var(--tx-2); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.detail-textarea { width: 100%; min-height: 180px; border-radius: 12px; border: 1px solid var(--line); background: rgba(10,9,24,.55); color: var(--tx); padding: 14px; font-size: 14px; line-height: 1.6; outline: none; box-sizing: border-box; } +.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.detail-stack, .activity-list { display: flex; flex-direction: column; gap: 10px; } +.mini-card { border: 1px solid var(--line); border-radius: 12px; padding: 12px; background: rgba(10,9,24,.45); } +.mini-card-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.mini-title { font-size: 13px; font-weight: 600; color: var(--tx); } +.mini-state { font-size: 10px; color: var(--tx-3); text-transform: uppercase; letter-spacing: .05em; } +.mini-copy { margin: 8px 0 0; font-size: 11.5px; color: var(--tx-2); line-height: 1.5; } +.activity-item { display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; align-items: start; } +.activity-dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 5px; background: linear-gradient(135deg, #8b5cf6, #3b82f6); box-shadow: 0 0 0 4px rgba(124,108,255,.12); } +.activity-message { color: var(--tx); font-size: 12.5px; line-height: 1.45; } +.activity-time { color: var(--tx-3); font-size: 10.5px; margin-top: 4px; } +.sidebar-card { border: 1px solid var(--line); border-radius: 16px; padding: 16px; background: rgba(10,9,24,.45); } +.sidebar-card.subtle { background: rgba(255,255,255,.02); } +.sidebar-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.sidebar-field:last-child { margin-bottom: 0; } +.slim { min-height: 38px; } +.snapshot-list { display: flex; flex-direction: column; gap: 12px; margin: 0; } +.snapshot-list div { display: flex; justify-content: space-between; gap: 12px; } +.snapshot-list dt { color: var(--tx-3); font-size: 11px; } +.snapshot-list dd { margin: 0; color: var(--tx); font-size: 12px; text-align: right; } +.board-columns::-webkit-scrollbar { height: 9px; } +.board-columns::-webkit-scrollbar-thumb { background: rgba(124,108,255,.22); border-radius: 9px; border: 2px solid transparent; background-clip: padding-box; } +.board-columns::-webkit-scrollbar-thumb:hover { background: rgba(124,108,255,.4); background-clip: padding-box; } +.board-columns::-webkit-scrollbar-track { background: transparent; } +@media (max-width: 1100px) { .detail-content { grid-template-columns: 1fr; } .detail-sidebar { border-left: none; border-top: 1px solid var(--line); } } +@media (max-width: 860px) { .board-columns { overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; } .col { min-width: 260px; } .detail-panel { width: 100vw; height: 100vh; border-radius: 0; } .detail-grid { grid-template-columns: 1fr; } .detail-main, .detail-sidebar { padding: 18px; } .detail-title-input { font-size: 24px; } } diff --git a/frontend/src/views/TaskDetailView.vue b/frontend/src/views/TaskDetailView.vue new file mode 100644 index 0000000..64933be --- /dev/null +++ b/frontend/src/views/TaskDetailView.vue @@ -0,0 +1,1109 @@ + + + + +