Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions app/frontend/src/components/LibraryEntryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,16 @@ function ProgressFields({
return (
<>
{units.includes('season') && (
<NumberField label="Season" max={metadata.seasonCount} name="progressSeason" onCommit={onUpdate} value={entry.progress.season} />
<NumberField label="Season" name="progressSeason" onCommit={onUpdate} value={entry.progress.season} />
)}
{units.includes('episode') && (
<NumberField label="Episode" max={metadata.episodeCount} name="progressEpisode" onCommit={onUpdate} value={entry.progress.episode} />
<NumberField label="Episode" name="progressEpisode" onCommit={onUpdate} value={entry.progress.episode} />
)}
{units.includes('chapter') && (
<NumberField decimals label="Chapter" max={metadata.chapterCount} name="progressChapter" onCommit={onUpdate} value={entry.progress.chapter} />
<NumberField decimals label="Chapter" name="progressChapter" onCommit={onUpdate} value={entry.progress.chapter} />
)}
{units.includes('volume') && (
<NumberField decimals label="Volume" max={metadata.volumeCount} name="progressVolume" onCommit={onUpdate} value={entry.progress.volume} />
<NumberField decimals label="Volume" name="progressVolume" onCommit={onUpdate} value={entry.progress.volume} />
)}
{units.includes('hours') && (
<NumberField decimals label="Hours played" name="hoursPlayed" onCommit={onUpdate} value={entry.progress.hours} />
Expand Down Expand Up @@ -151,6 +151,9 @@ export function LibraryEntryEditor({
const [error, setError] = useState('');
const [removing, setRemoving] = useState(false);
const unavailableSources = entry.item.sources.filter((source) => !source.active);
const unavailablePreferredSource = unavailableSources.find(
(source) => source.key === entry.preferredSource,
);
const finished = entry.state === 'completed' || entry.state === 'dropped';
const needsPlatform =
entry.item.category === 'game' && entry.progress.platforms.length === 0;
Expand Down Expand Up @@ -273,6 +276,12 @@ export function LibraryEntryEditor({
</p>
)
)}
{entry.notificationsEnabled && unavailablePreferredSource && (
<p className="mono-sm mt-1 mb-0 text-faint">
Paused while {unavailablePreferredSource.name} is unavailable. Choose another preferred
source or Automatic to get notifications from it instead.
</p>
)}
{entry.notificationsEnabled && emailsOff && (
<p className="mono-sm mt-1 mb-0 text-faint">
Emails for {categoryLabels[entry.item.category].toLowerCase()} are off in{' '}
Expand Down
24 changes: 9 additions & 15 deletions app/frontend/src/components/ReviewEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ import { useAuth } from '../auth';
import { reviewBodyLimit, type OwnReview } from '../reviews';
import { ConfirmDialog } from './ConfirmDialog';
import { ReviewCard } from './ReviewCard';
import { Toggle } from './Toggle';

export function ReviewEditor({
defaultVisibility,
itemId,
onClose,
onSaved,
review,
}: {
defaultVisibility: OwnReview['visibility'];
itemId: string;
onClose: () => void;
onSaved: (review: OwnReview | null) => void;
Expand All @@ -23,7 +22,7 @@ export function ReviewEditor({
const [title, setTitle] = useState(review?.title ?? '');
const [body, setBody] = useState(review?.body ?? '');
const [containsSpoilers, setContainsSpoilers] = useState(review?.containsSpoilers ?? false);
const [visibility, setVisibility] = useState(review?.visibility ?? defaultVisibility);
const [visibility, setVisibility] = useState<OwnReview['visibility']>(review?.visibility ?? 'public');
const [preview, setPreview] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
Expand Down Expand Up @@ -98,18 +97,13 @@ export function ReviewEditor({
</span>
)}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="field-label">
Visibility
<select
onChange={(event) => setVisibility(event.target.value as OwnReview['visibility'])}
value={visibility}
>
<option value="public">Public</option>
<option value="private">Private, only you and administrators</option>
</select>
</label>
<label className="flex items-center gap-2 self-end pb-3 text-sm text-muted">
<div className="grid items-center gap-4 sm:grid-cols-2">
<Toggle
checked={visibility === 'private'}
label="Private"
onChange={(checked) => setVisibility(checked ? 'private' : 'public')}
/>
<label className="flex items-center gap-2 text-sm text-muted">
<input
checked={containsSpoilers}
onChange={(event) => setContainsSpoilers(event.target.checked)}
Expand Down
9 changes: 0 additions & 9 deletions app/frontend/src/components/TitleReviews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import { LinesSkeleton, ListSkeleton, Skeleton } from './Skeleton';
const notInLibrary =
'Add this title to your library and finish or drop it to rate and review it.';

interface Privacy {
privacy: { isPublic: boolean; showReviews: boolean };
}

function YourReview({
entry,
itemId,
Expand All @@ -27,7 +23,6 @@ function YourReview({
}) {
const own = useResource<OwnReview | null>(`/items/${itemId}/review`, true);
const review = own.data;
const me = useResource<Privacy>(review === null ? '/me' : null, true);
const [editing, setEditing] = useState(false);
const eligible = entry?.state === 'completed' || entry?.state === 'dropped';

Expand All @@ -46,9 +41,6 @@ function YourReview({
return (
<div className="rounded-xl border border-line bg-surface p-5">
<ReviewEditor
defaultVisibility={
me.data?.privacy.isPublic && me.data.privacy.showReviews ? 'public' : 'private'
}
itemId={itemId}
onClose={() => setEditing(false)}
onSaved={(saved) => {
Expand All @@ -75,7 +67,6 @@ function YourReview({
)}
<button
className="secondary-button px-3 py-2 text-sm"
disabled={!review && !me.data && !me.error}
onClick={() => setEditing(true)}
type="button"
>
Expand Down
4 changes: 2 additions & 2 deletions app/frontend/src/components/Toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function Toggle({
onChange,
}: {
checked: boolean;
description: string;
description?: string;
disabled?: boolean;
label: string;
onChange: (checked: boolean) => void;
Expand All @@ -22,7 +22,7 @@ export function Toggle({
<span className="block font-medium" id={labelId}>
{label}
</span>
<span className="text-sm text-muted">{description}</span>
{description && <span className="text-sm text-muted">{description}</span>}
</span>
<button
aria-checked={checked}
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const sections: Array<[Exclude<PrivacySetting, 'isPublic'>, string, string]> = [
['showLibrary', 'Library', 'Every title with its list and progress.'],
['showActivity', 'Activity', 'Recent additions, list changes, ratings and reviews from the sections you show.'],
['showRatings', 'Ratings', 'Your scores, including in the library and statistics.'],
['showReviews', 'Reviews', 'Your public reviews. New reviews start public when this is on.'],
['showReviews', 'Reviews', 'Your public reviews.'],
];

const settingsSections = [
Expand Down
103 changes: 103 additions & 0 deletions app/frontend/test/review-editor.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider } from '../src/auth';
import { ReviewEditor } from '../src/components/ReviewEditor';
import type { OwnReview } from '../src/reviews';

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const json = (body: unknown) =>
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });

const session = {
accessToken: 'token',
user: {
id: 'user-1',
email: 'reader@example.com',
handle: 'reader',
displayName: 'Reader',
role: 'member',
showAdultContent: false,
blurAdultContent: true,
},
};

describe('ReviewEditor', () => {
let container: HTMLDivElement;
let root: Root;
let saved: Array<Record<string, unknown>>;

beforeEach(() => {
saved = [];
vi.stubGlobal(
'fetch',
vi.fn((input: string, init?: RequestInit) => {
if (String(input).endsWith('/auth/refresh')) return Promise.resolve(json(session));
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
saved.push(body);
return Promise.resolve(json({ id: 'review-id', ...body }));
}),
);
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

async function render(review: OwnReview | null) {
await act(async () =>
root.render(
<MemoryRouter>
<AuthProvider>
<ReviewEditor itemId="item-1" onClose={() => undefined} onSaved={() => undefined} review={review} />
</AuthProvider>
</MemoryRouter>,
),
);
}

const privateSwitch = () => container.querySelector<HTMLButtonElement>('button[role="switch"]');
const save = () =>
act(async () => {
container.querySelector('form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 20));
});

it('starts public with a Private switch and saves the choice', async () => {
await render(null);

expect(container.querySelector('select')).toBeNull();
expect(privateSwitch()?.getAttribute('aria-checked')).toBe('false');
expect(container.textContent).toContain('Private');
expect(container.textContent).not.toMatch(/administrator/i);

await save();
expect(saved.at(-1)).toMatchObject({ visibility: 'public' });

await act(async () => privateSwitch()?.click());
expect(privateSwitch()?.getAttribute('aria-checked')).toBe('true');
await save();
expect(saved.at(-1)).toMatchObject({ visibility: 'private' });
});

it('keeps an existing private review private', async () => {
await render({
id: 'review-id',
rating: 6,
title: null,
body: null,
containsSpoilers: false,
visibility: 'private',
hidden: false,
} as OwnReview);

expect(privateSwitch()?.getAttribute('aria-checked')).toBe('true');
});
});
1 change: 0 additions & 1 deletion app/server/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ export class AdminService {
async reports(status: 'open' | 'resolved', page: number) {
const where = {
resolution: status === 'open' ? null : { not: null },
review: moderatedReviewWhere,
};
const [total, reports] = await this.prisma.$transaction([
this.prisma.reviewReport.count({ where }),
Expand Down
Loading
Loading