pwa and mobile client

This commit is contained in:
azykov@mail.ru
2026-09-29 15:55:25 +03:00
parent 657009bd6c
commit 1935acf2ff
96 changed files with 8370 additions and 58 deletions
+7 -7
View File
@@ -5,22 +5,22 @@
"name": "server-be (local test)", "name": "server-be (local test)",
"runtimeExecutable": "pnpm", "runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@cardy/server-be", "exec", "tsx", "src/index.ts"], "runtimeArgs": ["--filter", "@cardy/server-be", "exec", "tsx", "src/index.ts"],
"port": 10002, "port": 10012,
"env": { "env": {
"PORT": "10012",
"KEY_ENCRYPTION_SECRET": "cHrUrVMk3KgaAw066+CPj7iYXuBfBXbH5lPLFwoBSLA=", "KEY_ENCRYPTION_SECRET": "cHrUrVMk3KgaAw066+CPj7iYXuBfBXbH5lPLFwoBSLA=",
"SUPERADMIN_EMAIL": "admin@local.test", "SUPERADMIN_EMAIL": "admin@local.test",
"ADMIN_PROXY_SECRET": "local-proxy-secret",
"DATA_DIR": "C:/Users/User/AppData/Local/Temp/claude/D--Documents-ProjectsGit-Cardy/bf03f49e-0768-4ffa-b6d2-c9a06a1562aa/scratchpad/data" "DATA_DIR": "C:/Users/User/AppData/Local/Temp/claude/D--Documents-ProjectsGit-Cardy/bf03f49e-0768-4ffa-b6d2-c9a06a1562aa/scratchpad/data"
} }
}, },
{ {
"name": "server-fe (local test)", "name": "mobile-client (local test)",
"runtimeExecutable": "pnpm", "runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@cardy/server-fe", "exec", "vite"], "runtimeArgs": ["--filter", "@cardy/mobile-client", "exec", "vite"],
"port": 10001, "port": 10013,
"env": { "env": {
"ADMIN_PROXY_SECRET": "local-proxy-secret", "PORT": "10013",
"DEV_REMOTE_EMAIL": "admin@local.test", "BACKEND_URL": "http://127.0.0.1:10012",
"ALLOWED_HOSTS": "localhost" "ALLOWED_HOSTS": "localhost"
} }
} }
+1
View File
@@ -1,5 +1,6 @@
node_modules/ node_modules/
dist/ dist/
dist-capacitor/
data/ data/
.env .env
*.log *.log
+16
View File
@@ -26,9 +26,25 @@ pnpm --filter @cardy/server-be dev # API on 127.0.0.1:10002 (tsx watch)
pnpm --filter @cardy/server-fe dev # admin UI on :10001 pnpm --filter @cardy/server-fe dev # admin UI on :10001
``` ```
```bash
pnpm --filter @cardy/mobile-client start # PWA: build + vite preview on :10003 (service worker needs a build)
```
Production-like: `pnpm --filter @cardy/server-be build && pnpm --filter @cardy/server-be start`, Production-like: `pnpm --filter @cardy/server-be build && pnpm --filter @cardy/server-be start`,
`pnpm --filter @cardy/server-fe start` (build + `vite preview` on :10001). `pnpm --filter @cardy/server-fe start` (build + `vite preview` on :10001).
The mobile client dev server (`pnpm --filter @cardy/mobile-client dev`) works locally at
`http://localhost:10003/m/`; behind the proxy use `start`.
## Android app
```bash
pnpm --filter @cardy/mobile-client build:android # web build for Capacitor + cap sync
```
Then open `mobile-client/android` in Android Studio (or `gradlew assembleDebug` there) to build the APK.
The app registers `cardy://login?...` links (key QR codes) and asks for camera access when scanning.
`server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`. `server-be` listens on `127.0.0.1` by default because `/admin/*` trusts `Remote-Email`.
If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add If the proxy runs on another machine, set `HOST` and `ADMIN_PROXY_SECRET`, and make the proxy add
`X-Cardy-Proxy-Secret` on the `/api/admin` route. `X-Cardy-Proxy-Secret` on the `/api/admin` route.
+15 -13
View File
@@ -1,6 +1,6 @@
# Cardy — техническое задание, версия 2 # Cardy — техническое задание, версия 2
Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональное лого + опциональные картинки (фото/скриншоты). Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональный комментарий (многострочный текст) + опциональное лого + опциональные картинки (фото/скриншоты).
Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались). Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались).
Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят. Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят.
@@ -101,6 +101,7 @@ cards:
color: "#E53935" # #RRGGBB color: "#E53935" # #RRGGBB
code: "4600000000000" code: "4600000000000"
codeType: EAN13 codeType: EAN13
comment: "Card of Anna\nScan twice" # многострочный, до 2000 символов, '' если пусто
logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null
images: # порядок = порядок добавления images: # порядок = порядок добавления
- { id: 5f6a…, file: 5f6a….webp, mime: image/webp } - { id: 5f6a…, file: 5f6a….webp, mime: image/webp }
@@ -149,7 +150,7 @@ cards:
- **My keys** (все пользователи): список своих ключей; для выбранного ключа — QR-код с deep link, сам ключ текстом, кнопка копирования. - **My keys** (все пользователи): список своих ключей; для выбранного ключа — QR-код с deep link, сам ключ текстом, кнопка копирования.
- **Users** (admin, superadmin): CRUD пользователей текущей группы (email, роль; у суперадмина — ещё группа), по каждому пользователю — список ключей, генерация ключа (с опциональной меткой), удаление ключа, показ QR. - **Users** (admin, superadmin): CRUD пользователей текущей группы (email, роль; у суперадмина — ещё группа), по каждому пользователю — список ключей, генерация ключа (с опциональной меткой), удаление ключа, показ QR.
- **Groups** (superadmin): CRUD групп (имя, уникально). - **Groups** (superadmin): CRUD групп (имя, уникально).
- **Cards** (admin, superadmin): список всех карт текущей группы, включая удалённые (фильтр по названию, переключатель «показывать удалённые»). Создание и редактирование: название, цвет, код, тип кода, предпросмотр кода, лого (загрузка/удаление), картинки (загрузка/удаление). Удаление помечает карту удалённой; удалённую карту можно восстановить или удалить окончательно (вместе с картинками). - **Cards** (admin, superadmin): список всех карт текущей группы, включая удалённые (фильтр по названию, переключатель «показывать удалённые»). Создание и редактирование: название, цвет, код, тип кода, предпросмотр кода, комментарий, лого (загрузка/удаление), картинки (загрузка/удаление). Удаление помечает карту удалённой; удалённую карту можно восстановить или удалить окончательно (вместе с картинками).
### 3.7. API административного UI (публично `/api/admin`, на сервере `/admin`) ### 3.7. API административного UI (публично `/api/admin`, на сервере `/admin`)
@@ -180,8 +181,8 @@ cards:
|---|---| |---|---|
| `GET /api/auth` | проверка ключа; возвращает email и группу | | `GET /api/auth` | проверка ключа; возвращает email и группу |
| `GET /api/cards` | все неудалённые карты группы, без пагинации | | `GET /api/cards` | все неудалённые карты группы, без пагинации |
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально файл `logo`); возвращает карту с ID | | `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment` и файл `logo`); возвращает карту с ID |
| `PUT /api/cards/:id` | изменить карту (multipart: те же поля, опционально файл `logo`, опционально `removeLogo=true`) | | `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий; опционально файл `logo`, опционально `removeLogo=true`) |
| `DELETE /api/cards/:id` | пометить карту удалённой | | `DELETE /api/cards/:id` | пометить карту удалённой |
| `GET /api/cards/:id/logo` | лого | | `GET /api/cards/:id/logo` | лого |
| `GET /api/cards/:id/images/:imageId` | картинка | | `GET /api/cards/:id/images/:imageId` | картинка |
@@ -192,19 +193,20 @@ cards:
## 4. Клиентское мобильное приложение ## 4. Клиентское мобильное приложение
React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 с базовым путём `/m` (`https://dev.aggtaa.com/m`). React + Vite + TS; PWA и Android-приложение через Capacitor. PWA раздаётся на порту 10003 (`vite preview` собранной версии; `vite` — для разработки) с базовым путём `/m` (`https://dev.aggtaa.com/m`); прокси срезает `/m`, сервер восстанавливает его сам. Сборка для Capacitor (`pnpm build:android`) — с относительным базовым путём, в `dist-capacitor`. Service worker кэширует приложение целиком (включая `bwip-js` и wasm-модуль сканера) для работы офлайн.
- Хранит один ключ, адрес сервера и хэш PIN-кода (Capacitor Preferences / localStorage). Картинки — в Filesystem (Capacitor) или IndexedDB (PWA). - Хранит один ключ, адрес API и хэш PIN-кода (SHA-256 с солью), список карт, даты использования и версии скачанных картинок — в Capacitor Preferences (в PWA это localStorage). Картинки — в IndexedDB (и в PWA, и в WebView Capacitor).
- **Deep link `cardy://login?server=…&key=…`** зарегистрирован в Android-приложении (intent filter для схемы `cardy`, хост `login`). Открытие ссылки (например, QR из системной камеры) открывает экран нового входа с заполненными полями. - **Deep link `cardy://login?server=…&key=…`** зарегистрирован в Android-приложении (intent filter для схемы `cardy`, хост `login`). Открытие ссылки (например, QR из системной камеры) открывает экран нового входа с заполненными полями.
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний. - **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически.
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход. - **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает.
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне.
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить». - **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить».
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. - **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
- **Экран карты**: название, код (отрисовка `bwip-js`), локальные картинки. На Android яркость поднимается до максимума. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением). - **Экран карты**: название, код (отрисовка `bwip-js`), комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
- **Экран добавления и редактирования**: название, цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (Android — ML Kit, PWA — `zxing-wasm`). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется. - **Экран добавления и редактирования**: название, комментарий (многострочное поле), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера. - После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся.
- Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет). - Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет).
## 5. Тесты ## 5. Тесты
Unit- и интеграционные тесты сервера на Vitest: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`). E2E-тестов нет. Vitest. Сервер: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`), интеграция с прокси. Клиент: пул загрузок, выбор карт для докачки, сортировка, PIN, распознаваемые форматы. E2E-тестов нет.
+9
View File
@@ -0,0 +1,9 @@
# Copy to .env if needed.
# Public host names the dev/preview server answers to (comma-separated).
ALLOWED_HOSTS=dev.aggtaa.com
# Dev/preview server port.
PORT=10003
# Local development without the reverse proxy only: /api/* is forwarded here with /api stripped.
# BACKEND_URL=http://127.0.0.1:10002
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+54
View File
@@ -0,0 +1,54 @@
apply plugin: 'com.android.application'
android {
namespace = "com.aggtaa.cardy"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.aggtaa.cardy"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,20 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-preferences')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Login links from key QR codes: cardy://login?server=...&key=... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cardy" android:host="login" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Scanning key QR codes and card barcodes (getUserMedia in the WebView). -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -0,0 +1,12 @@
package com.aggtaa.cardy;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(ScreenBrightnessPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -0,0 +1,29 @@
package com.aggtaa.cardy;
import android.view.WindowManager;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* Sets the brightness of the app window only (no system setting is changed).
* brightness: 0..1, or -1 to return to the system brightness.
*/
@CapacitorPlugin(name = "ScreenBrightness")
public class ScreenBrightnessPlugin extends Plugin {
@PluginMethod
public void set(PluginCall call) {
Float value = call.getFloat("brightness", -1f);
float brightness = value == null ? -1f : value;
getActivity().runOnUiThread(() -> {
WindowManager.LayoutParams params = getActivity().getWindow().getAttributes();
params.screenBrightness = brightness < 0
? WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
: Math.min(1f, brightness);
getActivity().getWindow().setAttributes(params);
call.resolve();
});
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Cardy</string>
<string name="title_activity_main">Cardy</string>
<string name="package_name">com.aggtaa.cardy</string>
<string name="custom_url_scheme">com.aggtaa.cardy</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,9 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../../node_modules/.pnpm/@capacitor+android@8.5.2_@capacitor+core@8.5.2/node_modules/@capacitor/android/capacitor')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capacitor+app@8.1.1_@capacitor+core@8.5.2/node_modules/@capacitor/app/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences/android')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+5
View File
@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'
+16
View File
@@ -0,0 +1,16 @@
ext {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}
+13
View File
@@ -0,0 +1,13 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.aggtaa.cardy',
appName: 'Cardy',
webDir: 'dist-capacitor',
backgroundColor: '#0f1115',
android: {
backgroundColor: '#0f1115',
},
};
export default config;
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0f1115" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
<title>Cardy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@cardy/mobile-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "vite build && vite preview",
"build:android": "vite build --mode capacitor && cap sync android",
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.2",
"@capacitor/preferences": "^8.0.1",
"@cardy/shared": "workspace:*",
"barcode-detector": "^3.2.2",
"bwip-js": "^4.11.4",
"idb-keyval": "^6.3.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"zxing-wasm": "3.1.3"
},
"devDependencies": {
"@capacitor/cli": "^8.5.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.1",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^5.0.2"
}
}
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#0f1115"/>
<rect x="96" y="150" width="320" height="212" rx="28" fill="#5b9dff"/>
<rect x="96" y="194" width="320" height="36" fill="#0b1220" opacity=".55"/>
<g fill="#0b1220">
<rect x="132" y="266" width="10" height="64"/>
<rect x="152" y="266" width="6" height="64"/>
<rect x="168" y="266" width="14" height="64"/>
<rect x="192" y="266" width="6" height="64"/>
<rect x="208" y="266" width="10" height="64"/>
<rect x="228" y="266" width="6" height="64"/>
<rect x="244" y="266" width="14" height="64"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 662 B

