* feat(feedback): a third identity mode with one shared colour tag per photo (#1197) Split out of #1178, where @boergu asked for a colour tag with no identity dimension at all: not everyone sharing a device's state, but everyone — on any device — sharing the PHOTO's state. Guest A marks it green, guest B later marks it orange, and the tag simply becomes orange. One collaboratively-agreed verdict per photo instead of per-person tallies. identity_mode gains 'shared'. The mode is scoped to the colour tag: likes, ratings, comments, favourites and reactions stay per-visitor exactly as in 'simple', because that is what was asked for and widening it would change what every other control means. Stored as an ordinary photo_feedback row under a reserved identifier rather than as a column on photos. That is what keeps the rest of the system working untouched — the per-colour tally simply has exactly one entry, so dominant_color_label, color_label_count, the admin colour filter and the XMP/CSV export that #745 reads all keep their existing shapes, and no consumer has to learn a second one. The identifier cannot be claimed: real ones are sha256 hashes or server-minted UUIDs, and the per-guest write path rejects it outright. Last write wins, inside a transaction that locks the photo row. Without the lock two guests tapping different colours in the same instant both read 'no tag', both insert, and the photo ends up carrying two shared tags — the per-guest tally this mode exists to remove. Re-sending the colour already on a photo clears it, from any guest: the same toggle every other colour path uses, and the only way to remove a tag without inventing a second control. Switching modes is non-destructive. Existing per-guest labels are left alone and simply not read while shared is on; the shared tag starts empty rather than collapsing marks nobody agreed on, and switching back restores every original exactly. An event can hold both sets, only one of which is live. The tag stays visible with show_feedback_to_guests off — it arrives through the per-viewer channel, being the photo's own state rather than someone else's opinion — while the per-colour tallies stay hidden. The colour filters answer from it for the same reason, so a gallery with sharing off cannot show colours on tiles that no filter can find. Attribution is gone by design, and the settings panel says so before an operator picks the mode. Decisions (1), (4) and (5) from the issue were settled up front, as it asked. Decision (3) turned out not to need anything: guest colour filters already read my_color_label, and the admin's my_color_labels filters photo_admin_marks (#1183), not guest identity — so nothing collapses on either side. * fix(feedback): shared mode saves on Postgres, and dormant labels stay dormant (#1197) Three findings from external review, all confirmed against source before fixing. **The mode could not be saved on Postgres at all.** Migration 078 created identity_mode with a CHECK constraint pinned to ('simple','guest'), guarded on `client === 'pg'` — so SQLite never has it and no SQLite test can see it, while the database every default production install runs rejects the new value outright. Migration 192 drops and re-adds the constraint with 'shared' included; its down() resets any event using the mode to 'simple' first, or the narrower constraint could not be restored. Verified against a real Postgres on a scratch database: the insert fails before, succeeds after, up() is re-runnable, and down() puts the old constraint back. **Dormant labels were still being read.** Switching modes is deliberately non-destructive, which leaves both sets of colour labels in the table with only one live — and every read that did not say which set it meant kept counting the other. The per-colour tallies, color_label_count, the admin grid badge, the XMP/CSV export, both admin colour filters and the guest colour filter all saw labels the mode does not show; switching back exposed the shared row as an anonymous other guest's dot. The settings panel promises these are 'kept but not shown', and that has to mean every surface, not just the badge. Scoped at the source — the two count helpers resolve the mode themselves — so the admin grid and the export are fixed without touching either. **The create form's identity mode was dropped.** CreateEventPage has always rendered the chooser and the create route never read it, so a gallery created as 'guest' came out 'simple' and had to be set again on the event afterwards. A pre-existing bug that adding a third option made worse; threaded through now, which fixes it for all three modes. Six regression tests, each verified to fail against the un-fixed code. * fix(feedback): keep every colour surface consistent across a mode change (#1197) Second review round, four findings, all confirmed in source first. **Stored counters went stale on a mode switch.** photos.color_label_count is denormalized and recomputed on feedback writes, so changing identity_mode — which changes nothing about the rows, only which of them are live — left the old mode's totals on the tiles, the admin grid and the filter summary until each photo happened to be touched again. On a finished gallery that is never. Recounted for the event when the mode actually changes, as two statements rather than a per-photo recompute: four of the five counters cannot have moved. **Duplicating an event dropped the mode**, the same shape as the create-form bug from the last round — a gallery cloned to reuse its proofing setup came back in 'simple'. **The event feedback summary counted dormant labels**, inflating total_feedback in the admin analytics and the guest /feedback-summary while every other surface hid them. **The swatch trusted its optimistic guess over the server.** In shared mode the tag belongs to the photo, so another guest can move it between this viewer's last read and their click: a viewer still showing green clicks green, the server sets green because the tag had become red meanwhile, and the optimistic 'same colour, so clear' blanked the swatch against a server that holds one. The response already says which happened, so it is used. The per-guest modes are unaffected — only the guest can move their own label, so guess and answer always agreed there. Three regression tests, each verified to fail against the un-fixed code. * fix(feedback): shared tag is not a participant, and the keyboard path reconciles too (#1197) Third review round, two findings. **feedback_count counted the shared tag as a guest.** It is COUNT(DISTINCT guest identity) across all feedback types, and the reserved identifier looked like a person: a photo with one rating and a shared tag reported two. The column is exported as rating_count (photoExportService), so merely tagging a photo inflated its rating count in the CSV and JSON exports. **The lightbox keyboard path still trusted its own guess.** The reconciliation from the last round covered clicks through PhotoColorLabels, but the proofing shortcuts call PhotoLightbox.submitColorLabel directly and set local state from a locally computed toggle. That is the path a proofing client actually uses, so it had the divergence the previous fix was for: another guest moves the tag, this viewer presses the key, the server sets a colour and the swatch blanks. Both branches now read the outcome off the response. One regression test, verified to fail against the un-fixed code. * fix(feedback): identity-mode lookup must survive a migration-time caller (#1197) updatePhotoFeedbackStats is called from migrations as well as from the request path — migration 186's duplicate-photo dedupe (#1162) recomputes the survivor's totals — and a migration runs against a half-built schema where event_feedback_settings need not exist yet. The new inner join threw there, which took the whole stats update down with it, so the reparented rows were never counted and eight assertions in the 186 suite failed. Falls back to 'simple', which is the right answer rather than merely a safe one: an install with no feedback settings table has no event in shared mode, so the non-shared scope is exactly correct. Caught by CI, not by me — I had been running affected suites rather than the full one after each review round. * fix(feedback): atomic shared-tag write, scoped feedback list, safe PG fallback (#1197) Round 4 of external review, and one of the three is about the fix I made for the CI failure two rounds ago. **The identity-mode fallback could poison a Postgres transaction.** The join was wrapped in try/catch so a migration-time caller with a half-built schema would fall back to 'simple'. On Postgres a failed statement aborts the entire transaction, so catching it and carrying on left the caller's trx poisoned and the aggregate that follows failed with 'current transaction is aborted' — defeating the very compatibility the fallback was added for. It now asks whether the table exists before issuing the join, which is safe to ask and aborts nothing. Memoised once true, since a table does not un-create itself and this sits on the feedback write path. **The shared-tag stats were recomputed after the commit.** A failure there returned 500 for a tag that had already been written, so the client reverted its swatch and the next tap on the same colour toggled the committed tag off instead of setting it. Two concurrent writers could also race their aggregate updates. Recomputed inside the transaction now, while the photo row is still locked. **The raw feedback list still carried both label sets.** Only the tallies and my_feedback had been scoped, so a dormant per-guest label was still visible to anyone reading the list — and with sharing off it came back flagged is_mine. getPhotoFeedback now filters colour labels to the active set. One test for the list; the migration suite that caught the original CI regression still passes. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
71eaf25d94
commit
22e00f80b6
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users, Smile, Palette, Keyboard } from 'lucide-react';
|
||||
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users, Smile, Palette, Keyboard, Tag } from 'lucide-react';
|
||||
import { Card } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { COLOR_LABELS, COLOR_LABEL_SWATCHES, KEYBIND_SCHEMES, type KeybindMode } from '../../services/feedback.service';
|
||||
@@ -25,7 +25,7 @@ interface FeedbackSettings {
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
identity_mode?: 'simple' | 'guest';
|
||||
identity_mode?: 'simple' | 'guest' | 'shared';
|
||||
// Per-guest caps (#655). null/0 = unlimited.
|
||||
max_favorites_per_guest?: number | null;
|
||||
max_likes_per_guest?: number | null;
|
||||
@@ -141,7 +141,50 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Shared colour tag (#1197). Deliberately worded around what
|
||||
it changes and what it does not: it drops the identity from
|
||||
the COLOUR TAG only, and it is the one mode where a guest
|
||||
can overwrite someone else's mark — both of which an
|
||||
operator has to know before picking it. */}
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
settings.identity_mode === 'shared'
|
||||
? 'border-accent-dark bg-accent-dark/15'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="identity_mode"
|
||||
value="shared"
|
||||
checked={settings.identity_mode === 'shared'}
|
||||
onChange={() => onChange({ ...settings, identity_mode: 'shared' })}
|
||||
className="mt-0.5 w-4 h-4 text-accent focus:ring-primary-500"
|
||||
/>
|
||||
<Tag className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('feedback.settings.identityModeShared', 'One shared colour tag')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeSharedDesc',
|
||||
'One colour per photo that everyone sees and anyone can change — for agreeing a single verdict. Likes, ratings and comments stay per-visitor.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.identity_mode === 'shared' && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{t(
|
||||
'feedback.settings.identityModeSharedNote',
|
||||
'Colour tags in this mode have no author, so the admin view cannot show who set one. Existing per-visitor colour labels are kept but not shown while this mode is on, and come back if you switch away.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The identity-mode chooser, and the third option added for #1197.
|
||||
*
|
||||
* The shared colour tag is the one mode where a guest can overwrite another
|
||||
* guest's mark and where the admin loses attribution, so the consequences are
|
||||
* spelled out in the panel rather than left to the release notes. These tests
|
||||
* pin that the option exists, that picking it reports the right value, and
|
||||
* that the warning is tied to the selection rather than always on.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FeedbackSettings } from '../FeedbackSettings';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: any) =>
|
||||
typeof fallback === 'string' ? fallback : _key,
|
||||
i18n: { language: 'en' }
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const baseSettings = {
|
||||
feedback_enabled: true,
|
||||
allow_ratings: true,
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
allow_reactions: true,
|
||||
allow_color_labels: true,
|
||||
require_name_email: false,
|
||||
moderate_comments: false,
|
||||
show_feedback_to_guests: true,
|
||||
enable_rate_limiting: false,
|
||||
};
|
||||
|
||||
const renderPanel = (overrides = {}) => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<FeedbackSettings
|
||||
settings={{ ...baseSettings, ...overrides } as any}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
return { onChange };
|
||||
};
|
||||
|
||||
describe('identity mode chooser', () => {
|
||||
it('offers all three modes', () => {
|
||||
renderPanel();
|
||||
expect(screen.getByRole('radio', { name: /Simple feedback/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /Per-guest selections/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /One shared colour tag/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports the shared mode when it is picked', async () => {
|
||||
const { onChange } = renderPanel();
|
||||
await userEvent.click(screen.getByRole('radio', { name: /One shared colour tag/i }));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ identity_mode: 'shared' })
|
||||
);
|
||||
});
|
||||
|
||||
it('says out loud that the colour tag loses its author', () => {
|
||||
renderPanel({ identity_mode: 'shared' });
|
||||
// Attribution disappearing is the point of the mode, not a bug — but an
|
||||
// operator has to meet that fact before they pick it, not after.
|
||||
expect(screen.getByText(/cannot show who set one/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('promises the existing per-visitor labels survive the switch', () => {
|
||||
renderPanel({ identity_mode: 'shared' });
|
||||
expect(screen.getByText(/come back if you switch away/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the warning out of the way in the other modes', () => {
|
||||
renderPanel({ identity_mode: 'guest' });
|
||||
expect(screen.queryByText(/cannot show who set one/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks only the selected mode as checked', () => {
|
||||
renderPanel({ identity_mode: 'shared' });
|
||||
expect(screen.getByRole('radio', { name: /One shared colour tag/i })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: /Simple feedback/i })).not.toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: /Per-guest selections/i })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,21 @@ export const PhotoColorLabels: React.FC<PhotoColorLabelsProps> = ({
|
||||
}
|
||||
return { previousColor };
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (result, data) => {
|
||||
// Correct the optimistic guess with what the server actually did.
|
||||
//
|
||||
// In shared mode the tag belongs to the photo, so another guest can have
|
||||
// changed it between this viewer's last read and this click (#1197). The
|
||||
// optimistic branch above assumes the toggle is computed against a
|
||||
// current value: if this viewer still had green on screen while the tag
|
||||
// had already become red, clicking green SETS green server-side, but the
|
||||
// optimistic path read "same colour, so clear" and blanked the swatch.
|
||||
// The response says which of the two happened, so use it rather than the
|
||||
// guess. In the per-guest modes the two always agree — only the guest
|
||||
// themself can move their own label — so this costs nothing there.
|
||||
if (onColorLabelChange) {
|
||||
onColorLabelChange(result?.removed ? null : data.color);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
},
|
||||
onError: (_error, _data, context) => {
|
||||
|
||||
@@ -477,7 +477,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
// a repeat submission off. With nothing set there is nothing to clear.
|
||||
const value = color ?? myColorLabel;
|
||||
if (!value) return;
|
||||
const willBeSet = value === myColorLabel ? null : value;
|
||||
// What the server did, not what this client guessed. In shared mode the
|
||||
// tag belongs to the photo and another guest can move it between this
|
||||
// viewer's last read and this keypress (#1197), so a locally computed
|
||||
// toggle can blank a swatch the server has just set. The per-guest modes
|
||||
// always agree with the guess — only the guest can move their own label.
|
||||
const resolve = (result: any) => (result?.removed ? null : value);
|
||||
|
||||
if (isGuestMode && guestIdentity) {
|
||||
try {
|
||||
@@ -486,11 +491,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
const result = await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'color_label',
|
||||
color_label: value,
|
||||
});
|
||||
setMyColorLabel(willBeSet);
|
||||
setMyColorLabel(resolve(result));
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
} catch (err) {
|
||||
if (handleLimitError(err)) return;
|
||||
@@ -506,13 +511,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
const result = await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||
feedback_type: 'color_label',
|
||||
color_label: value,
|
||||
guest_name: savedIdentity?.name,
|
||||
guest_email: savedIdentity?.email,
|
||||
});
|
||||
setMyColorLabel(willBeSet);
|
||||
setMyColorLabel(resolve(result));
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
} catch (err) {
|
||||
if (handleLimitError(err)) return;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The colour swatch reconciling with the server's answer (#1197).
|
||||
*
|
||||
* In the per-guest modes the optimistic toggle is always right — only the
|
||||
* guest can move their own label. The shared tag belongs to the photo, so
|
||||
* another guest can change it between this viewer's last read and their click,
|
||||
* and the optimistic branch ("same colour, so clear it") can then disagree
|
||||
* with what the server actually did. The response says which happened, so the
|
||||
* component follows it rather than its guess.
|
||||
*/
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PhotoColorLabels } from '../PhotoColorLabels';
|
||||
|
||||
const submitFeedback = vi.fn();
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: any) => (typeof fallback === 'string' ? fallback : _key),
|
||||
i18n: { language: 'en' }
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../services/feedback.service', async () => {
|
||||
const actual = await vi.importActual<any>('../../../services/feedback.service');
|
||||
return {
|
||||
...actual,
|
||||
feedbackService: { submitFeedback: (...args: any[]) => submitFeedback(...args) }
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: { error: vi.fn(), success: vi.fn() },
|
||||
toast: { error: vi.fn(), success: vi.fn() }
|
||||
}));
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
|
||||
const renderLabels = (myColorLabel: string | null) => {
|
||||
const onColorLabelChange = vi.fn();
|
||||
render(
|
||||
<PhotoColorLabels
|
||||
gallerySlug="g"
|
||||
photoId="1"
|
||||
isEnabled
|
||||
myColorLabel={myColorLabel as any}
|
||||
onColorLabelChange={onColorLabelChange}
|
||||
/>,
|
||||
{ wrapper }
|
||||
);
|
||||
return { onColorLabelChange };
|
||||
};
|
||||
|
||||
const clickGreen = async () => {
|
||||
const green = screen.getAllByRole('button').find((b) => /green/i.test(b.getAttribute('title') || b.getAttribute('aria-label') || ''));
|
||||
expect(green).toBeTruthy();
|
||||
await userEvent.click(green!);
|
||||
};
|
||||
|
||||
describe('shared colour tag reconciliation', () => {
|
||||
beforeEach(() => submitFeedback.mockReset());
|
||||
|
||||
it('keeps the colour when the server says it was set, not cleared', async () => {
|
||||
// The viewer's screen still says green; the tag had already moved on, so
|
||||
// the server treats this click as a set rather than a toggle-off.
|
||||
submitFeedback.mockResolvedValue({ success: true, updated: true });
|
||||
const { onColorLabelChange } = renderLabels('green');
|
||||
|
||||
await clickGreen();
|
||||
|
||||
await waitFor(() => {
|
||||
// The optimistic call blanked it; the server's answer puts it back.
|
||||
expect(onColorLabelChange).toHaveBeenLastCalledWith('green');
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the colour when the server says it removed the tag', async () => {
|
||||
submitFeedback.mockResolvedValue({ success: true, removed: true });
|
||||
const { onColorLabelChange } = renderLabels('green');
|
||||
|
||||
await clickGreen();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onColorLabelChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3732,6 +3732,9 @@
|
||||
"identityModeSimpleDesc": "Anonym, gerätebasiert. Alle Besucher auf demselben Gerät teilen den Zustand.",
|
||||
"identityModeGuest": "Gastbezogene Auswahl",
|
||||
"identityModeGuestDesc": "Jeder Besucher gibt seinen Namen ein. Ermöglicht gastbezogenes Tracking und Admin-Einblicke.",
|
||||
"identityModeShared": "Ein gemeinsamer Farb-Tag",
|
||||
"identityModeSharedDesc": "Eine Farbe pro Foto, die alle sehen und jeder ändern kann — für ein gemeinsam abgestimmtes Urteil. Likes, Bewertungen und Kommentare bleiben besucherbezogen.",
|
||||
"identityModeSharedNote": "Farb-Tags haben in diesem Modus keinen Urheber, die Admin-Ansicht kann also nicht zeigen, wer einen gesetzt hat. Vorhandene besucherbezogene Farbmarkierungen bleiben erhalten, werden in diesem Modus aber nicht angezeigt und kehren beim Wechsel zurück.",
|
||||
"privacyModeration": "Datenschutz & Moderation",
|
||||
"requireInfo": "Name & E-Mail erforderlich",
|
||||
"requireInfoDesc": "Gäste müssen Name und E-Mail angeben, um Feedback zu hinterlassen",
|
||||
|
||||
@@ -3753,6 +3753,9 @@
|
||||
"identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.",
|
||||
"identityModeGuest": "Per-guest selections",
|
||||
"identityModeGuestDesc": "Each visitor enters their name. Enables per-guest tracking and admin insights.",
|
||||
"identityModeShared": "One shared colour tag",
|
||||
"identityModeSharedDesc": "One colour per photo that everyone sees and anyone can change — for agreeing a single verdict. Likes, ratings and comments stay per-visitor.",
|
||||
"identityModeSharedNote": "Colour tags in this mode have no author, so the admin view cannot show who set one. Existing per-visitor colour labels are kept but not shown while this mode is on, and come back if you switch away.",
|
||||
"privacyModeration": "Privacy & Moderation",
|
||||
"requireInfo": "Require Name & Email",
|
||||
"requireInfoDesc": "Guests must provide name and email to leave feedback",
|
||||
|
||||
@@ -2460,6 +2460,9 @@
|
||||
"identityModeSimpleDesc": "Anónimo, basado en dispositivo. Visitantes del mismo dispositivo comparten estado.",
|
||||
"identityModeGuest": "Por invitado",
|
||||
"identityModeGuestDesc": "Cada visitante introduce su nombre. Habilita rastreo por invitado e insights admin.",
|
||||
"identityModeShared": "Una etiqueta de color compartida",
|
||||
"identityModeSharedDesc": "Un color por foto que todos ven y cualquiera puede cambiar — para acordar un único veredicto. Los me gusta, las valoraciones y los comentarios siguen siendo por visitante.",
|
||||
"identityModeSharedNote": "Las etiquetas de color no tienen autor en este modo, así que la vista de administración no puede mostrar quién la puso. Las etiquetas por visitante existentes se conservan pero no se muestran mientras este modo esté activo, y vuelven si cambias de modo.",
|
||||
"colorLabels": "Etiquetas de color",
|
||||
"colorLabelsDesc": "Un color por invitado y foto, con el conjunto de colores de Lightroom para que la selección se traslade mediante XMP",
|
||||
"keybindMode": "Atajos de teclado",
|
||||
|
||||
@@ -2612,6 +2612,9 @@
|
||||
"identityModeSimpleDesc": "Anonyme, basé sur l'appareil. Tous les visiteurs sur le même appareil partagent le même état.",
|
||||
"identityModeGuest": "Sélections par invité",
|
||||
"identityModeGuestDesc": "Chaque visiteur entre son nom. Permet le suivi par invité et les informations administratives.",
|
||||
"identityModeShared": "Une étiquette de couleur partagée",
|
||||
"identityModeSharedDesc": "Une couleur par photo, visible par tous et modifiable par n'importe qui — pour convenir d'un verdict unique. Les mentions j'aime, notes et commentaires restent propres à chaque visiteur.",
|
||||
"identityModeSharedNote": "Les étiquettes de couleur n'ont pas d'auteur dans ce mode : la vue d'administration ne peut donc pas indiquer qui les a posées. Les couleurs existantes par visiteur sont conservées mais masquées tant que ce mode est actif, et réapparaissent si vous en changez.",
|
||||
"privacyModeration": "Confidentialité & Modération",
|
||||
"requireInfo": "Nom et e-mail requis",
|
||||
"requireInfoDesc": "Les invités doivent fournir leur nom et e-mail pour laisser des commentaires",
|
||||
|
||||
@@ -2581,6 +2581,9 @@
|
||||
"identityModeSimpleDesc": "Anoniem, apparaatgebaseerd. Alle bezoekers op hetzelfde apparaat delen de toestand.",
|
||||
"identityModeGuest": "Selecties per gast",
|
||||
"identityModeGuestDesc": "Elke bezoeker voert zijn naam in. Maakt tracking per gast en beheerdersinzichten mogelijk.",
|
||||
"identityModeShared": "Eén gedeelde kleurtag",
|
||||
"identityModeSharedDesc": "Eén kleur per foto die iedereen ziet en iedereen kan wijzigen — om samen tot één oordeel te komen. Likes, beoordelingen en reacties blijven per bezoeker.",
|
||||
"identityModeSharedNote": "Kleurtags hebben in deze modus geen auteur, dus de beheerdersweergave kan niet tonen wie er een heeft gezet. Bestaande kleurlabels per bezoeker blijven bewaard maar worden in deze modus niet getoond, en komen terug als je van modus wisselt.",
|
||||
"privacyModeration": "Privacy & Moderatie",
|
||||
"requireInfo": "Naam en e-mail vereisen",
|
||||
"requireInfoDesc": "Gasten moeten naam en e-mail opgeven om feedback te plaatsen",
|
||||
|
||||
@@ -2606,6 +2606,9 @@
|
||||
"identityModeSimpleDesc": "Anónimo, baseado no dispositivo. Todos os visitantes no mesmo dispositivo partilham o estado.",
|
||||
"identityModeGuest": "Seleções por convidado",
|
||||
"identityModeGuestDesc": "Cada visitante introduz o seu nome. Ativa o rastreamento por convidado e informações de administração.",
|
||||
"identityModeShared": "Uma etiqueta de cor partilhada",
|
||||
"identityModeSharedDesc": "Uma cor por foto que todos veem e qualquer pessoa pode alterar — para acordar um único veredicto. Gostos, avaliações e comentários continuam a ser por visitante.",
|
||||
"identityModeSharedNote": "As etiquetas de cor não têm autor neste modo, por isso a vista de administração não consegue mostrar quem a definiu. As etiquetas por visitante existentes são mantidas mas não são mostradas enquanto este modo estiver ativo, e voltam se mudar de modo.",
|
||||
"privacyModeration": "Privacidade e Moderação",
|
||||
"requireInfo": "Exigir nome e e-mail",
|
||||
"requireInfoDesc": "Os convidados devem fornecer nome e e-mail para deixar feedback",
|
||||
|
||||
@@ -2631,6 +2631,9 @@
|
||||
"identityModeSimpleDesc": "Анонимно, на основе устройства. Все посетители на одном устройстве используют общее состояние.",
|
||||
"identityModeGuest": "Выборки по гостям",
|
||||
"identityModeGuestDesc": "Каждый посетитель вводит своё имя. Включает отслеживание по гостям и аналитику для администратора.",
|
||||
"identityModeShared": "Один общий цветовой тег",
|
||||
"identityModeSharedDesc": "Один цвет на фото, который видят все и может изменить любой — чтобы договориться об общем решении. Лайки, оценки и комментарии остаются индивидуальными.",
|
||||
"identityModeSharedNote": "У цветовых тегов в этом режиме нет автора, поэтому в админ-панели не видно, кто его поставил. Существующие цветовые метки отдельных посетителей сохраняются, но не отображаются, пока включён этот режим, и возвращаются при его смене.",
|
||||
"privacyModeration": "Конфиденциальность и модерация",
|
||||
"requireInfo": "Требовать имя и email",
|
||||
"requireInfoDesc": "Гости должны указать имя и email для оставления отзывов",
|
||||
|
||||
@@ -2601,6 +2601,9 @@
|
||||
"identityModeSimpleDesc": "Anonimno, vezano na napravo. Vsi obiskovalci na isti napravi delijo stanje.",
|
||||
"identityModeGuest": "Izbire po gostih",
|
||||
"identityModeGuestDesc": "Vsak obiskovalec vnese svoje ime. Omogoča sledenje po gostih in vpoglede za administratorja.",
|
||||
"identityModeShared": "Ena skupna barvna oznaka",
|
||||
"identityModeSharedDesc": "Ena barva na fotografijo, ki jo vidijo vsi in jo lahko kdorkoli spremeni — za dogovor o enotni oceni. Všečki, ocene in komentarji ostanejo ločeni po obiskovalcu.",
|
||||
"identityModeSharedNote": "Barvne oznake v tem načinu nimajo avtorja, zato skrbniški pogled ne more pokazati, kdo jo je nastavil. Obstoječe barvne oznake posameznih obiskovalcev se ohranijo, a niso prikazane, dokler je ta način vklopljen, in se vrnejo ob zamenjavi načina.",
|
||||
"privacyModeration": "Zasebnost in moderiranje",
|
||||
"requireInfo": "Zahtevaj ime in e-pošto",
|
||||
"requireInfoDesc": "Gostje morajo za oddajo povratnih informacij navesti ime in e-pošto",
|
||||
|
||||
@@ -70,6 +70,7 @@ interface FormData {
|
||||
require_name_email: boolean;
|
||||
moderate_comments: boolean;
|
||||
show_feedback_to_guests: boolean;
|
||||
identity_mode: 'simple' | 'guest' | 'shared';
|
||||
enable_rate_limiting: boolean;
|
||||
rate_limit_window_minutes?: number;
|
||||
rate_limit_max_requests?: number;
|
||||
@@ -141,6 +142,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
identity_mode: 'simple',
|
||||
enable_rate_limiting: true,
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
@@ -502,6 +504,9 @@ export const CreateEventPage: React.FC = () => {
|
||||
require_name_email: feedbackSettings.require_name_email,
|
||||
moderate_comments: feedbackSettings.moderate_comments,
|
||||
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
||||
// The chooser has always been on this form; the value was never sent, so
|
||||
// the gallery came out in the default mode whatever was picked (#1197).
|
||||
identity_mode: feedbackSettings.identity_mode,
|
||||
// Client access (#172)
|
||||
client_access_enabled: formData.client_access_enabled,
|
||||
client_password: formData.client_access_enabled ? formData.client_password : undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type IdentityMode = 'simple' | 'guest';
|
||||
export type IdentityMode = 'simple' | 'guest' | 'shared';
|
||||
|
||||
// Emoji reactions (#839): the fixed curated set. Mirrored in
|
||||
// backend/src/constants/reactions.js — update both together.
|
||||
|
||||
Reference in New Issue
Block a user