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
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:
parent
48d4c60af7
commit
6c98a71d34
@ -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%';" | \
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
39
apps/api/src/favorites/favorites.controller.ts
Normal file
39
apps/api/src/favorites/favorites.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
177
apps/api/src/favorites/favorites.e2e.db.test.ts
Normal file
177
apps/api/src/favorites/favorites.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
13
apps/api/src/favorites/favorites.module.ts
Normal file
13
apps/api/src/favorites/favorites.module.ts
Normal 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 {}
|
||||
57
apps/api/src/favorites/favorites.service.ts
Normal file
57
apps/api/src/favorites/favorites.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
116
apps/web/e2e/favorites.spec.ts
Normal file
116
apps/web/e2e/favorites.spec.ts
Normal 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();
|
||||
});
|
||||
45
apps/web/src/favorites/FavoriteToggle.tsx
Normal file
45
apps/web/src/favorites/FavoriteToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/favorites/use-favorites.ts
Normal file
33
apps/web/src/favorites/use-favorites.ts
Normal 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 };
|
||||
}
|
||||
@ -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} />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
15
packages/shared/src/favorites.ts
Normal file
15
packages/shared/src/favorites.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user