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
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,13 @@ restores the image but never the schema, so keep migrations additive.
lacks is sent from `/cameras/report#new` as its maker, its marking and
photos (`report_proposals`, migration 028), and publishing it from the
review queue adds the board (`boards.CreateModel`, alias source `club`)
-- the only way a model is made besides the importers. A report sent from
-- the only way a model is made besides the importers. A member may edit
their own report from `/club` (`reports.Store.Edit`, migration 031): the
note, the camera proposed, files out and in; ipctool's output and a backup
stay as sent. Like takedown it stands the guard down for its transaction,
and every change is logged in append-only `report_edits`. An edit to a
report already decided adds a review row `edit`: pending again, off the
board until a maintainer accepts it once more, the stars settled then. A report sent from
the camera is a member's when it carries a one-time code from `/club`
(`report_codes`, migration 029), in the note because every ipctool in the
field can send one: `ipctool upload --note club-XXXX-XXXX`. A code may
Expand Down
9 changes: 9 additions & 0 deletions data/locales/boards.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,15 @@ en:
rename_save: 'Save'
review_note_label: 'Reviewer'
your_note: 'Your note'
edit: 'Edit'
edit_save: 'Save'
edit_save_rereview: 'Save and send for review again'
edit_cancel: 'Cancel'
edit_rereview: 'A maintainer has already decided on this report. Once you save, it goes back to them, and it is off the catalogue until they accept it again.'
edit_remove: 'Take out'
edit_add: 'Add'
edited_at: 'Edited {time}'
review_edited_label: 'Edited by the sender'
cameras_title: 'My cameras on the Open Wall'
cameras_count: '{n} of {max} count towards stars'
cameras_empty: 'No camera linked yet. Link one below: it earns stars for every month it keeps sending real pictures to the Open Wall.'
Expand Down
9 changes: 9 additions & 0 deletions data/locales/boards.ru.yml
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,15 @@ ru:
rename_save: 'Сохранить'
review_note_label: 'Проверяющий'
your_note: 'Ваше примечание'
edit: 'Изменить'
edit_save: 'Сохранить'
edit_save_rereview: 'Сохранить и отправить на повторную проверку'
edit_cancel: 'Отмена'
edit_rereview: 'Мейнтейнер уже принял решение по этому отчёту. После сохранения он вернётся на проверку и пропадёт из каталога, пока его не примут снова.'
edit_remove: 'Убрать'
edit_add: 'Добавить'
edited_at: 'Изменён {time}'
review_edited_label: 'Изменён отправителем'
cameras_title: 'Мои камеры на Open Wall'
cameras_count: 'Звёзды приносят {n} из {max}'
cameras_empty: 'Пока ни одной камеры. Привяжите камеру ниже: она будет приносить звёзды за каждый месяц, в который присылает на Open Wall настоящие снимки.'
Expand Down
9 changes: 9 additions & 0 deletions data/locales/boards.zh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,15 @@ zh:
rename_save: '保存'
review_note_label: '审核者'
your_note: '你的备注'
edit: '编辑'
edit_save: '保存'
edit_save_rereview: '保存并重新提交审核'
edit_cancel: '取消'
edit_rereview: '维护者已经审核过这份报告。保存后它会重新进入审核,在再次通过之前不会出现在目录中。'
edit_remove: '移除'
edit_add: '添加'
edited_at: '编辑于 {time}'
review_edited_label: '发送者已编辑'
cameras_title: '我在 Open Wall 上的摄像头'
cameras_count: '{n} / {max} 台计入星星'
cameras_empty: '还没有绑定摄像头。在下方绑定一台:它每持续一个月向 Open Wall 发送真实画面,就会获得星星。'
Expand Down
23 changes: 18 additions & 5 deletions frontend/apps/site/src/components/club/Club.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { size } from '../../lib/reports';
import Cameras from './Cameras';
import Crashes from './Crashes';
import IpctoolCode from './IpctoolCode';
import EditReport from './EditReport';
import { STATUS_TONE, Stars } from './parts';

