From acb0bbdcd64ce08203f45a035d4a0d8792a05171 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Tue, 6 Oct 2026 10:17:02 +0000 Subject: [PATCH 1/2] Club: add a camera the catalogue does not have, with its photos A member could send photos only from an existing board's panel; a camera the catalogue lacked needed ipctool's output, and even then nothing could add it: only the importers make boards. Sending: /cameras/report#new takes the camera's maker, its board's marking or model, at least one photo, and optionally the SoC, ipctool's output and a boot log (POST /api/v1/club/reports, channel web, maker/board/soc in place of model). The proposal is kept as sent in report_proposals (migration 028), guarded like the other owner-report tables. The catalogue's empty searches and /club's My submissions link to the form. Reviewing: the queue shows the proposal and the board publishing would add (boards.Suggest): the catalogue's own maker when the name or an alias matches, ids made the way the importers make them, and the board that already answers to the marking, if one does. The reviewer corrects any of it; publishing with new_board adds the maker and the board (boards.CreateModel), with an alias of source "club" so a later archive import joins it instead of adding a twin, and refuses a board id or marking the catalogue already has. `openipc reports publish --new-board` does the same. Decide now adds the board, links the report and records the review in one transaction, so a refused board or a link to a missing one leaves nothing behind -- before, a failed link left earlier links in place. Stars are unchanged: 1 per photo and file, once published. --- CLAUDE.md | 6 +- data/locales/boards.en.yml | 27 ++++ data/locales/boards.ru.yml | 27 ++++ data/locales/boards.zh.yml | 27 ++++ .../site/src/components/boards/Boards.tsx | 18 ++- .../apps/site/src/components/club/Club.tsx | 9 +- .../src/components/club/NewCamera.test.tsx | 109 ++++++++++++++ .../src/components/club/NewCameraForm.tsx | 118 +++++++++++++++ .../apps/site/src/components/club/Review.tsx | 50 ++++++- .../site/src/components/pages/Report.astro | 10 ++ frontend/apps/site/src/i18n/boards.en.json | 26 ++++ frontend/apps/site/src/i18n/boards.ru.json | 26 ++++ frontend/apps/site/src/i18n/boards.zh.json | 26 ++++ frontend/apps/site/src/i18n/en.json | 1 + frontend/apps/site/src/i18n/ru.json | 1 + frontend/apps/site/src/i18n/zh.json | 1 + frontend/apps/site/src/lib/club.ts | 21 ++- service/cmd/openipc/main.go | 7 + service/cmd/openipc/reports.go | 49 +++++- service/deploytest/reports_test.go | 4 +- service/internal/boards/newmodel.go | 141 ++++++++++++++++++ service/internal/boards/newmodel_test.go | 97 ++++++++++++ service/internal/boards/snapshot.go | 9 +- service/internal/club/api.go | 20 ++- service/internal/club/newcamera_test.go | 106 +++++++++++++ .../db/migrations/028_report_proposals.sql | 21 +++ service/internal/reports/api_test.go | 62 ++++++++ service/internal/reports/club.go | 89 ++++++++--- service/internal/reports/handler.go | 53 ++++++- service/internal/reports/store.go | 29 ++++ 30 files changed, 1123 insertions(+), 67 deletions(-) create mode 100644 frontend/apps/site/src/components/club/NewCamera.test.tsx create mode 100644 frontend/apps/site/src/components/club/NewCameraForm.tsx create mode 100644 service/internal/boards/newmodel.go create mode 100644 service/internal/boards/newmodel_test.go create mode 100644 service/internal/club/newcamera_test.go create mode 100644 service/internal/db/migrations/028_report_proposals.sql diff --git a/CLAUDE.md b/CLAUDE.md index 2c5e119a..db95e75d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,7 +173,11 @@ restores the image but never the schema, so keep migrations additive. send form posts to `/api/v1/club/reports` instead of opening a GitHub issue, and a published report's text and photos become a contributed unit on its board (`boards.ApplyReportUnits`), as `boards/contributions.yml` - does for what arrived as issues (#365, #366). + does for what arrived as issues (#365, #366). A camera the catalogue + 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. - `internal/wallstars` -- **stars for keeping a camera on the Open Wall** (migration 024). A member links a camera by making it upload a one-time code from `/club`: pasted at the end of the WebUI's OpenWall caption on diff --git a/data/locales/boards.en.yml b/data/locales/boards.en.yml index a7037ab6..1a3ec934 100644 --- a/data/locales/boards.en.yml +++ b/data/locales/boards.en.yml @@ -32,6 +32,7 @@ en: fact2: "The MAC, the chip's serial and the cloud ID are replaced with hashes in everything published, in the report and in any log." fact3_title: "A backup is private unless you say otherwise" fact3_html: "It holds everything the camera stores, including Wi-Fi keys and passwords. It is published only if you sent it with --public." + new_title: "No shell on the camera? Send its photos" club: eyebrow: 'OpenIPC Club' title: 'OpenIPC Club' @@ -134,6 +135,8 @@ en: loading: Loading the boards… error: 'The board catalogue could not be loaded ({error}). Reload the page to try again.' empty: No board matches these filters. Clear one to widen the list. + new_missing: Not in the catalogue? + new_add: Add your camera unknown_maker: Unidentified maker also_marked: 'also marked {aliases}' board_count: @@ -453,6 +456,19 @@ en: kind_document: 'Document' kind_yaml: 'ipctool report' no_board: 'No board named' + new_link: 'Add a camera that is not in the catalogue' + new_lede: 'Name the maker and the board, and add photos of it: the board''s front and back, and the label. A maintainer adds it to the catalogue when they publish your report; each photo and file earns {n} ★.' + new_maker: 'Maker' + new_maker_hint: 'Xiongmai, Jooan, Hikvision…' + new_board: 'Board marking or model' + new_board_hint: 'What is printed on the board, or the model on the box' + new_soc: 'SoC, if you know it' + new_photos: 'Photos (at least one)' + new_more: 'Have ipctool''s output or a boot log? Add them too' + new_ipctool: 'ipctool''s output' + new_boot_log: 'Boot log or U-Boot console' + new_need: 'Name the maker and the board, and add at least one photo.' + proposed: 'New: {board}' rules_title: 'How stars work' rules_item: 'Boot log, U-Boot console, photo, pinout, ipctool report' rules_dump: 'Full flash dump the catalogue doesn''t have' @@ -496,6 +512,17 @@ en: review_points: '{points} ★' review_done_publish: 'Published. {points} ★ to the sender.' review_done_reject: 'Rejected.' + review_proposal: 'Proposed by the sender' + review_existing: 'The catalogue already has {board}: the report is published there unless you change the board ids below.' + review_new_board: 'Add this board to the catalogue when publishing' + review_maker_known: 'its maker is in the catalogue' + review_maker_new: 'a new maker' + review_new_maker_name: 'Maker' + review_new_maker_id: 'Maker id' + review_new_model: 'Marking, as printed' + review_new_model_id: 'Board id' + review_new_soc: 'SoC' + review_done_board: 'Published, and {board} added to the catalogue. {points} ★ to the sender.' show_yaml: 'ipctool output' tab_pending: 'Waiting' tab_published: 'Published' diff --git a/data/locales/boards.ru.yml b/data/locales/boards.ru.yml index 8340c7ed..418644b8 100644 --- a/data/locales/boards.ru.yml +++ b/data/locales/boards.ru.yml @@ -32,6 +32,7 @@ ru: fact2: "MAC, серийный номер чипа и облачный ID во всём опубликованном заменены хешами — и в отчёте, и в логах." fact3_title: "Бэкап закрыт, если вы не решите иначе" fact3_html: "В нём всё, что хранит камера, включая ключи Wi-Fi и пароли. Он публикуется, только если вы отправили его с --public." + new_title: "Нет доступа к консоли камеры? Пришлите фото" club: eyebrow: 'Клуб OpenIPC' title: 'Клуб OpenIPC' @@ -140,6 +141,8 @@ ru: loading: Загружаем платы… error: 'Не удалось загрузить каталог плат ({error}). Обновите страницу, чтобы попробовать ещё раз.' empty: Ни одна плата не подходит под эти фильтры. Уберите какой-нибудь из них. + new_missing: Нет в каталоге? + new_add: Добавьте свою камеру unknown_maker: Производитель неизвестен also_marked: 'также маркируется {aliases}' board_count: @@ -494,6 +497,19 @@ ru: kind_document: 'Документ' kind_yaml: 'Отчёт ipctool' no_board: 'Плата не указана' + new_link: 'Добавить камеру, которой нет в каталоге' + new_lede: 'Укажите производителя и плату и приложите фото: плату с обеих сторон и наклейку. Мейнтейнер добавит её в каталог, когда опубликует ваш отчёт; каждое фото и файл приносят {n} ★.' + new_maker: 'Производитель' + new_maker_hint: 'Xiongmai, Jooan, Hikvision…' + new_board: 'Маркировка платы или модель' + new_board_hint: 'Что напечатано на плате или модель на коробке' + new_soc: 'SoC, если знаете' + new_photos: 'Фото (хотя бы одно)' + new_more: 'Есть вывод ipctool или лог загрузки? Добавьте и их' + new_ipctool: 'Вывод ipctool' + new_boot_log: 'Лог загрузки или консоль U-Boot' + new_need: 'Укажите производителя и плату и приложите хотя бы одно фото.' + proposed: 'Новая: {board}' rules_title: 'Как начисляются звёзды' rules_item: 'Лог загрузки, консоль U-Boot, фото, распиновка, отчёт ipctool' rules_dump: 'Полный дамп флеш-памяти, которого нет в каталоге' @@ -537,6 +553,17 @@ ru: review_points: '{points} ★' review_done_publish: 'Опубликовано. Отправителю {points} ★.' review_done_reject: 'Отклонено.' + review_proposal: 'Предложено отправителем' + review_existing: 'В каталоге уже есть {board}: отчёт будет опубликован там, если не поменять идентификаторы плат ниже.' + review_new_board: 'Добавить эту плату в каталог при публикации' + review_maker_known: 'производитель есть в каталоге' + review_maker_new: 'новый производитель' + review_new_maker_name: 'Производитель' + review_new_maker_id: 'Идентификатор производителя' + review_new_model: 'Маркировка, как напечатана' + review_new_model_id: 'Идентификатор платы' + review_new_soc: 'SoC' + review_done_board: 'Опубликовано, {board} добавлена в каталог. Отправителю {points} ★.' show_yaml: 'Вывод ipctool' tab_pending: 'Ждут' tab_published: 'Опубликованы' diff --git a/data/locales/boards.zh.yml b/data/locales/boards.zh.yml index 9ac82b05..4a8e8301 100644 --- a/data/locales/boards.zh.yml +++ b/data/locales/boards.zh.yml @@ -32,6 +32,7 @@ zh: fact2: "在所有公开内容中(报告和日志),MAC、芯片序列号和云 ID 都会被替换为哈希值。" fact3_title: "备份默认不公开" fact3_html: "备份包含摄像头存储的一切,包括 Wi-Fi 密钥和密码。只有使用 --public 发送时才会公开。" + new_title: "无法进入摄像头的命令行?发送照片" club: eyebrow: 'OpenIPC 俱乐部' title: 'OpenIPC 俱乐部' @@ -134,6 +135,8 @@ zh: loading: 正在加载主板… error: '无法加载主板目录({error})。请刷新页面重试。' empty: 没有符合这些筛选条件的主板。去掉一个条件试试。 + new_missing: 目录中没有? + new_add: 添加你的摄像头 unknown_maker: 未知厂商 also_marked: '也标作 {aliases}' board_count: @@ -456,6 +459,19 @@ zh: kind_document: '文档' kind_yaml: 'ipctool 报告' no_board: '未指定电路板' + new_link: '添加目录中没有的摄像头' + new_lede: '填写厂商和电路板,并附上照片:电路板正反面和标签。维护者发布你的报告时会把它加入目录;每张照片和每个文件可获得 {n} ★。' + new_maker: '厂商' + new_maker_hint: 'Xiongmai、Jooan、Hikvision…' + new_board: '电路板丝印或型号' + new_board_hint: '电路板上印的文字,或包装盒上的型号' + new_soc: 'SoC(如果知道)' + new_photos: '照片(至少一张)' + new_more: '有 ipctool 输出或启动日志?也一起添加' + new_ipctool: 'ipctool 输出' + new_boot_log: '启动日志或 U-Boot 控制台' + new_need: '请填写厂商和电路板,并至少添加一张照片。' + proposed: '新:{board}' rules_title: '星星如何计算' rules_item: '启动日志、U-Boot 控制台、照片、引脚图、ipctool 报告' rules_dump: '目录中没有的完整闪存转储' @@ -499,6 +515,17 @@ zh: review_points: '{points} ★' review_done_publish: '已发布。提交者获得 {points} ★。' review_done_reject: '已拒绝。' + review_proposal: '发送者提议' + review_existing: '目录中已有 {board}:除非修改下方的电路板 ID,报告将发布在那里。' + review_new_board: '发布时将此电路板加入目录' + review_maker_known: '厂商已在目录中' + review_maker_new: '新厂商' + review_new_maker_name: '厂商' + review_new_maker_id: '厂商 ID' + review_new_model: '丝印(按原样)' + review_new_model_id: '电路板 ID' + review_new_soc: 'SoC' + review_done_board: '已发布,{board} 已加入目录。发送者获得 {points} ★。' show_yaml: 'ipctool 输出' tab_pending: '待审核' tab_published: '已发布' diff --git a/frontend/apps/site/src/components/boards/Boards.tsx b/frontend/apps/site/src/components/boards/Boards.tsx index d0af2a88..8d8b5300 100644 --- a/frontend/apps/site/src/components/boards/Boards.tsx +++ b/frontend/apps/site/src/components/boards/Boards.tsx @@ -22,7 +22,7 @@ import { lineLabel, lineOptions, sensorOptions, socName, socOptions, stats, subtitle, type Entry, type Group, type Heading, } from '../../lib/boards/model'; import { useBoardsTranslations, type BoardsT } from '../../lib/boards-i18n'; -import type { Locale } from '../../lib/i18n'; +import { pathFor, type Locale } from '../../lib/i18n'; import BoardPanel from './BoardPanel'; import { Chip, SocChip, SourceChips, Tags, Thumb, makerName, type SocLinks } from './parts'; @@ -42,6 +42,7 @@ const depthOf = (state: unknown): number => export default function Boards({ locale, socs }: { locale: Locale; socs: SocLinks }) { const t = useBoardsTranslations(locale); + const addNew = ; const initial = useMemo(() => readQueryString(typeof window === 'undefined' ? '' : window.location.search), []); const [view, setView] = useState({ ...initial, model: null }); const [open, setOpen] = useState(initial.model); @@ -255,12 +256,12 @@ export default function Boards({ locale, socs }: { locale: Locale; socs: SocLink )} 0 && all.every((m) => !has(m, 'boot_log'))} - t={t} names={names} href={href} onOpen={openModel} /> + t={t} names={names} href={href} onOpen={openModel} addNew={addNew} /> : <> {q.length > 0 &&

