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
@@ -18,7 +18,7 @@ Each user owns **projects**, and each project holds ordered **cards**.
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done | | 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done | | 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done |
| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done | | 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done |
| 11 | Persistent left sidebar Dashboard link + project list/new-project form; `/` dashboard placeholder | ✅ done | | 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of projects with their "New" inbox cards | ✅ done |
Registration signs the user in immediately and emails a magic link that verifies Registration signs the user in immediately and emails a magic link that verifies
the address; `user.email_verified` stays `false` until the link is opened. See the address; `user.email_verified` stays `false` until the link is opened. See
+4 -3
View File
@@ -52,9 +52,10 @@ it). The current page is highlighted via RouterLink's `active-class`.
`<RouterView :key="route.path">` remounts the view on every path change so `<RouterView :key="route.path">` remounts the view on every path change so
sidebar → project → project navigation always does a fresh load. sidebar → project → project navigation always does a fresh load.
`/` redirects to `/dashboard` (`DashboardView.vue`, an "under construction" `/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid of
placeholder). Signed-out routes (`/login`, `/register`, `/verify-email`) render project cards — each shows the project name and, under a **New** heading, its
without the sidebar. inbox cards (`status_id === null`), fetched per project. Signed-out routes
(`/login`, `/register`, `/verify-email`) render without the sidebar.
## Project detail ## Project detail
+1 -1
View File
@@ -9,7 +9,7 @@ const router = createRouter({
path: '/dashboard', path: '/dashboard',
name: 'dashboard', name: 'dashboard',
component: () => import('../views/DashboardView.vue'), component: () => import('../views/DashboardView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true, wide: true },
}, },
{ {
path: '/projects/:id(\\d+)', path: '/projects/:id(\\d+)',
+13 -4
View File
@@ -12,15 +12,24 @@ export const useProjectsStore = defineStore('projects', () => {
const loaded = ref(false) const loaded = ref(false)
const loading = ref(false) const loading = ref(false)
async function fetchProjects(): Promise<void> { // 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 loading.value = true
try { inFlight = apiRequest<{ projects: Project[] }>('/projects', { auth: true })
const { projects: fetched } = await apiRequest<{ projects: Project[] }>('/projects', { auth: true }) .then(({ projects: fetched }) => {
projects.value = fetched projects.value = fetched
loaded.value = true loaded.value = true
} finally { })
.finally(() => {
loading.value = false loading.value = false
inFlight = null
})
} }
return inFlight
} }
async function createProject(title: string): Promise<Project> { async function createProject(title: string): Promise<Project> {
+58 -10
View File
@@ -223,21 +223,69 @@ h1 {
font-size: 0.9rem; font-size: 0.9rem;
} }
.under-construction { /* --- dashboard: grid of projects with their inbox ------------------- */
margin-top: 1.5rem;
padding: 2.5rem 1rem; .dashboard__grid {
text-align: center; margin-top: 1rem;
border: 1px dashed var(--border); 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; border-radius: 10px;
} }
.under-construction__icon { .project-card__title {
font-size: 2rem; font-weight: 600;
line-height: 1; font-size: 1.05rem;
color: inherit;
text-decoration: none;
word-break: break-word;
} }
.under-construction p { .project-card__title:hover {
margin: 0.5rem 0 0; 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 { .badge {
+58 -8
View File
@@ -1,22 +1,72 @@
<script setup lang="ts"> <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 { useAuthStore } from '../stores/auth'
import { useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
const auth = useAuthStore() 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> </script>
<template> <template>
<section class="card"> <div class="dashboard">
<h1>Dashboard</h1>
<div v-if="!auth.emailVerified" class="notice"> <div v-if="!auth.emailVerified" class="notice">
Your email address <strong>{{ auth.user?.email }}</strong> has not been Your email address <strong>{{ auth.user?.email }}</strong> has not been
verified yet. verified yet.
</div> </div>
<div class="under-construction"> <p v-if="loadError" class="form-error">{{ loadError }}</p>
<span class="under-construction__icon" aria-hidden="true">🚧</span> <p v-else-if="loading" class="muted">Loading</p>
<p>This page is under construction.</p> <p v-else-if="projects.projects.length === 0" class="muted">
<p class="muted">Pick a project from the sidebar to get started.</p> 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>
</div> </div>
</section>
</template> </template>