Favorites: personal page stars, golden icons, sidebar filter (#132)
Some checks failed
CD / Build and push images (push) Successful in 3m57s
CD / Deploy to Test (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m32s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Failing after 2m51s
CI / Import/export fidelity gate (push) Has been skipped

Semantics changed from the issue during planning (documented there,
comment 1192): favorites are PERSONAL per user, not pond-wide — the
sys-fav label approach is dropped entirely. Storage is a page_favorites
table (userId+pageId, FK cascade); PUT/DELETE /pages/:id/favorite
toggles idempotently and needs read access only (#60 404 semantics —
a star is a note-to-self, not a page modification), GET
/ponds/:id/favorites lists the account's stars sliced to still-readable
pages. Trashed pages keep their rows, so restore keeps the star; purge
cascades it away.

Web: one shared ['favorites', pondId] query feeds the TopBar star
(between labels and history, golden when set), the golden tree icons in
the sidebar, and a latching "Favorites" filter button next to the view
switch that narrows either view (combinable with the label filter).
No public-API/MCP exposure — with the label approach gone, that parity
is no longer free; favorites stay UI-only for now.

New favorites e2e pack (star toggle, golden icon, filter, per-user
isolation) wired into CI; DB suite covers the round-trip, read gating,
and the trash/restore/purge lifecycle.

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-16 12:03:55 +02:00
parent 48d4c60af7
commit 6c98a71d34
25 changed files with 666 additions and 12 deletions

View File

@ -386,6 +386,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/graph.spec.ts
- name: Reset login rate limit before favorites pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run favorites pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/favorites.spec.ts
- name: Reset login rate limit before create-missing-page pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -0,0 +1,16 @@
-- Personal page favorites (issue #132).
CREATE TABLE "page_favorites" (
"user_id" TEXT NOT NULL,
"page_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "page_favorites_pkey" PRIMARY KEY ("user_id", "page_id")
);
CREATE INDEX "page_favorites_page_id_idx" ON "page_favorites"("page_id");
ALTER TABLE "page_favorites" ADD CONSTRAINT "page_favorites_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "page_favorites" ADD CONSTRAINT "page_favorites_page_id_fkey"
FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -59,6 +59,7 @@ model User {
comments Comment[]
watches Watch[]
notifications Notification[]
favorites PageFavorite[]
@@map("users")
}
@ -285,6 +286,7 @@ model Page {
comments Comment[]
incomingLinks PageLink[] @relation("incomingLinks")
conversionJobs ConversionJob[]
favorites PageFavorite[]
@@unique([pondId, slug])
@@index([pondId])
@ -450,6 +452,22 @@ model PageLabel {
@@map("page_labels")
}
/// Personal page favorites (issue #132) — per user, deliberately NOT
/// pond-wide (planning pivot documented on the issue). Trashed pages keep
/// their rows, so a restore keeps the star; a purge cascades them away.
model PageFavorite {
userId String @map("user_id")
pageId String @map("page_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@id([userId, pageId])
@@index([pageId])
@@map("page_favorites")
}
enum QuotaSubjectType {
USER
POND

View File

@ -37,6 +37,7 @@ import { TrashModule } from './trash/trash.module';
import { UsersModule } from './users/users.module';
import { NotificationsModule } from './notifications/notifications.module';
import { WatchesModule } from './watches/watches.module';
import { FavoritesModule } from './favorites/favorites.module';
import { VersionsModule } from './versions/versions.module';
@Module({
@ -60,6 +61,7 @@ import { VersionsModule } from './versions/versions.module';
PagesModule,
CommentsModule,
WatchesModule,
FavoritesModule,
NotificationsModule,
FilesModule,
TrashModule,

View File

@ -0,0 +1,39 @@
import { Controller, Delete, Get, Param, Put, Req } from '@nestjs/common';
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { FavoritesService } from './favorites.service';
/** Star/unstar pages + the per-pond favorites of the account (issue #132). */
@Controller()
export class FavoritesController {
constructor(private readonly favorites: FavoritesService) {}
@Get('ponds/:pondId/favorites')
@AuthenticatedOnly()
async list(
@Param('pondId') pondId: string,
@Req() request: AuthedRequest,
): Promise<PageFavoritesView> {
return this.favorites.listForPond(request.user!, pondId);
}
@Put('pages/:id/favorite')
@AuthenticatedOnly()
async favorite(
@Param('id') id: string,
@Req() request: AuthedRequest,
): Promise<FavoriteStateView> {
return this.favorites.favorite(request.user!, id);
}
@Delete('pages/:id/favorite')
@AuthenticatedOnly()
async unfavorite(
@Param('id') id: string,
@Req() request: AuthedRequest,
): Promise<FavoriteStateView> {
return this.favorites.unfavorite(request.user!, id);
}
}

View File

@ -0,0 +1,177 @@
import { INestApplication } from '@nestjs/common';
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
import { PrismaClient, User } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Personal favorites end to end (issue #132): per-user round-trip and
* isolation, read-gated starring (#60 semantics), idempotency, and the
* trash/restore/purge lifecycle (a star survives the trash, purge cascades
* it away).
*/
describe.skipIf(!hasTestDb)('favorites (e2e, issue #132)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'sterne fuer seiten 1';
const users: Record<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
const service = app.get(UsersService);
const username = `fav-${handle}-${suffix}`;
const user = await service.createUser({
username,
email: `${username}@example.org`,
displayName: `Fav ${handle}`,
password,
locale: 'en',
});
await service.markEmailVerified(user.id);
users[handle] = user;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
async function createPage(cookie: string, title: string): Promise<string> {
const res = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookie)
.send({ title })
.expect(201);
return (res.body as { id: string }).id;
}
async function favoritesOf(cookie: string): Promise<string[]> {
const res = await api()
.get(`/api/v1/ponds/${pondId}/favorites`)
.set('Cookie', cookie)
.expect(200);
return (res.body as PageFavoritesView).pageIds;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
for (const handle of ['owner', 'member', 'outsider']) await makeUser(handle);
const pond = await prisma.pond.create({
data: {
slug: `fav-pond-${suffix}`,
name: 'Favorite Pond',
type: 'SHARED',
ownerId: users.owner!.id,
},
});
pondId = pond.id;
for (const [handle, role] of [
['owner', 'POND_ADMIN'],
['member', 'EDITOR'],
] as const) {
await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: users[handle]!.id,
role,
scopeType: 'POND',
effect: 'ALLOW',
createdBy: users.owner!.id,
},
});
}
});
afterAll(async () => {
const ids = Object.values(users).map((u) => u.id);
await prisma.pageFavorite.deleteMany({ where: { userId: { in: ids } } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
await prisma.session.deleteMany({ where: { userId: { in: ids } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
await app.close();
});
it('round-trips the star per user and stays idempotent', async () => {
const pageId = await createPage(cookies.owner!, 'Starred page');
const on = (
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200)
).body as FavoriteStateView;
expect(on.favorite).toBe(true);
// Starring twice is fine — still exactly one favorite.
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200);
expect(await favoritesOf(cookies.member!)).toEqual([pageId]);
// Personal, not pond-wide: the owner's list stays empty.
expect(await favoritesOf(cookies.owner!)).toEqual([]);
const off = (
await api()
.delete(`/api/v1/pages/${pageId}/favorite`)
.set('Cookie', cookies.member!)
.expect(200)
).body as FavoriteStateView;
expect(off.favorite).toBe(false);
expect(await favoritesOf(cookies.member!)).toEqual([]);
// Unstarring an unstarred page is a no-op, not an error.
await api()
.delete(`/api/v1/pages/${pageId}/favorite`)
.set('Cookie', cookies.member!)
.expect(200);
});
it('gates starring and the pond list behind read access (404, #60)', async () => {
const pageId = await createPage(cookies.owner!, 'Hidden page');
await api()
.put(`/api/v1/pages/${pageId}/favorite`)
.set('Cookie', cookies.outsider!)
.expect(404);
await api()
.get(`/api/v1/ponds/${pondId}/favorites`)
.set('Cookie', cookies.outsider!)
.expect(404);
});
it('hides trashed favorites, revives them on restore, cascades on purge', async () => {
const pageId = await createPage(cookies.owner!, 'Cycling page');
await api().put(`/api/v1/pages/${pageId}/favorite`).set('Cookie', cookies.member!).expect(200);
// Trash: the page drops out of the favorites list, the row stays.
await prisma.page.update({
where: { id: pageId },
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
});
expect(await favoritesOf(cookies.member!)).toEqual([]);
expect(await prisma.pageFavorite.count({ where: { pageId } })).toBe(1);
// Restore: the star is back without re-starring.
await prisma.page.update({ where: { id: pageId }, data: { deletedAt: null, deletedBy: null } });
expect(await favoritesOf(cookies.member!)).toEqual([pageId]);
// Purge: the FK cascade removes the favorite rows for good.
await prisma.page.update({
where: { id: pageId },
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
});
await api().delete(`/api/v1/pages/${pageId}/purge`).set('Cookie', cookies.owner!).expect(204);
expect(await prisma.pageFavorite.count({ where: { pageId } })).toBe(0);
});
});

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module';
import { FavoritesController } from './favorites.controller';
import { FavoritesService } from './favorites.service';
@Module({
imports: [PermissionsModule],
controllers: [FavoritesController],
providers: [FavoritesService],
})
export class FavoritesModule {}

View File

@ -0,0 +1,57 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { FavoriteStateView, PageFavoritesView } from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* Personal page favorites (issue #132): a per-user star, deliberately NOT
* pond-wide (see the planning pivot on the issue). Starring needs read
* access to a live page (404 hides what the user cannot see, #60) it is
* a note-to-self, not a page modification, so write access is NOT required.
* Both directions are idempotent, mirroring the watches service.
*/
@Injectable()
export class FavoritesService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
async favorite(user: User, pageId: string): Promise<FavoriteStateView> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
select: { id: true, pondId: true },
});
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
throw new NotFoundException();
}
await this.prisma.pageFavorite.upsert({
where: { userId_pageId: { userId: user.id, pageId } },
create: { userId: user.id, pageId },
update: {},
});
return { favorite: true };
}
async unfavorite(user: User, pageId: string): Promise<FavoriteStateView> {
await this.prisma.pageFavorite.deleteMany({ where: { userId: user.id, pageId } });
return { favorite: false };
}
/** The user's own favorites within one pond, sliced to pages they can
* still read a revoked page must not confirm its continued existence. */
async listForPond(user: User, pondId: string): Promise<PageFavoritesView> {
if (!(await this.permissions.canSeePond(user, pondId))) throw new NotFoundException();
const rows = await this.prisma.pageFavorite.findMany({
where: { userId: user.id, page: { pondId, deletedAt: null } },
select: { pageId: true, page: { select: { id: true, pondId: true } } },
});
const pageIds: string[] = [];
for (const row of rows) {
if (await this.permissions.canAccessPage(user, row.page, 'read')) pageIds.push(row.pageId);
}
return { pageIds };
}
}

View File

@ -0,0 +1,116 @@
import { expect, test, type BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Favorites pack (issue #132): the TopBar star toggle, the golden tree
* icon, the latching favorites filter in the sidebar, and that favorites
* are personal stored per user, not per pond. Runs in its own shared
* pond so fixture ponds stay untouched. Language-independent selectors
* (CSS classes) throughout.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
async function json<T>(context: BrowserContext, url: string, data: unknown): Promise<T> {
const response = await context.request.post(url, { data });
if (!response.ok()) throw new Error(`post ${url}${response.status()}`);
return response.json() as Promise<T>;
}
test('star toggle, golden tree icon, and the favorites filter', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await json<{ id: string; slug: string }>(context, '/api/v1/ponds', {
name: `Fav Pack ${ts}`,
});
const starred = await json<{ id: string; slug: string }>(
context,
`/api/v1/ponds/${pond.id}/pages`,
{ title: `Fav Starred ${ts}` },
);
await json(context, `/api/v1/ponds/${pond.id}/pages`, { title: `Fav Plain ${ts}` });
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${starred.slug}`);
// Star the page from the TopBar (reading mode) — the button flips to
// "remove" state (aria-pressed) and turns golden.
const star = page.locator('.editor-page__favorite-toggle');
await expect(star).toHaveAttribute('aria-pressed', 'false');
await star.click();
await expect(star).toHaveAttribute('aria-pressed', 'true');
await expect(star).toHaveClass(/icon-button--favorite/);
// The sidebar tree marks the favorite with a golden icon.
const starredItem = page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("Fav Starred ${ts}")`) })
.first();
await expect(starredItem.locator('.sidebar__page-icon--favorite')).toBeVisible();
const plainItem = page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("Fav Plain ${ts}")`) })
.first();
await expect(plainItem.locator('.sidebar__page-icon--favorite')).toHaveCount(0);
// The latching filter narrows the sidebar to favorites only.
const filter = page.locator('.sidebar__view-btn--favorites');
await filter.click();
await expect(filter).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator(`.sidebar__page:text-is("Fav Starred ${ts}")`)).toBeVisible();
await expect(page.locator(`.sidebar__page:text-is("Fav Plain ${ts}")`)).toHaveCount(0);
await filter.click();
await expect(page.locator(`.sidebar__page:text-is("Fav Plain ${ts}")`)).toBeVisible();
// Unstar → the golden icon disappears.
await star.click();
await expect(star).toHaveAttribute('aria-pressed', 'false');
await expect(starredItem.locator('.sidebar__page-icon--favorite')).toHaveCount(0);
await context.close();
});
test('favorites are personal: another user does not see my star', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await json<{ id: string; slug: string }>(owner, '/api/v1/ponds', {
name: `Fav Personal ${ts}`,
});
const target = await json<{ id: string; slug: string }>(owner, `/api/v1/ponds/${pond.id}/pages`, {
title: `Fav Mine ${ts}`,
});
// Grant the fixture viewer read access via the grants API (never direct
// DB writes — the permission cache must see the change).
const viewerContext = await contextForUser(browser, BASE_URL, 'fixture-viewer');
const meRes = await viewerContext.request.get('/api/v1/auth/me');
const viewerId = ((await meRes.json()) as { id: string }).id;
await json(owner, `/api/v1/ponds/${pond.id}/grants`, {
subjectType: 'user',
subjectId: viewerId,
role: 'reader',
scopeType: 'pond',
effect: 'allow',
});
// Owner stars the page.
const ownerPage = await owner.newPage();
await ownerPage.goto(`/p/${pond.slug}/${target.slug}`);
await ownerPage.locator('.editor-page__favorite-toggle').click();
await expect(ownerPage.locator('.editor-page__favorite-toggle')).toHaveAttribute(
'aria-pressed',
'true',
);
// The viewer sees the page, but no star and no golden icon.
const viewerPage = await viewerContext.newPage();
await viewerPage.goto(`/p/${pond.slug}/${target.slug}`);
await expect(viewerPage.locator('.editor-page__favorite-toggle')).toHaveAttribute(
'aria-pressed',
'false',
);
await expect(viewerPage.locator('.sidebar__page-icon--favorite')).toHaveCount(0);
await owner.close();
await viewerContext.close();
});

View File

@ -0,0 +1,45 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { IconButton } from '../components/IconButton';
import { apiGet } from '../lib/api';
import { usePageFavorites } from './use-favorites';
/**
* The TopBar star (issue #132): golden and filled while the page is one of
* the account's favorites. Deliberately NOT `icon-button--active` the
* accent treatment marks open panels; the star carries its own gold.
*/
export function FavoriteToggle({
pageId,
pondSlug,
}: {
pageId: string;
pondSlug: string;
}): React.JSX.Element {
const { t } = useTranslation('editor');
// Cache-shared with the sidebar/editor pond queries — no extra request.
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const { ids, toggle } = usePageFavorites(pond.data?.id);
const active = ids.has(pageId);
return (
<IconButton
className={
active
? 'editor-page__favorite-toggle icon-button--favorite'
: 'editor-page__favorite-toggle'
}
label={t(active ? 'favorites.remove' : 'favorites.add')}
aria-pressed={active}
onClick={() => void toggle(pageId, !active)}
>
<Star aria-hidden fill={active ? 'currentColor' : 'none'} />
</IconButton>
);
}

View File

@ -0,0 +1,33 @@
import type { PageFavoritesView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import { apiDelete, apiGet, apiPut } from '../lib/api';
/**
* The account's favorites within one pond (issue #132) one shared query
* feeds the TopBar star and the sidebar's golden icons + filter. Favorites
* are personal (per user), so the cache key never needs a user dimension:
* a session change reloads the SPA.
*/
export function usePageFavorites(pondId: string | undefined): {
ids: Set<string>;
toggle: (pageId: string, next: boolean) => Promise<void>;
} {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['favorites', pondId],
queryFn: () => apiGet<PageFavoritesView>(`/ponds/${pondId}/favorites`),
enabled: Boolean(pondId),
});
const ids = useMemo(() => new Set(query.data?.pageIds ?? []), [query.data]);
async function toggle(pageId: string, next: boolean): Promise<void> {
if (next) await apiPut(`/pages/${pageId}/favorite`);
else await apiDelete(`/pages/${pageId}/favorite`);
await queryClient.invalidateQueries({ queryKey: ['favorites', pondId] });
}
return { ids, toggle };
}

View File

@ -14,6 +14,7 @@ import {
FileText,
Folder,
FolderOpen,
Star,
Trash2,
Waypoints,
} from 'lucide-react';
@ -23,6 +24,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels';
@ -89,6 +91,9 @@ function SidebarContent({
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
// The favorites filter (issue #132) — a latching push button, combinable
// with the label filter; both narrow the same list.
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dropIntoId, setDropIntoId] = useState<string | null>(null);
const [moveError, setMoveError] = useState<unknown>(null);
@ -111,6 +116,7 @@ function SidebarContent({
});
const { flat, byId } = usePondLabels(pond.id);
const { ids: favoriteIds } = usePageFavorites(pond.id);
const isOwner = Boolean(user && user.id === pond.ownerId);
@ -122,10 +128,13 @@ function SidebarContent({
return acc;
}, [filterIds, flat]);
const visiblePages =
const labelFiltered =
filterIds.size === 0
? pages.data
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
const visiblePages = favoritesOnly
? labelFiltered?.filter((p) => favoriteIds.has(p.id))
: labelFiltered;
// 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.
@ -209,7 +218,12 @@ function SidebarContent({
}
}
const showFlatFallback = view === 'folders' && filterIds.size > 0;
const showFlatFallback = view === 'folders' && (filterIds.size > 0 || favoritesOnly);
// The label view groups the (possibly favorites-narrowed) list; the label
// filter stays a folder-view affordance as before (#108).
const labelViewPages = favoritesOnly
? pages.data?.filter((p) => favoriteIds.has(p.id))
: pages.data;
return (
<>
@ -247,6 +261,18 @@ function SidebarContent({
{t(`layout.sidebar.view.${mode}`)}
</button>
))}
{/* Latching favorites filter (issue #132) — narrows either view. */}
<button
type="button"
className={`sidebar__view-btn sidebar__view-btn--favorites${
favoritesOnly ? ' sidebar__view-btn--active' : ''
}`}
aria-pressed={favoritesOnly}
onClick={() => setFavoritesOnly(!favoritesOnly)}
>
<Star aria-hidden fill={favoritesOnly ? 'currentColor' : 'none'} />
{t('layout.sidebar.favorites.filter')}
</button>
</div>
{view === 'folders' && flat.length > 0 && (
@ -290,15 +316,17 @@ function SidebarContent({
)}
{view === 'labels' ? (
pages.data && pages.data.length > 0 ? (
labelViewPages && labelViewPages.length > 0 ? (
<LabelGroupedPages
pages={pages.data}
pages={labelViewPages}
labels={flat}
pondSlug={pondSlug}
pageSlug={pageSlug}
/>
) : (
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
<p className="sidebar__hint">
{favoritesOnly ? t('layout.sidebar.favorites.empty') : t('layout.sidebar.empty')}
</p>
)
) : showFlatFallback ? (
visiblePages && visiblePages.length > 0 ? (
@ -311,7 +339,9 @@ function SidebarContent({
))}
</ul>
) : (
<p className="sidebar__hint">{tLabels('filter.none')}</p>
<p className="sidebar__hint">
{filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.favorites.empty')}
</p>
)
) : tree.length > 0 ? (
<ul className="sidebar__pages sidebar__pages--tree">
@ -320,6 +350,7 @@ function SidebarContent({
pondSlug={pondSlug}
pageSlug={pageSlug}
byId={byId}
favoriteIds={favoriteIds}
collapsedIds={collapsedIds}
onToggleCollapsed={toggleCollapsed}
canReorder={canReorder}
@ -420,6 +451,7 @@ interface PageTreeLevelProps {
pondSlug: string;
pageSlug: string | null;
byId: Map<string, LabelView>;
favoriteIds: Set<string>;
collapsedIds: string[];
onToggleCollapsed: (id: string) => void;
canReorder: boolean;
@ -445,6 +477,7 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
pondSlug,
pageSlug,
byId,
favoriteIds,
collapsedIds,
onToggleCollapsed,
canReorder,
@ -552,7 +585,15 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
) : (
<span className="sidebar__caret sidebar__caret--leaf" aria-hidden />
)}
<span className="sidebar__page-icon" aria-hidden>
{/* Favorites carry a golden icon (issue #132). */}
<span
className={
favoriteIds.has(node.id)
? 'sidebar__page-icon sidebar__page-icon--favorite'
: 'sidebar__page-icon'
}
aria-hidden
>
{hasChildren ? isCollapsed ? <Folder /> : <FolderOpen /> : <FileText />}
</span>
<PageLink page={node} pondSlug={pondSlug} pageSlug={pageSlug} />

View File

@ -22,6 +22,7 @@ import { useNavigate } from 'react-router-dom';
import { IconButton } from '../components/IconButton';
import { useToast } from '../components/Toast';
import { useDocumentExport } from '../export/use-document-export';
import { FavoriteToggle } from '../favorites/FavoriteToggle';
import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
import { WatchToggle } from '../watches/WatchToggle';
@ -110,6 +111,8 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
>
<Tag aria-hidden />
</IconButton>
{/* Between labels and history by design (issue #132). */}
<FavoriteToggle pageId={props.pageId} pondSlug={props.pondSlug} />
<IconButton
label={t('history.open')}
active={props.showHistory}

View File

@ -178,6 +178,24 @@ button {
background: var(--color-surface);
}
/* Favorites (issue #132): the latching filter button carries a small star;
golden treatment for the TopBar toggle and favorite tree icons. */
.sidebar__view-btn--favorites {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.sidebar__view-btn--favorites svg {
width: 0.85rem;
height: 0.85rem;
color: var(--color-favorite);
}
.icon-button--favorite {
color: var(--color-favorite);
}
.sidebar__tree-row {
display: flex;
align-items: center;
@ -243,6 +261,12 @@ button {
color: var(--color-text-muted);
}
/* Favorites carry a golden icon in the tree (issue #132) after the base
rule, so the equal-specificity color override wins. */
.sidebar__page-icon--favorite {
color: var(--color-favorite);
}
.sidebar__page-icon svg {
width: 0.875rem;
height: 0.875rem;

View File

@ -26,6 +26,8 @@
--color-accent-contrast: #ffffff;
--color-danger: #ab091e;
--color-ok: #14803c;
/* Favorite stars and tree icons (issue #132) — a readable gold. */
--color-favorite: #b8860b;
/* Spacing scale (rem-based). */
--space-1: 0.25rem;

View File

@ -63,6 +63,10 @@ Familien oder Projekte.
lokalen Nachbarschafts-Graphen.
- **Labels**, auf Wunsch hierarchisch, um einen Teich beliebig zu
gliedern — und um Zugriffsregeln zu begrenzen (siehe unten).
- **Persönliche Favoriten**: Markiere jede Seite mit einem Stern —
goldene Icons zeigen deine Favoriten im Seitenbaum, ein Filter
verengt die Liste darauf. Sterne gelten pro Nutzer und sind für
andere unsichtbar.
- **Schnelle Volltextsuche** über alles, was du lesen darfst —
unempfindlich gegen Akzente und Umlaute, mit Teilwort-Treffern.
- **Inhaltsverzeichnis, Seitenindizes, Diagramme** und mehr über

View File

@ -104,9 +104,18 @@ Die Werkzeugleiste bleibt beim Scrollen sichtbar.
Bei geöffneter Seite findest du neben dem Stift: **Beobachten** (Glocke
für diese Seite), **Kommentare** (mit Zähler für Ungelesenes),
**Anhänge**, **Seiten-Werkzeuge** (Inhaltsverzeichnis, Seitenindex —
sofern aktiviert), **Labels**, **Verlauf** und das **…**-Menü (Markdown
kopieren/herunterladen, Export nach Word/LibreOffice/PDF, Verschieben
nach…, Löschen).
sofern aktiviert), **Labels**, den **Favoriten-Stern**, **Verlauf** und
das **…**-Menü (Markdown kopieren/herunterladen, Export nach
Word/LibreOffice/PDF, Verschieben nach…, Löschen).
## Favoriten
Ein Klick auf den **Stern** in den Seiten-Aktionen markiert eine Seite
als Favorit — der Stern füllt sich golden, und auch das Icon der Seite
im Seitenbaum wird golden. Favoriten sind **persönlich**: Deine Sterne
gehören nur dir und sind für andere Mitglieder unsichtbar. Der Knopf
**Favoriten** neben der Ansichts-Umschaltung der Seitenleiste filtert
die Seitenliste auf deine Favoriten (kombinierbar mit dem Label-Filter).
## Labels

View File

@ -53,6 +53,9 @@ projects.
with a click; each page also gets a local neighborhood graph
- **Labels**, hierarchical if you like, to slice a pond any way you want
— and to scope access rules (see below).
- **Personal favorites**: star any page — golden icons mark your
favorites in the sidebar tree, and a filter narrows the list to them.
Stars are per user, invisible to everyone else.
- **Fast full-text search** across everything you may read — accent- and
umlaut-insensitive, with substring matching.
- **A table of contents, page indexes, diagrams** and more through

View File

@ -91,8 +91,18 @@ toolbar stays visible while you scroll.
When a page is open you find, next to the pencil: **watch** (bell for
this page), **comments** (with unread count), **attachments**,
**plugin tools** (table of contents, page index — when enabled),
**labels**, **history**, and the **…** overflow menu (copy/download
Markdown, export to Word/LibreOffice/PDF, move to…, delete).
**labels**, the **favorite star**, **history**, and the **…** overflow
menu (copy/download Markdown, export to Word/LibreOffice/PDF, move to…,
delete).
## Favorites
Click the **star** in the page actions to mark a page as one of your
favorites — the star fills golden, and the page's icon in the sidebar
tree turns golden too. Favorites are **personal**: your stars are yours
alone and never visible to other members. The **Favorites** button next
to the sidebar's view switch narrows the page list to your favorites
(combinable with the label filter).
## Labels

View File

@ -37,6 +37,10 @@
"defaultLabel": "Standard-Ansicht",
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",
"saved": "Gespeichert."
},
"favorites": {
"filter": "Favoriten",
"empty": "Noch keine Favoriten in diesem Teich."
}
},
"pondSwitcher": {

View File

@ -164,6 +164,10 @@
"pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.",
"restoreLink": "Im Papierkorb ansehen"
},
"favorites": {
"add": "Als Favorit markieren",
"remove": "Favorit entfernen"
},
"wikilink": {
"phantomTooltip": "Diese Seite existiert noch nicht",
"autocompleteLabel": "Auf eine Seite verlinken",

View File

@ -37,6 +37,10 @@
"defaultLabel": "Default view",
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",
"saved": "Saved."
},
"favorites": {
"filter": "Favorites",
"empty": "No favorites in this pond yet."
}
},
"pondSwitcher": {

View File

@ -164,6 +164,10 @@
"pageTrashedHint": "This page has been moved to the trash.",
"restoreLink": "View in trash"
},
"favorites": {
"add": "Mark as favorite",
"remove": "Remove favorite"
},
"wikilink": {
"phantomTooltip": "This page does not exist yet",
"autocompleteLabel": "Link to a page",

View File

@ -0,0 +1,15 @@
/**
* Personal page favorites (issue #132): a per-user star on a page
* deliberately NOT pond-wide (planning pivot documented on the issue), so
* every view is scoped to the requesting account.
*/
/** The requesting user's favorites within one pond (sidebar tree + filter). */
export interface PageFavoritesView {
pageIds: string[];
}
/** The star's toggle state on a single page. */
export interface FavoriteStateView {
favorite: boolean;
}

View File

@ -9,6 +9,7 @@ export * from './comments';
export * from './editor-schema';
export * from './env';
export * from './conversion';
export * from './favorites';
export * from './files';
export * from './fonts';
export * from './health';