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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions data/locales/boards.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <code>--public</code>."
new_title: "No shell on the camera? Send its photos"
club:
eyebrow: 'OpenIPC Club'
title: 'OpenIPC Club'
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
27 changes: 27 additions & 0 deletions data/locales/boards.ru.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ ru:
fact2: "MAC, серийный номер чипа и облачный ID во всём опубликованном заменены хешами — и в отчёте, и в логах."
fact3_title: "Бэкап закрыт, если вы не решите иначе"
fact3_html: "В нём всё, что хранит камера, включая ключи Wi-Fi и пароли. Он публикуется, только если вы отправили его с <code>--public</code>."
new_title: "Нет доступа к консоли камеры? Пришлите фото"
club:
eyebrow: 'Клуб OpenIPC'
title: 'Клуб OpenIPC'
Expand Down Expand Up @@ -140,6 +141,8 @@ ru:
loading: Загружаем платы…
error: 'Не удалось загрузить каталог плат ({error}). Обновите страницу, чтобы попробовать ещё раз.'
empty: Ни одна плата не подходит под эти фильтры. Уберите какой-нибудь из них.
new_missing: Нет в каталоге?
new_add: Добавьте свою камеру
unknown_maker: Производитель неизвестен
also_marked: 'также маркируется {aliases}'
board_count:
Expand Down Expand Up @@ -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: 'Полный дамп флеш-памяти, которого нет в каталоге'
Expand Down Expand Up @@ -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: 'Опубликованы'
Expand Down
27 changes: 27 additions & 0 deletions data/locales/boards.zh.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ zh:
fact2: "在所有公开内容中(报告和日志),MAC、芯片序列号和云 ID 都会被替换为哈希值。"
fact3_title: "备份默认不公开"
fact3_html: "备份包含摄像头存储的一切,包括 Wi-Fi 密钥和密码。只有使用 <code>--public</code> 发送时才会公开。"
new_title: "无法进入摄像头的命令行?发送照片"
club:
eyebrow: 'OpenIPC 俱乐部'
title: 'OpenIPC 俱乐部'
Expand Down Expand Up @@ -134,6 +135,8 @@ zh:
loading: 正在加载主板…
error: '无法加载主板目录({error})。请刷新页面重试。'
empty: 没有符合这些筛选条件的主板。去掉一个条件试试。
new_missing: 目录中没有?
new_add: 添加你的摄像头
unknown_maker: 未知厂商
also_marked: '也标作 {aliases}'
board_count:
Expand Down Expand Up @@ -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: '目录中没有的完整闪存转储'
Expand Down Expand Up @@ -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: '已发布'
Expand Down
18 changes: 12 additions & 6 deletions frontend/apps/site/src/components/boards/Boards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 = <AddNew href={`${pathFor(locale, '/cameras/report')}#new`} t={t} />;
const initial = useMemo(() => readQueryString(typeof window === 'undefined' ? '' : window.location.search), []);
const [view, setView] = useState<BoardsState>({ ...initial, model: null });
const [open, setOpen] = useState<string | null>(initial.model);
Expand Down Expand Up @@ -255,12 +256,12 @@ export default function Boards({ locale, socs }: { locale: Locale; socs: SocLink
</section>
)}
<Hits found={found} kept={kept} q={q} scope={view.scope} none={s.boards > 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 && <p class="mt-6 mb-0 text-sm text-body-secondary">{t('query_short')}</p>}
{filtered && kept.length > 0 && <p class="mt-5 mb-0 text-sm text-body-secondary">{t('matching', { count: kept.length })}</p>}
{kept.length === 0 && <Notice>{t('empty')}</Notice>}
{kept.length === 0 && <Notice>{t('empty')} {addNew}</Notice>}
{sections.map(({ maker, count, groups }) => (
<section key={maker.id} class="mt-9" aria-labelledby={`maker-${maker.id}`}>
<h2 id={`maker-${maker.id}`} class="mb-0 flex flex-wrap items-baseline gap-x-2.5 text-h3 font-semibold">
Expand Down Expand Up @@ -402,9 +403,9 @@ function Card({ m, level, socs, names, sources, t, href, onOpen, catalogue }: Ca

const HIT_CLASS: Record<Heading['kind'], string> = { 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<SearchResult> | null; kept: Entry[]; q: string; scope: Scope; none: boolean; t: BoardsT; names: Record<string, string>;
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 <p class="mt-8 text-body-secondary">{t('searching')}</p>;
if (found.state === 'error') {
Expand All @@ -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}`) })}
</p>
{hits.length === 0 && (
<Notice>{t('hits_none', { q })}{scope === 'boot_log' && none ? ` ${t('hits_none_boot_log')}` : ''}</Notice>
<Notice>{t('hits_none', { q })}{scope === 'boot_log' && none ? ` ${t('hits_none_boot_log')}` : ''} {addNew}</Notice>
)}
{hits.map((h) => (
<div key={`${h.url}:${h.line}`} class="grid gap-1.5 rounded-lg border border-hairline bg-white px-3.5 py-3">
Expand All @@ -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')} <a href={href}>{t('new_add')}</a></>;
}

function Notice({ children }: { children: ComponentChildren }) {
return <p class="mt-8 mb-0 border-l-[3px] border-brand-blue bg-surface-alt px-3 py-2">{children}</p>;
}
9 changes: 7 additions & 2 deletions frontend/apps/site/src/components/club/Club.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,10 @@ function MemberPage({ member, ways, locale, t, onChange }: {
{cams
? <Cameras data={cams} locale={locale} t={t} onChange={() => { void loadCams(); onChange(); }} />
: !error && <div class="m-4 h-24 animate-pulse rounded bg-surface-alt" />}
<h2 id="club-mine" class="m-0 border-b border-hairline px-4 py-3 text-lg font-semibold">{t('club.mine_title')}</h2>
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-hairline px-4 py-3">
<h2 id="club-mine" class="m-0 text-lg font-semibold">{t('club.mine_title')}</h2>
<a href={`${pathFor(locale, '/cameras/report')}#new`} class="text-sm">{t('club.new_link')}</a>
</div>
{error && <p class="m-4 text-sm text-[#9a5b00]" role="alert">{t('club.load_failed')}</p>}
{!error && reports === null && <div class="m-4 h-24 animate-pulse rounded bg-surface-alt" />}
{reports && reports.length === 0 && (
Expand All @@ -349,7 +352,9 @@ function Ledger({ reports, locale, t }: { reports: MemberReport[]; locale: Local
<tbody>
{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 (
<tr key={r.id} class="border-t border-hairline align-top">
<td class="grid gap-1 px-4 py-3">
Expand Down
Loading
Loading