+272
View File
@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { parseLoginDeepLink, type Card } from '@cardy/shared';
import { createApi } from './api.js';
import { useNavigation, type LoginPrefill } from './navigation.js';
import { onBackButton, onDeepLink, onResumeAfter } from './platform.js';
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
import { refreshCardList, syncImages } from './sync.js';
import { CardScreen } from './screens/CardScreen.js';
import { CardsScreen, type SyncState } from './screens/CardsScreen.js';
import { EditScreen } from './screens/EditScreen.js';
import { LoginScreen } from './screens/LoginScreen.js';
import { PinEnterScreen, PinSetScreen } from './screens/PinScreens.js';
/** Lock again after the app was in the background this long. */
const RELOCK_AFTER_MS = 30_000;
interface Boot {
session: Session | null;
pin: PinRecord | null;
cards: Card[];
usage: UsageMap;
}
export function App() {
const [boot, setBoot] = useState<Boot>();
useEffect(() => {
void Promise.all([storage.getSession(), storage.getPin(), storage.getCards(), storage.getUsage()]).then(
([session, pin, cards, usage]) => setBoot({ session, pin, cards, usage }),
);
}, []);
if (!boot) return <div className="splash" />;
return <Main boot={boot} />;
}
function Main({ boot }: { boot: Boot }) {
const [session, setSession] = useState(boot.session);
const [pin, setPin] = useState(boot.pin);
const [locked, setLocked] = useState(!!boot.pin);
const [cards, setCards] = useState(boot.cards);
const [usage, setUsage] = useState(boot.usage);
const [imageEpoch, setImageEpoch] = useState(0);
const [sync, setSync] = useState<SyncState>({ busy: false });
const [pendingLogin, setPendingLogin] = useState<LoginPrefill>();
const refreshAfterPin = useRef(false);
// After a reload the screen stack comes back from history (only when signed in).
const { nav, push, back, replaceTop, reset, openLockedLogin } = useNavigation(
boot.session ? [{ name: 'cards' }] : [{ name: 'login' }],
!!boot.session,
);
const { stack, lockedLogin } = nav;
const route = stack[stack.length - 1];
const api = useMemo(() => (session ? createApi(session) : undefined), [session]);
// ---- data ----
const refresh = useCallback(async () => {
if (!api) return;
setSync({ busy: true });
let list: Card[];
try {
list = await refreshCardList(api);
} catch (e) {
setSync({ busy: false, error: (e as Error).message });
return;
}
setCards(list);
setUsage(await storage.getUsage());
setSync({ busy: true, progress: { done: 0, total: 0 } });
try {
const result = await syncImages(
api,
list,
() => setImageEpoch((e) => e + 1),
(done, total) => setSync({ busy: true, progress: { done, total } }),
);
setSync(
result.failedFiles
? { busy: false, error: `${result.failedFiles} image(s) could not be downloaded. Refresh again to retry.` }
: { busy: false, info: `${list.length} card${list.length === 1 ? '' : 's'} updated` },
);
} catch (e) {
setSync({ busy: false, error: (e as Error).message });
}
}, [api]);
/** Applies a card returned by the server after add/edit. */
const saveCardLocally = useCallback(async (card: Card) => {
const list = await storage.getCards();
const index = list.findIndex((c) => c.id === card.id);
if (index >= 0) list[index] = card;
else list.push(card);
await storage.setCards(list);
// Images uploaded from this device are stored already; record the version only if nothing is missing.
const stored = await images.storedKeys();
if (cardImageIds(card).every((id) => stored.has(images.key(card.id, id)))) {
await images.prune(card.id, cardImageIds(card));
const versions = await storage.getVersions();
versions[card.id] = card.imagesVersion;
await storage.setVersions(versions);
}
setCards(list);
setImageEpoch((e) => e + 1);
}, []);
const removeCardLocally = useCallback(async (id: string) => {
const [list, u, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]);
const next = list.filter((c) => c.id !== id);
delete u[id];
delete versions[id];
await Promise.all([storage.setCards(next), storage.setUsage(u), storage.setVersions(versions), images.prune(id)]);
setCards(next);
setUsage({ ...u });
}, []);
// Showing the card screen (from the list, after creating or editing) counts as using the card.
const shownCardId = !locked && route?.name === 'card' ? route.id : undefined;
useEffect(() => {
if (!shownCardId) return;
void (async () => {
const u = await storage.getUsage();
u[shownCardId] = new Date().toISOString();
await storage.setUsage(u);
setUsage({ ...u });
})();
}, [shownCardId, stack.length]);
// A card that no longer exists (deleted here or by a refresh, or an old history entry): leave its screen.
const missingCard = (route?.name === 'card' || route?.name === 'edit') && route.id && !cards.some((c) => c.id === route.id);
useEffect(() => {
if (missingCard && !back()) reset([{ name: 'cards' }]);
}, [missingCard, back, reset]);
// ---- sign-in ----
const signedIn = useCallback(
async (next: Session) => {
await storage.clearAll();
await storage.setSession(next);
setCards([]);
setUsage({});
setPin(null);
setLocked(false);
setSync({ busy: false });
setSession(next);
reset([{ name: 'cards' }]);
refreshAfterPin.current = true;
},
[reset],
);
const pinCreated = useCallback(async (record: PinRecord) => {
await storage.setPin(record);
setPin(record);
}, []);
useEffect(() => {
if (pin && !locked && refreshAfterPin.current) {
refreshAfterPin.current = false;
void refresh();
}
}, [pin, locked, refresh]);
// ---- platform events ----
useEffect(
() =>
onDeepLink((url) => {
const parsed = parseLoginDeepLink(url);
if (parsed) setPendingLogin(parsed);
}),
[],
);
// A login link opens the login screen (it replaces the stored key only after a successful check).
useEffect(() => {
if (!pendingLogin) return;
setPendingLogin(undefined);
if (!session) reset([{ name: 'login', prefill: pendingLogin }]);
else if (locked) openLockedLogin(pendingLogin);
else push({ name: 'login', prefill: pendingLogin });
}, [pendingLogin, session, locked, push, reset, openLockedLogin]);
useEffect(() => onResumeAfter(RELOCK_AFTER_MS, () => pin && setLocked(true)), [pin]);
// Android back button = history back; on the lock screen and at the root it exits the app.
const lockedRef = useRef({ locked, lockedLogin });
lockedRef.current = { locked, lockedLogin };
useEffect(
() =>
onBackButton(() => {
const { locked: isLocked, lockedLogin: overLock } = lockedRef.current;
if (isLocked && !overLock) return false;
return back();
}),
[back],
);
const goBack = useCallback(() => void back(), [back]);
// ---- screens ----
if (!session || !api) {
const prefill = route?.name === 'login' ? route.prefill : undefined;
return <LoginScreen key={JSON.stringify(prefill ?? {})} prefill={prefill} onSignedIn={signedIn} />;
}
if (!pin) return <PinSetScreen onCreated={pinCreated} />;
if (locked) {
if (lockedLogin) {
const prefill = lockedLogin.prefill;
return <LoginScreen key={JSON.stringify(prefill ?? {})} prefill={prefill} current={session} onSignedIn={signedIn} onCancel={goBack} />;
}
return <PinEnterScreen pin={pin} onUnlocked={() => setLocked(false)} onForgot={() => openLockedLogin()} />;
}
switch (route?.name) {
case 'login':
return <LoginScreen key={JSON.stringify(route.prefill ?? {})} prefill={route.prefill} current={session} onSignedIn={signedIn} onCancel={goBack} />;
case 'card': {
const card = cards.find((c) => c.id === route.id);
if (!card) return null;
return (
<CardScreen
card={card}
imageEpoch={imageEpoch}
onBack={goBack}
onEdit={() => push({ name: 'edit', id: card.id })}
onDelete={async () => {
try {
await api.deleteCard(card.id);
} catch (e) {
// Already deleted elsewhere: drop it locally as well.
if ((e as { status?: number }).status !== 404) throw e;
}
await removeCardLocally(card.id);
}}
/>
);
}
case 'edit': {
const card = route.id ? cards.find((c) => c.id === route.id) : undefined;
if (route.id && !card) return null;
return (
<EditScreen
key={route.id ?? 'new'}
card={card}
api={api}
onCancel={goBack}
saveCardLocally={saveCardLocally}
onSaved={(saved) => (route.id ? goBack() : replaceTop({ name: 'card', id: saved.id }))}
onMissing={(id) => void removeCardLocally(id)}
/>
);
}
default:
return (
<CardsScreen
cards={cards}
usage={usage}
imageEpoch={imageEpoch}
sync={sync}
session={session}
onDismissSync={() => setSync({ busy: false })}
onRefresh={refresh}
onOpen={(id) => push({ name: 'card', id })}
onAdd={() => push({ name: 'edit' })}
onChangeKey={() => push({ name: 'login' })}
/>
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { PIN_LENGTH } from './pin.js';
import { Icon } from './ui.js';
/**
* Four-dot PIN entry with an on-screen keypad (a hardware keyboard works too).
* Changing `attempt` clears the input (and shakes when `error` is set).
*/
export function PinPad({
title,
subtitle,
error,
attempt,
busy,
onComplete,
footer,
}: {
title: string;
subtitle?: string;
error?: string;
attempt: number;
busy?: boolean;
onComplete: (pin: string) => void;
footer?: ReactNode;
}) {
const [pin, setPin] = useState('');
const [shake, setShake] = useState(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
useEffect(() => {
setPin('');
if (!error) return;
setShake(true);
const t = setTimeout(() => setShake(false), 400);
return () => clearTimeout(t);
}, [attempt, error]);
useEffect(() => {
if (pin.length !== PIN_LENGTH) return;
const t = setTimeout(() => onCompleteRef.current(pin), 120);
return () => clearTimeout(t);
}, [pin]);
const press = (digit: string) => !busy && setPin((p) => (p.length >= PIN_LENGTH ? p : p + digit));
const back = () => !busy && setPin((p) => p.slice(0, -1));
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (/^\d$/.test(e.key)) press(e.key);
else if (e.key === 'Backspace') back();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
return (
<div className="pin">
<div className="pin-head">
<h1>{title}</h1>
{subtitle && <p className="muted">{subtitle}</p>}
</div>
<div className={`pin-dots${shake ? ' shake' : ''}`} aria-label={`${pin.length} of ${PIN_LENGTH} digits`}>
{Array.from({ length: PIN_LENGTH }, (_, i) => (
<span key={i} className={i < pin.length ? 'filled' : ''} />
))}
</div>
<p className="pin-error" role="alert">
{error}
</p>
<div className="keypad">
{['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((d) => (
<button key={d} onClick={() => press(d)}>
{d}
</button>
))}
<span />
<button onClick={() => press('0')}>0</button>
<button onClick={back} aria-label="Delete digit" className="keypad-back">
<Icon name="backspace" size={26} />
</button>
</div>
<div className="pin-footer">{footer}</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react';
import type { ScanResult } from './scanner.js';
import { Icon } from './ui.js';
type Formats = 'card' | 'qr';
/** Fullscreen camera view that resolves with the first detected code. */
export function ScannerView({ formats, hint, onResult, onClose }: { formats: Formats; hint: string; onResult: (result: ScanResult) => void; onClose: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [error, setError] = useState<string>();
const onResultRef = useRef(onResult);
onResultRef.current = onResult;
useEffect(() => {
let stopped = false;
let stream: MediaStream | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
(async () => {
try {
const { createDetector, CARD_FORMATS, QR_FORMATS } = await import('./scanner.js');
const detect = createDetector(formats === 'qr' ? QR_FORMATS : CARD_FORMATS);
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false,
});
if (stopped) return;
const video = videoRef.current!;
video.srcObject = stream;
await video.play();
const tick = async () => {
if (stopped) return;
try {
if (video.readyState >= 2) {
const found = await detect(video);
if (found && !stopped) {
stopped = true;
onResultRef.current(found);
return;
}
}
} catch {
// a frame that could not be decoded
}
timer = setTimeout(tick, 200);
};
void tick();
} catch (e) {
const name = (e as Error).name;
setError(
name === 'NotAllowedError'
? 'Camera access was denied. Allow it in the app or browser settings.'
: name === 'NotFoundError'
? 'No camera found.'
: `Cannot start the camera: ${(e as Error).message}`,
);
}
})();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
stream?.getTracks().forEach((t) => t.stop());
};
}, [formats]);
return (
<div className="scanner">
<video ref={videoRef} playsInline muted />
<div className={`scanner-frame ${formats}`} />
<div className="scanner-top">
<button className="icon-button" onClick={onClose} aria-label="Close">
<Icon name="close" />
</button>
</div>
<div className="scanner-hint">{error ?? hint}</div>
</div>
);
}
/** Detects a code on a picked image file. */
export async function scanImageFile(file: Blob, formats: Formats): Promise<ScanResult | undefined> {
const { createDetector, CARD_FORMATS, QR_FORMATS } = await import('./scanner.js');
return createDetector(formats === 'qr' ? QR_FORMATS : CARD_FORMATS)(file);
}
+79
View File
@@ -0,0 +1,79 @@
import type { ApiAuthResponse, Card } from '@cardy/shared';
import type { Session } from './storage.js';
export class ApiError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
}
}
const JSON_TIMEOUT = 20_000;
const UPLOAD_TIMEOUT = 120_000;
/** Accepts "dev.aggtaa.com/api", "https://dev.aggtaa.com/api/" etc. */
export function normalizeServerUrl(input: string): string {
let url = input.trim();
if (!url) return '';
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) url = `https://${url}`;
return url.replace(/\/+$/, '');
}
export type Api = ReturnType<typeof createApi>;
export function createApi(session: Session) {
const base = normalizeServerUrl(session.server);
async function send(method: string, path: string, body?: FormData, timeout = JSON_TIMEOUT): Promise<Response> {
let res: Response;
try {
res = await fetch(base + path, {
method,
body,
headers: { Authorization: `Bearer ${session.key}` },
signal: AbortSignal.timeout(timeout),
cache: 'no-store',
});
} catch (e) {
const name = (e as Error).name;
if (name === 'TimeoutError' || name === 'AbortError') throw new ApiError('The server did not respond in time');
throw new ApiError('No connection to the server');
}
if (res.ok) return res;
let message: string | undefined;
try {
message = ((await res.json()) as { error?: string }).error;
} catch {
// not JSON
}
if (res.status === 401) throw new ApiError('The access key is not valid. Sign in again.', 401);
if (res.status === 404) throw new ApiError(message ?? 'Not found on the server', 404);
if (res.status === 502 || res.status === 503 || res.status === 504) {
throw new ApiError(`The server is unavailable (${res.status}). Try again later.`, res.status);
}
throw new ApiError(message ?? `Server error ${res.status}`, res.status);
}
const json = async <T>(method: string, path: string, body?: FormData, timeout?: number) =>
(await (await send(method, path, body, timeout)).json()) as T;
const enc = encodeURIComponent;
return {
auth: () => json<ApiAuthResponse>('GET', '/auth'),
cards: () => json<Card[]>('GET', '/cards'),
createCard: (form: FormData) => json<Card>('POST', '/cards', form, UPLOAD_TIMEOUT),
updateCard: (id: string, form: FormData) => json<Card>('PUT', `/cards/${enc(id)}`, form, UPLOAD_TIMEOUT),
deleteCard: async (id: string) => void (await send('DELETE', `/cards/${enc(id)}`)),
addImages: (id: string, form: FormData) => json<Card>('POST', `/cards/${enc(id)}/images`, form, UPLOAD_TIMEOUT),
removeImage: (id: string, imageId: string) => json<Card>('DELETE', `/cards/${enc(id)}/images/${enc(imageId)}`),
/** Logo when `imageId` is the card's logo id, otherwise one of its images. */
file: async (card: Card, imageId: string) => {
const path = card.logo?.id === imageId ? `/cards/${enc(card.id)}/logo` : `/cards/${enc(card.id)}/images/${enc(imageId)}`;
return (await send('GET', path, undefined, UPLOAD_TIMEOUT)).blob();
},
};
}
+19
View File
@@ -0,0 +1,19 @@
import type { CodeType } from '@cardy/shared';
/** Maps a BarcodeDetector / zxing format name to the card code type (undefined if unsupported). */
export function detectedFormatToCodeType(format: string): CodeType | undefined {
const f = format.toLowerCase();
if (f === 'ean_13' || f === 'isbn') return 'EAN13';
if (f === 'ean_8') return 'EAN8';
if (f === 'upc_a') return 'UPCA';
if (f === 'upc_e') return 'UPCE';
if (f === 'code_128') return 'CODE128';
if (f.startsWith('code_39')) return 'CODE39';
if (f.startsWith('itf')) return 'ITF';
if (f === 'codabar') return 'CODABAR';
if (f.includes('qr_code')) return 'QR';
if (f === 'data_matrix') return 'DATAMATRIX';
if (f.includes('pdf417')) return 'PDF417';
if (f.startsWith('aztec')) return 'AZTEC';
return undefined;
}
+32
View File
@@ -0,0 +1,32 @@
export const MAX_IMAGE_SIDE = 1000;
/**
* Re-encodes a picked image to WebP (JPEG where WebP encoding is unsupported), at most
* MAX_IMAGE_SIDE px on the longer side. Re-encoding drops EXIF; orientation is applied first.
*/
export async function compressImage(file: Blob): Promise<Blob> {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
try {
const scale = Math.min(1, MAX_IMAGE_SIDE / Math.max(bitmap.width, bitmap.height));
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas is not available');
ctx.drawImage(bitmap, 0, 0, width, height);
const encode = (type: string) => new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, type, 0.85));
const webp = await encode('image/webp');
if (webp?.type === 'image/webp') return webp;
const jpeg = await encode('image/jpeg');
if (!jpeg) throw new Error('Could not encode the image');
return jpeg;
} finally {
bitmap.close();
}
}
export function fileNameFor(blob: Blob, base: string): string {
return `${base}.${blob.type === 'image/webp' ? 'webp' : 'jpg'}`;
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import type { Card } from '@cardy/shared';
import { normalizeServerUrl } from './api.js';
import { detectedFormatToCodeType } from './codeFormats.js';
import { checkPin, createPinRecord } from './pin.js';
import { cardsNeedingImages, runPool, sortCards } from './sync.js';
const card = (id: string, name: string, extra: Partial<Card> = {}): Card => ({
id,
name,
color: '#123456',
code: '1',
codeType: 'QR',
comment: '',
logo: null,
images: [],
imagesVersion: 0,
deleted: false,
createdAt: '',
updatedAt: '',
...extra,
});
describe('runPool', () => {
it('never runs more than the limit at once and reports failures', async () => {
let running = 0;
let peak = 0;
const results = await runPool([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 5, async (n) => {
running++;
peak = Math.max(peak, running);
await new Promise((r) => setTimeout(r, 5));
running--;
if (n === 7) throw new Error('boom');
});
expect(peak).toBe(5);
expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1);
expect(results[6]!.status).toBe('rejected');
});
it('handles an empty list', async () => {
expect(await runPool([], 5, async () => undefined)).toEqual([]);
});
});
describe('cardsNeedingImages', () => {
const logo = { id: 'L', file: 'L.webp', mime: 'image/webp' };
const img = { id: 'I', file: 'I.webp', mime: 'image/webp' };
it('selects cards with a new version or missing files', () => {
const cards = [
card('a', 'A', { imagesVersion: 2, logo, images: [img] }),
card('b', 'B', { imagesVersion: 1, logo }),
card('c', 'C', { imagesVersion: 0 }),
];
const stored = new Set(['a/L', 'a/I', 'b/L']);
expect(cardsNeedingImages(cards, { a: 2, b: 1, c: 0 }, stored).map((c) => c.id)).toEqual([]);
expect(cardsNeedingImages(cards, { a: 1, b: 1, c: 0 }, stored).map((c) => c.id)).toEqual(['a']);
expect(cardsNeedingImages(cards, { a: 2, b: 1, c: 0 }, new Set(['a/L', 'a/I'])).map((c) => c.id)).toEqual(['b']);
expect(cardsNeedingImages(cards, {}, stored).map((c) => c.id)).toEqual(['a', 'b', 'c']);
});
});
describe('sortCards', () => {
it('puts recently used first, then the rest by name', () => {
const cards = [card('1', 'Zeta'), card('2', 'Alpha'), card('3', 'Beta'), card('4', 'Gamma')];
const usage = { '3': '2026-09-01T10:00:00Z', '4': '2026-09-02T10:00:00Z' };
expect(sortCards(cards, usage).map((c) => c.name)).toEqual(['Gamma', 'Beta', 'Alpha', 'Zeta']);
});
});
describe('PIN', () => {
it('hashes with a random salt and verifies', async () => {
const a = await createPinRecord('1234');
const b = await createPinRecord('1234');
expect(a.hash).not.toBe(b.hash);
expect(await checkPin('1234', a)).toBe(true);
expect(await checkPin('4321', a)).toBe(false);
});
});
describe('helpers', () => {
it('normalizes the server address', () => {
expect(normalizeServerUrl(' dev.aggtaa.com/api/ ')).toBe('https://dev.aggtaa.com/api');
expect(normalizeServerUrl('http://localhost:10002')).toBe('http://localhost:10002');
expect(normalizeServerUrl('')).toBe('');
});
it('maps detected formats to code types', () => {
expect(detectedFormatToCodeType('ean_13')).toBe('EAN13');
expect(detectedFormatToCodeType('code_39_extended')).toBe('CODE39');
expect(detectedFormatToCodeType('itf_14')).toBe('ITF');
expect(detectedFormatToCodeType('micro_qr_code')).toBe('QR');
expect(detectedFormatToCodeType('compact_pdf417')).toBe('PDF417');
expect(detectedFormatToCodeType('maxi_code')).toBeUndefined();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Screen stack kept in the browser history: every screen change is a history entry holding the whole
* stack, so the toolbar back button, the browser back button and the Android back button all go
* through history.back() and behave the same. A reload restores the stack from history.state.
*/
export interface LoginPrefill {
server: string;
key: string;
}
export type Route =
| { name: 'cards' }
| { name: 'card'; id: string }
| { name: 'edit'; id?: string }
| { name: 'login'; prefill?: LoginPrefill };
export interface NavState {
stack: Route[];
/** Login opened over the lock screen ("forgot PIN", login link); going back returns to the lock. */
lockedLogin?: { prefill?: LoginPrefill };
}
const STATE_KEY = 'cardy';
function readState(state: unknown): NavState | undefined {
const nav = (state as Record<string, unknown> | null)?.[STATE_KEY] as NavState | undefined;
return nav && Array.isArray(nav.stack) && nav.stack.length > 0 ? nav : undefined;
}
function hashFor(nav: NavState): string {
if (nav.lockedLogin) return '#/login';
const top = nav.stack[nav.stack.length - 1];
switch (top?.name) {
case 'card':
return `#/card/${top.id}`;
case 'edit':
return top.id ? `#/edit/${top.id}` : '#/new';
case 'login':
return '#/login';
default:
return '#/cards';
}
}
// The key is in the prefill of login links: never keep it in the address bar or in history.
function forHistory(nav: NavState): NavState {
return {
stack: nav.stack.map((r) => (r.name === 'login' ? { name: 'login' } : r)),
...(nav.lockedLogin ? { lockedLogin: {} } : {}),
};
}
/** Number of history entries this app pushed above the root screen. */
function depth(nav: NavState): number {
return nav.stack.length - 1 + (nav.lockedLogin ? 1 : 0);
}
const write = (mode: 'push' | 'replace', nav: NavState) =>
history[mode === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: forHistory(nav) }, '', hashFor(nav));
/**
* @param initial stack to start with; `restore` allows taking the stack from history.state (after a reload).
*/
export function useNavigation(initial: Route[], restore: boolean) {
const [nav, setNav] = useState<NavState>(() => (restore && readState(history.state)) || { stack: initial });
const navRef = useRef(nav);
navRef.current = nav;
/** Set while history.back()/go() is in flight: ignore further back requests until popstate. */
const pendingPop = useRef(false);
/** Target of reset(): applied when the history.go() it started lands. */
const pendingReset = useRef<NavState | undefined>(undefined);
useEffect(() => {
write('replace', navRef.current);
const onPop = (event: PopStateEvent) => {
pendingPop.current = false;
const target = pendingReset.current;
if (target) {
pendingReset.current = undefined;
write('replace', target);
setNav(target);
return;
}
// An entry without our state (e.g. the page before the app): fall back to the root screen.
setNav(readState(event.state) ?? { stack: navRef.current.stack.slice(0, 1) });
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const go = useCallback((next: NavState) => {
write('push', next);
setNav(next);
}, []);
const push = useCallback((route: Route) => go({ stack: [...navRef.current.stack, route] }), [go]);
/** Returns false when already at the root (nothing to go back to inside the app). */
const back = useCallback((): boolean => {
if (pendingPop.current) return true;
if (depth(navRef.current) === 0) return false;
pendingPop.current = true;
history.back();
return true;
}, []);
const replaceTop = useCallback((route: Route) => {
const next = { stack: [...navRef.current.stack.slice(0, -1), route] };
write('replace', next);
setNav(next);
}, []);
/** Replaces the whole stack, unwinding the app's history entries first. */
const reset = useCallback((stack: Route[]) => {
const next: NavState = { stack };
const entries = depth(navRef.current);
if (entries === 0) {
write('replace', next);
setNav(next);
return;
}
pendingReset.current = next;
pendingPop.current = true;
history.go(-entries);
}, []);
const openLockedLogin = useCallback((prefill?: LoginPrefill) => go({ ...navRef.current, lockedLogin: { prefill } }), [go]);
return { nav, push, back, replaceTop, reset, openLockedLogin };
}
+17
View File
@@ -0,0 +1,17 @@
import type { PinRecord } from './storage.js';
export const PIN_LENGTH = 4;
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export async function createPinRecord(pin: string): Promise<PinRecord> {
const salt = [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, '0')).join('');
return { salt, hash: await sha256Hex(`${salt}:${pin}`) };
}
export async function checkPin(pin: string, record: PinRecord): Promise<boolean> {
return (await sha256Hex(`${record.salt}:${pin}`)) === record.hash;
}
+55
View File
@@ -0,0 +1,55 @@
import { App } from '@capacitor/app';
import { Capacitor, registerPlugin } from '@capacitor/core';
export const isNative = Capacitor.isNativePlatform();
/** Native plugin in android/app (ScreenBrightnessPlugin.java). brightness: 0..1, or -1 for the system setting. */
const ScreenBrightness = registerPlugin<{ set(options: { brightness: number }): Promise<void> }>('ScreenBrightness');
export async function setMaxBrightness(on: boolean): Promise<void> {
if (!isNative) return;
try {
await ScreenBrightness.set({ brightness: on ? 1 : -1 });
} catch {
// plugin missing (e.g. older build): not critical
}
}
/** Android back button; return true when handled, otherwise the app exits. */
export function onBackButton(handler: () => boolean): () => void {
if (!isNative) return () => undefined;
const listener = App.addListener('backButton', () => {
if (!handler()) void App.exitApp();
});
return () => void listener.then((l) => l.remove());
}
/** Called with the URL of a cardy:// link that opened (or was sent to) the app. */
export function onDeepLink(handler: (url: string) => void): () => void {
if (!isNative) return () => undefined;
void App.getLaunchUrl().then((launch) => launch?.url && handler(launch.url));
const listener = App.addListener('appUrlOpen', ({ url }) => handler(url));
return () => void listener.then((l) => l.remove());
}
/** Called when the app returns to the foreground after being hidden for `minHiddenMs`. */
export function onResumeAfter(minHiddenMs: number, handler: () => void): () => void {
let hiddenAt: number | undefined;
const hide = () => (hiddenAt = Date.now());
const show = () => {
if (hiddenAt !== undefined && Date.now() - hiddenAt >= minHiddenMs) handler();
hiddenAt = undefined;
};
if (isNative) {
const listener = App.addListener('appStateChange', ({ isActive }) => (isActive ? show() : hide()));
return () => void listener.then((l) => l.remove());
}
const onVisibility = () => (document.hidden ? hide() : show());
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}
/** Default API address: same origin in the PWA, empty in the native app. */
export function defaultServerUrl(): string {
return isNative ? '' : `${location.origin}/api`;
}
+28
View File
@@ -0,0 +1,28 @@
// Loaded lazily: pulls in the zxing wasm module (served locally so scanning works offline).
import { BarcodeDetector, setZXingModuleOverrides, type BarcodeFormat } from 'barcode-detector/ponyfill';
import wasmUrl from 'zxing-wasm/reader/zxing_reader.wasm?url';
import { detectedFormatToCodeType } from './codeFormats.js';
setZXingModuleOverrides({
locateFile: (path: string, prefix: string) => (path.endsWith('.wasm') ? wasmUrl : prefix + path),
});
export const CARD_FORMATS: BarcodeFormat[] = [
'ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128', 'code_39', 'itf', 'codabar',
'qr_code', 'data_matrix', 'pdf417', 'aztec',
];
export const QR_FORMATS: BarcodeFormat[] = ['qr_code'];
export interface ScanResult {
value: string;
codeType: ReturnType<typeof detectedFormatToCodeType>;
}
export function createDetector(formats: BarcodeFormat[]) {
const detector = new BarcodeDetector({ formats });
return async (source: HTMLVideoElement | Blob | ImageBitmap): Promise<ScanResult | undefined> => {
const [found] = await detector.detect(source);
if (!found?.rawValue) return undefined;
return { value: found.rawValue, codeType: detectedFormatToCodeType(found.format) };
};
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import { CODE_TYPE_LABELS, textColorOn, type Card } from '@cardy/shared';
import { setMaxBrightness } from '../platform.js';
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, useStoredImage } from '../ui.js';
export function CardScreen({
card,
imageEpoch,
onBack,
onEdit,
onDelete,
}: {
card: Card;
imageEpoch: number;
onBack: () => void;
onEdit: () => void;
onDelete: () => Promise<void>;
}) {
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string>();
const [viewing, setViewing] = useState<string>();
// Scanners at the till read the code better at full brightness.
useEffect(() => {
void setMaxBrightness(true);
return () => void setMaxBrightness(false);
}, []);
const remove = async () => {
setConfirmDelete(false);
setDeleting(true);
setError(undefined);
try {
await onDelete();
} catch (e) {
setError((e as Error).message);
setDeleting(false);
}
};
return (
<Screen
className="card-screen"
title={card.name}
left={<IconButton icon="back" label="Back" onClick={onBack} />}
right={
<>
<IconButton icon="edit" label="Edit" onClick={onEdit} disabled={deleting} />
<IconButton icon="trash" label="Delete" onClick={() => setConfirmDelete(true)} disabled={deleting} />
</>
}
>
<div className="card-hero" style={{ background: card.color, color: textColorOn(card.color) }}>
<CardBadge card={card} size={56} version={imageEpoch} />
<div className="card-hero-name">{card.name}</div>
</div>
{error && <Banner onClose={() => setError(undefined)}>{error}</Banner>}
<CodeImage code={card.code} codeType={card.codeType} large />
<div className="code-caption">
<span className="mono">{card.code}</span>
<span className="muted">{CODE_TYPE_LABELS[card.codeType]}</span>
</div>
{card.comment && <p className="card-comment">{card.comment}</p>}
{card.images.length > 0 && (
<div className="gallery">
{card.images.map((img) => (
<Thumb key={img.id} cardId={card.id} imageId={img.id} version={imageEpoch} onOpen={() => setViewing(img.id)} />
))}
</div>
)}
{viewing && <ImageViewer cardId={card.id} imageId={viewing} version={imageEpoch} onClose={() => setViewing(undefined)} />}
{confirmDelete && (
<ConfirmDialog
title="Delete card?"
message={`“${card.name}” will be removed for everyone in your group.`}
confirmLabel="Delete"
danger
onCancel={() => setConfirmDelete(false)}
onConfirm={remove}
/>
)}
</Screen>
);
}
function Thumb({ cardId, imageId, version, onOpen }: { cardId: string; imageId: string; version: number; onOpen: () => void }) {
const url = useStoredImage(cardId, imageId, version);
return (
<button className="thumb" onClick={onOpen} disabled={!url} aria-label="Open image">
{url ? <img src={url} alt="" /> : <span className="muted small">Not downloaded</span>}
</button>
);
}
function ImageViewer({ cardId, imageId, version, onClose }: { cardId: string; imageId: string; version: number; onClose: () => void }) {
const url = useStoredImage(cardId, imageId, version);
return (
<div className="viewer" onClick={onClose}>
{url && <img src={url} alt="" />}
<button className="icon-button viewer-close" aria-label="Close">
<Icon name="close" />
</button>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useMemo, useRef, useState } from 'react';
import { textColorOn, type Card } from '@cardy/shared';
import type { Session, UsageMap } from '../storage.js';
import { sortCards } from '../sync.js';
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen } from '../ui.js';
export interface SyncState {
busy: boolean;
progress?: { done: number; total: number };
error?: string;
info?: string;
}
export function CardsScreen({
cards,
usage,
imageEpoch,
sync,
session,
onDismissSync,
onRefresh,
onOpen,
onAdd,
onChangeKey,
}: {
cards: Card[];
usage: UsageMap;
imageEpoch: number;
sync: SyncState;
session: Session;
onDismissSync: () => void;
onRefresh: () => void;
onOpen: (id: string) => void;
onAdd: () => void;
onChangeKey: () => void;
}) {
const [filter, setFilter] = useState('');
const [confirmKey, setConfirmKey] = useState(false);
const filterInput = useRef<HTMLInputElement>(null);
const visible = useMemo(() => {
const q = filter.trim().toLowerCase();
const sorted = sortCards(cards, usage);
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted;
}, [cards, usage, filter]);
return (
<Screen
title="Cards"
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
>
<div className="filter">
<Icon name="search" size={20} />
<input
ref={filterInput}
type="text"
enterKeyHint="search"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
placeholder="Filter by name"
aria-label="Filter by name"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{filter && (
<button
className="filter-clear"
aria-label="Clear filter"
onClick={() => {
setFilter('');
filterInput.current?.focus();
}}
>
<Icon name="close" size={18} />
</button>
)}
</div>
{sync.busy && sync.progress && sync.progress.total > 0 && (
<Banner kind="info">
Downloading images {sync.progress.done}/{sync.progress.total}
</Banner>
)}
{!sync.busy && sync.error && <Banner onClose={onDismissSync}>{sync.error}</Banner>}
{!sync.busy && sync.info && (
<Banner kind="info" onClose={onDismissSync}>
{sync.info}
</Banner>
)}
{cards.length === 0 ? (
<div className="empty">
<p>No cards on this device yet.</p>
<p className="muted">Tap refresh to download the cards of {session.groupName ? `“${session.groupName}”` : 'your group'}, or add a new one.</p>
</div>
) : visible.length === 0 ? (
<div className="empty muted">No cards match “{filter}”.</div>
) : (
<div className="tiles">
{visible.map((card) => (
<button key={card.id} className="tile" style={{ background: card.color, color: textColorOn(card.color) }} onClick={() => onOpen(card.id)}>
<CardBadge card={card} size={64} version={imageEpoch} />
<span className="tile-name">{card.name}</span>
</button>
))}
</div>
)}
<button className="fab" onClick={onAdd} aria-label="Add card">
<Icon name="plus" size={28} />
</button>
{confirmKey && (
<ConfirmDialog
title="Change access key?"
message={`Signed in as ${session.email ?? 'unknown'}. Signing in with another key removes all cards stored on this device.`}
confirmLabel="Continue"
onCancel={() => setConfirmKey(false)}
onConfirm={() => {
setConfirmKey(false);
onChangeKey();
}}
/>
)}
</Screen>
);
}
+356
View File
@@ -0,0 +1,356 @@
import { useRef, useState } from 'react';
import {
CARD_PALETTE,
CODE_TYPES,
CODE_TYPE_LABELS,
cardFieldsSchema,
randomCardColor,
type Card,
type CardFields,
type CodeType,
type ImageRef,
} from '@cardy/shared';
import { ApiError, type Api } from '../api.js';
import { compressImage, fileNameFor } from '../imageTools.js';
import { ScannerView, scanImageFile } from '../ScannerView.js';
import { images } from '../storage.js';
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, useBlobUrl, useStoredImage } from '../ui.js';
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
interface NewImage {
tempId: string;
blob: Blob;
}
const NUMERIC_TYPES: ReadonlySet<CodeType> = new Set(['EAN13', 'EAN8', 'UPCA', 'UPCE', 'ITF']);
let tempCounter = 0;
export function EditScreen({
card,
api,
onCancel,
onSaved,
onMissing,
saveCardLocally,
}: {
card?: Card;
api: Api;
onCancel: () => void;
onSaved: (card: Card) => void;
onMissing: (id: string) => void;
saveCardLocally: (card: Card) => Promise<void>;
}) {
const [fields, setFields] = useState<CardFields>(
card
? { name: card.name, color: card.color, code: card.code, codeType: card.codeType, comment: card.comment ?? '' }
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
);
// Set once the card exists on the server (also after a partially failed first save).
const [savedId, setSavedId] = useState(card?.id);
const [hadLogo, setHadLogo] = useState<ImageRef | null>(card?.logo ?? null);
const [logo, setLogo] = useState<LogoChange>({ kind: 'keep' });
const [kept, setKept] = useState<ImageRef[]>(card?.images ?? []);
const [removed, setRemoved] = useState<string[]>([]);
const [added, setAdded] = useState<NewImage[]>([]);
const [scanning, setScanning] = useState(false);
const [busy, setBusy] = useState<string>();
const [error, setError] = useState<string>();
const logoInput = useRef<HTMLInputElement>(null);
const logoCamera = useRef<HTMLInputElement>(null);
const photosInput = useRef<HTMLInputElement>(null);
const photosCamera = useRef<HTMLInputElement>(null);
const scanInput = useRef<HTMLInputElement>(null);
const set = <K extends keyof CardFields>(key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value }));
const withBusy = async (label: string, fn: () => Promise<void>) => {
setBusy(label);
setError(undefined);
try {
await fn();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(undefined);
}
};
const pickLogo = (file: File) =>
withBusy('Processing image…', async () => setLogo({ kind: 'new', blob: await compressImage(file) }));
const pickPhotos = (files: File[]) =>
withBusy('Processing images…', async () => {
const blobs = await Promise.all(files.map(compressImage));
setAdded((a) => [...a, ...blobs.map((blob) => ({ tempId: `new-${++tempCounter}`, blob }))]);
});
const scanFromImage = (file: File) =>
withBusy('Looking for a code…', async () => {
const found = await scanImageFile(file, 'card');
if (!found) throw new Error('No barcode or QR code found on this image');
setFields((f) => ({ ...f, code: found.value, codeType: found.codeType ?? f.codeType }));
});
const save = async () => {
const parsed = cardFieldsSchema.safeParse(fields);
if (!parsed.success) {
setError(parsed.error.issues.map((i) => i.message).join('. '));
return;
}
setBusy('Saving…');
setError(undefined);
// Steps run in order; on failure the completed ones are dropped from the pending state so Save can be retried.
let current: Card | undefined;
let logoDone = false;
const pendingRemoved = [...removed];
let addedDone = false;
try {
const form = new FormData();
for (const [k, v] of Object.entries(parsed.data)) form.append(k, v);
if (logo.kind === 'new') form.append('logo', logo.blob, fileNameFor(logo.blob, 'logo'));
if (logo.kind === 'remove' && hadLogo) form.append('removeLogo', 'true');
current = savedId ? await api.updateCard(savedId, form) : await api.createCard(form);
if (logo.kind === 'new' && current.logo) await images.put(current.id, current.logo.id, logo.blob);
logoDone = true;
while (pendingRemoved.length) {
current = await api.removeImage(current.id, pendingRemoved[0]!);
pendingRemoved.shift();
}
if (added.length) {
const before = new Set(current.images.map((i) => i.id));
const upload = new FormData();
added.forEach((a, i) => upload.append('file', a.blob, fileNameFor(a.blob, `photo-${i + 1}`)));
current = await api.addImages(current.id, upload);
// The server appends uploads in order: store our files under the new ids (no re-download).
const newRefs = current.images.filter((i) => !before.has(i.id));
await Promise.all(newRefs.map((ref, i) => (added[i] ? images.put(current!.id, ref.id, added[i]!.blob) : undefined)));
}
addedDone = true;
await saveCardLocally(current);
onSaved(current);
} catch (e) {
const status = e instanceof ApiError ? e.status : undefined;
if (status === 404 && (current?.id ?? savedId)) {
onMissing((current?.id ?? savedId)!);
return;
}
if (current) {
await saveCardLocally(current);
setSavedId(current.id);
setHadLogo(current.logo);
setKept(current.images);
}
if (logoDone) setLogo({ kind: 'keep' });
setRemoved(pendingRemoved);
if (addedDone) setAdded([]);
setError(`${(e as Error).message}${current ? '. The card was saved partially — tap Save to retry.' : ''}`);
setBusy(undefined);
}
};
if (scanning) {
return (
<ScannerView
formats="card"
hint="Point the camera at the barcode or QR code of the card"
onClose={() => setScanning(false)}
onResult={(found) => {
setScanning(false);
setFields((f) => ({ ...f, code: found.value, codeType: found.codeType ?? f.codeType }));
}}
/>
);
}
const visibleImages = kept.filter((i) => !removed.includes(i.id));
return (
<Screen
className="edit-screen"
title={card ? 'Edit card' : 'New card'}
left={<IconButton icon="close" label="Cancel" onClick={onCancel} disabled={busy === 'Saving…'} />}
right={
<button className="text-button strong" onClick={save} disabled={!!busy}>
Save
</button>
}
>
{error && <Banner onClose={() => setError(undefined)}>{error}</Banner>}
{busy && <Banner kind="info">{busy}</Banner>}
<label className="field">
<span>Name</span>
<input value={fields.name} onChange={(e) => set('name', e.target.value)} maxLength={200} placeholder="Store name" />
</label>
<div className="field">
<span>Color</span>
<div className="swatches">
{CARD_PALETTE.map((c) => (
<button
key={c}
className={`swatch${fields.color.toUpperCase() === c ? ' selected' : ''}`}
style={{ background: c }}
onClick={() => set('color', c)}
aria-label={`Color ${c}`}
/>
))}
<label className="swatch custom" style={{ background: fields.color }} aria-label="Custom color">
<input type="color" value={fields.color} onChange={(e) => set('color', e.target.value.toUpperCase())} />
</label>
</div>
</div>
<div className="field">
<span>Logo</span>
<div className="logo-row">
<LogoPreview card={card} name={fields.name} color={fields.color} logo={logo} hadLogo={hadLogo} savedId={savedId} />
<button className="button secondary" onClick={() => logoCamera.current?.click()} disabled={!!busy} aria-label="Take logo photo">
<Icon name="camera" size={18} /> Camera
</button>
<button className="button secondary" onClick={() => logoInput.current?.click()} disabled={!!busy} aria-label="Choose logo from gallery">
<Icon name="image" size={18} /> Gallery
</button>
{(logo.kind === 'new' || (logo.kind === 'keep' && hadLogo)) && (
<button
className="button secondary icon-only"
onClick={() => setLogo(hadLogo ? { kind: 'remove' } : { kind: 'keep' })}
disabled={!!busy}
aria-label="Remove logo"
title="Remove logo"
>
<Icon name="trash" size={18} />
</button>
)}
</div>
</div>
<div className="field">
<span>Code</span>
<div className="scan-row">
<button className="button secondary" onClick={() => setScanning(true)} disabled={!!busy}>
<Icon name="camera" size={18} /> Scan
</button>
<button className="button secondary" onClick={() => scanInput.current?.click()} disabled={!!busy}>
<Icon name="image" size={18} /> From image
</button>
</div>
<select value={fields.codeType} onChange={(e) => set('codeType', e.target.value as CodeType)} aria-label="Code type">
{CODE_TYPES.map((t) => (
<option key={t} value={t}>
{CODE_TYPE_LABELS[t]}
</option>
))}
</select>
<input
className="mono"
value={fields.code}
inputMode={NUMERIC_TYPES.has(fields.codeType) ? 'numeric' : 'text'}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
placeholder="Card number"
onChange={(e) => set('code', e.target.value)}
aria-label="Code"
/>
</div>
{fields.code && <CodeImage code={fields.code} codeType={fields.codeType} />}
<label className="field">
<span>Comment</span>
<textarea
rows={3}
maxLength={2000}
placeholder="Optional"
value={fields.comment ?? ''}
onChange={(e) => set('comment', e.target.value)}
/>
</label>
<div className="field">
<span>Photos</span>
<div className="photo-grid">
{visibleImages.map((img) => (
<StoredPhoto key={img.id} cardId={savedId!} imageId={img.id} onRemove={() => setRemoved((r) => [...r, img.id])} />
))}
{added.map((a) => (
<NewPhoto key={a.tempId} blob={a.blob} onRemove={() => setAdded((list) => list.filter((x) => x !== a))} />
))}
<button className="photo-add" onClick={() => photosCamera.current?.click()} disabled={!!busy} aria-label="Take a photo">
<Icon name="camera" size={26} />
<span>Camera</span>
</button>
<button className="photo-add" onClick={() => photosInput.current?.click()} disabled={!!busy} aria-label="Add photos from gallery">
<Icon name="image" size={26} />
<span>Gallery</span>
</button>
</div>
</div>
{/* Without `capture` phones offer the gallery/files; with it they open the camera directly. */}
<input ref={logoInput} type="file" accept="image/*" hidden onChange={(e) => onFiles(e, (f) => f[0] && pickLogo(f[0]))} />
<input ref={logoCamera} type="file" accept="image/*" capture="environment" hidden onChange={(e) => onFiles(e, (f) => f[0] && pickLogo(f[0]))} />
<input ref={photosInput} type="file" accept="image/*" multiple hidden onChange={(e) => onFiles(e, pickPhotos)} />
<input ref={photosCamera} type="file" accept="image/*" capture="environment" hidden onChange={(e) => onFiles(e, pickPhotos)} />
<input ref={scanInput} type="file" accept="image/*" hidden onChange={(e) => onFiles(e, (f) => f[0] && scanFromImage(f[0]))} />
</Screen>
);
}
function onFiles(e: React.ChangeEvent<HTMLInputElement>, handle: (files: File[]) => unknown) {
const files = [...(e.target.files ?? [])];
e.target.value = '';
if (files.length) handle(files);
}
function LogoPreview({
card,
name,
color,
logo,
hadLogo,
savedId,
}: {
card?: Card;
name: string;
color: string;
logo: LogoChange;
hadLogo: ImageRef | null;
savedId?: string;
}) {
const newUrl = useBlobUrl(logo.kind === 'new' ? logo.blob : undefined);
if (newUrl) {
return (
<span className="badge" style={{ width: 56, height: 56, background: color }}>
<img src={newUrl} alt="" />
</span>
);
}
const shown = logo.kind === 'keep' ? hadLogo : null;
return <CardBadge card={{ id: savedId ?? card?.id ?? 'new', name: name || '?', color, logo: shown }} size={56} />;
}
function StoredPhoto({ cardId, imageId, onRemove }: { cardId: string; imageId: string; onRemove: () => void }) {
const url = useStoredImage(cardId, imageId);
return <Photo url={url} onRemove={onRemove} />;
}
function NewPhoto({ blob, onRemove }: { blob: Blob; onRemove: () => void }) {
return <Photo url={useBlobUrl(blob)} onRemove={onRemove} />;
}
function Photo({ url, onRemove }: { url?: string; onRemove: () => void }) {
return (
<div className="photo">
{url ? <img src={url} alt="" /> : <span className="muted small">Not downloaded</span>}
<button className="photo-remove" onClick={onRemove} aria-label="Remove photo">
<Icon name="close" size={16} />
</button>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from 'react';
import { parseLoginDeepLink } from '@cardy/shared';
import { createApi, normalizeServerUrl } from '../api.js';
import type { LoginPrefill } from '../navigation.js';
import { defaultServerUrl } from '../platform.js';
import { ScannerView } from '../ScannerView.js';
import type { Session } from '../storage.js';
import { Banner, Icon, IconButton, Screen } from '../ui.js';
export type { LoginPrefill };
export function LoginScreen({
prefill,
current,
onSignedIn,
onCancel,
}: {
prefill?: LoginPrefill;
/** The stored session when changing the key. */
current?: Session;
onSignedIn: (session: Session) => void;
onCancel?: () => void;
}) {
const [server, setServer] = useState(prefill?.server ?? current?.server ?? defaultServerUrl());
const [key, setKey] = useState(prefill?.key ?? '');
const [scanning, setScanning] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const submit = async (s = server, k = key) => {
const normalized = normalizeServerUrl(s);
const trimmedKey = k.trim();
if (!normalized || !trimmedKey) {
setError('Enter the server address and the access key');
return;
}
setBusy(true);
setError(undefined);
try {
const me = await createApi({ server: normalized, key: trimmedKey }).auth();
onSignedIn({ server: normalized, key: trimmedKey, email: me.email, groupName: me.group.name });
} catch (e) {
setError((e as Error).message);
setBusy(false);
}
};
// A login link or a scanned QR code signs in right away.
const autoSubmitted = useRef(false);
useEffect(() => {
if (prefill && !autoSubmitted.current) {
autoSubmitted.current = true;
void submit(prefill.server, prefill.key);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (scanning) {
return (
<ScannerView
formats="qr"
hint="Point the camera at the key QR code from the Cardy admin page"
onClose={() => setScanning(false)}
onResult={({ value }) => {
setScanning(false);
const link = parseLoginDeepLink(value);
if (link) {
setServer(link.server);
setKey(link.key);
void submit(link.server, link.key);
} else {
// A QR code with just the key.
setKey(value.trim());
void submit(server, value);
}
}}
/>
);
}
return (
<Screen
title={current ? 'Change key' : undefined}
left={onCancel && <IconButton icon="back" label="Back" onClick={onCancel} />}
className="login"
>
{!current && (
<div className="login-hero">
<img src={`${import.meta.env.BASE_URL}icon.svg`} alt="" width={72} height={72} />
<h1>Cardy</h1>
<p className="muted">Sign in with the access key from your administrator.</p>
</div>
)}
{current && <p className="muted">Signing in with another key replaces the current one and removes all cards stored on this device.</p>}
<button className="button big" onClick={() => setScanning(true)} disabled={busy}>
<Icon name="camera" /> Scan key QR code
</button>
<div className="divider">or enter manually</div>
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<label className="field">
<span>API address</span>
<input
type="url"
inputMode="url"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
placeholder="https://example.com/api"
value={server}
onChange={(e) => setServer(e.target.value)}
/>
</label>
<label className="field">
<span>Access key</span>
<input
className="mono"
autoCapitalize="off"
autoCorrect="off"
autoComplete="off"
spellCheck={false}
placeholder="32 characters"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</label>
{error && <Banner>{error}</Banner>}
<button className="button big" type="submit" disabled={busy}>
{busy ? 'Checking…' : 'Sign in'}
</button>
</form>
</Screen>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'react';
import { checkPin, createPinRecord } from '../pin.js';
import { PinPad } from '../PinPad.js';
import type { PinRecord } from '../storage.js';
export function PinSetScreen({ onCreated }: { onCreated: (record: PinRecord) => void }) {
const [first, setFirst] = useState<string>();
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
return (
<PinPad
title={first ? 'Repeat the PIN' : 'Create a PIN'}
subtitle="It is asked every time you open the app."
error={error}
attempt={attempt}
onComplete={async (pin) => {
if (!first) {
setFirst(pin);
setError(undefined);
} else if (pin === first) {
onCreated(await createPinRecord(pin));
return;
} else {
setFirst(undefined);
setError("PINs don't match. Try again.");
}
setAttempt((a) => a + 1);
}}
/>
);
}
export function PinEnterScreen({ pin, onUnlocked, onForgot }: { pin: PinRecord; onUnlocked: () => void; onForgot: () => void }) {
const [error, setError] = useState<string>();
const [attempt, setAttempt] = useState(0);
const [busy, setBusy] = useState(false);
return (
<PinPad
title="Enter PIN"
error={error}
attempt={attempt}
busy={busy}
onComplete={async (entered) => {
setBusy(true);
const ok = await checkPin(entered, pin);
setBusy(false);
if (ok) return onUnlocked();
setError('Wrong PIN');
setAttempt((a) => a + 1);
}}
footer={
<button className="text-button" onClick={onForgot}>
Forgot PIN? Sign in again
</button>
}
/>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Preferences } from '@capacitor/preferences';
import { clear, createStore, del, delMany, get, keys, set } from 'idb-keyval';
import type { Card } from '@cardy/shared';
export interface Session {
/** Base URL of the client API, e.g. https://dev.aggtaa.com/api */
server: string;
key: string;
/** From the server at sign-in, for display. */
email?: string;
groupName?: string;
}
export interface PinRecord {
salt: string;
hash: string;
}
/** Card id -> ISO time of last opening. */
export type UsageMap = Record<string, string>;
/** Card id -> imagesVersion whose files are fully stored locally. */
export type VersionMap = Record<string, number>;
const K = {
session: 'session',
pin: 'pin',
cards: 'cards',
usage: 'usage',
versions: 'imageVersions',
} as const;
async function getJson<T>(key: string, fallback: T): Promise<T> {
const { value } = await Preferences.get({ key });
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
async function setJson(key: string, value: unknown): Promise<void> {
if (value === null || value === undefined) await Preferences.remove({ key });
else await Preferences.set({ key, value: JSON.stringify(value) });
}
export const storage = {
getSession: () => getJson<Session | null>(K.session, null),
setSession: (s: Session | null) => setJson(K.session, s),
getPin: () => getJson<PinRecord | null>(K.pin, null),
setPin: (p: PinRecord | null) => setJson(K.pin, p),
getCards: () => getJson<Card[]>(K.cards, []),
setCards: (cards: Card[]) => setJson(K.cards, cards),
getUsage: () => getJson<UsageMap>(K.usage, {}),
setUsage: (u: UsageMap) => setJson(K.usage, u),
getVersions: () => getJson<VersionMap>(K.versions, {}),
setVersions: (v: VersionMap) => setJson(K.versions, v),
/** Forgets everything: used when signing in with a new key. */
async clearAll(): Promise<void> {
await Preferences.clear();
await images.clear();
},
};
// ---- images (IndexedDB, works both in browsers and in the Capacitor WebView) ----
const imageStore = createStore('cardy', 'images');
const imageKey = (cardId: string, imageId: string) => `${cardId}/${imageId}`;
export const images = {
get: (cardId: string, imageId: string) => get<Blob>(imageKey(cardId, imageId), imageStore),
put: (cardId: string, imageId: string, blob: Blob) => set(imageKey(cardId, imageId), blob, imageStore),
delete: (cardId: string, imageId: string) => del(imageKey(cardId, imageId), imageStore),
/** All stored keys as "cardId/imageId". */
async storedKeys(): Promise<Set<string>> {
return new Set((await keys(imageStore)).map(String));
},
/** Deletes the card's images whose ids are not in `keep` (all of them when `keep` is empty). */
async prune(cardId: string, keep: Iterable<string> = []): Promise<void> {
const keepKeys = new Set([...keep].map((id) => imageKey(cardId, id)));
const prefix = `${cardId}/`;
const stale = (await keys(imageStore)).map(String).filter((k) => k.startsWith(prefix) && !keepKeys.has(k));
if (stale.length) await delMany(stale, imageStore);
},
clear: () => clear(imageStore),
key: imageKey,
};
/** Ids of all files (logo + images) of a card. */
export function cardImageIds(card: Card): string[] {
return [...(card.logo ? [card.logo.id] : []), ...card.images.map((i) => i.id)];
}
+829
View File
@@ -0,0 +1,829 @@
/* Dark theme only. Codes are always drawn black on white so they stay scannable. */
:root {
color-scheme: dark;
--bg: #0f1115;
--surface: #171a21;
--surface-2: #1f232c;
--border: #2c313c;
--text: #e6e8ec;
--muted: #8b93a3;
--accent: #5b9dff;
--accent-strong: #3d86f5;
--accent-text: #0b1220;
--danger: #ff6b6b;
--danger-bg: #3a1b1f;
--info-bg: #17263d;
--radius: 14px;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font: 16px/1.45 var(--font);
}
body {
overscroll-behavior-y: none;
}
h1,
h2 {
margin: 0;
}
p {
margin: 8px 0;
}
.muted {
color: var(--muted);
}
.small {
font-size: 0.8rem;
}
.mono {
font-family: var(--mono);
}
.splash {
min-height: 100vh;
background: var(--bg);
}
/* ---- controls ---- */
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.45;
cursor: default;
}
input,
select {
width: 100%;
min-height: 48px;
padding: 10px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
outline: none;
}
textarea {
width: 100%;
min-height: 88px;
padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
outline: none;
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
}
.card-comment {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0 0 18px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 10px 16px;
border: 1px solid transparent;
border-radius: 12px;
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
.button:active:not(:disabled) {
background: var(--accent-strong);
}
.button.big {
width: 100%;
min-height: 52px;
}
.button.secondary {
background: var(--surface-2);
border-color: var(--border);
color: var(--text);
}
.button.danger {
background: var(--danger);
color: #1b0b0b;
}
.text-button {
background: none;
border: none;
padding: 10px 12px;
color: var(--accent);
font-weight: 500;
}
.text-button.strong {
font-weight: 700;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text);
}
.icon-button:active:not(:disabled) {
background: var(--surface-2);
}
.icon-button.spinning svg {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 18px;
}
.field > span {
color: var(--muted);
font-size: 0.85rem;
}
/* ---- screen layout ---- */
.screen {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.appbar {
position: sticky;
top: 0;
z-index: 10;
display: grid;
grid-template-columns: minmax(44px, auto) 1fr minmax(44px, auto);
align-items: center;
gap: 4px;
padding: calc(6px + var(--safe-top)) 6px 6px;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.appbar-title {
font-size: 1.15rem;
font-weight: 600;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.appbar-side {
display: flex;
align-items: center;
}
.appbar-side.right {
justify-content: flex-end;
}
.screen-body {
flex: 1;
padding: 16px 16px calc(96px + var(--safe-bottom));
}
.banner {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 14px;
border-radius: 12px;
font-size: 0.92rem;
}
.banner span {
flex: 1;
}
.banner.error {
background: var(--danger-bg);
color: var(--danger);
}
.banner.info {
background: var(--info-bg);
color: var(--accent);
}
.banner-close {
background: none;
border: none;
color: inherit;
padding: 0;
display: inline-flex;
}
.empty {
text-align: center;
padding: 48px 12px;
}
/* ---- login ---- */
.login .screen-body {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 480px;
width: 100%;
margin: 0 auto;
}
.login-hero {
text-align: center;
padding: calc(24px + var(--safe-top)) 0 8px;
}
.login-hero h1 {
font-size: 1.8rem;
margin-top: 8px;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
color: var(--muted);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-top: 1px solid var(--border);
}
/* ---- PIN ---- */
.pin {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
padding: calc(24px + var(--safe-top)) 24px calc(24px + var(--safe-bottom));
}
.pin-head {
text-align: center;
}
.pin-head h1 {
font-size: 1.4rem;
}
.pin-dots {
display: flex;
gap: 18px;
}
.pin-dots span {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid var(--muted);
}
.pin-dots span.filled {
background: var(--accent);
border-color: var(--accent);
}
.pin-dots.shake {
animation: shake 0.35s;
}
@keyframes shake {
20%,
60% {
transform: translateX(-10px);
}
40%,
80% {
transform: translateX(10px);
}
}
.pin-error {
min-height: 1.4em;
margin: 0;
color: var(--danger);
}
.keypad {
display: grid;
grid-template-columns: repeat(3, 76px);
gap: 16px 28px;
}
.keypad button {
width: 76px;
height: 76px;
border-radius: 50%;
border: none;
background: var(--surface-2);
font-size: 1.7rem;
font-weight: 500;
}
.keypad button:active {
background: var(--border);
}
.keypad .keypad-back {
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pin-footer {
min-height: 44px;
}
/* ---- cards list ---- */
.filter {
position: relative;
display: flex;
align-items: center;
margin-bottom: 14px;
color: var(--muted);
}
.filter > svg {
position: absolute;
left: 14px;
pointer-events: none;
}
.filter input {
padding-left: 44px;
padding-right: 44px;
}
.filter-clear {
position: absolute;
right: 4px;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--muted);
display: inline-flex;
align-items: center;
justify-content: center;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 132px;
padding: 14px 10px;
border: none;
border-radius: var(--radius);
box-shadow: 0 2px 10px rgb(0 0 0 / 0.35);
}
.tile:active {
transform: scale(0.98);
}
.tile-name {
font-weight: 600;
text-align: center;
line-height: 1.2;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.tile .badge {
box-shadow: 0 0 0 2px rgb(255 255 255 / 0.18);
}
.badge {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 14px;
font-weight: 700;
}
.badge img {
width: 100%;
height: 100%;
object-fit: contain;
background: #fff;
}
.fab {
position: fixed;
right: 20px;
bottom: calc(20px + var(--safe-bottom));
width: 60px;
height: 60px;
border: none;
border-radius: 20px;
background: var(--accent);
color: var(--accent-text);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px rgb(0 0 0 / 0.5);
}
/* ---- card screen ---- */
.card-hero {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
border-radius: var(--radius);
margin-bottom: 16px;
}
.card-hero-name {
font-size: 1.25rem;
font-weight: 700;
}
.code-box {
background: #fff;
border-radius: var(--radius);
padding: 16px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10px;
}
.code-box svg {
width: 100%;
height: auto;
max-height: 200px;
}
.code-box.large svg {
max-height: 260px;
}
.code-box.two-d svg {
width: min(78vw, 320px);
max-height: none;
}
.code-box.loading {
min-height: 160px;
}
.code-error {
padding: 18px;
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--muted);
text-align: center;
margin-bottom: 10px;
}
.code-caption {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.9rem;
margin-bottom: 18px;
word-break: break-all;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
}
.thumb {
aspect-ratio: 1;
padding: 0;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-2);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.viewer {
position: fixed;
inset: 0;
z-index: 50;
background: #000;
display: flex;
align-items: center;
justify-content: center;
}
.viewer img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.viewer-close {
position: absolute;
top: calc(8px + var(--safe-top));
right: 8px;
background: rgb(0 0 0 / 0.5);
}
/* ---- edit screen ---- */
.swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
gap: 10px;
}
.swatch {
aspect-ratio: 1;
border-radius: 50%;
border: 2px solid transparent;
padding: 0;
position: relative;
}
.swatch.selected {
border-color: var(--text);
box-shadow: inset 0 0 0 3px var(--bg);
}
.swatch.custom {
display: block;
background-image: conic-gradient(red, yellow, lime, cyan, blue, magenta, red) !important;
cursor: pointer;
}
.swatch.custom input {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
cursor: pointer;
}
.logo-row,
.scan-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.scan-row .button {
flex: 1;
}
.logo-row .button {
padding: 10px 12px;
}
.button.icon-only {
width: 44px;
padding: 0;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 10px;
}
.photo,
.photo-add {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
background: var(--surface-2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-add {
flex-direction: column;
gap: 4px;
border-style: dashed;
color: var(--muted);
font-size: 0.8rem;
}
.photo-remove {
position: absolute;
top: 4px;
right: 4px;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgb(0 0 0 / 0.65);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ---- scanner ---- */
.scanner {
position: fixed;
inset: 0;
z-index: 40;
background: #000;
}
.scanner video {
width: 100%;
height: 100%;
object-fit: cover;
}
.scanner-frame {
position: absolute;
left: 50%;
top: 45%;
transform: translate(-50%, -50%);
border: 3px solid rgb(255 255 255 / 0.85);
border-radius: 18px;
box-shadow: 0 0 0 100vmax rgb(0 0 0 / 0.45);
}
.scanner-frame.qr {
width: min(70vw, 300px);
aspect-ratio: 1;
}
.scanner-frame.card {
width: min(86vw, 420px);
aspect-ratio: 1.6;
}
.scanner-top {
position: absolute;
top: calc(8px + var(--safe-top));
right: 8px;
}
.scanner-top .icon-button {
background: rgb(0 0 0 / 0.5);
color: #fff;
}
.scanner-hint {
position: absolute;
left: 16px;
right: 16px;
bottom: calc(40px + var(--safe-bottom));
text-align: center;
color: #fff;
text-shadow: 0 1px 4px #000;
}
/* ---- dialog ---- */
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 60;
background: rgb(0 0 0 / 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.dialog {
width: min(400px, 100%);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
padding: 20px;
}
.dialog h2 {
font-size: 1.15rem;
margin-bottom: 6px;
}
.dialog p {
color: var(--muted);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
+123
View File
@@ -0,0 +1,123 @@
import type { Card } from '@cardy/shared';
import type { Api } from './api.js';
import { cardImageIds, images, storage, type UsageMap, type VersionMap } from './storage.js';
export const MAX_PARALLEL_DOWNLOADS = 5;
/** Runs `worker` over `items` with at most `limit` running at once. */
export async function runPool<T>(items: readonly T[], limit: number, worker: (item: T) => Promise<void>): Promise<PromiseSettledResult<void>[]> {
const results: PromiseSettledResult<void>[] = new Array(items.length);
let next = 0;
const lane = async () => {
while (next < items.length) {
const index = next++;
try {
await worker(items[index]!);
results[index] = { status: 'fulfilled', value: undefined };
} catch (reason) {
results[index] = { status: 'rejected', reason };
}
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
return results;
}
/** Cards whose images changed (other imagesVersion) or are not all stored locally. */
export function cardsNeedingImages(cards: Card[], versions: VersionMap, stored: Set<string>): Card[] {
return cards.filter(
(c) => versions[c.id] !== c.imagesVersion || cardImageIds(c).some((id) => !stored.has(images.key(c.id, id))),
);
}
/**
* Downloads the list of cards and replaces the local list (only when the download succeeded).
* Cards gone from the server are removed locally with their images and usage.
*/
export async function refreshCardList(api: Api): Promise<Card[]> {
const fresh = await api.cards();
const [old, usage, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]);
await storage.setCards(fresh);
const freshIds = new Set(fresh.map((c) => c.id));
const gone = old.filter((c) => !freshIds.has(c.id));
if (gone.length) {
for (const c of gone) {
delete usage[c.id];
delete versions[c.id];
await images.prune(c.id);
}
await Promise.all([storage.setUsage(usage), storage.setVersions(versions)]);
}
return fresh;
}
export interface ImageSyncResult {
cards: number;
failedFiles: number;
}
/**
* Downloads missing logos and images of changed cards, at most MAX_PARALLEL_DOWNLOADS requests at once.
* A card's version is recorded only after all its files are stored, so failures are retried next time.
*/
export async function syncImages(
api: Api,
cards: Card[],
onCardDone?: (card: Card) => void,
onProgress?: (done: number, total: number) => void,
): Promise<ImageSyncResult> {
const stored = await images.storedKeys();
const changed = cardsNeedingImages(cards, await storage.getVersions(), stored);
const tasks = changed.flatMap((card) =>
cardImageIds(card)
.filter((id) => !stored.has(images.key(card.id, id)))
.map((id) => ({ card, id })),
);
const remaining = new Map(changed.map((c) => [c.id, tasks.filter((t) => t.card === c).length]));
const failedCards = new Set<string>();
let done = 0;
onProgress?.(0, tasks.length);
// Version updates are read-modify-write: serialize them across parallel downloads.
let versionWrites = Promise.resolve();
const finishCard = (card: Card) => {
if (failedCards.has(card.id)) return versionWrites;
versionWrites = versionWrites.then(async () => {
await images.prune(card.id, cardImageIds(card));
const versions = await storage.getVersions();
versions[card.id] = card.imagesVersion;
await storage.setVersions(versions);
onCardDone?.(card);
});
return versionWrites;
};
// Cards with nothing to download (e.g. only deletions) are complete right away.
for (const card of changed) if (remaining.get(card.id) === 0) await finishCard(card);
const results = await runPool(tasks, MAX_PARALLEL_DOWNLOADS, async ({ card, id }) => {
try {
await images.put(card.id, id, await api.file(card, id));
} catch (e) {
failedCards.add(card.id);
throw e;
} finally {
onProgress?.(++done, tasks.length);
const left = (remaining.get(card.id) ?? 1) - 1;
remaining.set(card.id, left);
if (left === 0) await finishCard(card);
}
});
return { cards: changed.length, failedFiles: results.filter((r) => r.status === 'rejected').length };
}
/** Most recently used first, never used cards by name. */
export function sortCards(cards: Card[], usage: UsageMap): Card[] {
return [...cards].sort((a, b) => {
const ua = usage[a.id] ?? '';
const ub = usage[b.id] ?? '';
if (ua !== ub) return ua < ub ? 1 : -1;
return a.name.localeCompare(b.name);
});
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
TWO_DIMENSIONAL_CODE_TYPES,
bwipRenderOptions,
cardAbbreviation,
textColorOn,
type Card,
type CodeType,
} from '@cardy/shared';
import { images } from './storage.js';
// ---- icons ----
const paths = {
back: 'M15 18l-6-6 6-6',
search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM21 21l-4.35-4.35',
refresh: 'M20 11a8 8 0 0 0-14.9-3.5M4 4v4h4M4 13a8 8 0 0 0 14.9 3.5M20 20v-4h-4',
plus: 'M12 5v14M5 12h14',
edit: 'M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4',
trash: 'M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3',
camera: 'M4 8h3l2-3h6l2 3h3v11H4zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
image: 'M4 5h16v14H4zM4 16l5-5 4 4 3-3 4 4M15 9.5a1.5 1.5 0 1 0 0-.01',
close: 'M6 6l12 12M18 6L6 18',
key: 'M14 10a4 4 0 1 0-3.5 3.97L10 14v3H8v2H5v-3l5.03-5.03A4 4 0 0 0 14 10zM15.5 8.5h.01',
backspace: 'M21 5H8l-6 7 6 7h13zM11 9l6 6M17 9l-6 6',
} as const;
export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={paths[name]} />
</svg>
);
}
export function IconButton({ icon, label, onClick, disabled, spinning }: { icon: keyof typeof paths; label: string; onClick: () => void; disabled?: boolean; spinning?: boolean }) {
return (
<button className={`icon-button${spinning ? ' spinning' : ''}`} onClick={onClick} disabled={disabled} aria-label={label} title={label}>
<Icon name={icon} />
</button>
);
}
// ---- layout ----
export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) {
return (
<div className={`screen ${className ?? ''}`}>
{(title !== undefined || left || right) && (
<header className="appbar">
<div className="appbar-side">{left}</div>
<h1 className="appbar-title">{title}</h1>
<div className="appbar-side right">{right}</div>
</header>
)}
<div className="screen-body">{children}</div>
</div>
);
}
export function Banner({ kind = 'error', children, onClose }: { kind?: 'error' | 'info'; children: ReactNode; onClose?: () => void }) {
return (
<div className={`banner ${kind}`} role={kind === 'error' ? 'alert' : 'status'}>
<span>{children}</span>
{onClose && (
<button className="banner-close" onClick={onClose} aria-label="Dismiss">
<Icon name="close" size={18} />
</button>
)}
</div>
);
}
export function ConfirmDialog({ title, message, confirmLabel, danger, onConfirm, onCancel }: { title: string; message: ReactNode; confirmLabel: string; danger?: boolean; onConfirm: () => void; onCancel: () => void }) {
return (
<div className="dialog-backdrop" onClick={(e) => e.target === e.currentTarget && onCancel()}>
<div className="dialog" role="alertdialog" aria-modal="true" aria-label={title}>
<h2>{title}</h2>
<p>{message}</p>
<div className="dialog-actions">
<button className="text-button" onClick={onCancel}>
Cancel
</button>
<button className={danger ? 'button danger' : 'button'} onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
// ---- images ----
/** Object URL for a blob; revoked on change/unmount. */
export function useBlobUrl(blob: Blob | undefined): string | undefined {
const [url, setUrl] = useState<string>();
useEffect(() => {
if (!blob) {
setUrl(undefined);
return;
}
const created = URL.createObjectURL(blob);
setUrl(created);
return () => URL.revokeObjectURL(created);
}, [blob]);
return url;
}
/** Object URL of a locally stored card image; `version` forces a reload after downloads. */
export function useStoredImage(cardId: string | undefined, imageId: string | undefined, version = 0): string | undefined {
const [blob, setBlob] = useState<Blob>();
useEffect(() => {
let alive = true;
setBlob(undefined);
if (cardId && imageId) void images.get(cardId, imageId).then((b) => alive && setBlob(b));
return () => void (alive = false);
}, [cardId, imageId, version]);
return useBlobUrl(blob);
}
export function CardBadge({ card, size, version }: { card: Pick<Card, 'id' | 'name' | 'color' | 'logo'>; size: number; version?: number }) {
const logoUrl = useStoredImage(card.id, card.logo?.id, version);
const style = { width: size, height: size, background: card.color, color: textColorOn(card.color) };
return (
<span className="badge" style={style}>
{logoUrl ? <img src={logoUrl} alt="" /> : <span style={{ fontSize: size * 0.36 }}>{cardAbbreviation(card.name)}</span>}
</span>
);
}
// ---- codes ----
type Bwip = typeof import('bwip-js/browser');
let bwipPromise: Promise<Bwip> | undefined;
const loadBwip = () => (bwipPromise ??= import('bwip-js/browser').then((m) => (m as { default?: Bwip }).default ?? m));
/** Renders a code black-on-white (so it stays scannable in the dark theme). */
export function CodeImage({ code, codeType, large }: { code: string; codeType: CodeType; large?: boolean }) {
const [bwip, setBwip] = useState<Bwip>();
useEffect(() => void loadBwip().then((b) => setBwip(() => b)), []);
const result = useMemo(() => {
if (!code) return { error: 'No code' };
if (!bwip) return { loading: true };
const twoD = TWO_DIMENSIONAL_CODE_TYPES.has(codeType);
try {
return { svg: bwip.toSVG(bwipRenderOptions(codeType, code, large ? 22 : 14)), twoD };
} catch (e) {
return { error: String((e as Error).message ?? e).replace(/^Error:\s*/, '') };
}
}, [bwip, code, codeType, large]);
if ('loading' in result) return <div className="code-box loading" />;
if ('error' in result) return <div className="code-error">{result.error}</div>;
return <div className={`code-box${result.twoD ? ' two-d' : ''}${large ? ' large' : ''}`} dangerouslySetInnerHTML={{ __html: result.svg }} />;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src", "vite.config.ts", "capacitor.config.ts"]
}
+90
View File
@@ -0,0 +1,90 @@
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
// Web build: public at https://dev.aggtaa.com/m/, the reverse proxy strips "/m" (X-Forwarded-Prefix).
// Capacitor build (--mode capacitor): served from the app bundle root.
//
// Env (mobile-client/.env or process env):
// PORT dev/preview server port (default 10003)
// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com)
// BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002)
const WEB_BASE = '/m/';
/** The proxy strips /m; put it back so Vite's base-aware dev and preview servers find the files. */
function restoreStrippedBase(): Plugin {
const rewrite = (req: { url?: string }, _res: unknown, next: () => void) => {
const url = req.url ?? '/';
if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url;
next();
};
return {
name: 'cardy-restore-stripped-base',
configureServer: (server) => void server.middlewares.use(rewrite),
configurePreviewServer: (server) => void server.middlewares.use(rewrite),
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const capacitor = mode === 'capacitor';
// Local development without the reverse proxy: /api/* -> server-be with /api stripped.
const proxy: Record<string, ProxyOptions> = {
'/api': {
target: env.BACKEND_URL || 'http://127.0.0.1:10002',
rewrite: (path) => path.replace(/^\/api/, '') || '/',
headers: { 'X-Forwarded-Prefix': '/api' },
},
};
const server = {
host: true,
port: Number(env.PORT) || 10003,
strictPort: true,
allowedHosts: (env.ALLOWED_HOSTS || 'dev.aggtaa.com').split(',').map((h) => h.trim()),
proxy,
};
return {
base: capacitor ? './' : WEB_BASE,
plugins: [
react(),
restoreStrippedBase(),
VitePWA({
disable: capacitor,
registerType: 'autoUpdate',
injectRegister: 'auto',
includeAssets: ['icon.svg'],
manifest: {
name: 'Cardy',
short_name: 'Cardy',
description: 'Discount cards',
theme_color: '#0f1115',
background_color: '#0f1115',
display: 'standalone',
orientation: 'portrait',
start_url: '.',
scope: '.',
icons: [
{ src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
{ src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' },
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,wasm,webmanifest}'],
// bwip-js and the zxing wasm module are large but needed offline.
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api(\/|$)/],
},
}),
],
build: {
outDir: capacitor ? 'dist-capacitor' : 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 1100,
},
server,
preview: server,
};
});
+2 -1
View File
@@ -7,6 +7,7 @@
"test": "pnpm -r test", "test": "pnpm -r test",
"typecheck": "pnpm -r typecheck", "typecheck": "pnpm -r typecheck",
"dev:server": "pnpm --filter @cardy/server-be dev", "dev:server": "pnpm --filter @cardy/server-be dev",
"dev:admin": "pnpm --filter @cardy/server-fe dev" "dev:admin": "pnpm --filter @cardy/server-fe dev",
"dev:mobile": "pnpm --filter @cardy/mobile-client dev"
} }
} }
+4112 -22
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,5 +2,6 @@ packages:
- shared - shared
- server-be - server-be
- server-fe - server-fe
- mobile-client
allowBuilds: allowBuilds:
esbuild: true esbuild: true
+2
View File
@@ -17,6 +17,7 @@ const cardSchema = z.object({
color: z.string(), color: z.string(),
code: z.coerce.string(), code: z.coerce.string(),
codeType: z.enum(CODE_TYPES), codeType: z.enum(CODE_TYPES),
comment: z.string().default(''),
logo: imageRefSchema.nullable().default(null), logo: imageRefSchema.nullable().default(null),
images: z.array(imageRefSchema).default([]), images: z.array(imageRefSchema).default([]),
imagesVersion: z.number().int().default(0), imagesVersion: z.number().int().default(0),
@@ -110,6 +111,7 @@ export class CardsService {
const card: Card = { const card: Card = {
id, id,
...fields, ...fields,
comment: fields.comment ?? '',
logo: logoRef, logo: logoRef,
images: [], images: [],
imagesVersion: logoRef ? 1 : 0, imagesVersion: logoRef ? 1 : 0,
+28
View File
@@ -124,6 +124,34 @@ describe('client API', () => {
expect((await theirs('DELETE', `/cards/${mine.id}`)).statusCode).toBe(404); expect((await theirs('DELETE', `/cards/${mine.id}`)).statusCode).toBe(404);
}); });
it('stores a multiline comment and keeps it on partial updates', async () => {
const { app, key } = await setup();
const api = client(app, key);
const comment = 'Line one\nLine two\n\n indented';
const created = (await api('POST', '/cards', [...cardParts(), { name: 'comment', value: ' Line one\r\nLine two\r\n\r\n indented\n' }])).json() as Card;
expect(created.comment).toBe(comment);
const renamed = (await api('PUT', `/cards/${created.id}`, [{ name: 'name', value: 'Renamed' }])).json() as Card;
expect(renamed.comment).toBe(comment);
const cleared = (await api('PUT', `/cards/${created.id}`, [{ name: 'comment', value: '' }])).json() as Card;
expect(cleared.comment).toBe('');
expect((await api('PUT', `/cards/${created.id}`, [{ name: 'comment', value: 'x'.repeat(2001) }])).statusCode).toBe(400);
const withoutComment = (await api('POST', '/cards', cardParts())).json() as Card;
expect(withoutComment.comment).toBe('');
const patched = await app.inject({
method: 'PATCH',
url: `/admin/cards/${created.id}`,
headers: adminHeaders,
payload: { comment: 'From admin\nsecond line' },
});
expect((patched.json() as Card).comment).toBe('From admin\nsecond line');
});
it('returns JSON 404 for unknown API routes', async () => { it('returns JSON 404 for unknown API routes', async () => {
const { app, key } = await setup(); const { app, key } = await setup();
const res = await client(app, key)('GET', '/nope'); const res = await client(app, key)('GET', '/nope');
+2 -12
View File
@@ -1,8 +1,7 @@
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { import {
BWIP_BCID, bwipRenderOptions,
TWO_DIMENSIONAL_CODE_TYPES,
cardAbbreviation, cardAbbreviation,
textColorOn, textColorOn,
type AccessKeyView, type AccessKeyView,
@@ -142,16 +141,7 @@ export function CodePreview({ code, codeType }: { code: string; codeType: CodeTy
if (!code) return { error: 'Enter a code to see the preview' }; if (!code) return { error: 'Enter a code to see the preview' };
if (!bwipjs) return { error: 'Loading…' }; if (!bwipjs) return { error: 'Loading…' };
try { try {
const svg = bwipjs.toSVG({ return { svg: bwipjs.toSVG(bwipRenderOptions(codeType, code)) };
bcid: BWIP_BCID[codeType],
text: code,
scale: 3,
...(TWO_DIMENSIONAL_CODE_TYPES.has(codeType) ? {} : { height: 14, includetext: true, textxalign: 'center' as const }),
paddingwidth: 4,
paddingheight: 4,
backgroundcolor: 'FFFFFF',
});
return { svg };
} catch (e) { } catch (e) {
return { error: String((e as Error).message ?? e).replace(/^Error:\s*/, '') }; return { error: String((e as Error).message ?? e).replace(/^Error:\s*/, '') };
} }
+20 -3
View File
@@ -27,15 +27,21 @@ function Editor({ initial }: { initial?: Card }) {
const [card, setCard] = useState<Card | undefined>(initial); const [card, setCard] = useState<Card | undefined>(initial);
const [fields, setFields] = useState<CardFields>( const [fields, setFields] = useState<CardFields>(
initial initial
? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType } ? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType, comment: initial.comment ?? '' }
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13' }, : { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
); );
const action = useAction(); const action = useAction();
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
useEffect(() => setSaved(false), [fields]); useEffect(() => setSaved(false), [fields]);
const dirty = !card || card.name !== fields.name || card.color !== fields.color || card.code !== fields.code || card.codeType !== fields.codeType; const dirty =
!card ||
card.name !== fields.name ||
card.color !== fields.color ||
card.code !== fields.code ||
card.codeType !== fields.codeType ||
(card.comment ?? '') !== (fields.comment ?? '');
const set = <K extends keyof CardFields>(key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value })); const set = <K extends keyof CardFields>(key: K, value: CardFields[K]) => setFields((f) => ({ ...f, [key]: value }));
const save = async (e: React.FormEvent) => { const save = async (e: React.FormEvent) => {
@@ -116,6 +122,17 @@ function Editor({ initial }: { initial?: Card }) {
<textarea id="code" className="mono" rows={2} value={fields.code} onChange={(e) => set('code', e.target.value)} required /> <textarea id="code" className="mono" rows={2} value={fields.code} onChange={(e) => set('code', e.target.value)} required />
</div> </div>
<CodePreview code={fields.code} codeType={fields.codeType} /> <CodePreview code={fields.code} codeType={fields.codeType} />
<div className="field">
<label htmlFor="comment">Comment</label>
<textarea
id="comment"
rows={3}
maxLength={2000}
placeholder="Optional, e.g. whose card it is or how to use it"
value={fields.comment ?? ''}
onChange={(e) => set('comment', e.target.value)}
/>
</div>
<div className="row"> <div className="row">
<button type="submit" disabled={action.busy || (!dirty && !!card)}> <button type="submit" disabled={action.busy || (!dirty && !!card)}>
{card ? 'Save' : 'Create card'} {card ? 'Save' : 'Create card'}
+19
View File
@@ -47,3 +47,22 @@ export const BWIP_BCID: Record<CodeType, string> = {
}; };
export const TWO_DIMENSIONAL_CODE_TYPES: ReadonlySet<CodeType> = new Set(['QR', 'DATAMATRIX', 'AZTEC']); export const TWO_DIMENSIONAL_CODE_TYPES: ReadonlySet<CodeType> = new Set(['QR', 'DATAMATRIX', 'AZTEC']);
/** EAN/UPC have their own human-readable text layout (digits between the guard bars). */
const EAN_UPC_TYPES: ReadonlySet<CodeType> = new Set(['EAN13', 'EAN8', 'UPCA', 'UPCE']);
/** bwip-js options to draw a card code black on white; `barHeight` is in mm, for linear codes only. */
export function bwipRenderOptions(codeType: CodeType, text: string, barHeight = 14) {
const common = {
bcid: BWIP_BCID[codeType],
text,
scale: 3,
paddingwidth: 6,
paddingheight: 6,
backgroundcolor: 'FFFFFF',
};
if (TWO_DIMENSIONAL_CODE_TYPES.has(codeType)) return common;
// Centering the text of EAN/UPC would move the digits onto the guard bars.
if (EAN_UPC_TYPES.has(codeType)) return { ...common, height: barHeight, includetext: true };
return { ...common, height: barHeight, includetext: true, textxalign: 'center' as const };
}
+2
View File
@@ -20,6 +20,8 @@ export interface Card {
color: string; color: string;
code: string; code: string;
codeType: CodeType; codeType: CodeType;
/** Free text, may be multiline; '' when empty. */
comment: string;
logo: ImageRef | null; logo: ImageRef | null;
images: ImageRef[]; images: ImageRef[];
imagesVersion: number; imagesVersion: number;
+7
View File
@@ -9,6 +9,13 @@ export const cardFieldsSchema = z.object({
color: colorSchema, color: colorSchema,
code: z.string().min(1, 'Code is required').max(4000), code: z.string().min(1, 'Code is required').max(4000),
codeType: z.enum(CODE_TYPES), codeType: z.enum(CODE_TYPES),
// Free text, may be multiline. No default: partial updates must not clear it.
// Multipart forms send CRLF line breaks: store plain LF.
comment: z
.string()
.transform((s) => s.replace(/\r\n?/g, '\n').trim())
.pipe(z.string().max(2000, 'Comment is too long (max 2000 characters)'))
.optional(),
}); });
export type CardFields = z.infer<typeof cardFieldsSchema>; export type CardFields = z.infer<typeof cardFieldsSchema>;