4da29562bd
Frontend foundation for Issues + Systems (spec #825, S4a). - frontend/src/api/systems.ts: typed client (System + list/create/update/delete) over /api/projects/<id>/systems, matching the rulebooks api style. - frontend/src/stores/systems.ts: Pinia store keyed by project (fetch/create/ update/archive/unarchive/delete), toast-on-error. - frontend/src/components/SystemsSection.vue: a Systems management section — cards (color swatch, name, description, 'N open' issue-count badge) with inline create/edit, archive (hidden behind a 'show archived' toggle), and a delete-confirm modal. v1 quality: loading skeleton, empty state, error toasts, keyboard a11y, focus rings; reuses existing CSS tokens (no new colors). - ProjectView.vue: new 'Systems' tab (between Notes and Rules), rendering <SystemsSection :project-id>, wired like the existing rules tab. S4b (next) adds issue-editor controls (kind=issue/system multi-select/arose-from), open-issues lists, and the dashboard surface. NEEDS operator browser verification (CI typechecks but can't render). Refs plan 825 (S4a). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1367 lines
49 KiB
Vue
1367 lines
49 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import { useTasksStore } from "@/stores/tasks";
|
|
import { relativeTime } from "@/composables/useRelativeTime";
|
|
import ShareDialog from "@/components/ShareDialog.vue";
|
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
|
import SystemsSection from "@/components/SystemsSection.vue";
|
|
import {
|
|
LayoutGrid,
|
|
Clock,
|
|
FileText,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Pencil,
|
|
Trash2,
|
|
Check,
|
|
} from "lucide-vue-next";
|
|
|
|
interface Milestone {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
order_index: number;
|
|
pct: number;
|
|
total: number;
|
|
completed: number;
|
|
status_counts: { todo: number; in_progress: number; done: number };
|
|
}
|
|
|
|
interface Project {
|
|
id: number;
|
|
user_id: number;
|
|
title: string;
|
|
description: string | null;
|
|
goal: string | null;
|
|
status: "active" | "paused" | "completed" | "archived";
|
|
color: string | null;
|
|
permission?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
summary?: {
|
|
task_counts: { todo: number; in_progress: number; done: number };
|
|
note_count: number;
|
|
last_activity: string | null;
|
|
milestone_summary: Milestone[];
|
|
};
|
|
}
|
|
|
|
interface NoteItem {
|
|
id: number;
|
|
title: string;
|
|
type: "task" | "note";
|
|
status?: string;
|
|
priority?: string;
|
|
due_date?: string | null;
|
|
updated_at: string;
|
|
milestone_id?: number | null;
|
|
}
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const toast = useToastStore();
|
|
const tasksStore = useTasksStore();
|
|
|
|
const project = ref<Project | null>(null);
|
|
const loading = ref(false);
|
|
|
|
const showStartPlanning = ref(false);
|
|
const planTitle = ref("");
|
|
const planningBusy = ref(false);
|
|
|
|
async function confirmStartPlanning() {
|
|
const title = planTitle.value.trim();
|
|
if (!title || !project.value) return;
|
|
planningBusy.value = true;
|
|
try {
|
|
const result = await tasksStore.startPlanning(project.value.id, title);
|
|
planTitle.value = "";
|
|
showStartPlanning.value = false;
|
|
router.push(`/tasks/${result.task.id}`);
|
|
} finally {
|
|
planningBusy.value = false;
|
|
}
|
|
}
|
|
const saving = ref(false);
|
|
const error = ref<string | null>(null);
|
|
|
|
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
|
|
|
|
const tasks = ref<NoteItem[]>([]);
|
|
const notes = ref<NoteItem[]>([]);
|
|
const tasksLoading = ref(false);
|
|
const notesLoading = ref(false);
|
|
|
|
const milestones = ref<Milestone[]>([]);
|
|
const collapsedMilestones = ref<Set<number>>(new Set());
|
|
const showNewMilestone = ref(false);
|
|
const newMilestoneTitle = ref("");
|
|
const creatingMilestone = ref(false);
|
|
|
|
// Milestone edit/delete state
|
|
const renamingMilestoneId = ref<number | null>(null);
|
|
const renamingMilestoneTitle = ref("");
|
|
const deletingMilestone = ref<Milestone | null>(null);
|
|
|
|
// Edit state
|
|
const editTitle = ref("");
|
|
const editDescription = ref("");
|
|
const editGoal = ref("");
|
|
const editStatus = ref<Project["status"]>("active");
|
|
const editDirty = ref(false);
|
|
|
|
const projectId = computed(() => Number(route.params.id));
|
|
|
|
// Group tasks by milestone_id for the milestone view
|
|
interface MilestoneGroup {
|
|
milestone: Milestone | null;
|
|
tasks: NoteItem[];
|
|
}
|
|
|
|
const milestoneGroups = computed((): MilestoneGroup[] => {
|
|
const groups: MilestoneGroup[] = [];
|
|
// One group per milestone (ordered by order_index)
|
|
for (const ms of milestones.value) {
|
|
groups.push({
|
|
milestone: ms,
|
|
tasks: tasks.value.filter((t) => t.milestone_id === ms.id),
|
|
});
|
|
}
|
|
// Unassigned tasks at the bottom
|
|
const assignedIds = new Set(milestones.value.map((m) => m.id));
|
|
const unassigned = tasks.value.filter(
|
|
(t) => !t.milestone_id || !assignedIds.has(t.milestone_id)
|
|
);
|
|
if (unassigned.length > 0 || milestones.value.length === 0) {
|
|
groups.push({ milestone: null, tasks: unassigned });
|
|
}
|
|
return groups;
|
|
});
|
|
|
|
function toggleMilestoneCollapse(id: number) {
|
|
if (collapsedMilestones.value.has(id)) {
|
|
collapsedMilestones.value.delete(id);
|
|
} else {
|
|
collapsedMilestones.value.add(id);
|
|
}
|
|
}
|
|
|
|
function autoCollapseCompleted(msList: Milestone[]) {
|
|
for (const ms of msList) {
|
|
if (ms.total > 0 && ms.completed === ms.total) {
|
|
collapsedMilestones.value.add(ms.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadProject() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const data = await apiGet<Project>(`/api/projects/${projectId.value}`);
|
|
project.value = data;
|
|
editTitle.value = data.title;
|
|
editDescription.value = data.description ?? "";
|
|
editGoal.value = data.goal ?? "";
|
|
editStatus.value = data.status;
|
|
editDirty.value = false;
|
|
milestones.value = data.summary?.milestone_summary ?? [];
|
|
autoCollapseCompleted(milestones.value);
|
|
} catch {
|
|
error.value = "Failed to load project.";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadMilestones() {
|
|
try {
|
|
const data = await apiGet<{ milestones: Milestone[] }>(
|
|
`/api/projects/${projectId.value}/milestones`
|
|
);
|
|
milestones.value = data.milestones;
|
|
autoCollapseCompleted(milestones.value);
|
|
} catch {
|
|
// silent
|
|
}
|
|
}
|
|
|
|
async function createMilestone() {
|
|
if (!newMilestoneTitle.value.trim() || creatingMilestone.value) return;
|
|
creatingMilestone.value = true;
|
|
try {
|
|
await apiPost(`/api/projects/${projectId.value}/milestones`, {
|
|
title: newMilestoneTitle.value.trim(),
|
|
});
|
|
newMilestoneTitle.value = "";
|
|
showNewMilestone.value = false;
|
|
await loadMilestones();
|
|
toast.show("Milestone created");
|
|
} catch {
|
|
toast.show("Failed to create milestone", "error");
|
|
} finally {
|
|
creatingMilestone.value = false;
|
|
}
|
|
}
|
|
|
|
function startRenameMilestone(ms: Milestone) {
|
|
renamingMilestoneId.value = ms.id;
|
|
renamingMilestoneTitle.value = ms.title;
|
|
}
|
|
|
|
async function commitRenameMilestone(ms: Milestone) {
|
|
const newTitle = renamingMilestoneTitle.value.trim();
|
|
renamingMilestoneId.value = null;
|
|
if (!newTitle || newTitle === ms.title) return;
|
|
try {
|
|
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, { title: newTitle });
|
|
await loadMilestones();
|
|
toast.show("Milestone renamed");
|
|
} catch {
|
|
toast.show("Failed to rename milestone", "error");
|
|
}
|
|
}
|
|
|
|
async function confirmDeleteMilestone() {
|
|
const ms = deletingMilestone.value;
|
|
if (!ms) return;
|
|
deletingMilestone.value = null;
|
|
try {
|
|
await apiDelete(`/api/projects/${projectId.value}/milestones/${ms.id}`);
|
|
await loadMilestones();
|
|
await loadTasks();
|
|
toast.show("Milestone deleted");
|
|
} catch {
|
|
toast.show("Failed to delete milestone", "error");
|
|
}
|
|
}
|
|
|
|
async function loadTasks() {
|
|
tasksLoading.value = true;
|
|
try {
|
|
const data = await apiGet<{ notes: NoteItem[]; total: number }>(
|
|
`/api/projects/${projectId.value}/notes?type=task&limit=100`
|
|
);
|
|
tasks.value = data.notes;
|
|
} catch {
|
|
// Silently fail — tasks just won't show
|
|
} finally {
|
|
tasksLoading.value = false;
|
|
}
|
|
}
|
|
|
|
const advancingTaskId = ref<number | null>(null);
|
|
|
|
const taskStatusNext: Record<string, string> = {
|
|
todo: "in_progress",
|
|
in_progress: "done",
|
|
};
|
|
|
|
async function advanceTaskStatus(task: NoteItem, e: Event) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const next = taskStatusNext[task.status ?? ""];
|
|
if (!next || advancingTaskId.value === task.id) return;
|
|
advancingTaskId.value = task.id;
|
|
try {
|
|
await apiPatch(`/api/notes/${task.id}`, { status: next });
|
|
const idx = tasks.value.findIndex((t) => t.id === task.id);
|
|
if (idx !== -1) tasks.value[idx] = { ...tasks.value[idx], status: next };
|
|
} catch {
|
|
toast.show("Failed to update task", "error");
|
|
} finally {
|
|
advancingTaskId.value = null;
|
|
}
|
|
}
|
|
|
|
async function loadNotes() {
|
|
notesLoading.value = true;
|
|
try {
|
|
const data = await apiGet<{ notes: NoteItem[]; total: number }>(
|
|
`/api/projects/${projectId.value}/notes?type=note&limit=100`
|
|
);
|
|
notes.value = data.notes;
|
|
} catch {
|
|
// Silently fail
|
|
} finally {
|
|
notesLoading.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadProject();
|
|
loadTasks();
|
|
loadNotes();
|
|
});
|
|
|
|
watch(projectId, async () => {
|
|
await loadProject();
|
|
loadTasks();
|
|
loadNotes();
|
|
});
|
|
|
|
watch(
|
|
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
|
|
() => {
|
|
if (!project.value) return;
|
|
editDirty.value =
|
|
editTitle.value !== project.value.title ||
|
|
editDescription.value !== (project.value.description ?? "") ||
|
|
editGoal.value !== (project.value.goal ?? "") ||
|
|
editStatus.value !== project.value.status;
|
|
}
|
|
);
|
|
|
|
async function saveProject() {
|
|
if (!project.value || saving.value) return;
|
|
saving.value = true;
|
|
try {
|
|
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
|
|
title: editTitle.value.trim(),
|
|
description: editDescription.value.trim() || null,
|
|
goal: editGoal.value.trim() || null,
|
|
status: editStatus.value,
|
|
});
|
|
project.value = { ...project.value, ...updated };
|
|
editDirty.value = false;
|
|
toast.show("Project saved");
|
|
} catch {
|
|
toast.show("Failed to save project", "error");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
const showDeleteConfirm = ref(false);
|
|
const showShare = ref(false);
|
|
|
|
async function confirmDelete() {
|
|
if (!project.value) return;
|
|
showDeleteConfirm.value = false;
|
|
try {
|
|
await apiDelete(`/api/projects/${project.value.id}`);
|
|
toast.show("Project deleted");
|
|
router.push("/projects");
|
|
} catch {
|
|
toast.show("Failed to delete project", "error");
|
|
}
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<main class="project-view">
|
|
|
|
<!-- Nav bar -->
|
|
<div class="page-header">
|
|
<router-link to="/projects" class="btn-back">← Projects</router-link>
|
|
<div class="page-header-actions">
|
|
<template v-if="showStartPlanning">
|
|
<input
|
|
v-model="planTitle"
|
|
class="plan-title-input"
|
|
placeholder="Plan title…"
|
|
@keyup.enter="confirmStartPlanning"
|
|
/>
|
|
<button
|
|
class="btn-workspace"
|
|
:disabled="!planTitle.trim() || planningBusy"
|
|
@click="confirmStartPlanning"
|
|
>
|
|
Create plan
|
|
</button>
|
|
<button class="btn-share" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
|
</template>
|
|
<button
|
|
v-else-if="project"
|
|
class="btn-workspace"
|
|
@click="showStartPlanning = true"
|
|
>
|
|
Start planning
|
|
</button>
|
|
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-workspace">
|
|
<LayoutGrid :size="16" />
|
|
Workspace
|
|
</router-link>
|
|
<button v-if="project && !showStartPlanning" class="btn-share" @click="showShare = true">Share</button>
|
|
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Skeleton -->
|
|
<div v-if="loading" class="proj-skeleton" aria-label="Loading project">
|
|
<div class="skel-title"></div>
|
|
<div class="skel-goal"></div>
|
|
<div class="skel-stats"></div>
|
|
<div class="skel-body"></div>
|
|
</div>
|
|
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
|
|
|
<template v-else-if="project">
|
|
|
|
<!-- Project identity header -->
|
|
<div class="project-header">
|
|
<div class="title-row">
|
|
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
|
|
<span :class="['status-badge', `status-${project.status}`]">
|
|
{{ project.status.charAt(0).toUpperCase() + project.status.slice(1) }}
|
|
</span>
|
|
</div>
|
|
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
|
|
<p v-if="project.summary?.last_activity" class="project-activity">
|
|
<Clock :size="16" />
|
|
Active {{ relativeTime(project.summary.last_activity) }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Summary stat chips -->
|
|
<div v-if="project.summary" class="summary-stats">
|
|
<div class="stat-chip stat-todo">
|
|
<span class="stat-dot dot-todo"></span>
|
|
<span class="stat-val">{{ project.summary.task_counts.todo }}</span>
|
|
<span class="stat-label">todo</span>
|
|
</div>
|
|
<div class="stat-chip stat-inprogress">
|
|
<span class="stat-dot dot-inprogress"></span>
|
|
<span class="stat-val">{{ project.summary.task_counts.in_progress }}</span>
|
|
<span class="stat-label">in progress</span>
|
|
</div>
|
|
<div class="stat-chip stat-done">
|
|
<span class="stat-dot dot-done"></span>
|
|
<span class="stat-val">{{ project.summary.task_counts.done }}</span>
|
|
<span class="stat-label">done</span>
|
|
</div>
|
|
<div class="stat-chip stat-notes">
|
|
<FileText :size="16" style="opacity:0.6" />
|
|
<span class="stat-val">{{ project.summary.note_count }}</span>
|
|
<span class="stat-label">notes</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="project-body">
|
|
|
|
<!-- Edit panel -->
|
|
<aside class="edit-panel">
|
|
<h3 class="panel-heading">Details</h3>
|
|
<div class="edit-field">
|
|
<label class="edit-label">Goal</label>
|
|
<input v-model="editGoal" type="text" class="edit-input" placeholder="What are you trying to achieve?" />
|
|
</div>
|
|
<div class="edit-field">
|
|
<label class="edit-label">Description</label>
|
|
<textarea v-model="editDescription" class="edit-textarea" rows="4" placeholder="Optional description..."></textarea>
|
|
</div>
|
|
<div class="edit-field">
|
|
<label class="edit-label">Status</label>
|
|
<select v-model="editStatus" class="edit-select">
|
|
<option value="active">Active</option>
|
|
<option value="paused">Paused</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
|
|
{{ saving ? "Saving..." : "Save Changes" }}
|
|
</button>
|
|
</aside>
|
|
|
|
<!-- Main content area -->
|
|
<div class="content-area">
|
|
<div class="tab-bar">
|
|
<button :class="['tab-btn', { active: activeTab === 'tasks' }]" @click="activeTab = 'tasks'">
|
|
Tasks
|
|
<span v-if="project.summary" class="tab-count">{{ (project.summary.task_counts.todo ?? 0) + (project.summary.task_counts.in_progress ?? 0) + (project.summary.task_counts.done ?? 0) }}</span>
|
|
</button>
|
|
<button :class="['tab-btn', { active: activeTab === 'notes' }]" @click="activeTab = 'notes'">
|
|
Notes
|
|
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
|
|
</button>
|
|
<button :class="['tab-btn', { active: activeTab === 'systems' }]" @click="activeTab = 'systems'">
|
|
Systems
|
|
</button>
|
|
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
|
Rules
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tasks tab — milestone-grouped kanban -->
|
|
<div v-if="activeTab === 'tasks'" class="tasks-view">
|
|
<div v-if="tasksLoading" class="proj-skeleton-inline">
|
|
<div class="skel-row"></div>
|
|
<div class="skel-row skel-row--short"></div>
|
|
<div class="skel-row"></div>
|
|
</div>
|
|
<template v-else>
|
|
<div class="milestone-actions">
|
|
<button v-if="!showNewMilestone" class="btn-add-milestone" @click="showNewMilestone = true">
|
|
+ Milestone
|
|
</button>
|
|
<div v-else class="new-milestone-row">
|
|
<input
|
|
v-model="newMilestoneTitle"
|
|
class="milestone-title-input"
|
|
placeholder="Milestone title"
|
|
autofocus
|
|
@keydown.enter="createMilestone"
|
|
@keydown.escape="showNewMilestone = false; newMilestoneTitle = ''"
|
|
/>
|
|
<button class="btn-ms-confirm" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
|
|
{{ creatingMilestone ? "..." : "Add" }}
|
|
</button>
|
|
<button class="btn-ms-cancel" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-for="group in milestoneGroups" :key="group.milestone?.id ?? 'unassigned'" class="milestone-group">
|
|
<div
|
|
class="milestone-header"
|
|
:class="{ clickable: !!group.milestone && renamingMilestoneId !== group.milestone?.id }"
|
|
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)"
|
|
>
|
|
<span class="ms-chevron" v-if="group.milestone">
|
|
<ChevronRight v-if="collapsedMilestones.has(group.milestone.id)" :size="16" />
|
|
<ChevronDown v-else :size="16" />
|
|
</span>
|
|
<template v-if="group.milestone && renamingMilestoneId === group.milestone.id">
|
|
<input
|
|
class="ms-rename-input"
|
|
v-model="renamingMilestoneTitle"
|
|
@click.stop
|
|
@keydown.enter.stop="commitRenameMilestone(group.milestone)"
|
|
@keydown.escape.stop="renamingMilestoneId = null"
|
|
@blur="commitRenameMilestone(group.milestone)"
|
|
autofocus
|
|
/>
|
|
</template>
|
|
<span v-else class="ms-name">{{ group.milestone?.title ?? "No Milestone" }}</span>
|
|
<span class="ms-count">{{ group.tasks.length }}</span>
|
|
<template v-if="group.milestone">
|
|
<div class="ms-progress-track">
|
|
<div class="ms-progress-fill" :style="{ width: group.milestone.pct + '%' }"></div>
|
|
</div>
|
|
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
|
<div class="ms-actions" @click.stop>
|
|
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
|
|
<Pencil :size="16" />
|
|
</button>
|
|
<button class="ms-action-btn ms-action-delete" title="Delete" @click="deletingMilestone = group.milestone">
|
|
<Trash2 :size="16" />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
|
|
<!-- Todo column -->
|
|
<div class="kanban-col col-todo">
|
|
<div class="kanban-col-header">
|
|
<span class="col-status-dot dot-todo"></span>
|
|
<span class="col-label">Todo</span>
|
|
<span class="col-count">{{ group.tasks.filter(t => t.status === 'todo').length }}</span>
|
|
<router-link
|
|
:to="`/tasks/new?projectId=${projectId}${group.milestone ? '&milestoneId=' + group.milestone.id : ''}`"
|
|
class="col-add-btn" title="Add task"
|
|
>+</router-link>
|
|
</div>
|
|
<div class="kanban-cards">
|
|
<router-link
|
|
v-for="task in group.tasks.filter(t => t.status === 'todo')"
|
|
:key="task.id" :to="`/tasks/${task.id}`"
|
|
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
|
>
|
|
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
|
<div class="task-card-footer">
|
|
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
|
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
|
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
|
|
</div>
|
|
<button
|
|
class="task-advance-btn"
|
|
title="Move to In Progress"
|
|
:disabled="advancingTaskId === task.id"
|
|
@click="advanceTaskStatus(task, $event)"
|
|
>→</button>
|
|
</div>
|
|
</router-link>
|
|
<p v-if="!group.tasks.filter(t => t.status === 'todo').length" class="col-empty">No tasks</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- In Progress column -->
|
|
<div class="kanban-col col-inprogress">
|
|
<div class="kanban-col-header">
|
|
<span class="col-status-dot dot-inprogress"></span>
|
|
<span class="col-label">In Progress</span>
|
|
<span class="col-count">{{ group.tasks.filter(t => t.status === 'in_progress').length }}</span>
|
|
</div>
|
|
<div class="kanban-cards">
|
|
<router-link
|
|
v-for="task in group.tasks.filter(t => t.status === 'in_progress')"
|
|
:key="task.id" :to="`/tasks/${task.id}`"
|
|
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
|
>
|
|
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
|
<div class="task-card-footer">
|
|
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
|
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
|
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
|
|
</div>
|
|
<button
|
|
class="task-advance-btn task-advance-btn--done"
|
|
title="Mark as Done"
|
|
:disabled="advancingTaskId === task.id"
|
|
@click="advanceTaskStatus(task, $event)"
|
|
><Check :size="16" /></button>
|
|
</div>
|
|
</router-link>
|
|
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Done column -->
|
|
<div class="kanban-col col-done">
|
|
<div class="kanban-col-header">
|
|
<span class="col-status-dot dot-done"></span>
|
|
<span class="col-label">Done</span>
|
|
<span class="col-count">{{ group.tasks.filter(t => t.status === 'done').length }}</span>
|
|
</div>
|
|
<div class="kanban-cards">
|
|
<router-link
|
|
v-for="task in group.tasks.filter(t => t.status === 'done')"
|
|
:key="task.id" :to="`/tasks/${task.id}`"
|
|
class="task-card task-card-done"
|
|
>
|
|
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
|
<div v-if="task.due_date" class="task-meta">
|
|
<span class="due-date">{{ task.due_date }}</span>
|
|
</div>
|
|
</router-link>
|
|
<p v-if="!group.tasks.filter(t => t.status === 'done').length" class="col-empty">No tasks</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="tasks.length === 0" class="empty-msg">No tasks in this project yet.</p>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Notes tab -->
|
|
<div v-if="activeTab === 'notes'" class="notes-list">
|
|
<div v-if="notesLoading" class="proj-skeleton-inline">
|
|
<div class="skel-row"></div>
|
|
<div class="skel-row skel-row--short"></div>
|
|
<div class="skel-row"></div>
|
|
</div>
|
|
<template v-else>
|
|
<router-link v-for="note in notes" :key="note.id" :to="`/notes/${note.id}`" class="note-row">
|
|
<FileText class="note-icon" :size="16" />
|
|
<span class="note-title">{{ note.title || "Untitled" }}</span>
|
|
<span class="note-date">{{ relativeTime(note.updated_at) }}</span>
|
|
</router-link>
|
|
<p v-if="!notes.length" class="empty-msg">No notes in this project.</p>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Systems tab -->
|
|
<SystemsSection v-if="activeTab === 'systems'" :project-id="projectId" />
|
|
|
|
<!-- Rules tab -->
|
|
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Milestone delete confirmation -->
|
|
<teleport to="body">
|
|
<div v-if="deletingMilestone" class="modal-overlay" @click.self="deletingMilestone = null">
|
|
<div class="modal-card">
|
|
<h3 class="modal-title">Delete Milestone</h3>
|
|
<p class="modal-message">
|
|
Delete <strong>{{ deletingMilestone.title }}</strong>?
|
|
Tasks will be unlinked from this milestone but not deleted.
|
|
</p>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" @click="deletingMilestone = null">Cancel</button>
|
|
<button class="modal-btn modal-btn-danger" @click="confirmDeleteMilestone">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</teleport>
|
|
|
|
<!-- Project delete confirmation -->
|
|
<teleport to="body">
|
|
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="showDeleteConfirm = false">
|
|
<div class="modal-card">
|
|
<h3 class="modal-title">Delete Project</h3>
|
|
<p class="modal-message">Are you sure you want to delete this project? This cannot be undone.</p>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
|
|
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</teleport>
|
|
|
|
<ShareDialog
|
|
v-if="showShare && project"
|
|
resource-type="project"
|
|
:resource-id="project.id"
|
|
:resource-title="project.title"
|
|
@close="showShare = false"
|
|
/>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Layout ─────────────────────────────────────────────────── */
|
|
.project-view {
|
|
max-width: var(--page-max-width);
|
|
margin: 2rem auto;
|
|
padding: 0 var(--page-padding-x);
|
|
overflow-x: clip;
|
|
}
|
|
|
|
/* ── Nav bar ─────────────────────────────────────────────────── */
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
|
.plan-title-input {
|
|
background: var(--color-bg, #111113);
|
|
color: inherit;
|
|
border: 1px solid var(--color-border, #2a2a2e);
|
|
border-radius: 6px;
|
|
padding: 0.4rem 0.6rem;
|
|
font: inherit;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.btn-back {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.4rem 0.9rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: none;
|
|
color: var(--color-text-secondary);
|
|
text-decoration: none;
|
|
font-size: 0.875rem;
|
|
transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
|
|
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
|
|
the project's "central feature moment" — entering the focused workspace
|
|
is a Scribe-flavored action, not a plain operation. */
|
|
.btn-workspace {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.45rem 1rem;
|
|
background: var(--gradient-cta);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
box-shadow: var(--glow-cta);
|
|
transition: box-shadow 0.15s, opacity 0.15s;
|
|
}
|
|
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
|
|
|
|
/* Share: Bronze action-secondary — alternate path */
|
|
.btn-share {
|
|
padding: 0.4rem 0.8rem;
|
|
background: var(--color-action-secondary);
|
|
border: none;
|
|
color: #fff;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
|
|
|
/* Delete project: Oxblood action-destructive ghost — outline form since
|
|
the actual confirm modal carries the filled destructive treatment */
|
|
.btn-danger-outline {
|
|
padding: 0.4rem 0.8rem;
|
|
background: none;
|
|
border: 1px solid var(--color-action-destructive);
|
|
color: var(--color-action-destructive);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
|
|
|
|
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
|
|
|
/* ── Project identity header ─────────────────────────────────── */
|
|
.project-header { margin-bottom: 1rem; }
|
|
|
|
.title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
.project-title-input {
|
|
flex: 1;
|
|
font-size: 1.75rem;
|
|
font-weight: 500;
|
|
font-family: "Fraunces", Georgia, serif;
|
|
color: var(--color-text);
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1.5px solid transparent;
|
|
outline: none;
|
|
padding: 0.1rem 0;
|
|
min-width: 0;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.project-title-input:focus { border-bottom-color: var(--color-primary); }
|
|
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; }
|
|
|
|
.status-badge {
|
|
font-size: 0.68rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.18rem 0.55rem;
|
|
border-radius: 999px;
|
|
flex-shrink: 0;
|
|
}
|
|
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
|
|
.status-paused { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent); }
|
|
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
|
|
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
|
|
|
|
.project-goal {
|
|
font-size: 1rem;
|
|
color: var(--color-text-secondary);
|
|
margin: 0 0 0.3rem;
|
|
line-height: 1.45;
|
|
}
|
|
.project-activity {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
font-size: 0.78rem;
|
|
color: var(--color-text-muted);
|
|
margin: 0;
|
|
}
|
|
|
|
/* ── Summary stat chips ──────────────────────────────────────── */
|
|
.summary-stats {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1.25rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.stat-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.3rem 0.65rem;
|
|
border-radius: var(--radius-md);
|
|
font-size: 0.82rem;
|
|
border: 1px solid;
|
|
}
|
|
.stat-val { font-weight: 500; font-size: 0.9rem; }
|
|
.stat-label { color: inherit; opacity: 0.8; }
|
|
|
|
.stat-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.dot-todo { background: transparent; border: 2px solid var(--color-text-muted); }
|
|
.dot-inprogress { background: var(--color-status-in-progress, #3b82f6); }
|
|
.dot-done { background: var(--color-status-done, #22c55e); }
|
|
|
|
.stat-todo { background: color-mix(in srgb, var(--color-text-muted) 8%, transparent); color: var(--color-text-secondary); border-color: var(--color-border); }
|
|
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
|
|
.stat-done { background: color-mix(in srgb, var(--color-success) 10%, transparent); color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 28%, transparent); }
|
|
.stat-notes { background: color-mix(in srgb, var(--color-primary) 8%, transparent); color: var(--color-primary); border-color: color-mix(in srgb, var(--color-primary) 22%, transparent); }
|
|
|
|
/* ── Two-column body ─────────────────────────────────────────── */
|
|
.project-body {
|
|
display: grid;
|
|
grid-template-columns: 248px 1fr;
|
|
gap: 1.25rem;
|
|
align-items: start;
|
|
}
|
|
|
|
/* ── Edit panel ──────────────────────────────────────────────── */
|
|
.edit-panel {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1rem 1.1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.85rem;
|
|
position: sticky;
|
|
top: 1rem;
|
|
}
|
|
.panel-heading {
|
|
margin: 0 0 0.1rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
.edit-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
color: var(--color-text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
}
|
|
.edit-input, .edit-textarea, .edit-select {
|
|
padding: 0.4rem 0.6rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
}
|
|
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--color-primary); }
|
|
.edit-textarea { resize: vertical; }
|
|
|
|
/* Save panel: Moss action-primary per Hybrid rule */
|
|
.btn-save-panel {
|
|
padding: 0.45rem 0.9rem;
|
|
background: var(--color-action-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
font-family: inherit;
|
|
width: 100%;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
|
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
|
|
|
|
/* ── Content area ────────────────────────────────────────────── */
|
|
.content-area { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
|
|
.tab-bar {
|
|
display: flex;
|
|
gap: 0;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
.tab-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.45rem 0.9rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
color: var(--color-text-secondary);
|
|
font-family: inherit;
|
|
margin-bottom: -1px;
|
|
transition: color 0.15s;
|
|
}
|
|
.tab-btn:hover { color: var(--color-primary); }
|
|
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 500; }
|
|
.tab-count {
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 999px;
|
|
padding: 0 0.4rem;
|
|
line-height: 1.5;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.tab-btn.active .tab-count {
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
/* ── Tasks view ──────────────────────────────────────────────── */
|
|
.tasks-view { display: flex; flex-direction: column; gap: 1rem; }
|
|
|
|
.milestone-actions { display: flex; align-items: center; }
|
|
.btn-add-milestone {
|
|
background: none;
|
|
border: 1px dashed var(--color-border);
|
|
color: var(--color-text-secondary);
|
|
padding: 0.28rem 0.65rem;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
}
|
|
.btn-add-milestone:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
|
|
.new-milestone-row { display: flex; gap: 0.4rem; align-items: center; flex: 1; }
|
|
.milestone-title-input {
|
|
flex: 1;
|
|
padding: 0.3rem 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
}
|
|
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
|
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
|
.btn-ms-confirm {
|
|
padding: 0.3rem 0.65rem;
|
|
background: var(--color-action-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
|
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
|
|
.btn-ms-cancel {
|
|
padding: 0.3rem 0.65rem;
|
|
background: var(--color-action-secondary);
|
|
border: none;
|
|
color: #fff;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.78rem;
|
|
font-family: inherit;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
|
|
|
|
/* ── Milestone group ─────────────────────────────────────────── */
|
|
.milestone-group {
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
|
}
|
|
.milestone-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.55rem 0.85rem;
|
|
background: var(--color-bg-secondary);
|
|
font-size: 0.85rem;
|
|
user-select: none;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
.milestone-header.clickable { cursor: pointer; }
|
|
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
|
|
|
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
|
|
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.ms-count {
|
|
font-size: 0.7rem;
|
|
color: var(--color-text-muted);
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 999px;
|
|
padding: 0.05rem 0.4rem;
|
|
flex-shrink: 0;
|
|
font-weight: 500;
|
|
}
|
|
.ms-progress-track {
|
|
width: 72px;
|
|
height: 6px;
|
|
background: var(--color-border);
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
}
|
|
.ms-progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 70%, var(--color-success)));
|
|
border-radius: 999px;
|
|
transition: width 0.4s ease;
|
|
}
|
|
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 500; }
|
|
|
|
.ms-actions { display: flex; gap: 0.15rem; margin-left: 0.2rem; opacity: 0; transition: opacity 0.15s; }
|
|
.milestone-header:hover .ms-actions { opacity: 1; }
|
|
.ms-action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-text-muted);
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.ms-action-btn:hover { background: var(--color-bg-card); color: var(--color-text); }
|
|
.ms-action-delete:hover { color: var(--color-danger, #e74c3c); }
|
|
|
|
.ms-rename-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 0.1rem 0.35rem;
|
|
border: 1px solid var(--color-primary);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
font-weight: 500;
|
|
outline: none;
|
|
}
|
|
|
|
/* ── Kanban ──────────────────────────────────────────────────── */
|
|
.kanban {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 0.75rem;
|
|
align-items: start;
|
|
padding: 0.75rem;
|
|
background: color-mix(in srgb, var(--color-bg) 60%, var(--color-bg-secondary));
|
|
}
|
|
.kanban-col {
|
|
background: var(--color-bg-secondary);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.65rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
border-top: 3px solid;
|
|
}
|
|
.col-todo { border-top-color: var(--color-border); }
|
|
.col-inprogress { border-top-color: var(--color-status-in-progress, #3b82f6); }
|
|
.col-done { border-top-color: var(--color-status-done, #22c55e); }
|
|
|
|
.kanban-col-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.72rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--color-text-secondary);
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
.col-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
.col-label { flex: 1; }
|
|
.col-count {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 999px;
|
|
padding: 0.05rem 0.4rem;
|
|
font-size: 0.68rem;
|
|
color: var(--color-text-muted);
|
|
font-weight: 500;
|
|
}
|
|
.col-add-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 18px;
|
|
height: 18px;
|
|
color: var(--color-text-muted);
|
|
text-decoration: none;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
border-radius: 3px;
|
|
margin-left: auto;
|
|
}
|
|
.col-add-btn:hover { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
|
|
|
.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
|
|
.task-card {
|
|
display: block;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-left: 3px solid transparent;
|
|
border-radius: var(--radius-sm);
|
|
padding: 0.45rem 0.55rem;
|
|
text-decoration: none;
|
|
color: var(--color-text);
|
|
font-size: 0.85rem;
|
|
transition: border-color 0.12s, box-shadow 0.18s, transform 0.18s ease;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|
}
|
|
.task-card:hover {
|
|
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
|
border-left-color: var(--color-primary);
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
|
|
transform: translateY(-2px);
|
|
}
|
|
/* Priority left-border colors */
|
|
.task-card.pri-high { border-left-color: var(--color-danger, #e74c3c); }
|
|
.task-card.pri-medium { border-left-color: #f59e0b; }
|
|
.task-card.pri-low { border-left-color: var(--color-success); }
|
|
|
|
.task-card-done { opacity: 0.65; }
|
|
.task-card-done .task-title { text-decoration: line-through; }
|
|
|
|
.task-title { display: block; font-weight: 500; margin-bottom: 0.2rem; line-height: 1.3; word-break: break-word; }
|
|
.task-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.35rem; min-height: 1.2rem; }
|
|
.task-meta { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
|
|
.task-advance-btn {
|
|
flex-shrink: 0;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 1.4rem; height: 1.4rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.75rem;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
|
line-height: 1;
|
|
}
|
|
.task-card:hover .task-advance-btn { opacity: 1; }
|
|
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
|
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
|
|
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
|
|
|
|
.priority-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.dot-pri-high { background: var(--color-danger, #e74c3c); }
|
|
.dot-pri-medium { background: #f59e0b; }
|
|
.dot-pri-low { background: var(--color-success); }
|
|
|
|
.due-date { font-size: 0.7rem; color: var(--color-text-muted); }
|
|
.col-empty {
|
|
text-align: center;
|
|
padding: 1.25rem 0.5rem;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.78rem;
|
|
border: 1px dashed var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* ── Notes list ──────────────────────────────────────────────── */
|
|
.notes-list { display: flex; flex-direction: column; gap: 0.35rem; }
|
|
.note-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.6rem 0.9rem;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
text-decoration: none;
|
|
color: var(--color-text);
|
|
font-size: 0.9rem;
|
|
transition: border-color 0.12s, box-shadow 0.15s, transform 0.15s;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|
}
|
|
.note-row:hover {
|
|
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
|
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
|
|
transform: translateY(-1px);
|
|
}
|
|
.note-icon { color: var(--color-text-muted); flex-shrink: 0; }
|
|
.note-title { font-weight: 500; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.note-date { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
|
|
|
|
.empty-msg { color: var(--color-text-muted); font-size: 0.875rem; text-align: center; padding: 1rem; }
|
|
|
|
/* ── Modal ───────────────────────────────────────────────────── */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0;
|
|
background: var(--color-overlay, rgba(0,0,0,0.45));
|
|
display: flex; align-items: center; justify-content: center;
|
|
z-index: 200;
|
|
}
|
|
.modal-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1.5rem;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
box-shadow: 0 8px 32px var(--color-shadow);
|
|
}
|
|
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
|
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
|
|
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
|
.modal-btn {
|
|
padding: 0.4rem 0.9rem;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
}
|
|
.modal-btn:hover { background: var(--color-bg); }
|
|
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
|
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
|
|
|
/* ── Skeleton ────────────────────────────────────────────────── */
|
|
@keyframes skel-shine { to { background-position: 200% center; } }
|
|
.proj-skeleton, .proj-skeleton-inline {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.65rem;
|
|
}
|
|
.skel-title, .skel-goal, .skel-stats, .skel-body, .skel-row {
|
|
border-radius: var(--radius-sm);
|
|
background: linear-gradient(
|
|
90deg,
|
|
var(--color-bg-secondary) 25%,
|
|
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
|
var(--color-bg-secondary) 75%
|
|
);
|
|
background-size: 200% 100%;
|
|
animation: skel-shine 1.5s ease infinite;
|
|
}
|
|
.skel-title { height: 2.2rem; width: 55%; border-radius: var(--radius-md); }
|
|
.skel-goal { height: 1rem; width: 75%; }
|
|
.skel-stats { height: 2rem; width: 50%; border-radius: var(--radius-md); }
|
|
.skel-body { height: 200px; border-radius: var(--radius-md); }
|
|
.skel-row { height: 2.5rem; border-radius: var(--radius-sm); }
|
|
.skel-row--short { width: 65%; }
|
|
|
|
/* ── Responsive ──────────────────────────────────────────────── */
|
|
@media (max-width: 900px) {
|
|
.project-body { grid-template-columns: 1fr; }
|
|
.edit-panel { position: static; }
|
|
.kanban { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|