Sidebar folder view, label view, and the view toggle (#108)
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
Some checks failed
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
The sidebar now presents pages as a collapsible tree built from parentId (folder view) or grouped under the hierarchical label tree (label view, read-only; multi-label pages appear under each label, untagged ones in an 'unlabeled' group). The pond owner sets the default via a new sidebarView pond setting (PATCH-merged like the other keys); every user can override it locally (ui.sidebar.view.<pondId>), and the toggle sits above the page list. Collapse state persists per pond. New pages created while a page is open become its children — the inline form says so and sends parentId. Reordering (buttons and drag-between) now operates within one sibling group; the label filter stays a folder-view feature and falls back to the flat list while active, so the filtered order is never mistaken for a partial tree. SidebarContent is keyed by pond id so the per-pond localStorage hooks mount with the right key. e2e hooks (.sidebar__pages, .sidebar__page, reorder buttons) kept; reorder/labels/content packs green locally, plus a live smoke of nesting, collapse persistence, and both views. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
12ff3c099f
commit
15184876bd
@ -150,6 +150,7 @@ export class PondsService {
|
|||||||
// whichever of the settings keys this request changes (#26/#66/#91/#104).
|
// whichever of the settings keys this request changes (#26/#66/#91/#104).
|
||||||
const settingsChanged =
|
const settingsChanged =
|
||||||
input.sidebarSort !== undefined ||
|
input.sidebarSort !== undefined ||
|
||||||
|
input.sidebarView !== undefined ||
|
||||||
input.fonts !== undefined ||
|
input.fonts !== undefined ||
|
||||||
input.commentPolicy !== undefined ||
|
input.commentPolicy !== undefined ||
|
||||||
input.apiEnabled !== undefined ||
|
input.apiEnabled !== undefined ||
|
||||||
@ -159,6 +160,7 @@ export class PondsService {
|
|||||||
: {
|
: {
|
||||||
...(pond.settings as object),
|
...(pond.settings as object),
|
||||||
...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}),
|
...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}),
|
||||||
|
...(input.sidebarView !== undefined ? { sidebarView: input.sidebarView } : {}),
|
||||||
...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
|
...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
|
||||||
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
|
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
|
||||||
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
...(input.apiEnabled !== undefined ? { apiEnabled: input.apiEnabled } : {}),
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import { apiPost } from '../lib/api';
|
|||||||
interface NewPageFormProps {
|
interface NewPageFormProps {
|
||||||
pondId: string;
|
pondId: string;
|
||||||
pondSlug: string;
|
pondSlug: string;
|
||||||
|
/** Parent for the new page (issue #108): the currently open page, so new
|
||||||
|
* pages nest under where the user is; `null` creates at the root. */
|
||||||
|
parentId?: string | null;
|
||||||
|
parentTitle?: string;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@ -19,6 +23,8 @@ interface NewPageFormProps {
|
|||||||
export function NewPageForm({
|
export function NewPageForm({
|
||||||
pondId,
|
pondId,
|
||||||
pondSlug,
|
pondSlug,
|
||||||
|
parentId = null,
|
||||||
|
parentTitle,
|
||||||
onCreated,
|
onCreated,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: NewPageFormProps): React.JSX.Element {
|
}: NewPageFormProps): React.JSX.Element {
|
||||||
@ -30,7 +36,7 @@ export function NewPageForm({
|
|||||||
const onSubmit = form.handleSubmit(async (input) => {
|
const onSubmit = form.handleSubmit(async (input) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const page = await apiPost<PageView>(`/ponds/${pondId}/pages`, input);
|
const page = await apiPost<PageView>(`/ponds/${pondId}/pages`, { ...input, parentId });
|
||||||
onCreated();
|
onCreated();
|
||||||
navigate(`/p/${pondSlug}/${page.slug}`);
|
navigate(`/p/${pondSlug}/${page.slug}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -51,6 +57,11 @@ export function NewPageForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{parentId && parentTitle && (
|
||||||
|
<p className="sidebar__new-page-parent">
|
||||||
|
{t('layout.sidebar.newPageUnder', { title: parentTitle })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="sidebar__new-page-actions">
|
<div className="sidebar__new-page-actions">
|
||||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||||
{t('layout.sidebar.create')}
|
{t('layout.sidebar.create')}
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import type { PageListItemView, PondView, SidebarSortMode } from '@dorfteich/shared';
|
import type {
|
||||||
import { collectSubtreeIds, labelDepth } from '@dorfteich/shared';
|
LabelView,
|
||||||
|
PageListItemView,
|
||||||
|
PondView,
|
||||||
|
SidebarSortMode,
|
||||||
|
SidebarViewMode,
|
||||||
|
TreeNode,
|
||||||
|
} 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 { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -10,6 +17,7 @@ import { ImportControl } from '../import/ImportControl';
|
|||||||
import { LabelChips } from '../labels/LabelChips';
|
import { LabelChips } from '../labels/LabelChips';
|
||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
|
import { usePersistentState } from '../lib/use-persistent-state';
|
||||||
import { NewPageForm } from './NewPageForm';
|
import { NewPageForm } from './NewPageForm';
|
||||||
import { dropIndex, neighborsForMove } from './reorder';
|
import { dropIndex, neighborsForMove } from './reorder';
|
||||||
import { useCurrentPondRoute } from './use-pond-route';
|
import { useCurrentPondRoute } from './use-pond-route';
|
||||||
@ -19,23 +27,20 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
|
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
|
||||||
|
const VIEW_MODES: SidebarViewMode[] = ['folders', 'labels'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left sidebar: the current pond's page list, sort mode, active-page
|
* Left sidebar: the current pond's page list, sort mode, active-page
|
||||||
* highlight, "new page" flow (issue #26), and label chips + a
|
* highlight, "new page" flow (issue #26), and label chips + a
|
||||||
* descendant-inclusive label filter (issue #44). Collapse behavior and the
|
* descendant-inclusive label filter (issue #44). Since issue #108 pages
|
||||||
* layout contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
|
* render as a collapsible tree ("folders") or grouped under the label tree
|
||||||
|
* ("labels") — the pond default (`settings.sidebarView`) is owner-set and
|
||||||
|
* every user can override it locally. Collapse behavior and the layout
|
||||||
|
* contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
|
||||||
*/
|
*/
|
||||||
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { t: tLabels } = useTranslation('labels');
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
const { user } = useAuth();
|
|
||||||
const { pondSlug, pageSlug } = useCurrentPondRoute();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
|
|
||||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
|
||||||
const [announcement, setAnnouncement] = useState('');
|
|
||||||
|
|
||||||
const pond = useQuery({
|
const pond = useQuery({
|
||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
@ -43,15 +48,59 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
enabled: pondSlug !== null,
|
enabled: pondSlug !== null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
|
||||||
|
aria-hidden={collapsed}
|
||||||
|
aria-label={t('layout.sidebar.label')}
|
||||||
|
>
|
||||||
|
{!pond.data ? (
|
||||||
|
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
||||||
|
) : (
|
||||||
|
// Keyed by pond so the per-pond persistent hooks (view override,
|
||||||
|
// collapsed nodes) mount with the right localStorage key.
|
||||||
|
<SidebarContent key={pond.data.id} pond={pond.data} pondSlug={pondSlug!} />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({
|
||||||
|
pond,
|
||||||
|
pondSlug,
|
||||||
|
}: {
|
||||||
|
pond: PondView;
|
||||||
|
pondSlug: string;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { t: tLabels } = useTranslation('labels');
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { pageSlug } = useCurrentPondRoute();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [announcement, setAnnouncement] = useState('');
|
||||||
|
|
||||||
|
// Per-user view override (issue #108); `null` follows the pond default.
|
||||||
|
const [viewOverride, setViewOverride] = usePersistentState<SidebarViewMode | null>(
|
||||||
|
`ui.sidebar.view.${pond.id}`,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const view: SidebarViewMode = viewOverride ?? pond.settings.sidebarView;
|
||||||
|
const [collapsedIds, setCollapsedIds] = usePersistentState<string[]>(
|
||||||
|
`ui.sidebar.collapsedPages.${pond.id}`,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const pages = useQuery({
|
const pages = useQuery({
|
||||||
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
|
queryKey: ['pages', pond.id, pond.settings.sidebarSort],
|
||||||
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.data!.id}/pages`),
|
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.id}/pages`),
|
||||||
enabled: Boolean(pond.data),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { flat, byId } = usePondLabels(pond.data?.id);
|
const { flat, byId } = usePondLabels(pond.id);
|
||||||
|
|
||||||
const isOwner = Boolean(user && pond.data && user.id === pond.data.ownerId);
|
const isOwner = Boolean(user && user.id === pond.ownerId);
|
||||||
|
|
||||||
// A filter on a label matches pages tagged with that label or any of its
|
// A filter on a label matches pages tagged with that label or any of its
|
||||||
// descendants (permissions/vision: sub-labels belong to their parent).
|
// descendants (permissions/vision: sub-labels belong to their parent).
|
||||||
@ -66,6 +115,12 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
? pages.data
|
? pages.data
|
||||||
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
|
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
|
||||||
|
|
||||||
|
// The page tree, built from the list's global sort order (issue #108); a
|
||||||
|
// filtered list falls back to the flat rendering, so no filter here.
|
||||||
|
const tree = useMemo(() => buildTree(pages.data ?? []), [pages.data]);
|
||||||
|
|
||||||
|
const currentPage = pageSlug ? pages.data?.find((p) => p.slug === pageSlug) : undefined;
|
||||||
|
|
||||||
function toggleFilter(id: string, on: boolean): void {
|
function toggleFilter(id: string, on: boolean): void {
|
||||||
setFilterIds((prev) => {
|
setFilterIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@ -75,215 +130,416 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSortMode(mode: SidebarSortMode): Promise<void> {
|
function toggleCollapsed(id: string): void {
|
||||||
if (!pond.data) return;
|
setCollapsedIds(
|
||||||
await apiPatch(`/ponds/${pond.data.id}`, { sidebarSort: mode });
|
collapsedIds.includes(id) ? collapsedIds.filter((c) => c !== id) : [...collapsedIds, id],
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reordering is offered only in manual mode, to the owner, and while no label
|
|
||||||
// filter narrows the list (the visible order would then be a subset).
|
|
||||||
const canReorder =
|
|
||||||
isOwner && pond.data?.settings.sidebarSort === 'manual' && filterIds.size === 0;
|
|
||||||
|
|
||||||
/** Move `movedId` to `newIndex` (in the list with itself removed), persist the
|
|
||||||
* new position server-side, and announce it for screen readers. */
|
|
||||||
async function moveTo(movedId: string, newIndex: number, title: string): Promise<void> {
|
|
||||||
if (!pond.data || !pages.data) return;
|
|
||||||
const orderedIds = pages.data.map((p) => p.id);
|
|
||||||
const { afterId, beforeId } = neighborsForMove(orderedIds, movedId, newIndex);
|
|
||||||
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data.id] });
|
|
||||||
const position = Math.max(0, Math.min(newIndex, orderedIds.length - 1)) + 1;
|
|
||||||
setAnnouncement(
|
|
||||||
t('layout.sidebar.reorder.moved', { title, position, count: orderedIds.length }),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
async function setSortMode(mode: SidebarSortMode): Promise<void> {
|
||||||
<nav
|
await apiPatch(`/ponds/${pond.id}`, { sidebarSort: mode });
|
||||||
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
|
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
|
||||||
aria-hidden={collapsed}
|
}
|
||||||
aria-label={t('layout.sidebar.label')}
|
|
||||||
>
|
|
||||||
{!pond.data ? (
|
|
||||||
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="sidebar__header">
|
|
||||||
<h2 className="sidebar__pond-name">{pond.data.name}</h2>
|
|
||||||
{isOwner && (
|
|
||||||
<select
|
|
||||||
className="sidebar__sort"
|
|
||||||
aria-label={t('layout.sidebar.sortLabel')}
|
|
||||||
value={pond.data.settings.sidebarSort}
|
|
||||||
onChange={(event) => void setSortMode(event.target.value as SidebarSortMode)}
|
|
||||||
>
|
|
||||||
{SORT_MODES.map((mode) => (
|
|
||||||
<option key={mode} value={mode}>
|
|
||||||
{t(`layout.sidebar.sortMode.${mode}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{flat.length > 0 && (
|
// Reordering is offered only in manual mode, to the owner, while no label
|
||||||
<details className="sidebar__filter">
|
// filter narrows the list, and in folder view (label view is read-only).
|
||||||
<summary>
|
const canReorder =
|
||||||
{tLabels('filter.toggle')}
|
isOwner && pond.settings.sidebarSort === 'manual' && filterIds.size === 0 && view === 'folders';
|
||||||
{filterIds.size > 0 && ` (${filterIds.size})`}
|
|
||||||
</summary>
|
/** Move `movedId` to `newIndex` within its sibling group and persist it.
|
||||||
<ul className="sidebar__filter-list">
|
* Fractional keys between two siblings keep the group order intact under
|
||||||
{flat.map((label) => (
|
* the pond-wide sequence (issue #106), so no other page is touched. */
|
||||||
<li
|
async function moveWithinSiblings(
|
||||||
key={label.id}
|
movedId: string,
|
||||||
style={{ paddingInlineStart: `${(labelDepth(flat, label.id) - 1) * 1}rem` }}
|
siblingIds: string[],
|
||||||
>
|
newIndex: number,
|
||||||
<label className="sidebar__filter-option">
|
title: string,
|
||||||
<input
|
): Promise<void> {
|
||||||
type="checkbox"
|
const { afterId, beforeId } = neighborsForMove(siblingIds, movedId, newIndex);
|
||||||
checked={filterIds.has(label.id)}
|
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
|
||||||
onChange={(event) => toggleFilter(label.id, event.target.checked)}
|
await queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
|
||||||
/>
|
const position = Math.max(0, Math.min(newIndex, siblingIds.length - 1)) + 1;
|
||||||
<span
|
setAnnouncement(
|
||||||
className="label-chip__swatch"
|
t('layout.sidebar.reorder.moved', { title, position, count: siblingIds.length }),
|
||||||
style={{ backgroundColor: label.color }}
|
);
|
||||||
aria-hidden
|
}
|
||||||
/>
|
|
||||||
<span>{label.name}</span>
|
const showFlatFallback = view === 'folders' && filterIds.size > 0;
|
||||||
</label>
|
|
||||||
</li>
|
return (
|
||||||
))}
|
<>
|
||||||
</ul>
|
<div className="sidebar__header">
|
||||||
{filterIds.size > 0 && (
|
<h2 className="sidebar__pond-name">{pond.name}</h2>
|
||||||
|
{isOwner && (
|
||||||
|
<select
|
||||||
|
className="sidebar__sort"
|
||||||
|
aria-label={t('layout.sidebar.sortLabel')}
|
||||||
|
value={pond.settings.sidebarSort}
|
||||||
|
onChange={(event) => void setSortMode(event.target.value as SidebarSortMode)}
|
||||||
|
>
|
||||||
|
{SORT_MODES.map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{t(`layout.sidebar.sortMode.${mode}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="sidebar__view-toggle"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('layout.sidebar.view.toggleLabel')}
|
||||||
|
>
|
||||||
|
{VIEW_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={`sidebar__view-btn${view === mode ? ' sidebar__view-btn--active' : ''}`}
|
||||||
|
aria-pressed={view === mode}
|
||||||
|
onClick={() => setViewOverride(mode)}
|
||||||
|
>
|
||||||
|
{t(`layout.sidebar.view.${mode}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'folders' && flat.length > 0 && (
|
||||||
|
<details className="sidebar__filter">
|
||||||
|
<summary>
|
||||||
|
{tLabels('filter.toggle')}
|
||||||
|
{filterIds.size > 0 && ` (${filterIds.size})`}
|
||||||
|
</summary>
|
||||||
|
<ul className="sidebar__filter-list">
|
||||||
|
{flat.map((label) => (
|
||||||
|
<li
|
||||||
|
key={label.id}
|
||||||
|
style={{ paddingInlineStart: `${(labelDepth(flat, label.id) - 1) * 1}rem` }}
|
||||||
|
>
|
||||||
|
<label className="sidebar__filter-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filterIds.has(label.id)}
|
||||||
|
onChange={(event) => toggleFilter(label.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="label-chip__swatch"
|
||||||
|
style={{ backgroundColor: label.color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span>{label.name}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{filterIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike sidebar__filter-clear"
|
||||||
|
onClick={() => setFilterIds(new Set())}
|
||||||
|
>
|
||||||
|
{tLabels('filter.clear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'labels' ? (
|
||||||
|
pages.data && pages.data.length > 0 ? (
|
||||||
|
<LabelGroupedPages
|
||||||
|
pages={pages.data}
|
||||||
|
labels={flat}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
pageSlug={pageSlug}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
|
||||||
|
)
|
||||||
|
) : showFlatFallback ? (
|
||||||
|
visiblePages && visiblePages.length > 0 ? (
|
||||||
|
<ul className="sidebar__pages">
|
||||||
|
{visiblePages.map((p) => (
|
||||||
|
<li key={p.id} className="sidebar__page-item">
|
||||||
|
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||||
|
<LabelChips labelIds={p.labelIds} byId={byId} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="sidebar__hint">{tLabels('filter.none')}</p>
|
||||||
|
)
|
||||||
|
) : tree.length > 0 ? (
|
||||||
|
<ul className="sidebar__pages sidebar__pages--tree">
|
||||||
|
<PageTreeLevel
|
||||||
|
nodes={tree}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
pageSlug={pageSlug}
|
||||||
|
byId={byId}
|
||||||
|
collapsedIds={collapsedIds}
|
||||||
|
onToggleCollapsed={toggleCollapsed}
|
||||||
|
canReorder={canReorder}
|
||||||
|
draggedId={draggedId}
|
||||||
|
setDraggedId={setDraggedId}
|
||||||
|
onMove={moveWithinSiblings}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<NewPageForm
|
||||||
|
pondId={pond.id}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
parentId={currentPage?.id ?? null}
|
||||||
|
parentTitle={currentPage?.title}
|
||||||
|
onCreated={() => {
|
||||||
|
setCreating(false);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
|
||||||
|
}}
|
||||||
|
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. */}
|
||||||
|
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
|
||||||
|
{announcement}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* The trash stays a text link, pinned to the sidebar's bottom
|
||||||
|
(M10 follow-up; pond settings moved to the TopBar gear icon). */}
|
||||||
|
{isOwner && (
|
||||||
|
<div className="sidebar__footer">
|
||||||
|
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
||||||
|
{t('editor:trash.link')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageLink({
|
||||||
|
page,
|
||||||
|
pondSlug,
|
||||||
|
pageSlug,
|
||||||
|
}: {
|
||||||
|
page: PageListItemView;
|
||||||
|
pondSlug: string;
|
||||||
|
pageSlug: string | null;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/p/${pondSlug}/${page.slug}`}
|
||||||
|
className={page.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'}
|
||||||
|
aria-current={page.slug === pageSlug ? 'page' : undefined}
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
{page.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageTreeLevelProps {
|
||||||
|
nodes: TreeNode<PageListItemView>[];
|
||||||
|
pondSlug: string;
|
||||||
|
pageSlug: string | null;
|
||||||
|
byId: Map<string, LabelView>;
|
||||||
|
collapsedIds: string[];
|
||||||
|
onToggleCollapsed: (id: string) => void;
|
||||||
|
canReorder: boolean;
|
||||||
|
draggedId: string | null;
|
||||||
|
setDraggedId: (id: string | null) => void;
|
||||||
|
onMove: (movedId: string, siblingIds: string[], newIndex: number, title: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One sibling group of the folder view (issue #108), rendered recursively.
|
||||||
|
* Reordering (buttons and drag-between) stays within the group; dropping
|
||||||
|
* onto a page to reparent arrives with #109. */
|
||||||
|
function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
pondSlug,
|
||||||
|
pageSlug,
|
||||||
|
byId,
|
||||||
|
collapsedIds,
|
||||||
|
onToggleCollapsed,
|
||||||
|
canReorder,
|
||||||
|
draggedId,
|
||||||
|
setDraggedId,
|
||||||
|
onMove,
|
||||||
|
} = props;
|
||||||
|
const siblingIds = nodes.map((n) => n.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{nodes.map((node, index) => {
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const isCollapsed = collapsedIds.includes(node.id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={node.id}
|
||||||
|
className={
|
||||||
|
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : 'sidebar__page-item'
|
||||||
|
}
|
||||||
|
draggable={canReorder}
|
||||||
|
onDragStart={
|
||||||
|
canReorder
|
||||||
|
? (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setDraggedId(node.id);
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDragEnd={canReorder ? () => setDraggedId(null) : undefined}
|
||||||
|
onDragOver={canReorder ? (event) => event.preventDefault() : undefined}
|
||||||
|
onDrop={
|
||||||
|
canReorder
|
||||||
|
? (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!draggedId || draggedId === node.id) return;
|
||||||
|
// Drag-between stays inside one sibling group (#108);
|
||||||
|
// cross-group drops become reparenting with #109.
|
||||||
|
if (!siblingIds.includes(draggedId)) return;
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const after = event.clientY - rect.top > rect.height / 2;
|
||||||
|
const target = dropIndex(siblingIds, draggedId, node.id, after);
|
||||||
|
const title = nodes.find((n) => n.id === draggedId)?.title ?? '';
|
||||||
|
void onMove(draggedId, siblingIds, target, title);
|
||||||
|
setDraggedId(null);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="sidebar__tree-row">
|
||||||
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="linklike sidebar__filter-clear"
|
|
||||||
onClick={() => setFilterIds(new Set())}
|
|
||||||
>
|
|
||||||
{tLabels('filter.clear')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visiblePages && visiblePages.length > 0 ? (
|
|
||||||
<ul className="sidebar__pages">
|
|
||||||
{visiblePages.map((p, index) => (
|
|
||||||
<li
|
|
||||||
key={p.id}
|
|
||||||
className={
|
className={
|
||||||
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : undefined
|
isCollapsed ? 'sidebar__caret sidebar__caret--collapsed' : 'sidebar__caret'
|
||||||
}
|
}
|
||||||
draggable={canReorder}
|
aria-expanded={!isCollapsed}
|
||||||
onDragStart={
|
aria-label={t(
|
||||||
canReorder
|
isCollapsed ? 'layout.sidebar.view.expand' : 'layout.sidebar.view.collapseNode',
|
||||||
? (event) => {
|
{ title: node.title },
|
||||||
setDraggedId(p.id);
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onDragEnd={canReorder ? () => setDraggedId(null) : undefined}
|
|
||||||
onDragOver={canReorder ? (event) => event.preventDefault() : undefined}
|
|
||||||
onDrop={
|
|
||||||
canReorder
|
|
||||||
? (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!draggedId || draggedId === p.id || !pages.data) return;
|
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
|
||||||
const after = event.clientY - rect.top > rect.height / 2;
|
|
||||||
const orderedIds = pages.data.map((page) => page.id);
|
|
||||||
const target = dropIndex(orderedIds, draggedId, p.id, after);
|
|
||||||
const title =
|
|
||||||
pages.data.find((page) => page.id === draggedId)?.title ?? '';
|
|
||||||
void moveTo(draggedId, target, title);
|
|
||||||
setDraggedId(null);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={`/p/${pondSlug}/${p.slug}`}
|
|
||||||
className={
|
|
||||||
p.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'
|
|
||||||
}
|
|
||||||
aria-current={p.slug === pageSlug ? 'page' : undefined}
|
|
||||||
draggable={false}
|
|
||||||
>
|
|
||||||
{p.title}
|
|
||||||
</Link>
|
|
||||||
{canReorder && (
|
|
||||||
<span className="sidebar__reorder">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sidebar__reorder-btn"
|
|
||||||
aria-label={t('layout.sidebar.reorder.up')}
|
|
||||||
disabled={index === 0}
|
|
||||||
onClick={() => void moveTo(p.id, index - 1, p.title)}
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sidebar__reorder-btn"
|
|
||||||
aria-label={t('layout.sidebar.reorder.down')}
|
|
||||||
disabled={index === visiblePages.length - 1}
|
|
||||||
onClick={() => void moveTo(p.id, index + 1, p.title)}
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<LabelChips labelIds={p.labelIds} byId={byId} />
|
onClick={() => onToggleCollapsed(node.id)}
|
||||||
|
>
|
||||||
|
▸
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="sidebar__caret sidebar__caret--leaf" aria-hidden />
|
||||||
|
)}
|
||||||
|
<PageLink page={node} pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||||
|
{canReorder && (
|
||||||
|
<span className="sidebar__reorder">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar__reorder-btn"
|
||||||
|
aria-label={t('layout.sidebar.reorder.up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => void onMove(node.id, siblingIds, index - 1, node.title)}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar__reorder-btn"
|
||||||
|
aria-label={t('layout.sidebar.reorder.down')}
|
||||||
|
disabled={index === nodes.length - 1}
|
||||||
|
onClick={() => void onMove(node.id, siblingIds, index + 1, node.title)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<LabelChips labelIds={node.labelIds} byId={byId} />
|
||||||
|
</span>
|
||||||
|
{hasChildren && !isCollapsed && (
|
||||||
|
<ul className="sidebar__pages sidebar__tree-children">
|
||||||
|
<PageTreeLevel {...props} nodes={node.children} />
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label view (issue #108): pages grouped under the hierarchical label
|
||||||
|
* tree, read-only. A page tagged with several labels appears under each;
|
||||||
|
* untagged pages collect in a trailing "unlabeled" group.
|
||||||
|
*/
|
||||||
|
function LabelGroupedPages({
|
||||||
|
pages,
|
||||||
|
labels,
|
||||||
|
pondSlug,
|
||||||
|
pageSlug,
|
||||||
|
}: {
|
||||||
|
pages: PageListItemView[];
|
||||||
|
labels: LabelView[];
|
||||||
|
pondSlug: string;
|
||||||
|
pageSlug: string | null;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unlabeled = pages.filter((p) => p.labelIds.length === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar__label-view">
|
||||||
|
{labels.map((label) => {
|
||||||
|
const tagged = pages.filter((p) => p.labelIds.includes(label.id));
|
||||||
|
if (tagged.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={label.id}
|
||||||
|
className="sidebar__label-group"
|
||||||
|
style={{ paddingInlineStart: `${(labelDepth(labels, label.id) - 1) * 0.75}rem` }}
|
||||||
|
>
|
||||||
|
<h3 className="sidebar__label-heading">
|
||||||
|
<span
|
||||||
|
className="label-chip__swatch"
|
||||||
|
style={{ backgroundColor: label.color }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</h3>
|
||||||
|
<ul className="sidebar__pages">
|
||||||
|
{tagged.map((p) => (
|
||||||
|
<li key={p.id} className="sidebar__page-item">
|
||||||
|
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
</section>
|
||||||
<p className="sidebar__hint">
|
);
|
||||||
{filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.empty')}
|
})}
|
||||||
</p>
|
{unlabeled.length > 0 && (
|
||||||
)}
|
<section className="sidebar__label-group sidebar__label-group--unlabeled">
|
||||||
|
<h3 className="sidebar__label-heading">{t('layout.sidebar.view.unlabeled')}</h3>
|
||||||
{creating ? (
|
<ul className="sidebar__pages">
|
||||||
<NewPageForm
|
{unlabeled.map((p) => (
|
||||||
pondId={pond.data.id}
|
<li key={p.id} className="sidebar__page-item">
|
||||||
pondSlug={pondSlug!}
|
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||||
onCreated={() => {
|
</li>
|
||||||
setCreating(false);
|
))}
|
||||||
void queryClient.invalidateQueries({ queryKey: ['pages', pond.data!.id] });
|
</ul>
|
||||||
}}
|
</section>
|
||||||
onCancel={() => setCreating(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="linklike sidebar__new-page"
|
|
||||||
onClick={() => setCreating(true)}
|
|
||||||
>
|
|
||||||
{t('layout.sidebar.newPage')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ImportControl pondId={pond.data.id} pondSlug={pondSlug!} />
|
|
||||||
|
|
||||||
{/* Keyboard/drag reordering announcements for screen readers. */}
|
|
||||||
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
|
|
||||||
{announcement}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* The trash stays a text link, pinned to the sidebar's bottom
|
|
||||||
(M10 follow-up; pond settings moved to the TopBar gear icon). */}
|
|
||||||
{isOwner && (
|
|
||||||
<div className="sidebar__footer">
|
|
||||||
<Link to={`/p/${pondSlug}/trash`} className="linklike sidebar__trash-link">
|
|
||||||
{t('editor:trash.link')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</nav>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
61
apps/web/src/layout/SidebarViewSetting.tsx
Normal file
61
apps/web/src/layout/SidebarViewSetting.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { SidebarViewMode } from '@dorfteich/shared';
|
||||||
|
import { SIDEBAR_VIEW_MODES } from '@dorfteich/shared';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { FormError, FormSuccess } from '../components/forms';
|
||||||
|
import { apiPatch } from '../lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pond's default sidebar view (issue #108): folders (page tree) or
|
||||||
|
* labels. Owner-set; every member can still override it locally via the
|
||||||
|
* sidebar toggle. Rides the generic pond PATCH like CommentPolicySetting.
|
||||||
|
*/
|
||||||
|
export function SidebarViewSetting({
|
||||||
|
pondId,
|
||||||
|
pondSlug,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
pondId: string;
|
||||||
|
pondSlug: string;
|
||||||
|
value: SidebarViewMode;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const save = async (view: SidebarViewMode): Promise<void> => {
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
await apiPatch(`/ponds/${pondId}`, { sidebarView: view });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
|
||||||
|
setSaved(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar-view-setting">
|
||||||
|
<FormError error={error} />
|
||||||
|
<p className="sidebar-view-setting__hint">{t('layout.sidebar.view.defaultHint')}</p>
|
||||||
|
<label>
|
||||||
|
{t('layout.sidebar.view.defaultLabel')}{' '}
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => void save(event.target.value as SidebarViewMode)}
|
||||||
|
>
|
||||||
|
{SIDEBAR_VIEW_MODES.map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{t(`layout.sidebar.view.${mode}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{saved && <FormSuccess message={t('layout.sidebar.view.saved')} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import { AccessRulesManager } from '../access/AccessRulesManager';
|
|||||||
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
|
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
|
||||||
import { PondFileManager } from '../files/PondFileManager';
|
import { PondFileManager } from '../files/PondFileManager';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
import { SidebarViewSetting } from '../layout/SidebarViewSetting';
|
||||||
import { MemberManager } from '../members/MemberManager';
|
import { MemberManager } from '../members/MemberManager';
|
||||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||||
@ -37,6 +38,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
const { t: tComments } = useTranslation('comments');
|
const { t: tComments } = useTranslation('comments');
|
||||||
const { t: tApiTokens } = useTranslation('apiTokens');
|
const { t: tApiTokens } = useTranslation('apiTokens');
|
||||||
const { t: tFont } = useTranslation('font');
|
const { t: tFont } = useTranslation('font');
|
||||||
|
const { t: tCommon } = useTranslation();
|
||||||
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@ -95,6 +97,16 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>
|
||||||
|
<SidebarViewSetting
|
||||||
|
pondId={pond.data.id}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
value={pond.data.settings.sidebarView}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
{canModify && (
|
{canModify && (
|
||||||
<section>
|
<section>
|
||||||
<h2>{tComments('policy.title')}</h2>
|
<h2>{tComments('policy.title')}</h2>
|
||||||
|
|||||||
@ -159,6 +159,97 @@ button {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Folder view (issue #108): the page tree with collapsible nodes. */
|
||||||
|
.sidebar__view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__view-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__view-btn--active {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__tree-row .sidebar__page {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__tree-children {
|
||||||
|
margin: 0 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__caret {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1rem;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__caret--collapsed {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__caret--leaf {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label view (issue #108): pages grouped under the label tree. */
|
||||||
|
.sidebar__label-group {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__label-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__label-group .sidebar__pages {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__new-page-parent {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar__page {
|
.sidebar__page {
|
||||||
display: block;
|
display: block;
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: var(--space-1) var(--space-2);
|
||||||
|
|||||||
@ -21,8 +21,21 @@
|
|||||||
},
|
},
|
||||||
"newPage": "+ Neue Seite",
|
"newPage": "+ Neue Seite",
|
||||||
"newPageTitle": "Titel",
|
"newPageTitle": "Titel",
|
||||||
|
"newPageUnder": "Wird unter „{{title}}“ angelegt.",
|
||||||
"create": "Erstellen",
|
"create": "Erstellen",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen",
|
||||||
|
"view": {
|
||||||
|
"toggleLabel": "Ansicht der Seitenleiste",
|
||||||
|
"folders": "Ordner",
|
||||||
|
"labels": "Labels",
|
||||||
|
"unlabeled": "Ohne Label",
|
||||||
|
"expand": "{{title}} ausklappen",
|
||||||
|
"collapseNode": "{{title}} einklappen",
|
||||||
|
"defaultTitle": "Seitenleisten-Ansicht",
|
||||||
|
"defaultLabel": "Standard-Ansicht",
|
||||||
|
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",
|
||||||
|
"saved": "Gespeichert."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pondSwitcher": {
|
"pondSwitcher": {
|
||||||
"label": "Teich wechseln",
|
"label": "Teich wechseln",
|
||||||
|
|||||||
@ -21,8 +21,21 @@
|
|||||||
},
|
},
|
||||||
"newPage": "+ New page",
|
"newPage": "+ New page",
|
||||||
"newPageTitle": "Title",
|
"newPageTitle": "Title",
|
||||||
|
"newPageUnder": "Will be created under “{{title}}”.",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"view": {
|
||||||
|
"toggleLabel": "Sidebar view",
|
||||||
|
"folders": "Folders",
|
||||||
|
"labels": "Labels",
|
||||||
|
"unlabeled": "Unlabeled",
|
||||||
|
"expand": "Expand {{title}}",
|
||||||
|
"collapseNode": "Collapse {{title}}",
|
||||||
|
"defaultTitle": "Sidebar view",
|
||||||
|
"defaultLabel": "Default view",
|
||||||
|
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",
|
||||||
|
"saved": "Saved."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"pondSwitcher": {
|
"pondSwitcher": {
|
||||||
"label": "Switch pond",
|
"label": "Switch pond",
|
||||||
|
|||||||
@ -14,6 +14,11 @@ export type PondType = (typeof POND_TYPES)[number];
|
|||||||
export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const;
|
export const SIDEBAR_SORT_MODES = ['alpha', 'created', 'manual'] as const;
|
||||||
export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number];
|
export type SidebarSortMode = (typeof SIDEBAR_SORT_MODES)[number];
|
||||||
|
|
||||||
|
/** How the sidebar presents a pond's pages (issue #108): as the page tree
|
||||||
|
* (`folders`) or grouped under the label tree (`labels`). */
|
||||||
|
export const SIDEBAR_VIEW_MODES = ['folders', 'labels'] as const;
|
||||||
|
export type SidebarViewMode = (typeof SIDEBAR_VIEW_MODES)[number];
|
||||||
|
|
||||||
/** One font slot per ADR 0016; values reference the curated catalog. */
|
/** One font slot per ADR 0016; values reference the curated catalog. */
|
||||||
const fontSlotSchema = z.object({
|
const fontSlotSchema = z.object({
|
||||||
family: z.string().min(1).max(80),
|
family: z.string().min(1).max(80),
|
||||||
@ -36,6 +41,9 @@ export type PondFonts = z.infer<typeof pondFontsSchema>;
|
|||||||
*/
|
*/
|
||||||
export const pondSettingsSchema = z.object({
|
export const pondSettingsSchema = z.object({
|
||||||
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
|
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
|
||||||
|
/** The pond default for the sidebar's page presentation (issue #108);
|
||||||
|
* every member can override it locally (`ui.sidebar.view.<pondId>`). */
|
||||||
|
sidebarView: z.enum(SIDEBAR_VIEW_MODES).default('folders'),
|
||||||
fonts: pondFontsSchema.default({}),
|
fonts: pondFontsSchema.default({}),
|
||||||
/** Who may write comments (issue #91): every reader, or editors only. */
|
/** Who may write comments (issue #91): every reader, or editors only. */
|
||||||
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
commentPolicy: z.enum(COMMENT_POLICIES).default('readers'),
|
||||||
@ -73,6 +81,7 @@ export const updatePondInputSchema = z
|
|||||||
name: pondNameSchema,
|
name: pondNameSchema,
|
||||||
description: z.string().trim().max(500, 'validation.tooLong'),
|
description: z.string().trim().max(500, 'validation.tooLong'),
|
||||||
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
|
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
|
||||||
|
sidebarView: z.enum(SIDEBAR_VIEW_MODES),
|
||||||
fonts: pondFontsSchema,
|
fonts: pondFontsSchema,
|
||||||
commentPolicy: z.enum(COMMENT_POLICIES),
|
commentPolicy: z.enum(COMMENT_POLICIES),
|
||||||
apiEnabled: z.boolean(),
|
apiEnabled: z.boolean(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user