diff --git a/README.md b/README.md index 7584819..a7efa93 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/web/README.md b/web/README.md index 986667a..3569e21 100644 --- a/web/README.md +++ b/web/README.md @@ -52,9 +52,10 @@ it). The current page is highlighted via RouterLink's `active-class`. `` 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 diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 0cfd0a4..ff4970c 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -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+)', diff --git a/web/src/stores/projects.ts b/web/src/stores/projects.ts index 485cf6b..8847108 100644 --- a/web/src/stores/projects.ts +++ b/web/src/stores/projects.ts @@ -12,15 +12,24 @@ export const useProjectsStore = defineStore('projects', () => { const loaded = ref(false) const loading = ref(false) - async function fetchProjects(): Promise { - 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 | null = null + + function fetchProjects(): Promise { + 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 { diff --git a/web/src/style.css b/web/src/style.css index b7ba44b..c179148 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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 { diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 8097d99..364fe6b 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -1,22 +1,72 @@