Make the dashboard a grid of projects with their inbox

DashboardView drops the placeholder for a full-width grid of project cards.
Each card links to the project and lists its inbox cards (status_id === null)
under a "New" heading, or "Nothing new." when empty. Cards are fetched per
project (one GET /projects/{id}/cards each) after the project list resolves.

projects store: fetchProjects() now shares one in-flight request between
concurrent callers (the sidebar and the dashboard mount together).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:02:47 +01:00
co-authored by Claude Sonnet 5
parent cd54b41ab7
commit 8f8ad8593d
6 changed files with 139 additions and 31 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ const router = createRouter({
path: '/dashboard',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, wide: true },
},
{
path: '/projects/:id(\\d+)',
+17 -8
View File
@@ -12,15 +12,24 @@ export const useProjectsStore = defineStore('projects', () => {
const loaded = ref(false)
const loading = ref(false)
async function fetchProjects(): Promise<void> {
loading.value = true
try {
const { projects: fetched } = await apiRequest<{ projects: Project[] }>('/projects', { auth: true })
projects.value = fetched
loaded.value = true
} finally {
loading.value = false
// Concurrent callers (e.g. the sidebar and the dashboard mounting together)
// share one request.
let inFlight: Promise<void> | null = null
function fetchProjects(): Promise<void> {
if (!inFlight) {
loading.value = true
inFlight = apiRequest<{ projects: Project[] }>('/projects', { auth: true })
.then(({ projects: fetched }) => {
projects.value = fetched
loaded.value = true
})
.finally(() => {
loading.value = false
inFlight = null
})
}
return inFlight
}
async function createProject(title: string): Promise<Project> {
+58 -10
View File
@@ -223,21 +223,69 @@ h1 {
font-size: 0.9rem;
}
.under-construction {
margin-top: 1.5rem;
padding: 2.5rem 1rem;
text-align: center;
border: 1px dashed var(--border);
/* --- dashboard: grid of projects with their inbox ------------------- */
.dashboard__grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 1rem;
align-items: start;
}
.project-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.under-construction__icon {
font-size: 2rem;
line-height: 1;
.project-card__title {
font-weight: 600;
font-size: 1.05rem;
color: inherit;
text-decoration: none;
word-break: break-word;
}
.under-construction p {
margin: 0.5rem 0 0;
.project-card__title:hover {
color: var(--accent);
}
.project-card__heading {
margin: 0.3rem 0 0;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.project-card__cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 16rem;
overflow-y: auto;
}
.project-card__cards li {
font-size: 0.9rem;
padding: 0.35rem 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.project-card__empty {
margin: 0;
font-size: 0.85rem;
}
.badge {
+58 -8
View File
@@ -1,22 +1,72 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ApiError, apiRequest } from '../lib/api'
import { useAuthStore } from '../stores/auth'
import { useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
const auth = useAuthStore()
const projects = useProjectsStore()
const loading = ref(true)
const loadError = ref<string | null>(null)
/** project id -> its inbox cards (status_id === null) */
const inbox = ref<Record<number, Card[]>>({})
onMounted(load)
async function load() {
loading.value = true
loadError.value = null
try {
await projects.fetchProjects()
const entries = await Promise.all(
projects.projects.map(async (project): Promise<[number, Card[]]> => {
const { cards } = await apiRequest<{ cards: Card[] }>(`/projects/${project.id}/cards`, {
auth: true,
})
return [project.id, cards.filter((card) => card.status_id === null)]
}),
)
inbox.value = Object.fromEntries(entries)
} catch (e) {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.'
} finally {
loading.value = false
}
}
</script>
<template>
<section class="card">
<h1>Dashboard</h1>
<div class="dashboard">
<div v-if="!auth.emailVerified" class="notice">
Your email address <strong>{{ auth.user?.email }}</strong> has not been
verified yet.
</div>
<div class="under-construction">
<span class="under-construction__icon" aria-hidden="true">🚧</span>
<p>This page is under construction.</p>
<p class="muted">Pick a project from the sidebar to get started.</p>
<p v-if="loadError" class="form-error">{{ loadError }}</p>
<p v-else-if="loading" class="muted">Loading</p>
<p v-else-if="projects.projects.length === 0" class="muted">
No projects yet create one from the sidebar.
</p>
<div v-else class="dashboard__grid">
<article v-for="project in projects.projects" :key="project.id" class="project-card">
<RouterLink
:to="{ name: 'project', params: { id: project.id } }"
class="project-card__title"
>
{{ project.title }}
</RouterLink>
<p class="project-card__heading">New</p>
<ul v-if="inbox[project.id]?.length" class="project-card__cards">
<li v-for="card in inbox[project.id]" :key="card.id">{{ card.text }}</li>
</ul>
<p v-else class="project-card__empty muted">Nothing new.</p>
</article>
</div>
</section>
</div>
</template>