[web] use a dashboard type table and compact sign-in sizing #195

Merged
eleith merged 2 commits from ui/piece-types-table into main 2026-11-05 04:18:19 +00:00
6 changed files with 186 additions and 48 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Show dashboard piece types in a compact table while retaining their names, counts, browse links and type-specific create actions.
+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Use compact sign-in typography, card sizing and form spacing while preserving themed colors and fonts, 16px credential inputs, 44px controls and existing authentication behavior. Leave public-site typography unchanged.
+123 -21
View File
@@ -120,24 +120,35 @@
{#if data.pieceStats.byType.length}
<section class="dashboard-section">
<h2 class="section-header">pieces by type</h2>
<div class="stats-row">
{#each data.pieceStats.byType as { type, count } (type)}
<div class="stat-card-wrapper">
<a class="stat-card" href={resolve(`pieces/${type}`)}>
<span class="stat-number">{count.toLocaleString()}</span>
<span class="stat-label">{type}</span>
</a>
<a
class="quick-add"
href={resolve(`admin/pieces/create?type=${type}`)}
aria-label="add {type}"
title="add {type}"
>
<PlusIcon style="width: 1em; height: 1em;" />
</a>
</div>
{/each}
<h2 id="piece-types-title" class="section-header">pieces by type</h2>
<div class="table-card">
<table class="type-table" aria-labelledby="piece-types-title">
<thead>
<tr>
<th scope="col">type</th>
<th scope="col" class="type-count">pieces</th>
<th scope="col" class="type-actions">action</th>
</tr>
</thead>
<tbody>
{#each data.pieceStats.byType as { type, count } (type)}
<tr>
<th scope="row"><a href={resolve(`pieces/${type}`)} title={type}>{type}</a></th>
<td class="type-count">{count.toLocaleString()}</td>
<td class="type-actions">
<a
class="type-add"
href={resolve(`admin/pieces/create?type=${type}`)}
aria-label="add {type}"
title="add {type}"
>
<PlusIcon aria-hidden="true" style="width: 1em; height: 1em;" />
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
@@ -175,6 +186,11 @@
<style>
section.dashboard {
--middle-column-width: 8rem;
--last-column-width: 9rem;
--column-gap: var(--space-3);
--row-block-padding: var(--space-3);
--row-inline-padding: var(--space-4);
margin: var(--space-4);
margin-bottom: var(--space-8);
margin-left: auto;
@@ -272,13 +288,92 @@
border-radius: var(--radius-small);
}
.type-table {
width: 100%;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
font: inherit;
text-align: left;
}
.type-table th,
.type-table td {
padding: var(--row-block-padding) var(--row-inline-padding);
text-align: left;
vertical-align: middle;
border-bottom: 1px solid var(--color-outline-variant);
}
.type-table th {
font-weight: inherit;
}
.type-table thead {
background: var(--color-surface-container-high);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-on-surface-variant);
}
.type-table tbody tr:last-child > * {
border-bottom: none;
}
.type-table tbody tr:hover {
background: var(--color-surface-container-low);
}
.type-table a {
color: inherit;
text-decoration: none;
}
.type-table th a {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.type-table a:hover {
color: var(--color-primary);
text-decoration: underline;
}
.type-table a:focus-visible {
outline: auto;
outline-offset: 2px;
}
/* Table cells include padding; the recent grid sizes its content tracks separately. */
.type-count {
width: calc(var(--middle-column-width) + var(--column-gap));
font-size: var(--font-size-xs);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.type-actions {
width: calc(var(--last-column-width) + 2 * var(--row-inline-padding));
}
.type-add {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius-small);
}
.recent-header,
.recent-row {
display: grid;
grid-template-columns: 1fr 8rem 9rem;
gap: var(--space-3);
grid-template-columns: 1fr var(--middle-column-width) var(--last-column-width);
gap: var(--column-gap);
align-items: center;
padding: var(--space-3) var(--space-4);
padding: var(--row-block-padding) var(--row-inline-padding);
}
.recent-header {
@@ -337,6 +432,13 @@
}
@media screen and (max-width: 640px) {
.type-table {
--middle-column-width: 5rem;
--last-column-width: 3rem;
--column-gap: var(--space-2);
--row-inline-padding: var(--space-2);
}
.recent-header {
display: none;
}
+27 -3
View File
@@ -4,6 +4,7 @@ import type { ComponentProps } from 'svelte'
import type { WebPieces } from '@luzzle/web.db'
import { resolve } from '$app/paths'
import { makePublicConfig } from '#lib/server/config.fixture.js'
import type { PieceStats } from '#lib/server/dashboardStats.js'
import Page from './+page.svelte'
vi.mock('$app/paths', () => ({
@@ -33,7 +34,7 @@ beforeEach(() => {
})
afterEach(() => vi.useRealTimers())
function html(pieces = [piece]) {
function html(pieces = [piece], pieceStats: PieceStats = { total: pieces.length, byType: [] }) {
const config = makePublicConfig({
url: { app: 'http://localhost', app_assets: '', luzzle_assets: '' }
})
@@ -44,8 +45,8 @@ function html(pieces = [piece]) {
pieceTypes: ['books'],
config,
meta: { title: 'Dashboard' },
pieceStats: { total: pieces.length, byType: [] },
fileCount: pieces.length,
pieceStats,
fileCount: pieceStats.total,
recentlyEditedPieces: pieces,
lastPublish: null,
setup: {
@@ -60,6 +61,29 @@ function html(pieces = [piece]) {
}).body.replace(/<!--[\s\S]*?-->/g, '')
}
test('type rows retain their name, count, browse link and type-specific create link', () => {
const stats = {
total: 15,
byType: [
{ type: 'books', count: 12 },
{ type: 'films', count: 3 }
]
}
const output = html([], stats)
const body = output.match(/<tbody\b[^>]*>([\s\S]*?)<\/tbody>/)?.[1] ?? ''
const rows = [...body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/g)].map((match) => match[1])
expect(rows).toHaveLength(stats.byType.length)
for (const [index, { type, count }] of stats.byType.entries()) {
const row = rows[index]
expect(row).toContain(`href="/pieces/${type}"`)
expect(row).toContain(`>${type}</a>`)
expect(row).toMatch(new RegExp(`<td\\b[^>]*>\\s*${count.toLocaleString()}\\s*</td>`))
expect(row).toContain(`href="/admin/pieces/create?type=${type}"`)
expect(row).toContain(`aria-label="add ${type}"`)
}
})
test('each recent row is one native link containing title, type, and updated time', () => {
const output = html([piece, { ...piece, id: 'book-2', file_path: 'books/another.books.md' }])
const list = output.match(/<ul\b[^>]*class="recent-list[^"]*"[^>]*>([\s\S]*?)<\/ul>/)?.[1]
+6 -6
View File
@@ -33,18 +33,18 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-5);
padding: 8px 16px;
color: var(--color-on-surface-variant);
}
.signin-tools a {
display: inline-flex;
align-items: center;
gap: var(--space-2);
gap: 8px;
min-height: 44px;
color: inherit;
text-decoration: none;
font-size: var(--font-size-small);
font-size: 14px;
}
.signin-tools a:hover {
@@ -61,7 +61,7 @@
border-radius: var(--radius-small);
background: transparent;
color: inherit;
font-size: var(--font-size-large);
font-size: 20px;
cursor: pointer;
}
@@ -77,13 +77,13 @@
align-items: center;
justify-content: center;
min-width: 0;
padding: var(--space-6) var(--space-4) var(--space-8);
padding: 24px 16px 32px;
}
@media (max-width: 30rem) {
main {
align-items: flex-start;
padding-block-start: var(--space-5);
padding-block-start: 20px;
}
}
</style>
+20 -18
View File
@@ -153,11 +153,13 @@
<style>
.signin-card {
width: 100%;
max-width: 25rem;
max-width: 400px;
min-width: 0;
display: grid;
gap: var(--space-6);
padding: var(--space-8);
gap: 20px;
padding: 32px;
font-size: 14px;
line-height: 1.5;
background: var(--color-surface-container-low);
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-large);
@@ -168,7 +170,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
gap: 8px;
text-align: center;
}
@@ -178,7 +180,7 @@
h1 {
margin: 0;
font-size: var(--font-size-xl);
font-size: 24px;
font-weight: var(--font-weight-bold);
line-height: 1.2;
overflow-wrap: anywhere;
@@ -189,32 +191,32 @@
.javascript-notice {
margin: 0;
color: var(--color-on-surface-variant);
font-size: var(--font-size-small);
font-size: 14px;
line-height: 1.5;
text-align: center;
}
.signin-form {
display: grid;
gap: var(--space-5);
gap: 16px;
}
.field {
display: grid;
gap: var(--space-2);
gap: 4px;
min-width: 0;
}
label {
font-size: var(--font-size-small);
font-size: 14px;
font-weight: var(--font-weight-medium);
}
input {
width: 100%;
min-width: 0;
min-height: 50px;
padding: var(--space-3) var(--space-4);
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--color-outline);
border-radius: var(--radius-small);
background: var(--color-surface-container-highest);
@@ -239,25 +241,25 @@
justify-content: center;
width: 100%;
height: auto;
min-height: 50px;
padding: var(--space-3) var(--space-4);
min-height: 44px;
padding: 10px 12px;
text-transform: none;
white-space: normal;
overflow-wrap: anywhere;
font-size: var(--font-size-normal);
font-size: 16px;
line-height: 1.4;
}
.error-banner {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
gap: 8px;
padding: 12px;
border: 1px solid var(--color-error);
border-radius: var(--radius-small);
background: var(--color-error-container);
color: var(--color-on-error-container);
font-size: var(--font-size-small);
font-size: 14px;
line-height: 1.5;
overflow-wrap: anywhere;
}
@@ -274,7 +276,7 @@
@media (max-width: 30rem) {
.signin-card {
padding: var(--space-4);
padding: 20px;
border: none;
box-shadow: none;
background: transparent;