Sidebar: pin the four actions as an icon row at the bottom

Graph, new page, import, and trash collapse from scattered text links
into one icon row pinned to the sidebar's bottom edge, in that order,
each with a hover hint (the trash reads "Papierkorb anzeigen"). The
new-page button now toggles the inline form, which still renders above
the footer with the same classes; the import trigger becomes an icon
whose progress list floats above the row so the icons stay put. All
e2e class hooks (.sidebar__new-page, .sidebar__graph-link,
.sidebar__import-*) are unchanged.

Fixes #124

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
This commit is contained in:
Claude Fable 5 2026-07-15 13:09:31 +02:00
parent 1781f12f6e
commit 44a90a53ac
7 changed files with 64 additions and 45 deletions

View File

@ -1,4 +1,5 @@
import { IMPORT_EXTENSIONS } from '@dorfteich/shared'; import { IMPORT_EXTENSIONS } from '@dorfteich/shared';
import { Import } from 'lucide-react';
import { useRef } from 'react'; import { useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -28,10 +29,12 @@ export function ImportControl({ pondId, pondSlug }: ImportControlProps): React.J
<div className="sidebar__import"> <div className="sidebar__import">
<button <button
type="button" type="button"
className="linklike sidebar__import-action" className="icon-button sidebar__import-action"
title={t('action')}
aria-label={t('action')}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
{t('action')} <Import aria-hidden />
</button> </button>
<input <input
ref={inputRef} ref={inputRef}

View File

@ -8,7 +8,15 @@ import type {
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { buildTree, collectSubtreeIds, labelDepth } from '@dorfteich/shared'; import { buildTree, collectSubtreeIds, labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronRight, FileText, Folder, FolderOpen } from 'lucide-react'; import {
ChevronRight,
FilePlus,
FileText,
Folder,
FolderOpen,
Trash2,
Waypoints,
} from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -329,7 +337,7 @@ function SidebarContent({
{moveError !== null && <FormError error={moveError} />} {moveError !== null && <FormError error={moveError} />}
{creating ? ( {creating && (
<NewPageForm <NewPageForm
pondId={pond.id} pondId={pond.id}
pondSlug={pondSlug} pondSlug={pondSlug}
@ -341,33 +349,44 @@ function SidebarContent({
}} }}
onCancel={() => setCreating(false)} onCancel={() => setCreating(false)}
/> />
) : (
<button
type="button"
className="linklike sidebar__new-page"
onClick={() => setCreating(true)}
>
{t('layout.sidebar.newPage')}
</button>
)} )}
<ImportControl pondId={pond.id} pondSlug={pondSlug} />
{/* Keyboard/drag reordering announcements for screen readers. */} {/* Keyboard/drag reordering announcements for screen readers. */}
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite"> <p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
{announcement} {announcement}
</p> </p>
{/* Graph + trash stay text links, pinned to the sidebar's bottom {/* All four actions live as an icon row pinned to the sidebar's
(M10 follow-up; pond settings moved to the TopBar gear icon). bottom (#124): graph, new page, import, trash hover hints via
The graph is for every member (#112); the trash is owner-only. */} title. The graph is for every member (#112); trash is owner-only. */}
<div className="sidebar__footer"> <div className="sidebar__footer">
<Link to={`/p/${pondSlug}/graph`} className="linklike sidebar__graph-link"> <Link
{t('graph:link')} to={`/p/${pondSlug}/graph`}
className="icon-button sidebar__graph-link"
title={t('graph:link')}
aria-label={t('graph:link')}
>
<Waypoints aria-hidden />
</Link> </Link>
<button
type="button"
className="icon-button sidebar__new-page"
title={t('layout.sidebar.newPageHint')}
aria-label={t('layout.sidebar.newPageHint')}
aria-expanded={creating}
onClick={() => setCreating(!creating)}
>
<FilePlus aria-hidden />
</button>
<ImportControl pondId={pond.id} pondSlug={pondSlug} />
{isOwner && ( {isOwner && (
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link"> <Link
{t('editor:trash.link')} to={`/p/${pondSlug}/trash`}
className="icon-button sidebar__trash-link"
title={t('editor:trash.showLink')}
aria-label={t('editor:trash.showLink')}
>
<Trash2 aria-hidden />
</Link> </Link>
)} )}
</div> </div>

View File

@ -148,10 +148,6 @@ button {
padding: var(--space-1) var(--space-2); padding: var(--space-1) var(--space-2);
} }
.sidebar__trash-link {
font-size: 0.85rem;
white-space: nowrap;
}
.sidebar__pages { .sidebar__pages {
list-style: none; list-style: none;
@ -369,13 +365,6 @@ button {
font-weight: 600; font-weight: 600;
} }
.sidebar__new-page {
font-size: 0.9rem;
/* The sidebar is a flex column now; keep the stretched button reading
like the left-aligned link it used to be. */
text-align: left;
}
.sidebar__new-page-form { .sidebar__new-page-form {
margin-top: var(--space-2); margin-top: var(--space-2);
} }
@ -390,21 +379,29 @@ button {
gap: var(--space-3); gap: var(--space-3);
} }
/* The import control sits in the pinned footer icon row (#124); its
progress list floats ABOVE the row so the icons stay put. */
.sidebar__import { .sidebar__import {
margin-top: var(--space-2); position: relative;
} display: inline-flex;
.sidebar__import-action {
font-size: 0.9rem;
} }
.sidebar__import-list { .sidebar__import-list {
list-style: none; list-style: none;
margin: var(--space-2) 0 0; position: absolute;
padding: 0; bottom: calc(100% + var(--space-1));
left: 0;
width: 14rem;
margin: 0;
padding: var(--space-2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgb(0 0 0 / 10%);
z-index: 30;
} }
.sidebar__import-item { .sidebar__import-item {
@ -1760,12 +1757,8 @@ button {
margin-top: auto; margin-top: auto;
padding-top: var(--space-4); padding-top: var(--space-4);
display: flex; display: flex;
gap: var(--space-3); align-items: center;
} gap: var(--space-1);
.sidebar__graph-link {
font-size: 0.85rem;
white-space: nowrap;
} }
/* Knowledge graph (issue #112) + local graph panel (#113). */ /* Knowledge graph (issue #112) + local graph panel (#113). */

View File

@ -21,6 +21,7 @@
"reparented": "{{title}} unter {{parent}} eingeordnet." "reparented": "{{title}} unter {{parent}} eingeordnet."
}, },
"newPage": "+ Neue Seite", "newPage": "+ Neue Seite",
"newPageHint": "Neue Seite",
"newPageTitle": "Titel", "newPageTitle": "Titel",
"newPageUnder": "Wird unter „{{title}}“ angelegt.", "newPageUnder": "Wird unter „{{title}}“ angelegt.",
"create": "Erstellen", "create": "Erstellen",

View File

@ -142,6 +142,7 @@
}, },
"trash": { "trash": {
"link": "Papierkorb", "link": "Papierkorb",
"showLink": "Papierkorb anzeigen",
"title": "Papierkorb — {{pond}}", "title": "Papierkorb — {{pond}}",
"empty": "Der Papierkorb ist leer.", "empty": "Der Papierkorb ist leer.",
"deletedAt": "Gelöscht am {{date}}", "deletedAt": "Gelöscht am {{date}}",

View File

@ -21,6 +21,7 @@
"reparented": "Moved {{title}} under {{parent}}." "reparented": "Moved {{title}} under {{parent}}."
}, },
"newPage": "+ New page", "newPage": "+ New page",
"newPageHint": "New page",
"newPageTitle": "Title", "newPageTitle": "Title",
"newPageUnder": "Will be created under “{{title}}”.", "newPageUnder": "Will be created under “{{title}}”.",
"create": "Create", "create": "Create",

View File

@ -142,6 +142,7 @@
}, },
"trash": { "trash": {
"link": "Trash", "link": "Trash",
"showLink": "Show trash",
"title": "Trash — {{pond}}", "title": "Trash — {{pond}}",
"empty": "The trash is empty.", "empty": "The trash is empty.",
"deletedAt": "Deleted {{date}}", "deletedAt": "Deleted {{date}}",