3836f08007
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>
1205 lines
34 KiB
Vue
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>
|