type Load = { state: 'loading' } | { state: 'ok'; me: Me } | { state: 'error' };
Expand Down Expand Up @@ -244,9 +245,8 @@ function MemberPage({ member, ways, locale, t, onChange }: {
// asks /me on every page and needs only the total.
const pending = (reports ?? []).reduce((n, r) => n + (r.status === 'pending' ? r.pending : 0), 0);

useEffect(() => {
fetchMine().then((r) => setReports(r.reports)).catch(() => setError(true));
}, [member.id]);
const loadReports = () => fetchMine().then((r) => setReports(r.reports)).catch(() => setError(true));
useEffect(() => { void loadReports(); }, [member.id]);
const loadCams = () => fetchCameras().then(setCams).catch(() => setError(true));
useEffect(() => { void loadCams(); }, [member.id]);
// A link to #cameras (the bot's "My cameras") lands on the section once it exists.
Expand Down Expand Up @@ -339,14 +339,15 @@ function MemberPage({ member, ways, locale, t, onChange }: {
{reports && reports.length === 0 && (
<p class="m-4 text-body-secondary">{t('club.mine_empty')} <a href={pathFor(locale, '/cameras/boards')}>{t('club.boards_link')}</a></p>
)}
{reports && reports.length > 0 && <Ledger reports={reports} locale={locale} t={t} />}
{reports && reports.length > 0 && <Ledger reports={reports} locale={locale} t={t} onChange={() => { void loadReports(); onChange(); }} />}
<div class="border-t border-hairline px-4 py-4"><Rules t={t} /></div>
</div>
</section>
);
}

function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Locale; t: BoardsT }) {
function Ledger({ reports, locale, t, onChange }: { reports: MemberReport[]; locale: Locale; t: BoardsT; onChange: () => void }) {
const [editing, setEditing] = useState<string | null>(null);
return (
<div class="overflow-x-auto">
<table class="w-full min-w-[520px] border-collapse text-sm">
Expand Down Expand Up @@ -384,6 +385,18 @@ function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Local
<span class="text-[12.5px] whitespace-pre-wrap text-body"><b class="font-semibold">{t('club.your_note')}:</b> {r.note}</span>
)}
{r.duplicate && <span class="text-[12.5px] text-body-secondary">{t('club.duplicate')}</span>}
{r.edited_at && (
<span class="text-[12px] text-body-secondary">
{t('club.edited_at', { time: new Date(r.edited_at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) })}
</span>
)}
{editing === r.id
? <EditReport report={r} t={t} onCancel={() => setEditing(null)} onSaved={() => { setEditing(null); onChange(); }} />
: r.status !== 'withdrawn' && (
<button type="button" class="w-fit cursor-pointer p-0 text-left text-[13px] text-brand-blue underline" onClick={() => setEditing(r.id)}>
{t('club.edit')}
</button>
)}
{r.status !== 'rejected' && !r.joins && <IpctoolCode joins={r.id} locale={locale} t={t} label={t('club.code_add_ipctool')} />}
{r.review_note && (
<span class="text-[12.5px] text-body"><b class="font-semibold">{t('club.review_note_label')}:</b> {r.review_note}</span>
Expand Down
60 changes: 60 additions & 0 deletions frontend/apps/site/src/components/club/EditReport.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
/**
* A member's edit to a report, as /club sends it: only what changed, files
* to take out by position, files to add as their kind; and before a decided
* report is saved, the warning that it goes back for review.
*/
import { afterEach, expect, test, vi } from 'vitest';
import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact';
import { useBoardsTranslations } from '../../lib/boards-i18n';
import type { MemberReport } from '../../lib/club';
import EditReport from './EditReport.tsx';

const t = useBoardsTranslations('en');
afterEach(() => { cleanup(); vi.unstubAllGlobals(); });

const report = (over: Partial<MemberReport>): MemberReport => ({
id: 'r-abcd2345', received_at: '2026-10-08T10:00:00Z', status: 'pending', note: 'from a market',
proposal: { maker: 'Jooan', board: 'Q9' }, stars: 0, pending: 2,
files: [
{ position: 1, kind: 'photo', name: 'front.jpg', bytes: 100, url: '', points: 1 },
{ position: 2, kind: 'photo', name: 'back.jpg', bytes: 100, url: '', points: 1 },
],
...over,
});

function capture() {
const sent: { url: string; form: FormData }[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
sent.push({ url, form: init?.body as FormData });
return new Response(JSON.stringify({ id: 'r-abcd2345', status: 'pending', rereview: false }));
}));
return sent;
}

test('an edit sends what changed: the note, a photo out, a boot log in', async () => {
const sent = capture();
const saved = vi.fn();
const { container, getByText, getByLabelText } = render(<EditReport report={report({})} t={t} onSaved={saved} onCancel={() => {}} />);
expect(container.textContent).not.toContain('goes back to them');
fireEvent.input(getByLabelText('Your note'), { target: { value: 'bought on Ozon as Q9 Pro' } });
fireEvent.click(getByText(/back\.jpg/));
fireEvent.change(container.querySelector('select')!, { target: { value: 'boot_log' } });
fireEvent.change(getByLabelText('Add', { selector: 'input' }), { target: { files: [new File(['U-Boot\n'], 'boot.log')] } });
fireEvent.click(getByText('Save'));
await waitFor(() => expect(saved).toHaveBeenCalled());
const f = sent[0].form;
expect(sent[0].url).toBe('/api/v1/club/reports/r-abcd2345/edit');
expect(f.get('note')).toBe('bought on Ozon as Q9 Pro');
expect(f.getAll('remove')).toEqual(['2']);
expect((f.get('boot_log') as File).name).toBe('boot.log');
// The camera was not touched, so it is not sent.
expect(f.get('maker')).toBeNull();
});

test('a decided report says it goes back for review before it is saved', () => {
capture();
const { getByRole, getByText } = render(<EditReport report={report({ status: 'published' })} t={t} onSaved={() => {}} onCancel={() => {}} />);
expect(getByRole('note').textContent).toContain('goes back to them');
expect(getByText('Save and send for review again')).toBeTruthy();
});
103 changes: 103 additions & 0 deletions frontend/apps/site/src/components/club/EditReport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* A member changing a report of theirs from /club
* (POST /api/v1/club/reports/{id}/edit): the note, the camera they proposed,
* files taken out, photos and text added. ipctool's output and a backup stay
* as sent. A report a maintainer has decided goes back to them: the form says
* so before it is saved.
*/
import { useState } from 'preact/hooks';
import type { BoardsT } from '../../lib/boards-i18n';
import { editReport, type MemberReport } from '../../lib/club';

type AddKind = 'photo' | 'boot_log' | 'uboot_env';
const ADD_KINDS: AddKind[] = ['photo', 'boot_log', 'uboot_env'];
const field = 'rounded-md border border-hairline px-2.5 py-1.5 text-sm text-body';

export default function EditReport({ report, t, onSaved, onCancel }: {
report: MemberReport; t: BoardsT; onSaved: () => void; onCancel: () => void;
}) {
const [note, setNote] = useState(report.note ?? '');
const [maker, setMaker] = useState(report.proposal?.maker ?? '');
const [board, setBoard] = useState(report.proposal?.board ?? '');
const [soc, setSoc] = useState(report.proposal?.soc ?? '');
const [remove, setRemove] = useState<Set<number>>(new Set());
const [kind, setKind] = useState<AddKind>('photo');
const [add, setAdd] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const decided = report.status === 'published' || report.status === 'rejected';

const toggle = (pos: number) => {
const next = new Set(remove);
if (next.has(pos)) next.delete(pos); else next.add(pos);
setRemove(next);
};
const save = (e: Event) => {
e.preventDefault();
const form = new FormData();
if (note.trim() !== (report.note ?? '')) form.set('note', note.trim());
if (report.proposal && (maker.trim() !== report.proposal.maker || board.trim() !== report.proposal.board || soc.trim() !== (report.proposal.soc ?? ''))) {
form.set('maker', maker.trim());
form.set('board', board.trim());
form.set('soc', soc.trim());
}
for (const pos of remove) form.append('remove', String(pos));
for (const f of add) form.append(kind, f, f.name);
setBusy(true);
setError(null);
editReport(report.id, form)
.then(onSaved)
.catch((err: Error) => setError(t('club.send_failed', { error: err.message })))
.finally(() => setBusy(false));
};

return (
<form class="grid gap-2.5 rounded-md border border-hairline bg-surface-alt p-3 text-[13px]" onSubmit={save}>
{decided && <p class="m-0 rounded-md bg-[#fff4e2] px-3 py-2 text-[#8a4b00]" role="note">{t('club.edit_rereview')}</p>}
{report.proposal && (
<div class="grid gap-2 sm:grid-cols-[1fr_1fr_8rem]">
<label class="grid gap-1 text-body-secondary">{t('club.new_maker')}
<input value={maker} maxLength={80} onInput={(e) => setMaker((e.target as HTMLInputElement).value)} class={field} />
</label>
<label class="grid gap-1 text-body-secondary">{t('club.new_board')}
<input value={board} maxLength={80} onInput={(e) => setBoard((e.target as HTMLInputElement).value)} class={field} />
</label>
<label class="grid gap-1 text-body-secondary">{t('club.new_soc')}
<input value={soc} maxLength={40} onInput={(e) => setSoc((e.target as HTMLInputElement).value)} class={field} />
</label>
</div>
)}
<label class="grid gap-1 text-body-secondary">{t('club.your_note')}
<textarea value={note} maxLength={4000} onInput={(e) => setNote((e.target as HTMLTextAreaElement).value)}
class={`${field} min-h-[56px] resize-y`} />
</label>
{report.files.some((f) => f.kind !== 'backup') && (
<fieldset class="m-0 grid gap-1 border-0 p-0">
<legend class="mb-1 p-0 text-body-secondary">{t('club.edit_remove')}</legend>
{report.files.filter((f) => f.kind !== 'backup').map((f) => (
<label key={f.position} class="flex items-center gap-2">
<input type="checkbox" checked={remove.has(f.position)} onChange={() => toggle(f.position)} />
<span class={remove.has(f.position) ? 'line-through text-body-secondary' : ''}>{t(`club.kind_${f.kind}`)} · {f.name}</span>
</label>
))}
</fieldset>
)}
<div class="grid gap-1">
<span class="text-body-secondary">{t('club.edit_add')}</span>
<div class="flex flex-wrap items-center gap-2">
<select value={kind} onChange={(e) => { setKind((e.target as HTMLSelectElement).value as AddKind); setAdd([]); }} class={field}>
{ADD_KINDS.map((k) => <option key={k} value={k}>{t(`club.kind_${k}`)}</option>)}
</select>
{/* keyed on the kind: switching it clears what was picked, input and all */}
<input key={kind} type="file" multiple aria-label={t('club.edit_add')} accept={kind === 'photo' ? 'image/jpeg,image/png,image/webp' : '.txt,.log,text/plain'}
onChange={(e) => setAdd([...((e.target as HTMLInputElement).files ?? [])])} class="text-sm text-body" />
Comment thread
widgetii marked this conversation as resolved.
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<button type="submit" class="site-btn site-btn-primary site-btn-sm" disabled={busy}>{t(decided ? 'club.edit_save_rereview' : 'club.edit_save')}</button>
<button type="button" class="site-btn site-btn-outline-secondary site-btn-sm" onClick={onCancel}>{t('club.edit_cancel')}</button>
{error && <span class="text-[#a3262e]" role="alert">{error}</span>}
</div>
</form>
);
}
6 changes: 3 additions & 3 deletions frontend/apps/site/src/components/club/NewCamera.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ test('a dump read with a programmer goes with the photos, private unless ticked'

const queued = (over: Partial<Queued>): Queued => ({
id: 'r-new23456', received_at: '2026-10-06T10:00:00Z', channel: 'web', status: 'pending', chip: '', sensor: '',
board: null, models: [], backup_consent: 'none', member: 'Ivan', file_list: [], potential: 2,
board: null, models: [], backup_consent: 'none', member: 'Ivan', file_list: [], potential: 2, revision: 0,
proposal: { maker: 'Jooan', board: 'Q9 v2', soc: 'SSC335' },
new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9-v2', model: 'Q9 v2', soc: 'SSC335', maker_known: false },
...over,
Expand Down Expand Up @@ -114,7 +114,7 @@ test('the reviewer publishes a proposal as the board they corrected', async () =
fireEvent.click(getByRole('button', { name: /^Publish ·/ }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0].body).toEqual({
decision: 'publish', models: [], note: '',
decision: 'publish', models: [], note: '', revision: 0,
new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9', model: 'Q9 v2', soc: 'SSC335' },
});
expect(await findByText(/jooan-q9 added to the catalogue/)).toBeTruthy();
Expand All @@ -126,5 +126,5 @@ test('a proposal the catalogue already has is published on that board, not a twi
expect(await findByText(/already has jooan-q9-v2/)).toBeTruthy();
fireEvent.click(getByRole('button', { name: /^Publish ·/ }));
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0].body).toEqual({ decision: 'publish', models: ['jooan-q9-v2'], note: '' });
expect(calls[0].body).toEqual({ decision: 'publish', models: ['jooan-q9-v2'], note: '', revision: 0 });
});
4 changes: 3 additions & 1 deletion frontend/apps/site/src/components/club/Review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT;
setBusy(true);
setError(null);
const ids = models.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
decide(q.id, decision, ids, note, decision === 'publish' && create && board ? board : undefined)
decide(q.id, decision, ids, note, decision === 'publish' && create && board ? board : undefined, q.revision)
.then((r) => {
setResult(r.board ? t('club.review_done_board', { board: r.board, points: r.points }) : t(`club.review_done_${decision}`, { points: r.points }));
setTimeout(onDone, 1200);
Expand All @@ -107,6 +107,8 @@ function Item({ q, locale, t, onDone }: { q: Queued; locale: Locale; t: BoardsT;
<dd class="m-0">{q.member ? t('club.review_from', { who: q.member }) : t('club.review_anon')}</dd>
{q.board && <><dt class="text-body-secondary">{t('club.review_board')}</dt>
<dd class="m-0"><a href={`${pathFor(locale, '/cameras/boards')}?model=${encodeURIComponent(q.board.id)}`} class="font-mono">{q.board.id}</a></dd></>}
{q.edited_at && <><dt class="text-body-secondary">{t('club.review_edited_label')}</dt>
<dd class="m-0">{new Date(q.edited_at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' })}</dd></>}
{q.joins && <><dt class="text-body-secondary">{t('club.review_joins_label')}</dt>
<dd class="m-0">{t('club.goes_with', { id: q.joins })}</dd></>}
{q.proposal && <><dt class="text-body-secondary">{t('club.review_proposal')}</dt>
Expand Down
Loading
Loading