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 |
|
| 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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+)',
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user