Ctrl K
ComunidadGuías…

README.en.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<p align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="docs/logo/nupplo-logo-hell.svg">
    <img src="docs/logo/nupplo-logo.svg" alt="Nupplo" width="320">
  </picture>
</p>

# Nupplo SE 🧱

> **Formerly Brickfolio.** Since 3.0.0 the app is called **Nupplo SE** – SE
> stands for *Server Edition*, the edition you run yourself. Same app, new
> name; existing installations keep running without any change.

*🇬🇧 English · [🇩🇪 Deutsch](README.md)*

[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-ffdd00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/melle79)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

A self-hosted PWA to scan, manage and value a LEGO® collection – built for the
whole family on one shared database, with an optional **Collector-Pro mode**
for anyone who buys and sells at flea markets.

**Photo → recognition → collection.** Recognition runs on the free
[Brickognize API](https://brickognize.com); prices, set inventories and
metadata come from [BrickLink](https://www.bricklink.com) and
[Rebrickable](https://rebrickable.com) (your own API keys required, see below).

> 📖 The full **user manual** lives at [`docs/MANUAL.md`](docs/MANUAL.md)
> · [🇩🇪 deutsche Fassung](docs/HANDBUCH.md).

## Features

**Capture & manage**
- 📷 Scan minifigures and sets straight from the phone camera (candidate list
  with match score) or search by name/number – plain numbers are looked up as
  both a set *and* a figure automatically
- 📦 Quantities, condition (new/used), notes, image gallery, full-text search
  (with 🔍 icon and ✕ to clear), sorting and type filter; optional **grid
  view** (two per row) for browsing
- 🔎 Catalogue search from three characters, **10 hits per page** with “load
  more results” – every match is reachable
- 👥 **Add a set's figures with it**: when a set enters the collection, the app
  asks which of its minifigures are included (all, none or a selection, with
  their own condition) – without double-counting them in the total value
- 👥 Set linking: sets know their figures (“👥 3/4 ✔” completeness), figures
  show which of your sets they belong to; missing set figures move to the
  wishlist with a single tap
- 🖼 **Reload the image** for an entry if it is missing or wrong
- 🔎 **Detail view everywhere**: tapping a search or scan hit opens a popup
  with year, theme, prices and – for minifigures – the **parts they contain,
  with colour names**
- 🎨 **Custom figures**: home-made builds with a number the app assigns for
  you (`custom-001`, `-002` …) and your own picture – optionally the photo
  straight from the scan. They live under the “Custom” theme
- 🗂️ **Theme cards**: sorted by theme, the collection shows collapsible cards
  (Star Wars, City, Custom …) with count and value per theme. The last sort
  order is remembered per profile

**Prices & value**
- 💶 BrickLink average prices (new/used) fetched automatically in the
  background, with a built-in **price-history log** and per-item chart
- 🌍 **Selectable price region** (More → Price region): worldwide, Germany,
  Austria, Switzerland or Europe – with no sales in the chosen region the app
  widens in two steps (Europe first, then worldwide). Existing prices can be
  recalculated step by step, priceless items re-fetched with one click. In the
  item details a small **flag** (🇩🇪/🇪🇺/🌍) on every price row shows where the
  price comes from
- 🏷 **Asking prices** (optional): on top of the average sale price, *from how
  much* a figure is available right now – in the item details and as a tag in
  wish and shopping lists
- 📊 **Stats tab**: key figures, overall value trend, split by type/condition,
  value by release year (tap a bar for the year’s details), Top 10

**Wishlist**
- ⭐ Save from any scan/search result, average-price widgets, “bought” hand-off
  into the collection incl. condition choice
- 🧩 Figures that belong to one of your sets and are still missing are flagged
  with “missing from your set” – a tap jumps straight to the set

**Trading network** (optional, invite only)

> **It is off by default, and nothing is shared.** Your collection stays on
> your server. The network only exists for you once you join with an
> invitation code – and even then only what you explicitly choose goes out:
> the items you offer, your profile and, if you switch it on, your wishlist.
> Leaving takes everything out again.

- 🤝 Several Nupplo instances connect through a small **trading hub**:
  everyone offers the items they want to pass on – **for trade, for sale or
  both** – and sees what the others offer
- 🔢 **You pick what goes in**, and how many: out of three identical figures
  you can offer just one
- 🧭 **Discover**: who has what you're looking for, who wants what you have
  spare, and who collects the same themes – worked out on your own instance
- 👤 **Profiles**: about me, area, favourite themes, trade record – all
  optional; the collection size only rounded and only if you want
- 📦 **Trades with a beginning and an end**: accepted → sent → arrived →
  booked → completed. Adding and removing book straight into the collection,
  purchase log included
- 💬 **Messages are end-to-end encrypted** (X25519 + AES-256-GCM). The hub
  cannot read them and deletes them once delivered; the readable history stays
  on the instances involved
- ✉️ **Invitations with a quota**: everyone may invite three friends and can
  request more. Nobody gets in without an invite
- ⏸ **Pause after inactivity**: if you haven't been around for a while, the hub
  hides your offers until you come back
- ⚑ **Reports with a follow-up**: the report stays visible to the reporter, the
  hub admin can ask questions and take a measure (notice, warning, block) that
  the person concerned sees in their app – without learning who reported them
- 🖼️ Custom figures travel with a **downscaled thumbnail** so the other side
  doesn’t just see a placeholder

**Collector-Pro mode** (a role the admin grants per user)
- 💰 Purchase price per entry – pre-filled with the BrickLink average of the
  capture day (⚙️) or entered manually (✏️), with profit/loss display
- 🛒 **Shopping lists** for the flea market: fill by scanning, live market
  value, purchase price per item, a **bundle offer** with proportional
  distribution by market value and a 60 % price suggestion
- 📋 **Sell list**: every duplicate with its giveaway quantity and sale value –
  figures needed for your own sets stay reserved, everything else keeps one
  copy for you
- 🧩 **Missing set figures**: across all your sets, which minifigures are still
  missing (count, sets, estimated cost) – add them to the wishlist one by one
  or all at once, export as CSV or print
- 🗒 When receiving from a shopping list, the **list name is written into the
  notes** of the collection entry (origin stays traceable)
- 📈 Extra stats: total paid, profit, best value gains

**Family & operations**
- 🔐 Multi-user with token login (PBKDF2-hashed passwords), admin and pro
  roles (admins can appoint further admins), self-service password/name change
- 💾 Full **backup** as JSON (download & restore), CSV export and print-ready
  lists
- 🏷 Configurable **display name** in logo and title (empty by default); ideal
  when several family members each run their own instance
- 🌌 Three **themes** to choose from (More → Design): light “Classic”, dark
  “Galaxy” with a starfield, and “Nova” – a modern glass design with a blue
  accent; the choice is per device
- 🔔 **Notices on the start screen**: if BrickLink renames or removes a number
  from your collection, it shows up in the Scan tab – and stays there until
  someone dismisses it. The app looks up the new number in the BrickLink
  Catalog Change Log and applies it everywhere with one click
- 🌐 **External-access helper** (More → External access, admin): turns your
  Cloudflare tunnel token into a ready-to-paste `docker-compose` block – reach the
  app on the go without port forwarding. The app never starts the tunnel itself
  (no Docker access); the token stays in the browser
- 🐞 **Error report** (More → Error report, admin): errors from every device
  report themselves to your own server and identical ones are grouped. With a
  GitHub token stored, one click turns an entry into an issue; API keys and the
  token are stripped from the text first
- 🖥 **Responsive to screen width**: bottom tab bar on phones, a left sidebar
  with a wider grid (four to five cards per row) on desktop – same app, just
  laid out to use the space
- ✨ **Modern look**: items open in a window that leads with the **image
  across the full width**; below it the name, tags and – for dealers –
  three tiles (paid, value, profit). The rest sits on three tabs (copy,
  prices, more) instead of stacking up. In the collection the product
  image shows as a subtle card background
- 📲 Installable as a PWA – on a phone the scan page offers to put it **on the
  home screen** (a button on Android, instructions on iPhone) and hides the
  hint once it is there. Offline shell, no cloud – everything stays on your
  own server

## Screenshots

| Scan | Collection |
|:---:|:---:|
| <img src="docs/screenshots/scannen.png" width="260" alt="Scan"> | <img src="docs/screenshots/sammlung.png" width="260" alt="Collection"> |
| **Stats** | **Shopping list (flea-market mode)** |
| <img src="docs/screenshots/statistik.png" width="260" alt="Stats"> | <img src="docs/screenshots/einkaufsliste.png" width="260" alt="Shopping list"> |

*(Screenshots with demo data)*

## Quick start (Docker)

No source code, no build – two commands:

```bash
mkdir nupplo && cd nupplo
curl -sLo docker-compose.yml https://raw.githubusercontent.com/Melle79/nupplo/main/docker-compose.example.yml
docker compose up -d
```

Open `http://<server>:8300` – on first visit a **setup wizard** walks you
through everything: admin account, display name, API keys (with a test
button) and, if you have one, your invite to the trading network. Every step
can be skipped; anything can be added later under *More*. The database is
stored persistently at `./data/brickfolio.db`.

The image (`ghcr.io/melle79/nupplo:latest`, mirrored as
`melle79/nupplo:latest` on Docker Hub) is built for **amd64** – that is
**x86-64**, any ordinary Intel *or* AMD machine (Synology,
Intel NAS, PC) and **arm64** (Raspberry Pi, ARM NAS).

### Synology NAS

No shell needed: **Container Manager → Project → Create**, paste the YAML,
done. Step by step in [`docs/SYNOLOGY.md`](docs/SYNOLOGY.md) (in German).
Over SSH it works just as well – create `/volume1/docker/nupplo` and run
the commands above with `sudo`.

### Other NAS systems and computers

It is an ordinary OCI image on two public registries – it runs wherever
containers run. Same YAML as above, just pasted somewhere else:

| System | Where the YAML goes |
|---|---|
| **QNAP** | Container Station → *Applications* → Create |
| **UGREEN** | Docker → *Project* → Create |
| **TerraMaster** | Docker Manager → *Compose* |
| **Asustor** | Portainer (from App Central) → *Stacks* |
| **Unraid** | Docker tab, or *Compose Manager* from Community Apps |
| **TrueNAS SCALE** | Apps → *Custom App* (YAML) |
| **OpenMediaVault** | omv-extras → Compose → *Files* |
| **Linux / Raspberry Pi / Mac / Windows** | create a folder, `docker compose up -d` |

Very old 32-bit ARM devices (`armv7`) are not supported.

**Unraid** has it easier: under *Docker → Settings → Template Repositories*
add this address –

```
https://github.com/Melle79/unraid-templates
```

– and Nupplo appears under *Docker → Add Container* in the template
picker, with the port, the `/data` path and the optional keys pre-filled.
The template is maintained in
[Melle79/unraid-templates](https://github.com/Melle79/unraid-templates);
a copy lives here under [`unraid/brickfolio.xml`](unraid/brickfolio.xml).

### Hosted instead of at home

If you would rather not run your own hardware, rent the instance. **It
belongs to whoever creates it** – data, cost and access. There is still no
Nupplo service in between.

| Provider | How | Note |
|---|---|---|
| **Render** | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Melle79/nupplo) | Reads [`render.yaml`](render.yaml). Needs the smallest **paid** plan: Nupplo requires a persistent disk for `/data`, and the free plan has none |
| **Railway** | New project → *Deploy from GitHub* → this repo, then set *Settings → Config-as-Code* to `deploy/railway/railway.json` | Afterwards attach a **volume** at `/data`, otherwise the collection is gone after every restart |
| **Coolify / Dokploy** | Paste `docker-compose.example.yml` | On your own VPS, full control |

> **Why no free plan?** Without a persistent disk the database lives inside
> the container – every restart would wipe the collection. Better an honest
> note than a deployment that quietly loses data.

Without a compose UI:

```bash
docker run -d --name nupplo --restart unless-stopped \
  -p 8300:8300 -v /path/to/data:/data \
  ghcr.io/melle79/nupplo:latest
```

### Building it yourself (instead of the ready-made image)

Only needed if you want to run your own changes:

```bash
mkdir nupplo && cd nupplo
curl -sL https://github.com/Melle79/nupplo/archive/refs/heads/main.tar.gz | tar xz --strip-components=1
cp docker-compose.example.yml docker-compose.yml
sed -i 's|image: ghcr.io/melle79/nupplo:latest|build: .|' docker-compose.yml
docker compose up -d --build
```

## Configuration

| Variable | Required | Description |
|---|---|---|
| `ADMIN_USER` / `ADMIN_PASSWORD` | no | Optional: create the admin automatically (otherwise via browser setup) |
| `DB_PATH` | no | Path to the SQLite file (container default: `/data/brickfolio.db`) |
| `BL_CONSUMER_KEY` / `BL_CONSUMER_SECRET` / `BL_TOKEN` / `BL_TOKEN_SECRET` | no | BrickLink Store API for prices & set inventories ([request a key](https://www.bricklink.com/v2/api/register_consumer.page)) |
| `BACKUP_KEEP` | no | Keep this many automatic daily backups (default 14, 0 = off) |
| `REBRICKABLE_KEY` | no | Rebrickable API for name search ([create a key](https://rebrickable.com/api/)) |
| `GITHUB_REPO` | no | Target repository for issues from the error report (default `Melle79/nupplo`) |
| `BRICKFOLIO_NAME` | no | Display name in logo/title (empty by default); also settable in-app |

All API keys can alternatively be stored **in the app** (More → API keys,
admin only) – environment variables serve as a fallback.

## Permissions overview

| Action | Default | Collector-Pro | Admin |
|---|:-:|:-:|:-:|
| Scan, collection, wishlist, stats | ✔ | ✔ | ✔ |
| See shopping lists & mark an item “received” | ✔¹ | ✔ | ✔¹ |
| Create/fill/archive lists, bundle offer, sell list | – | ✔ | – |
| See purchase prices & profit | – | ✔ | – |
| Users, roles, API keys, backup | – | – | ✔ |

¹ The tab only appears when at least one active list exists.
Roles combine (the admin can grant themselves the pro role).

## Updates & backup

**The app tells you.** Under *More → Version & updates* it compares the running
version against the latest release, whichever way you installed.

**With the ready-made image** (the quick start above), two commands in the
project folder:

```bash
docker compose pull && docker compose up -d
```

On a Synology the same works without a shell: *Container Manager → Project →
nupplo → Action → Build and restart*.

The `data/` folder is untouched, database migrations run automatically on
start and are idempotent. **A snapshot beforehand never hurts** – download the
JSON file in the app under *More → Backup*.

> `update.sh` and `update-watch.sh` are **not** in the image and not in your
> folder if you only fetched the `docker-compose.yml`. If you want them
> (automatic snapshot, updating from inside the app), fetch them once:
>
> ```bash
> curl -sLO https://raw.githubusercontent.com/Melle79/nupplo/main/update.sh
> curl -sLO https://raw.githubusercontent.com/Melle79/nupplo/main/update-watch.sh
> ```

**Built from source**: `sudo bash update.sh` in the project folder. It takes a
database snapshot first (the last three are kept) and detects on its own
whether to pull the image or rebuild.

### Updating from inside the app (optional)

**Entirely optional** – without setup nothing changes and updates keep working
via `update.sh` over SSH. The button only appears once the helper below is in
place; until then the app just points to it.

**How it works.** The app does **not** run the update itself – it can’t, since
it lives inside the container. It only writes `data/update-requested.json`. A
small script on the server picks that up and runs `update.sh`. That way the app
needs **no Docker access** – handing the socket to a container is effectively
root on the host.

#### Setup

Run `update-watch.sh` regularly. **Once a minute is plenty** – the update
itself takes one to three minutes anyway.

**Synology (DSM):** Control Panel → Task Scheduler → Create → Scheduled Task →
User-defined script

| Tab | Setting |
| --- | --- |
| General | User: **`root`** (otherwise the script may not run `docker compose`) |
| Schedule | Daily · start `00:00` · “Continue running within the same day” ✔ · repeat **every minute** · last run time: **`23:59`** |
| Task Settings | Command: `sh /path/to/nupplo/update-watch.sh` |

> ⚠️ “Last run time” defaults to `00:59` – the task would then only run during
> the first hour of the day. Set it to `23:59`.

**Linux with cron:** `* * * * * sh /path/to/nupplo/update-watch.sh`

#### Multiple instances

Running several Nupplos (each in its own folder with its own
`docker-compose.yml`)? Prefer **one task per instance**. That is the most
robust setup: each runs independently, and the task scheduler shows you per
instance whether it succeeded.

If you would rather use a single task, append `|| true` to every line:

```sh
sh /volume1/docker/nupplo/update-watch.sh || true
sh /volume1/docker/nupplo-zweit/update-watch.sh || true
```

> ⚠️ Without `|| true` the second line may never run: if the first one exits
> with an error (missing permissions, say), the scheduler aborts the whole
> script and the second instance never gets a heartbeat.

Either way the instances stay independent – each has its own marker in its own
`data` folder, so updating one leaves the other alone.

#### Flow

The admin picks now / 1 min / 5 min → every signed-in browser shows a countdown
(“please finish your edits”), then a lock screen. Once the server is back, the
browsers reload themselves. While the countdown runs, the admin can cancel.

- The helper touches `data/update-watch-alive` on every run. That is how the
  app knows it is set up – if the heartbeat is older than five minutes, the
  update is not offered at all.
- Log of every run: `data/update-watch.log`.
- Backup: in-app under More → Backup (JSON with all data incl. users and
  price history) **or** simply copy `data/brickfolio.db`.

## Tech

FastAPI + SQLite (no ORM) · Vanilla-JS PWA (no build step) ·
Docker deployment · APIs: Brickognize, BrickLink Store API (OAuth1),
Rebrickable.

### What lives in its own repo

This repo holds the **program**. Three things beside it move at their own
pace and therefore have their own repo – all three private, because they
describe credentials or carry screenshots of a real collection:

| what | repo | for |
|---|---|---|
| project site brickfolio.cc | `brickfolio-website` | static Cloudflare Worker, published by hand |
| catalogue service | `brickfolio-katalogdienst` | builds the catalogue index and writes it here, encrypted, to `katalog/index.nupplo` |
| hub console | `brickfolio-hub-admin` | interface for running the trade network |
| trade hub | `brickfolio-hub` | Cloudflare Worker + D1 that instances talk to for trading |

The app itself only talks to the hub over HTTP; it does not need its
source.

## Legal

LEGO® is a trademark of the LEGO Group, which does not sponsor, authorise or
endorse this project. BrickLink, Rebrickable and Brickognize are trademarks of
their respective owners; their APIs are subject to their respective terms of
use. This is a private hobby project with no commercial intent.

Data and images come from Rebrickable (catalogue search), BrickLink (prices,
set contents, images) and Brickognize (image recognition – taking a photo
sends it there). The same notes are shown in the app under
**More → Sources & legal**.

The **Nunito** typeface (SIL Open Font License 1.1, licence text in
`frontend/fonts/OFL.txt`) is served locally – so no visitor data goes to font
CDNs, and the interface still looks right when the server has no internet
connection.

### What works without internet

Your collection and the interface both live on your own server. If the server
is reachable on the local network but has no internet, these keep working:

- browsing and editing the collection, wishlist and shopping lists
- manual entry, including custom figures with your own picture
- statistics, CSV export and print lists
- downloading and restoring a backup

Everything that asks the outside world needs internet: **scanning**
(Brickognize), **search by name** (Rebrickable), **prices and set contents**
(BrickLink), the **catalogue images** (they are hosted on their servers –
images you uploaded yourself are not), the **update check** and the **trading
network**.

With no server reachable at all, the installed PWA still loads its interface
from the cache but shows no data – data always comes live from your own
server, none of it is kept in the browser.

## Support

Nupplo is a private hobby project and free to use. If you like it and want
to support development, I'd be happy about a coffee ☕

<a href="https://buymeacoffee.com/melle79"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-melle79-ffdd00?logo=buymeacoffee&logoColor=black" alt="Buy Me a Coffee"></a>

## License

[MIT](LICENSE) for the program code. The figure catalogue in `katalog/`
is excluded: it may only be used within Nupplo, see
[katalog/LIZENZ.md](katalog/LIZENZ.md).