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:
@@ -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 |
|
||||
| 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 |
|
||||
| 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
|
||||
the address; `user.email_verified` stays `false` until the link is opened. See
|
||||
|
||||
+4
-3
@@ -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
|
||||
sidebar → project → project navigation always does a fresh load.
|
||||
|
||||
`/` redirects to `/dashboard` (`DashboardView.vue`, an "under construction"
|
||||
placeholder). Signed-out routes (`/login`, `/register`, `/verify-email`) render
|
||||
without the sidebar.
|
||||
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid of
|
||||
project cards — each shows the project name and, under a **New** heading, its
|
||||
inbox cards (`status_id === null`), fetched per project. Signed-out routes
|
||||
(`/login`, `/register`, `/verify-email`) render without the sidebar.
|
||||
|
||||
## Project detail
|
||||
|
||||
|
||||
@@ -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+)',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user