My Studies: show studies as a grid or a list
Build and Push Docker Image / build-and-push (push) Failing after 19s

A Grid / List toggle beside the sort and tag filters. List is one compact
row per study (small book cover, title with Rename, passage, tags, date,
Resume and Delete). The choice is remembered on the device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:00:30 -04:00
1 parent a0971eab29
commit dc825f0112
3 files changed
+57 -13

No files matched your search

+16 -7
View File
@@ -1,13 +1,13 @@
// The picture on a study's card: a coloured cover for the kind of book, with the book's name.
import { coverFor } from '../utils/bookCovers.js';
export default function BookCover({ summary, className = '' }) {
export default function BookCover({ summary, className = '', compact = false }) {
const { group, name, extra } = coverFor(summary);
const [from, to] = group.colors;
const id = `cover-${group.id}`;
return (
<div className={`relative overflow-hidden rounded-xl ${className}`} role="img" aria-label={`${name}${extra ? ` and ${extra} more` : ''}, ${group.label}`}>
<svg viewBox="0 0 400 120" preserveAspectRatio="xMidYMid slice" className="block h-full w-full" aria-hidden="true">
<svg viewBox={compact ? '0 0 200 120' : '0 0 400 120'} preserveAspectRatio="xMidYMid slice" className="block h-full w-full" aria-hidden="true">
<defs>
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor={from} /><stop offset="1" stopColor={to} /></linearGradient>
</defs>
@@ -16,11 +16,20 @@ export default function BookCover({ summary, className = '' }) {
<circle cx="360" cy="20" r="70" /><circle cx="360" cy="20" r="50" /><circle cx="360" cy="20" r="30" />
<circle cx="30" cy="120" r="60" /><circle cx="30" cy="120" r="40" />
</g>
<rect x="20" y="22" width="28" height="3" rx="1.5" fill="#fff" fillOpacity="0.7" />
<text x="20" y="62" fill="#fff" fontFamily="Georgia, 'Times New Roman', serif" fontSize={name.length > 13 ? 26 : 32} fontWeight="600">{name}</text>
<text x="20" y="90" fill="#fff" fillOpacity="0.8" fontFamily="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" fontSize="13" letterSpacing="1.5">
{group.label.toUpperCase()}{extra ? ` · +${extra} MORE` : ''}
</text>
<rect x={compact ? 14 : 20} y="22" width="28" height="3" rx="1.5" fill="#fff" fillOpacity="0.7" />
{compact ? (
<>
<text x="14" y="70" fill="#fff" fontFamily="Georgia, 'Times New Roman', serif" fontSize={name.length > 11 ? 21 : name.length > 8 ? 25 : 30} fontWeight="600">{name}</text>
{extra > 0 && <text x="14" y="98" fill="#fff" fillOpacity="0.85" fontFamily="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" fontSize="14" letterSpacing="1">+{extra} MORE</text>}
</>
) : (
<>
<text x="20" y="62" fill="#fff" fontFamily="Georgia, 'Times New Roman', serif" fontSize={name.length > 13 ? 26 : 32} fontWeight="600">{name}</text>
<text x="20" y="90" fill="#fff" fillOpacity="0.8" fontFamily="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" fontSize="13" letterSpacing="1.5">
{group.label.toUpperCase()}{extra ? ` · +${extra} MORE` : ''}
</text>
</>
)}
</svg>
</div>
);
+22 -6
View File
@@ -51,6 +51,12 @@ export default function HomePage() {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
}); // 'recent' | 'title' | 'passage'
const [homeTagFilter, setHomeTagFilter] = useState('');
const [homeView, setHomeView] = useState(() => {
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
}); // 'grid' | 'list'
useEffect(() => {
try { localStorage.setItem('homeView', homeView); } catch {}
}, [homeView]);
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
const fullTextTimerRef = useRef(null);
const [renamingId, setRenamingId] = useState(null);
@@ -163,6 +169,12 @@ export default function HomePage() {
<option value="passage">Passage</option>
</select>
</label>
<div role="group" aria-label="Studies layout" className="ml-auto flex overflow-hidden rounded-xl border border-[var(--r-line)] text-sm">
{[['grid', 'Grid'], ['list', 'List']].map(([id, label]) => (
<button key={id} type="button" aria-pressed={homeView === id} onClick={() => setHomeView(id)}
className={`px-3 py-1.5 ${homeView === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
))}
</div>
{allTags.length > 0 && (
<label className="text-sm text-[var(--r-sub)]">Tag{' '}
<select value={homeTagFilter} onChange={(e) => setHomeTagFilter(e.target.value)} className={`${fieldCls} ml-1`}>
@@ -199,11 +211,15 @@ export default function HomePage() {
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
{visible.map((entry) => (
<div key={entry.id} className="flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<BookCover summary={entry.chapterSummary} className="h-24" />
<div className="flex-1">
<div key={entry.id} className={homeView === 'list'
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
{homeView === 'list'
? <BookCover summary={entry.chapterSummary} compact className="h-14 w-full sm:w-28 sm:shrink-0" />
: <BookCover summary={entry.chapterSummary} className="h-24" />}
<div className="min-w-0 flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
@@ -216,7 +232,7 @@ export default function HomePage() {
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
</div>
) : (
<div className="flex items-start justify-between gap-2">
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
<h2 className="text-base font-medium">{entry.title}</h2>
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
</div>
@@ -231,7 +247,7 @@ export default function HomePage() {
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
</div>
<div className="flex items-center gap-2">
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete</button>
</div>
+19
View File
@@ -27,6 +27,25 @@ describe('Home page', () => {
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
test('studies can be shown as a grid or a list, and the choice is remembered', async () => {
seedProject();
seedProject({ id: 'p2', title: 'Second Study' });
const user = userEvent.setup();
const { unmount } = render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
const layout = screen.getByRole('group', { name: /studies layout/i });
expect(within(layout).getByRole('button', { name: 'Grid' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('heading', { name: 'Titus Notes' }).closest('.sm\\:grid-cols-2')).not.toBeNull();
await user.click(within(layout).getByRole('button', { name: 'List' }));
expect(within(layout).getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('heading', { name: 'Titus Notes' }).closest('.sm\\:grid-cols-2')).toBeNull();
expect(screen.getAllByRole('button', { name: 'Resume' })).toHaveLength(2); // everything is still there
expect(localStorage.getItem('homeView')).toBe('list');
unmount();
await renderApp(); // reopening keeps the list
expect(within(screen.getByRole('group', { name: /studies layout/i })).getByRole('button', { name: 'List' })).toHaveAttribute('aria-pressed', 'true');
});
test('searching 3+ characters finds matches inside study notes', async () => {
seedProject();
const user = userEvent.setup();