CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 47s
21 buttons filled with --color-primary, which resolves to Scribe's violet accent: every auth Submit (Login, Register, Invite, Forgot, Reset), plus Invite, Add, Confirm, Restore, Generate, Log-save, Subtask-confirm, Toggle-open, the modal primary, the version-restore, the inline-assist button, the milestone-plan actions, the task-advance hover, and both empty-state CTAs. The house style is explicit that the accent never appears on an action button: action colours are universal across the family precisely so a Save button looks identical in every app, while the accent carries identity. Doing both makes the accent mean two things and neither clearly. Operator's call, and the reasoning is worth keeping: the violet-on-Scribe- actions treatment was a deliberate early choice to give the web UI its own personality, made when much more of the app was user-facing. That is no longer true, so consistency is now worth more than the distinction it was buying. Found in two passes, which is the part worth noting. The first scan looked for `.btn-*` and found 13. Seven more were the same thing under different names — .modal-btn-primary, .vh-btn-restore, .inline-assist-btn, .empty-action, .task-advance-btn — plus .ms-plan-actions .btn-primary, a compound override flagged in the previous commit. Searching by naming convention finds what was named consistently, which is never the whole set. Deliberately NOT changed: progress-bar fills, active tab / page / selection states, tag-pill hover, the duration badge, the skip link, the assist pulse. Those are identity and active-state, which is exactly where the accent belongs. After this the accent appears only there, which is what makes it read as identity rather than as decoration. Colour swaps only — 25 lines changed, no geometry, no structure, no templates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
603 lines
15 KiB
Vue
603 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { apiGet, apiPost } from "@/api/client";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import { milestoneColor } from "@/utils/palette";
|
|
|
|
interface MilestoneSummary {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
pct: number;
|
|
total: number;
|
|
completed: 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;
|
|
is_shared?: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
summary?: {
|
|
task_counts: { todo?: number; in_progress?: number; done?: number };
|
|
note_count: number;
|
|
milestone_summary: MilestoneSummary[];
|
|
};
|
|
}
|
|
|
|
const router = useRouter();
|
|
const toast = useToastStore();
|
|
|
|
const projects = ref<Project[]>([]);
|
|
const loading = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const activeTab = ref<"all" | "active" | "paused" | "completed" | "archived">("all");
|
|
|
|
// New project modal
|
|
const showNewProjectModal = ref(false);
|
|
const newTitle = ref("");
|
|
const newDescription = ref("");
|
|
const newGoal = ref("");
|
|
const creating = ref(false);
|
|
|
|
const filteredProjects = computed(() => {
|
|
if (activeTab.value === "all") return projects.value;
|
|
return projects.value.filter((p) => p.status === activeTab.value);
|
|
});
|
|
|
|
async function loadProjects() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
const data = await apiGet<{ projects: Project[] }>("/api/projects?include_summary=true");
|
|
projects.value = data.projects;
|
|
} catch {
|
|
error.value = "Failed to load projects.";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
|
|
onMounted(loadProjects);
|
|
|
|
function openNewProjectModal() {
|
|
newTitle.value = "";
|
|
newDescription.value = "";
|
|
newGoal.value = "";
|
|
showNewProjectModal.value = true;
|
|
}
|
|
|
|
function closeModal() {
|
|
showNewProjectModal.value = false;
|
|
}
|
|
|
|
async function createProject() {
|
|
if (!newTitle.value.trim()) return;
|
|
creating.value = true;
|
|
try {
|
|
const project = await apiPost<Project>("/api/projects", {
|
|
title: newTitle.value.trim(),
|
|
description: newDescription.value.trim() || undefined,
|
|
goal: newGoal.value.trim() || undefined,
|
|
});
|
|
projects.value.unshift(project);
|
|
showNewProjectModal.value = false;
|
|
toast.show("Project created");
|
|
router.push(`/projects/${project.id}`);
|
|
} catch {
|
|
toast.show("Failed to create project", "error");
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
}
|
|
|
|
function statusLabel(status: Project["status"]): string {
|
|
if (status === "active") return "Active";
|
|
if (status === "paused") return "Paused";
|
|
if (status === "completed") return "Completed";
|
|
if (status === "archived") return "Archived";
|
|
return status;
|
|
}
|
|
|
|
function truncate(text: string | null, max = 120): string {
|
|
if (!text) return "";
|
|
return text.length > max ? text.slice(0, max) + "..." : text;
|
|
}
|
|
|
|
function overallPct(project: Project): { total: number; pct: number } {
|
|
const counts = project.summary?.task_counts;
|
|
if (!counts) return { total: 0, pct: 0 };
|
|
const total = (counts.todo ?? 0) + (counts.in_progress ?? 0) + (counts.done ?? 0);
|
|
const pct = total > 0 ? Math.round((counts.done ?? 0) / total * 100) : 0;
|
|
return { total, pct };
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="projects-list">
|
|
<div class="page-header">
|
|
<h1>Projects</h1>
|
|
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
|
|
</div>
|
|
|
|
<!-- Filter tabs -->
|
|
<div class="filter-tabs">
|
|
<button
|
|
v-for="tab in ['all', 'active', 'paused', 'completed', 'archived'] as const"
|
|
:key="tab"
|
|
:class="['tab-btn', { active: activeTab === tab }]"
|
|
@click="activeTab = tab"
|
|
>
|
|
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="loading" class="skeleton-grid">
|
|
<div class="skeleton-card" v-for="i in 4" :key="i"></div>
|
|
</div>
|
|
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
|
|
|
<div v-else-if="filteredProjects.length === 0" class="empty-state-rich">
|
|
<div class="empty-icon">◈</div>
|
|
<p class="empty-title">No projects yet</p>
|
|
<p class="empty-sub">Organise your notes and tasks into a project</p>
|
|
<button class="empty-action" @click="openNewProjectModal">New project →</button>
|
|
</div>
|
|
|
|
<div v-else class="projects-grid">
|
|
<div
|
|
v-for="project in filteredProjects"
|
|
:key="project.id"
|
|
class="project-card"
|
|
@click="router.push(`/projects/${project.id}`)"
|
|
>
|
|
<div class="card-header">
|
|
<span class="project-title">{{ project.title }}</span>
|
|
<span
|
|
:class="['status-badge', `status-${project.status}`]"
|
|
>{{ statusLabel(project.status) }}</span>
|
|
</div>
|
|
<p v-if="project.goal" class="project-goal">
|
|
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
|
|
</p>
|
|
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
|
|
|
|
<!-- Overall completion bar -->
|
|
<div
|
|
v-if="overallPct(project).total > 0"
|
|
class="overall-bar-row"
|
|
:title="`${project.summary?.task_counts.done ?? 0} of ${overallPct(project).total} tasks complete`"
|
|
>
|
|
<div class="overall-bar-track">
|
|
<div class="overall-bar-fill" :style="{ width: overallPct(project).pct + '%' }"></div>
|
|
</div>
|
|
<span class="overall-bar-pct">{{ overallPct(project).pct }}%</span>
|
|
</div>
|
|
|
|
<!-- Milestone progress bars -->
|
|
<div
|
|
v-if="project.summary?.milestone_summary?.length"
|
|
class="milestone-bars"
|
|
>
|
|
<div
|
|
v-for="(ms, i) in project.summary.milestone_summary"
|
|
:key="ms.id"
|
|
class="milestone-bar-row"
|
|
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
|
|
>
|
|
<span class="milestone-bar-label">{{ ms.title }}</span>
|
|
<div class="milestone-bar-track">
|
|
<div
|
|
class="milestone-bar-fill"
|
|
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
|
></div>
|
|
</div>
|
|
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-footer">
|
|
<span class="meta-date">Updated {{ new Date(project.updated_at).toLocaleDateString() }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New Project Modal -->
|
|
<teleport to="body">
|
|
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
|
<div class="modal-card">
|
|
<h3 class="modal-title">New Project</h3>
|
|
<div class="modal-field">
|
|
<label>Title <span class="required">*</span></label>
|
|
<input
|
|
v-model="newTitle"
|
|
type="text"
|
|
class="modal-input"
|
|
placeholder="Project title"
|
|
autofocus
|
|
@keydown.enter="createProject"
|
|
@keydown.escape="closeModal"
|
|
/>
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>Goal</label>
|
|
<input
|
|
v-model="newGoal"
|
|
type="text"
|
|
class="modal-input"
|
|
placeholder="What are you trying to achieve?"
|
|
@keydown.escape="closeModal"
|
|
/>
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>Description</label>
|
|
<textarea
|
|
v-model="newDescription"
|
|
class="modal-textarea"
|
|
placeholder="Optional description..."
|
|
rows="3"
|
|
@keydown.escape="closeModal"
|
|
></textarea>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="modal-btn" @click="closeModal">Cancel</button>
|
|
<button
|
|
class="modal-btn modal-btn-primary"
|
|
@click="createProject"
|
|
:disabled="!newTitle.trim() || creating"
|
|
>
|
|
{{ creating ? "Creating..." : "Create" }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</teleport>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.projects-list {
|
|
max-width: var(--page-max-width);
|
|
margin: 2rem auto;
|
|
padding: 0 var(--page-padding-x);
|
|
overflow-x: clip;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.page-header h1 {
|
|
margin: 0;
|
|
}
|
|
|
|
/* Moss action-primary per Hybrid — list-view utility action,
|
|
not a brand moment. Empty-state .empty-action below keeps accent. */
|
|
|
|
.filter-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
margin-bottom: 1.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: 500;
|
|
}
|
|
|
|
.loading-msg,
|
|
.error-msg {
|
|
color: var(--color-text-muted);
|
|
font-size: 0.9rem;
|
|
margin-top: 1rem;
|
|
}
|
|
.error-msg {
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
|
|
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
|
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
|
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
|
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-action-primary); border-radius: var(--radius-sm); color: var(--color-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
|
.empty-action:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
|
|
|
.skeleton-card {
|
|
height: 140px;
|
|
border-radius: var(--radius-md);
|
|
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-shimmer 1.4s ease infinite;
|
|
}
|
|
@keyframes skeleton-shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
.skeleton-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.projects-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.project-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1rem 1.1rem;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.project-card:hover {
|
|
border-color: var(--color-primary);
|
|
box-shadow: 0 2px 8px var(--color-shadow);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
}
|
|
.project-title {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
color: var(--color-text);
|
|
min-width: 0;
|
|
flex: 1;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.status-badge {
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.15rem 0.45rem;
|
|
border-radius: 999px;
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
}
|
|
.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 {
|
|
font-size: 0.875rem;
|
|
color: var(--color-text);
|
|
margin: 0;
|
|
line-height: 1.4;
|
|
}
|
|
.field-label {
|
|
font-weight: 500;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.project-desc {
|
|
font-size: 0.82rem;
|
|
color: var(--color-text-secondary);
|
|
margin: 0;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.overall-bar-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.72rem;
|
|
margin-top: 0.1rem;
|
|
}
|
|
.overall-bar-track {
|
|
flex: 1;
|
|
height: 6px;
|
|
background: var(--color-border);
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
}
|
|
.overall-bar-fill {
|
|
height: 100%;
|
|
border-radius: 999px;
|
|
background: var(--color-primary);
|
|
transition: width 0.3s ease;
|
|
}
|
|
.overall-bar-pct {
|
|
color: var(--color-text-secondary);
|
|
flex-shrink: 0;
|
|
min-width: 2.5rem;
|
|
text-align: right;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.milestone-bars {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
margin-top: 0.1rem;
|
|
}
|
|
.milestone-bar-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.72rem;
|
|
}
|
|
.milestone-bar-label {
|
|
color: var(--color-text-secondary);
|
|
min-width: 0;
|
|
flex: 0 0 30%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.milestone-bar-track {
|
|
flex: 1;
|
|
height: 5px;
|
|
background: var(--color-border);
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
}
|
|
.milestone-bar-fill {
|
|
height: 100%;
|
|
border-radius: 999px;
|
|
transition: width 0.3s ease;
|
|
}
|
|
.milestone-bar-pct {
|
|
color: var(--color-text-muted);
|
|
flex-shrink: 0;
|
|
min-width: 2.5rem;
|
|
text-align: right;
|
|
}
|
|
|
|
.card-footer {
|
|
margin-top: auto;
|
|
}
|
|
.meta-date {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
/* 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: 480px;
|
|
box-shadow: 0 8px 32px var(--color-shadow);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
.modal-title {
|
|
margin: 0;
|
|
font-size: 1.1rem;
|
|
}
|
|
.modal-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
}
|
|
.modal-field label {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: var(--color-text);
|
|
}
|
|
.required {
|
|
color: var(--color-danger);
|
|
}
|
|
.modal-input,
|
|
.modal-textarea {
|
|
padding: 0.45rem 0.7rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: 0.9rem;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
}
|
|
.modal-input:focus,
|
|
.modal-textarea:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
}
|
|
.modal-textarea {
|
|
resize: vertical;
|
|
}
|
|
.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-primary {
|
|
background: var(--color-action-primary);
|
|
border-color: var(--color-action-primary);
|
|
color: var(--fs-text-on-action);
|
|
}
|
|
.modal-btn-primary:hover:not(:disabled) {
|
|
opacity: 0.9;
|
|
}
|
|
.modal-btn-primary:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.projects-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.modal-card {
|
|
margin: 1rem;
|
|
max-width: calc(100vw - 2rem);
|
|
}
|
|
}
|
|
</style>
|