From 6c98a71d34ade7d5f85a07c5d149d2a9a05859bd Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 16 Jul 2026 12:03:55 +0200 Subject: [PATCH] Favorites: personal page stars, golden icons, sidebar filter (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn --- .gitea/workflows/ci.yml | 10 + .../migration.sql | 16 ++ apps/api/prisma/schema.prisma | 18 ++ apps/api/src/app.module.ts | 2 + .../api/src/favorites/favorites.controller.ts | 39 ++++ .../src/favorites/favorites.e2e.db.test.ts | 177 ++++++++++++++++++ apps/api/src/favorites/favorites.module.ts | 13 ++ apps/api/src/favorites/favorites.service.ts | 57 ++++++ apps/web/e2e/favorites.spec.ts | 116 ++++++++++++ apps/web/src/favorites/FavoriteToggle.tsx | 45 +++++ apps/web/src/favorites/use-favorites.ts | 33 ++++ apps/web/src/layout/Sidebar.tsx | 55 +++++- apps/web/src/pages/PageActions.tsx | 3 + apps/web/src/styles/base.css | 24 +++ apps/web/src/styles/tokens.css | 2 + docs/de/features.md | 4 + docs/de/manual/user-guide.md | 15 +- docs/features.md | 3 + docs/manual/user-guide.md | 14 +- packages/shared/i18n/de/common.json | 4 + packages/shared/i18n/de/editor.json | 4 + packages/shared/i18n/en/common.json | 4 + packages/shared/i18n/en/editor.json | 4 + packages/shared/src/favorites.ts | 15 ++ packages/shared/src/index.ts | 1 + 25 files changed, 666 insertions(+), 12 deletions(-) create mode 100644 apps/api/prisma/migrations/20260716000000_page_favorites/migration.sql create mode 100644 apps/api/src/favorites/favorites.controller.ts create mode 100644 apps/api/src/favorites/favorites.e2e.db.test.ts create mode 100644 apps/api/src/favorites/favorites.module.ts create mode 100644 apps/api/src/favorites/favorites.service.ts create mode 100644 apps/web/e2e/favorites.spec.ts create mode 100644 apps/web/src/favorites/FavoriteToggle.tsx create mode 100644 apps/web/src/favorites/use-favorites.ts create mode 100644 packages/shared/src/favorites.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9b5ffd5..b32b1d1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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%';" | \ diff --git a/apps/api/prisma/migrations/20260716000000_page_favorites/migration.sql b/apps/api/prisma/migrations/20260716000000_page_favorites/migration.sql new file mode 100644 index 0000000..92040bd --- /dev/null +++ b/apps/api/prisma/migrations/20260716000000_page_favorites/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2c43fb5..c316ff6 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1d581c0..adbeb98 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/favorites/favorites.controller.ts b/apps/api/src/favorites/favorites.controller.ts new file mode 100644 index 0000000..a0e82e2 --- /dev/null +++ b/apps/api/src/favorites/favorites.controller.ts @@ -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 { + return this.favorites.listForPond(request.user!, pondId); + } + + @Put('pages/:id/favorite') + @AuthenticatedOnly() + async favorite( + @Param('id') id: string, + @Req() request: AuthedRequest, + ): Promise { + return this.favorites.favorite(request.user!, id); + } + + @Delete('pages/:id/favorite') + @AuthenticatedOnly() + async unfavorite( + @Param('id') id: string, + @Req() request: AuthedRequest, + ): Promise { + return this.favorites.unfavorite(request.user!, id); + } +} diff --git a/apps/api/src/favorites/favorites.e2e.db.test.ts b/apps/api/src/favorites/favorites.e2e.db.test.ts new file mode 100644 index 0000000..34d3d29 --- /dev/null +++ b/apps/api/src/favorites/favorites.e2e.db.test.ts @@ -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 = {}; + const cookies: Record = {}; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + 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 { + 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 { + 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); + }); +}); diff --git a/apps/api/src/favorites/favorites.module.ts b/apps/api/src/favorites/favorites.module.ts new file mode 100644 index 0000000..22d1c89 --- /dev/null +++ b/apps/api/src/favorites/favorites.module.ts @@ -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 {} diff --git a/apps/api/src/favorites/favorites.service.ts b/apps/api/src/favorites/favorites.service.ts new file mode 100644 index 0000000..0aed306 --- /dev/null +++ b/apps/api/src/favorites/favorites.service.ts @@ -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 { + 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 { + 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 { + 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 }; + } +} diff --git a/apps/web/e2e/favorites.spec.ts b/apps/web/e2e/favorites.spec.ts new file mode 100644 index 0000000..5c6044d --- /dev/null +++ b/apps/web/e2e/favorites.spec.ts @@ -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(context: BrowserContext, url: string, data: unknown): Promise { + const response = await context.request.post(url, { data }); + if (!response.ok()) throw new Error(`post ${url} → ${response.status()}`); + return response.json() as Promise; +} + +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(); +}); diff --git a/apps/web/src/favorites/FavoriteToggle.tsx b/apps/web/src/favorites/FavoriteToggle.tsx new file mode 100644 index 0000000..11e4c6f --- /dev/null +++ b/apps/web/src/favorites/FavoriteToggle.tsx @@ -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(`/ponds/${pondSlug}`), + }); + const { ids, toggle } = usePageFavorites(pond.data?.id); + const active = ids.has(pageId); + + return ( + void toggle(pageId, !active)} + > + + + ); +} diff --git a/apps/web/src/favorites/use-favorites.ts b/apps/web/src/favorites/use-favorites.ts new file mode 100644 index 0000000..84729f4 --- /dev/null +++ b/apps/web/src/favorites/use-favorites.ts @@ -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; + toggle: (pageId: string, next: boolean) => Promise; +} { + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: ['favorites', pondId], + queryFn: () => apiGet(`/ponds/${pondId}/favorites`), + enabled: Boolean(pondId), + }); + + const ids = useMemo(() => new Set(query.data?.pageIds ?? []), [query.data]); + + async function toggle(pageId: string, next: boolean): Promise { + if (next) await apiPut(`/pages/${pageId}/favorite`); + else await apiDelete(`/pages/${pageId}/favorite`); + await queryClient.invalidateQueries({ queryKey: ['favorites', pondId] }); + } + + return { ids, toggle }; +} diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index f729092..5bbb44d 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -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>(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(null); const [dropIntoId, setDropIntoId] = useState(null); const [moveError, setMoveError] = useState(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}`)} ))} + {/* Latching favorites filter (issue #132) — narrows either view. */} + {view === 'folders' && flat.length > 0 && ( @@ -290,15 +316,17 @@ function SidebarContent({ )} {view === 'labels' ? ( - pages.data && pages.data.length > 0 ? ( + labelViewPages && labelViewPages.length > 0 ? ( ) : ( -

{t('layout.sidebar.empty')}

+

+ {favoritesOnly ? t('layout.sidebar.favorites.empty') : t('layout.sidebar.empty')} +

) ) : showFlatFallback ? ( visiblePages && visiblePages.length > 0 ? ( @@ -311,7 +339,9 @@ function SidebarContent({ ))} ) : ( -

{tLabels('filter.none')}

+

+ {filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.favorites.empty')} +

) ) : tree.length > 0 ? (
    @@ -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; + favoriteIds: Set; 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 { ) : ( )} - + {/* Favorites carry a golden icon (issue #132). */} + {hasChildren ? isCollapsed ? : : } diff --git a/apps/web/src/pages/PageActions.tsx b/apps/web/src/pages/PageActions.tsx index 5606821..156b8f0 100644 --- a/apps/web/src/pages/PageActions.tsx +++ b/apps/web/src/pages/PageActions.tsx @@ -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 { > + {/* Between labels and history by design (issue #132). */} +