Files
FabledScribe/frontend/src/views/ProjectView.vue
T
bvandeusen 3836f08007 Add overflow-x: clip and min-width: 0 to all list view containers
Prevents content from escaping max-width boundaries across notes,
tasks, projects list, and project detail views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 22:13:58 -05:00

1205 lines
34 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";
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;
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
color: string | null;
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 project = ref<Project | null>(null);
const loading = ref(false);
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes">("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);
}
}
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 ?? [];
} 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;
} 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;
}
}
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);
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");
}
}
function priorityClass(priority?: string): string {
if (priority === "high") return "priority-high";
if (priority === "medium") return "priority-medium";
if (priority === "low") return "priority-low";
return "";
}
function formatDate(dateStr?: string | null): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString();
}
</script>
<template>
<main class="project-view">
<div class="page-header">
<router-link to="/projects" class="btn-back">Back to Projects</router-link>
<button
v-if="project"
class="btn-danger-outline"
@click="showDeleteConfirm = true"
>
Delete Project
</button>
</div>
<p v-if="loading" class="loading-msg">Loading...</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else-if="project">
<!-- Project header -->
<div class="project-header">
<div class="title-row">
<input
v-model="editTitle"
type="text"
class="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 && !editGoal" class="project-goal-display">{{ project.goal }}</p>
</div>
<!-- Summary bar -->
<div v-if="project.summary" class="summary-bar">
<span class="summary-item">
<span class="summary-num">{{ project.summary.task_counts.todo }}</span> todo
</span>
<span class="summary-sep">·</span>
<span class="summary-item">
<span class="summary-num">{{ project.summary.task_counts.in_progress }}</span> in progress
</span>
<span class="summary-sep">·</span>
<span class="summary-item">
<span class="summary-num">{{ project.summary.task_counts.done }}</span> done
</span>
<span class="summary-sep">·</span>
<span class="summary-item">
<span class="summary-num">{{ project.summary.note_count }}</span> notes
</span>
</div>
<div class="project-body">
<!-- Edit panel -->
<aside class="edit-panel">
<h3>Details</h3>
<div class="edit-field">
<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>Description</label>
<textarea v-model="editDescription" class="edit-textarea" rows="4" placeholder="Optional description..."></textarea>
</div>
<div class="edit-field">
<label>Status</label>
<select v-model="editStatus" class="edit-select">
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
<button
class="btn-save"
@click="saveProject"
:disabled="!editDirty || saving"
>
{{ saving ? "Saving..." : "Save" }}
</button>
</aside>
<!-- Main content area -->
<div class="content-area">
<!-- Tab bar -->
<div class="tab-bar">
<button
:class="['tab-btn', { active: activeTab === 'tasks' }]"
@click="activeTab = 'tasks'"
>Tasks</button>
<button
:class="['tab-btn', { active: activeTab === 'notes' }]"
@click="activeTab = 'notes'"
>Notes</button>
</div>
<!-- Tasks tab milestone-grouped -->
<div v-if="activeTab === 'tasks'" class="tasks-view">
<p v-if="tasksLoading" class="loading-msg">Loading tasks...</p>
<template v-else>
<!-- Add milestone button -->
<div class="milestone-actions">
<button v-if="!showNewMilestone" class="btn-add-milestone" @click="showNewMilestone = true">
+ Add 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>
<!-- Milestone groups -->
<div
v-for="group in milestoneGroups"
:key="group.milestone?.id ?? 'unassigned'"
class="milestone-group"
>
<!-- Milestone header -->
<div
class="milestone-header"
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)"
:class="{ clickable: !!group.milestone && renamingMilestoneId !== group.milestone?.id }"
>
<span class="ms-chevron" v-if="group.milestone">
{{ collapsedMilestones.has(group.milestone.id) ? '▶' : '▼' }}
</span>
<!-- Inline rename input -->
<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>
<!-- Edit / delete actions -->
<div class="ms-actions" @click.stop>
<button
class="ms-action-btn"
title="Rename milestone"
@click="startRenameMilestone(group.milestone)"
></button>
<button
class="ms-action-btn ms-action-delete"
title="Delete milestone"
@click="deletingMilestone = group.milestone"
></button>
</div>
</template>
</div>
<!-- Task list (collapsible) -->
<div
v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)"
class="kanban"
>
<div class="kanban-col">
<div class="kanban-col-header">
<span>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"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-badge', priorityClass(task.priority)]">{{ task.priority }}</span>
<span v-if="task.due_date" class="due-date">{{ formatDate(task.due_date) }}</span>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'todo').length" class="col-empty">No tasks</p>
</div>
</div>
<div class="kanban-col">
<div class="kanban-col-header">
<span>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"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-badge', priorityClass(task.priority)]">{{ task.priority }}</span>
<span v-if="task.due_date" class="due-date">{{ formatDate(task.due_date) }}</span>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
</div>
</div>
<div class="kanban-col">
<div class="kanban-col-header">
<span>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 class="task-meta">
<span v-if="task.due_date" class="due-date">{{ formatDate(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">
<p v-if="notesLoading" class="loading-msg">Loading notes...</p>
<template v-else>
<router-link
v-for="note in notes"
:key="note.id"
:to="`/notes/${note.id}`"
class="note-row"
>
<span class="note-title">{{ note.title || "Untitled" }}</span>
<span class="note-date">{{ formatDate(note.updated_at) }}</span>
</router-link>
<p v-if="!notes.length" class="empty-msg">No notes in this project.</p>
</template>
</div>
</div>
</div>
</template>
<!-- Milestone delete confirmation modal -->
<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 modal -->
<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>
</main>
</template>
<style scoped>
.project-view {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
overflow-x: clip;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
}
.btn-back {
color: var(--color-primary);
text-decoration: none;
font-size: 0.875rem;
}
.btn-back:hover {
text-decoration: underline;
}
.btn-danger-outline {
padding: 0.35rem 0.8rem;
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
}
.btn-danger-outline:hover {
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
}
.loading-msg { color: var(--color-text-muted); font-size: 0.9rem; }
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
.project-header {
margin-bottom: 0.75rem;
}
.title-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.title-input {
flex: 1;
font-size: 1.4rem;
font-weight: 700;
color: var(--color-text);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
outline: none;
padding: 0.1rem 0;
font-family: inherit;
min-width: 0;
}
.title-input:focus {
border-bottom-color: var(--color-primary);
}
.status-badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
}
.status-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 15%, transparent); color: var(--color-text-muted); }
.project-goal-display {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin: 0;
font-style: italic;
}
.summary-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
flex-wrap: wrap;
}
.summary-num {
font-weight: 700;
color: var(--color-text);
}
.summary-sep {
color: var(--color-text-muted);
}
.project-body {
display: grid;
grid-template-columns: 260px 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;
}
.edit-panel h3 {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.edit-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.edit-field label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.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;
}
.btn-save {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
width: 100%;
}
.btn-save:hover:not(:disabled) { opacity: 0.9; }
.btn-save:disabled { opacity: 0.5; cursor: default; }
/* Main content */
.content-area {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tab-bar {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
}
.tab-btn {
padding: 0.4rem 0.85rem;
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;
}
.tab-btn:hover { color: var(--color-primary); }
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
/* Tasks view (wraps milestone groups) */
.tasks-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Milestone management */
.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.3rem 0.7rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
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.35rem 0.55rem;
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);
}
.btn-ms-confirm {
padding: 0.3rem 0.7rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
}
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-ms-cancel {
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
}
/* Milestone group */
.milestone-group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.milestone-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.8rem;
background: var(--color-bg-secondary);
font-size: 0.85rem;
user-select: none;
}
.milestone-header.clickable {
cursor: pointer;
}
.milestone-header.clickable:hover {
background: var(--color-bg-card);
}
.ms-chevron {
font-size: 0.65rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.ms-name {
font-weight: 600;
color: var(--color-text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-count {
font-size: 0.75rem;
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;
}
.ms-progress-track {
width: 80px;
height: 5px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
}
.ms-progress-fill {
height: 100%;
background: var(--color-primary);
border-radius: 999px;
transition: width 0.3s ease;
}
.ms-pct {
font-size: 0.72rem;
color: var(--color-text-muted);
flex-shrink: 0;
min-width: 2.2rem;
text-align: right;
}
/* Kanban */
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
align-items: start;
padding: 0.75rem;
background: var(--color-bg);
}
.kanban-col {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kanban-col-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
margin-bottom: 0.35rem;
}
.col-count {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.05rem 0.45rem;
font-size: 0.7rem;
color: var(--color-text-muted);
font-weight: 600;
}
.col-add-btn {
margin-left: auto;
color: var(--color-text-muted);
text-decoration: none;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
border-radius: var(--radius-sm);
}
.col-add-btn:hover { color: var(--color-primary); }
.kanban-cards {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.task-card {
display: block;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
text-decoration: none;
color: var(--color-text);
font-size: 0.875rem;
transition: border-color 0.15s;
}
.task-card:hover {
border-color: var(--color-primary);
}
.task-card-done .task-title {
text-decoration: line-through;
opacity: 0.6;
}
.task-title {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
line-height: 1.3;
word-break: break-word;
}
.task-meta {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.priority-badge {
font-size: 0.67rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.priority-high { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
.priority-medium { background: rgba(201, 138, 0, 0.15); color: #c98a00; }
.priority-low { background: color-mix(in srgb, var(--color-success) 12%, transparent); color: var(--color-success); }
.due-date {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.col-empty {
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
padding: 0.5rem 0;
margin: 0;
}
/* Notes list */
.notes-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.note-row {
display: flex;
justify-content: space-between;
align-items: center;
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.15s;
}
.note-row:hover {
border-color: var(--color-primary);
}
.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;
margin-left: 0.75rem;
}
.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-danger, #e74c3c);
border-color: var(--color-danger, #e74c3c);
color: #fff;
}
.modal-btn-danger:hover { opacity: 0.9; }
/* Milestone rename input */
.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: 600;
}
.ms-rename-input:focus {
outline: none;
}
/* Milestone action buttons */
.ms-actions {
display: flex;
gap: 0.15rem;
margin-left: 0.25rem;
opacity: 0;
transition: opacity 0.15s;
}
.milestone-header:hover .ms-actions {
opacity: 1;
}
.ms-action-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
border-radius: var(--radius-sm);
line-height: 1;
font-family: inherit;
}
.ms-action-btn:hover {
background: var(--color-bg-card);
color: var(--color-text);
}
.ms-action-delete:hover {
color: var(--color-danger, #e74c3c);
}
@media (max-width: 900px) {
.project-body {
grid-template-columns: 1fr;
}
.kanban {
grid-template-columns: 1fr;
}
}
.empty-msg {
color: var(--color-text-muted);
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
</style>