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 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/graph.spec.ts
|
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
|
- name: Reset login rate limit before create-missing-page pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
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[]
|
comments Comment[]
|
||||||
watches Watch[]
|
watches Watch[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
|
favorites PageFavorite[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -285,6 +286,7 @@ model Page {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
incomingLinks PageLink[] @relation("incomingLinks")
|
incomingLinks PageLink[] @relation("incomingLinks")
|
||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
|
favorites PageFavorite[]
|
||||||
|
|
||||||
@@unique([pondId, slug])
|
@@unique([pondId, slug])
|
||||||
@@index([pondId])
|
@@index([pondId])
|
||||||
@ -450,6 +452,22 @@ model PageLabel {
|
|||||||
@@map("page_labels")
|
@@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 {
|
enum QuotaSubjectType {
|
||||||
USER
|
USER
|
||||||
POND
|
POND
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import { TrashModule } from './trash/trash.module';
|
|||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { WatchesModule } from './watches/watches.module';
|
import { WatchesModule } from './watches/watches.module';
|
||||||
|
import { FavoritesModule } from './favorites/favorites.module';
|
||||||
import { VersionsModule } from './versions/versions.module';
|
import { VersionsModule } from './versions/versions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -60,6 +61,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PagesModule,
|
PagesModule,
|
||||||
CommentsModule,
|
CommentsModule,
|
||||||
WatchesModule,
|
WatchesModule,
|
||||||
|
FavoritesModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
TrashModule,
|
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,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
Waypoints,
|
Waypoints,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@ -23,6 +24,7 @@ import { Link } from 'react-router-dom';
|
|||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
|
import { usePageFavorites } from '../favorites/use-favorites';
|
||||||
import { ImportControl } from '../import/ImportControl';
|
import { ImportControl } from '../import/ImportControl';
|
||||||
import { LabelChips } from '../labels/LabelChips';
|
import { LabelChips } from '../labels/LabelChips';
|
||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
@ -89,6 +91,9 @@ function SidebarContent({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
|
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 [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
const [dropIntoId, setDropIntoId] = useState<string | null>(null);
|
const [dropIntoId, setDropIntoId] = useState<string | null>(null);
|
||||||
const [moveError, setMoveError] = useState<unknown>(null);
|
const [moveError, setMoveError] = useState<unknown>(null);
|
||||||
@ -111,6 +116,7 @@ function SidebarContent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { flat, byId } = usePondLabels(pond.id);
|
const { flat, byId } = usePondLabels(pond.id);
|
||||||
|
const { ids: favoriteIds } = usePageFavorites(pond.id);
|
||||||
|
|
||||||
const isOwner = Boolean(user && user.id === pond.ownerId);
|
const isOwner = Boolean(user && user.id === pond.ownerId);
|
||||||
|
|
||||||
@ -122,10 +128,13 @@ function SidebarContent({
|
|||||||
return acc;
|
return acc;
|
||||||
}, [filterIds, flat]);
|
}, [filterIds, flat]);
|
||||||
|
|
||||||
const visiblePages =
|
const labelFiltered =
|
||||||
filterIds.size === 0
|
filterIds.size === 0
|
||||||
? pages.data
|
? pages.data
|
||||||
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
|
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
|
||||||
|
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
|
// 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.
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -247,6 +261,18 @@ function SidebarContent({
|
|||||||
{t(`layout.sidebar.view.${mode}`)}
|
{t(`layout.sidebar.view.${mode}`)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{view === 'folders' && flat.length > 0 && (
|
{view === 'folders' && flat.length > 0 && (
|
||||||
@ -290,15 +316,17 @@ function SidebarContent({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'labels' ? (
|
{view === 'labels' ? (
|
||||||
pages.data && pages.data.length > 0 ? (
|
labelViewPages && labelViewPages.length > 0 ? (
|
||||||
<LabelGroupedPages
|
<LabelGroupedPages
|
||||||
pages={pages.data}
|
pages={labelViewPages}
|
||||||
labels={flat}
|
labels={flat}
|
||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
pageSlug={pageSlug}
|
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 ? (
|
) : showFlatFallback ? (
|
||||||
visiblePages && visiblePages.length > 0 ? (
|
visiblePages && visiblePages.length > 0 ? (
|
||||||
@ -311,7 +339,9 @@ function SidebarContent({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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 ? (
|
) : tree.length > 0 ? (
|
||||||
<ul className="sidebar__pages sidebar__pages--tree">
|
<ul className="sidebar__pages sidebar__pages--tree">
|
||||||
@ -320,6 +350,7 @@ function SidebarContent({
|
|||||||
pondSlug={pondSlug}
|
pondSlug={pondSlug}
|
||||||
pageSlug={pageSlug}
|
pageSlug={pageSlug}
|
||||||
byId={byId}
|
byId={byId}
|
||||||
|
favoriteIds={favoriteIds}
|
||||||
collapsedIds={collapsedIds}
|
collapsedIds={collapsedIds}
|
||||||
onToggleCollapsed={toggleCollapsed}
|
onToggleCollapsed={toggleCollapsed}
|
||||||
canReorder={canReorder}
|
canReorder={canReorder}
|
||||||
@ -420,6 +451,7 @@ interface PageTreeLevelProps {
|
|||||||
pondSlug: string;
|
pondSlug: string;
|
||||||
pageSlug: string | null;
|
pageSlug: string | null;
|
||||||
byId: Map<string, LabelView>;
|
byId: Map<string, LabelView>;
|
||||||
|
favoriteIds: Set<string>;
|
||||||
collapsedIds: string[];
|
collapsedIds: string[];
|
||||||
onToggleCollapsed: (id: string) => void;
|
onToggleCollapsed: (id: string) => void;
|
||||||
canReorder: boolean;
|
canReorder: boolean;
|
||||||
@ -445,6 +477,7 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
|||||||
pondSlug,
|
pondSlug,
|
||||||
pageSlug,
|
pageSlug,
|
||||||
byId,
|
byId,
|
||||||
|
favoriteIds,
|
||||||
collapsedIds,
|
collapsedIds,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
canReorder,
|
canReorder,
|
||||||
@ -552,7 +585,15 @@ function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
|
|||||||
) : (
|
) : (
|
||||||
<span className="sidebar__caret sidebar__caret--leaf" aria-hidden />
|
<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 />}
|
{hasChildren ? isCollapsed ? <Folder /> : <FolderOpen /> : <FileText />}
|
||||||
</span>
|
</span>
|
||||||
<PageLink page={node} pondSlug={pondSlug} pageSlug={pageSlug} />
|
<PageLink page={node} pondSlug={pondSlug} pageSlug={pageSlug} />
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { IconButton } from '../components/IconButton';
|
import { IconButton } from '../components/IconButton';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { useDocumentExport } from '../export/use-document-export';
|
import { useDocumentExport } from '../export/use-document-export';
|
||||||
|
import { FavoriteToggle } from '../favorites/FavoriteToggle';
|
||||||
import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api';
|
import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api';
|
||||||
import { useDismissable } from '../lib/use-dismissable';
|
import { useDismissable } from '../lib/use-dismissable';
|
||||||
import { WatchToggle } from '../watches/WatchToggle';
|
import { WatchToggle } from '../watches/WatchToggle';
|
||||||
@ -110,6 +111,8 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Tag aria-hidden />
|
<Tag aria-hidden />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{/* Between labels and history by design (issue #132). */}
|
||||||
|
<FavoriteToggle pageId={props.pageId} pondSlug={props.pondSlug} />
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t('history.open')}
|
label={t('history.open')}
|
||||||
active={props.showHistory}
|
active={props.showHistory}
|
||||||
|
|||||||
@ -178,6 +178,24 @@ button {
|
|||||||
background: var(--color-surface);
|
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 {
|
.sidebar__tree-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -243,6 +261,12 @@ button {
|
|||||||
color: var(--color-text-muted);
|
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 {
|
.sidebar__page-icon svg {
|
||||||
width: 0.875rem;
|
width: 0.875rem;
|
||||||
height: 0.875rem;
|
height: 0.875rem;
|
||||||
|
|||||||
@ -26,6 +26,8 @@
|
|||||||
--color-accent-contrast: #ffffff;
|
--color-accent-contrast: #ffffff;
|
||||||
--color-danger: #ab091e;
|
--color-danger: #ab091e;
|
||||||
--color-ok: #14803c;
|
--color-ok: #14803c;
|
||||||
|
/* Favorite stars and tree icons (issue #132) — a readable gold. */
|
||||||
|
--color-favorite: #b8860b;
|
||||||
|
|
||||||
/* Spacing scale (rem-based). */
|
/* Spacing scale (rem-based). */
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
|
|||||||
@ -63,6 +63,10 @@ Familien oder Projekte.
|
|||||||
lokalen Nachbarschafts-Graphen.
|
lokalen Nachbarschafts-Graphen.
|
||||||
- **Labels**, auf Wunsch hierarchisch, um einen Teich beliebig zu
|
- **Labels**, auf Wunsch hierarchisch, um einen Teich beliebig zu
|
||||||
gliedern — und um Zugriffsregeln zu begrenzen (siehe unten).
|
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 —
|
- **Schnelle Volltextsuche** über alles, was du lesen darfst —
|
||||||
unempfindlich gegen Akzente und Umlaute, mit Teilwort-Treffern.
|
unempfindlich gegen Akzente und Umlaute, mit Teilwort-Treffern.
|
||||||
- **Inhaltsverzeichnis, Seitenindizes, Diagramme** und mehr über
|
- **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
|
Bei geöffneter Seite findest du neben dem Stift: **Beobachten** (Glocke
|
||||||
für diese Seite), **Kommentare** (mit Zähler für Ungelesenes),
|
für diese Seite), **Kommentare** (mit Zähler für Ungelesenes),
|
||||||
**Anhänge**, **Seiten-Werkzeuge** (Inhaltsverzeichnis, Seitenindex —
|
**Anhänge**, **Seiten-Werkzeuge** (Inhaltsverzeichnis, Seitenindex —
|
||||||
sofern aktiviert), **Labels**, **Verlauf** und das **…**-Menü (Markdown
|
sofern aktiviert), **Labels**, den **Favoriten-Stern**, **Verlauf** und
|
||||||
kopieren/herunterladen, Export nach Word/LibreOffice/PDF, Verschieben
|
das **…**-Menü (Markdown kopieren/herunterladen, Export nach
|
||||||
nach…, Löschen).
|
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
|
## Labels
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,9 @@ projects.
|
|||||||
with a click; each page also gets a local neighborhood graph
|
with a click; each page also gets a local neighborhood graph
|
||||||
- **Labels**, hierarchical if you like, to slice a pond any way you want
|
- **Labels**, hierarchical if you like, to slice a pond any way you want
|
||||||
— and to scope access rules (see below).
|
— 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
|
- **Fast full-text search** across everything you may read — accent- and
|
||||||
umlaut-insensitive, with substring matching.
|
umlaut-insensitive, with substring matching.
|
||||||
- **A table of contents, page indexes, diagrams** and more through
|
- **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
|
When a page is open you find, next to the pencil: **watch** (bell for
|
||||||
this page), **comments** (with unread count), **attachments**,
|
this page), **comments** (with unread count), **attachments**,
|
||||||
**plugin tools** (table of contents, page index — when enabled),
|
**plugin tools** (table of contents, page index — when enabled),
|
||||||
**labels**, **history**, and the **…** overflow menu (copy/download
|
**labels**, the **favorite star**, **history**, and the **…** overflow
|
||||||
Markdown, export to Word/LibreOffice/PDF, move to…, delete).
|
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
|
## Labels
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,10 @@
|
|||||||
"defaultLabel": "Standard-Ansicht",
|
"defaultLabel": "Standard-Ansicht",
|
||||||
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",
|
"defaultHint": "Der Standard für alle Mitglieder; jeder kann seine eigene Seitenleiste lokal umschalten.",
|
||||||
"saved": "Gespeichert."
|
"saved": "Gespeichert."
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"filter": "Favoriten",
|
||||||
|
"empty": "Noch keine Favoriten in diesem Teich."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pondSwitcher": {
|
"pondSwitcher": {
|
||||||
|
|||||||
@ -164,6 +164,10 @@
|
|||||||
"pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.",
|
"pageTrashedHint": "Diese Seite wurde in den Papierkorb verschoben.",
|
||||||
"restoreLink": "Im Papierkorb ansehen"
|
"restoreLink": "Im Papierkorb ansehen"
|
||||||
},
|
},
|
||||||
|
"favorites": {
|
||||||
|
"add": "Als Favorit markieren",
|
||||||
|
"remove": "Favorit entfernen"
|
||||||
|
},
|
||||||
"wikilink": {
|
"wikilink": {
|
||||||
"phantomTooltip": "Diese Seite existiert noch nicht",
|
"phantomTooltip": "Diese Seite existiert noch nicht",
|
||||||
"autocompleteLabel": "Auf eine Seite verlinken",
|
"autocompleteLabel": "Auf eine Seite verlinken",
|
||||||
|
|||||||
@ -37,6 +37,10 @@
|
|||||||
"defaultLabel": "Default view",
|
"defaultLabel": "Default view",
|
||||||
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",
|
"defaultHint": "The default for all members; everyone can switch their own sidebar locally.",
|
||||||
"saved": "Saved."
|
"saved": "Saved."
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"filter": "Favorites",
|
||||||
|
"empty": "No favorites in this pond yet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pondSwitcher": {
|
"pondSwitcher": {
|
||||||
|
|||||||
@ -164,6 +164,10 @@
|
|||||||
"pageTrashedHint": "This page has been moved to the trash.",
|
"pageTrashedHint": "This page has been moved to the trash.",
|
||||||
"restoreLink": "View in trash"
|
"restoreLink": "View in trash"
|
||||||
},
|
},
|
||||||
|
"favorites": {
|
||||||
|
"add": "Mark as favorite",
|
||||||
|
"remove": "Remove favorite"
|
||||||
|
},
|
||||||
"wikilink": {
|
"wikilink": {
|
||||||
"phantomTooltip": "This page does not exist yet",
|
"phantomTooltip": "This page does not exist yet",
|
||||||
"autocompleteLabel": "Link to a page",
|
"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 './editor-schema';
|
||||||
export * from './env';
|
export * from './env';
|
||||||
export * from './conversion';
|
export * from './conversion';
|
||||||
|
export * from './favorites';
|
||||||
export * from './files';
|
export * from './files';
|
||||||
export * from './fonts';
|
export * from './fonts';
|
||||||
export * from './health';
|
export * from './health';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user