{t('query_short')}

} {filtered && kept.length > 0 &&

{t('matching', { count: kept.length })}

} - {kept.length === 0 && {t('empty')}} + {kept.length === 0 && {t('empty')} {addNew}} {sections.map(({ maker, count, groups }) => (

@@ -402,9 +403,9 @@ function Card({ m, level, socs, names, sources, t, href, onOpen, catalogue }: Ca const HIT_CLASS: Record = { code: 'font-mono font-bold', name: 'font-semibold', none: 'font-medium' }; -function Hits({ found, kept, q, scope, none, t, names, href, onOpen }: { +function Hits({ found, kept, q, scope, none, t, names, href, onOpen, addNew }: { found: Load | null; kept: Entry[]; q: string; scope: Scope; none: boolean; t: BoardsT; names: Record; - href: (model: string | null) => string; onOpen: (id: string) => void; + href: (model: string | null) => string; onOpen: (id: string) => void; addNew: ComponentChildren; }) { if (!found || found.state === 'loading') return

{t('searching')}

; if (found.state === 'error') { @@ -424,7 +425,7 @@ function Hits({ found, kept, q, scope, none, t, names, href, onOpen }: { {t('hits_lines', { count: hits.length })} {t('hits_boards', { count: boards })} · {t('hits_scope', { scope: t(`scope_${scope}`) })}

{hits.length === 0 && ( - {t('hits_none', { q })}{scope === 'boot_log' && none ? ` ${t('hits_none_boot_log')}` : ''} + {t('hits_none', { q })}{scope === 'boot_log' && none ? ` ${t('hits_none_boot_log')}` : ''} {addNew} )} {hits.map((h) => (
@@ -448,6 +449,11 @@ function Hits({ found, kept, q, scope, none, t, names, href, onOpen }: { ); } +/** A camera the catalogue lacks is added from the report page's form. */ +function AddNew({ href, t }: { href: string; t: BoardsT }) { + return <>{t('new_missing')} {t('new_add')}; +} + function Notice({ children }: { children: ComponentChildren }) { return

{children}

; } diff --git a/frontend/apps/site/src/components/club/Club.tsx b/frontend/apps/site/src/components/club/Club.tsx index 70f05dc2..fda6c675 100644 --- a/frontend/apps/site/src/components/club/Club.tsx +++ b/frontend/apps/site/src/components/club/Club.tsx @@ -322,7 +322,10 @@ function MemberPage({ member, ways, locale, t, onChange }: { {cams ? { void loadCams(); onChange(); }} /> : !error &&
} -

{t('club.mine_title')}

+
+

{t('club.mine_title')}

+ {t('club.new_link')} +
{error && } {!error && reports === null &&
} {reports && reports.length === 0 && ( @@ -349,7 +352,9 @@ function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Local {reports.map((r) => { const shown = r.status === 'pending' ? r.pending : r.stars; - const board = r.board ? `${r.board.manufacturer} ${r.board.model}` : (r.chip || t('club.no_board')); + const board = r.board ? `${r.board.manufacturer} ${r.board.model}` + : r.proposal ? t('club.proposed', { board: `${r.proposal.maker} ${r.proposal.board}` }) + : (r.chip || t('club.no_board')); return ( diff --git a/frontend/apps/site/src/components/club/NewCamera.test.tsx b/frontend/apps/site/src/components/club/NewCamera.test.tsx new file mode 100644 index 00000000..4ce922dd --- /dev/null +++ b/frontend/apps/site/src/components/club/NewCamera.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +/** + * A camera the catalogue does not have, both ends: the form posts what the + * service reads (reports.proposalOf -- channel web, maker, board, soc, the + * photos, no model), and the review page publishes it with the board the + * service suggested, as the reviewer corrected it. + */ +import { afterEach, expect, test, vi } from 'vitest'; +import { cleanup, fireEvent, render, waitFor } from '@testing-library/preact'; +import NewCameraForm from './NewCameraForm.tsx'; +import Review from './Review.tsx'; +import type { Queued } from '../../lib/club'; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); localStorage.clear(); }); + +function capture() { + const sent: FormData[] = []; + vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => { + sent.push(init?.body as FormData); + return new Response(JSON.stringify({ id: 'r-abcd2345', receipt_url: '', files: [] }), { status: 201 }); + })); + return sent; +} + +/** The field whose label reads label: exactly, else at its start. */ +function input(c: HTMLElement, label: string) { + const labels = [...c.querySelectorAll('label')]; + const own = (l: HTMLLabelElement) => l.childNodes[0]?.textContent?.trim() ?? ''; + const l = labels.find((x) => own(x) === label) ?? labels.find((x) => own(x).startsWith(label)); + return l!.querySelector('input, textarea')!; +} + +test('the form sends the maker, the marking and the photos, and no board id', async () => { + const sent = capture(); + const { container, getByText } = render(); + fireEvent.input(input(container, 'Maker'), { target: { value: ' Jooan ' } }); + fireEvent.input(input(container, 'Board marking'), { target: { value: 'Q9 v2' } }); + fireEvent.input(input(container, 'SoC'), { target: { value: 'SSC335' } }); + fireEvent.input(input(container, 'Boot log'), { target: { value: 'U-Boot 2015.01\n' } }); + const photos = [new File(['a'], 'front.jpg', { type: 'image/jpeg' }), new File(['b'], 'back.jpg', { type: 'image/jpeg' })]; + fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: photos } }); + fireEvent.submit(container.querySelector('form')!); + await waitFor(() => expect(getByText(/Received as r-abcd2345/)).toBeTruthy()); + const f = sent[0]; + expect(f.get('channel')).toBe('web'); + expect(f.get('maker')).toBe('Jooan'); + expect(f.get('board')).toBe('Q9 v2'); + expect(f.get('soc')).toBe('SSC335'); + expect(f.get('model')).toBeNull(); + expect(f.get('yaml')).toBeNull(); + expect(f.getAll('photo').map((p) => (p as File).name)).toEqual(['front.jpg', 'back.jpg']); + expect(await (f.get('boot_log') as File).text()).toBe('U-Boot 2015.01\n'); +}); + +test('the form asks for the maker, the board and a photo before sending', () => { + const sent = capture(); + const { container, getByRole } = render(); + fireEvent.input(input(container, 'Maker'), { target: { value: 'Jooan' } }); + fireEvent.input(input(container, 'Board marking'), { target: { value: 'Q9' } }); + fireEvent.submit(container.querySelector('form')!); + expect(getByRole('alert').textContent).toContain('at least one photo'); + expect(sent).toHaveLength(0); +}); + +const queued = (over: Partial): 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, + 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, +}); + +function review(q: Queued) { + const calls: { url: string; body: unknown }[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + if (url.startsWith('/api/v1/club/review?')) return new Response(JSON.stringify({ reports: [q] })); + if (url.startsWith('/api/v1/club/review/')) { + calls.push({ url, body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify({ points: 2, total: 2, board: 'jooan-q9' })); + } + return new Response(JSON.stringify({ member: null })); + })); + return calls; +} + +test('the reviewer publishes a proposal as the board they corrected', async () => { + const calls = review(queued({})); + const { container, findByText, getByText, getByRole } = render(); + expect(await findByText('Jooan Q9 v2')).toBeTruthy(); + expect(getByText('Jooan · Q9 v2 · SSC335')).toBeTruthy(); + expect(getByText(/a new maker/)).toBeTruthy(); + fireEvent.input(input(container, 'Board id'), { target: { value: 'jooan-q9' } }); + fireEvent.click(getByRole('button', { name: /^Publish ·/ })); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].body).toEqual({ + decision: 'publish', models: [], note: '', + 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(); +}); + +test('a proposal the catalogue already has is published on that board, not a twin', async () => { + const calls = review(queued({ new_board: { maker_id: 'jooan', maker_name: 'Jooan', model_id: 'jooan-q9-v2', model: 'Q9 v2', maker_known: true, existing: 'jooan-q9-v2' } })); + const { findByText, getByRole } = render(); + 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: '' }); +}); diff --git a/frontend/apps/site/src/components/club/NewCameraForm.tsx b/frontend/apps/site/src/components/club/NewCameraForm.tsx new file mode 100644 index 00000000..7fe1a1f2 --- /dev/null +++ b/frontend/apps/site/src/components/club/NewCameraForm.tsx @@ -0,0 +1,118 @@ +/** + * A camera the catalogue does not have: its maker, its board's marking and + * photos of it are a report (POST /api/v1/club/reports, channel web, maker, + * board and soc instead of a board's model). ipctool's output and a boot log + * help the reviewer and are optional. A maintainer who publishes the report + * adds the board to the catalogue, and the photos become its first unit. + * + * Signing in is not required, as with SendForm; stars are a member's. + */ +import { useEffect, useState } from 'preact/hooks'; +import { useBoardsTranslations } from '../../lib/boards-i18n'; +import { pathFor, type Locale } from '../../lib/i18n'; +import { STARS, fetchMe, remembered, sendReport, type Member, type Sent } from '../../lib/club'; + +const field = 'rounded-md border border-hairline px-2.5 py-1.5 text-sm text-body'; +const area = 'min-h-[72px] w-full resize-y rounded-md border border-hairline bg-surface-alt p-2 font-mono text-[12.5px] leading-snug text-body'; + +export default function NewCameraForm({ locale }: { locale: Locale }) { + const t = useBoardsTranslations(locale); + const [maker, setMaker] = useState(''); + const [board, setBoard] = useState(''); + const [soc, setSoc] = useState(''); + const [photos, setPhotos] = useState([]); + const [ipctool, setIpctool] = useState(''); + const [bootLog, setBootLog] = useState(''); + const [note, setNote] = useState(''); + const [member, setMember] = useState(() => (remembered() ? undefined : null)); + const [state, setState] = useState<{ s: 'idle' | 'sending' } | { s: 'sent'; sent: Sent } | { s: 'error'; error: string }>({ s: 'idle' }); + + useEffect(() => { + if (remembered()) fetchMe().then((me) => setMember(me.member)).catch(() => setMember(null)); + }, []); + + const submit = (e: Event) => { + e.preventDefault(); + if (!maker.trim() || !board.trim() || photos.length === 0) { + setState({ s: 'error', error: t('club.new_need') }); + return; + } + const form = new FormData(); + form.set('channel', 'web'); + form.set('maker', maker.trim()); + form.set('board', board.trim()); + if (soc.trim()) form.set('soc', soc.trim()); + if (note.trim()) form.set('note', note.trim()); + if (ipctool.trim()) form.set('yaml', ipctool); + if (bootLog.trim()) form.append('boot_log', new Blob([bootLog], { type: 'text/plain' }), 'boot_log.txt'); + for (const f of photos) form.append('photo', f, f.name); + setState({ s: 'sending' }); + sendReport(form) + .then((sent) => setState({ s: 'sent', sent })) + .catch((err: Error) => setState({ s: 'error', error: t('club.send_failed', { error: err.message }) })); + }; + + if (state.s === 'sent') { + const id = state.sent.id; + return ( +
+

{t(member ? 'club.sent_member' : 'club.sent_guest', { id })}

+

+ {member + ? {t('club.my_link')} + : {t('club.receipt')}} +

+
+ ); + } + + return ( +
+

{t('club.new_lede', { n: STARS.item })}

+
+ + + +
+ +
+ {t('club.new_more')} +
+