pwa and mobile client
@@ -5,22 +5,22 @@
|
||||
"name": "server-be (local test)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@cardy/server-be", "exec", "tsx", "src/index.ts"],
|
||||
"port": 10002,
|
||||
"port": 10012,
|
||||
"env": {
|
||||
"PORT": "10012",
|
||||
"KEY_ENCRYPTION_SECRET": "cHrUrVMk3KgaAw066+CPj7iYXuBfBXbH5lPLFwoBSLA=",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server-fe (local test)",
|
||||
"name": "mobile-client (local test)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@cardy/server-fe", "exec", "vite"],
|
||||
"port": 10001,
|
||||
"runtimeArgs": ["--filter", "@cardy/mobile-client", "exec", "vite"],
|
||||
"port": 10013,
|
||||
"env": {
|
||||
"ADMIN_PROXY_SECRET": "local-proxy-secret",
|
||||
"DEV_REMOTE_EMAIL": "admin@local.test",
|
||||
"PORT": "10013",
|
||||
"BACKEND_URL": "http://127.0.0.1:10012",
|
||||
"ALLOWED_HOSTS": "localhost"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-capacitor/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
```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`,
|
||||
`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`.
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Cardy — техническое задание, версия 2
|
||||
|
||||
Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональное лого + опциональные картинки (фото/скриншоты).
|
||||
Приложение для хранения скидочных карт: код (штрихкод или 2D-код) + тип кода + название + цвет + опциональный комментарий (многострочный текст) + опциональное лого + опциональные картинки (фото/скриншоты).
|
||||
|
||||
Весь пользовательский интерфейс (серверный и клиентский) — на английском, только тёмная тема (коды на экране — чёрным по белому, чтобы сканировались).
|
||||
Вопросы деплоя (Dockerfile, docker-compose, конфиги прокси) в задание не входят.
|
||||
@@ -101,6 +101,7 @@ cards:
|
||||
color: "#E53935" # #RRGGBB
|
||||
code: "4600000000000"
|
||||
codeType: EAN13
|
||||
comment: "Card of Anna\nScan twice" # многострочный, до 2000 символов, '' если пусто
|
||||
logo: { id: 1d2e…, file: 1d2e….webp, mime: image/webp } # или null
|
||||
images: # порядок = порядок добавления
|
||||
- { id: 5f6a…, file: 5f6a….webp, mime: image/webp }
|
||||
@@ -149,7 +150,7 @@ cards:
|
||||
- **My keys** (все пользователи): список своих ключей; для выбранного ключа — QR-код с deep link, сам ключ текстом, кнопка копирования.
|
||||
- **Users** (admin, superadmin): CRUD пользователей текущей группы (email, роль; у суперадмина — ещё группа), по каждому пользователю — список ключей, генерация ключа (с опциональной меткой), удаление ключа, показ QR.
|
||||
- **Groups** (superadmin): CRUD групп (имя, уникально).
|
||||
- **Cards** (admin, superadmin): список всех карт текущей группы, включая удалённые (фильтр по названию, переключатель «показывать удалённые»). Создание и редактирование: название, цвет, код, тип кода, предпросмотр кода, лого (загрузка/удаление), картинки (загрузка/удаление). Удаление помечает карту удалённой; удалённую карту можно восстановить или удалить окончательно (вместе с картинками).
|
||||
- **Cards** (admin, superadmin): список всех карт текущей группы, включая удалённые (фильтр по названию, переключатель «показывать удалённые»). Создание и редактирование: название, цвет, код, тип кода, предпросмотр кода, комментарий, лого (загрузка/удаление), картинки (загрузка/удаление). Удаление помечает карту удалённой; удалённую карту можно восстановить или удалить окончательно (вместе с картинками).
|
||||
|
||||
### 3.7. API административного UI (публично `/api/admin`, на сервере `/admin`)
|
||||
|
||||
@@ -180,8 +181,8 @@ cards:
|
||||
|---|---|
|
||||
| `GET /api/auth` | проверка ключа; возвращает email и группу |
|
||||
| `GET /api/cards` | все неудалённые карты группы, без пагинации |
|
||||
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально файл `logo`); возвращает карту с ID |
|
||||
| `PUT /api/cards/:id` | изменить карту (multipart: те же поля, опционально файл `logo`, опционально `removeLogo=true`) |
|
||||
| `POST /api/cards` | создать карту (multipart: поля `name`, `color`, `code`, `codeType`, опционально `comment` и файл `logo`); возвращает карту с ID |
|
||||
| `PUT /api/cards/:id` | изменить карту (multipart: любые из тех же полей — не переданные не меняются, пустой `comment` очищает комментарий; опционально файл `logo`, опционально `removeLogo=true`) |
|
||||
| `DELETE /api/cards/:id` | пометить карту удалённой |
|
||||
| `GET /api/cards/:id/logo` | лого |
|
||||
| `GET /api/cards/:id/images/:imageId` | картинка |
|
||||
@@ -192,19 +193,20 @@ cards:
|
||||
|
||||
## 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 из системной камеры) открывает экран нового входа с заполненными полями.
|
||||
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний.
|
||||
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход.
|
||||
- **Экран нового входа**: поле адреса API (в PWA по умолчанию — `<текущий origin>/api`), сканирование QR-кода ключа камерой (из QR берутся и адрес, и ключ), ручной ввод ключа. Обязательна проверка ключа на сервере (`GET <адрес API>/auth`). Экран можно вызвать и при сохранённом ключе — старый ключ удаляется, хранится только последний; вместе с ним удаляются все локальные данные (карты, картинки, PIN), после входа задаётся новый PIN и карты скачиваются автоматически.
|
||||
- **Навигация** хранится в истории браузера: каждый переход между экранами — запись в истории (с хэшем вида `#/card/<id>`), поэтому кнопка «назад» в тулбаре, кнопка «назад» браузера и системная кнопка Android работают одинаково; «вперёд» тоже работает. После перезагрузки PWA (и ввода PIN) открывается тот же экран. Ключ из ссылки входа в историю и адресную строку не попадает.
|
||||
- **Экран PIN-кода** (4 цифры): задаётся при смене ключа, сверяется при каждом открытии приложения. Защиты от перебора нет. Сменить PIN можно только через повторный вход («Forgot PIN? Sign in again» на экране PIN; отмена входа возвращает к экрану PIN). «Открытием» считается и возврат в приложение после 30 секунд в фоне.
|
||||
- **Экран карт**: список локально хранящихся карт (цветные плашки с лого или, если лого нет, с аббревиатурой названия на цветном фоне), сортировка по давности использования, фильтр по подстроке названия (от 1 символа), кнопки «обновить» и «добавить».
|
||||
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно.
|
||||
- **Экран карты**: название, код (отрисовка `bwip-js`), локальные картинки. На Android яркость поднимается до максимума. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
|
||||
- **Экран добавления и редактирования**: название, цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (Android — ML Kit, PWA — `zxing-wasm`). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
|
||||
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера.
|
||||
- **«Обновить»**: скачивает список карт с сервера; при успехе заменяет локальный список (карты, пропавшие с сервера, удаляются локально вместе с картинками; даты использования сохраняются по ID карты). Затем для карт, у которых изменился `imagesVersion`, асинхронно скачиваются лого и картинки — не больше 5 запросов одновременно. Докачиваются и файлы, которых нет локально (например, после сбоя); версия картинок карты запоминается только когда скачаны все её файлы.
|
||||
- **Экран карты**: название, код (отрисовка `bwip-js`), комментарий (с переносами строк), локальные картинки. На Android яркость окна поднимается до максимума (собственный плагин `ScreenBrightness` в `android/`) и возвращается к системной при уходе с экрана. Открытие экрана считается использованием карты (сохраняются дата и время). Кнопки «редактировать» и «удалить» (с подтверждением).
|
||||
- **Экран добавления и редактирования**: название, комментарий (многострочное поле), цвет (по умолчанию случайный из палитры цветов, контрастных с белым текстом), лого, картинки, код и тип кода — вручную или распознаванием с камеры или из загруженной картинки (`barcode-detector` на `zxing-wasm` и в PWA, и в Android WebView; wasm-модуль входит в сборку). Лого и картинки можно снять камерой (кнопка «Camera», поле выбора файла с `capture`) или выбрать из галереи («Gallery», для фото — сразу несколько). Лого и картинки перед отправкой пережимаются в WebP до 1000px по большей стороне, EXIF удаляется.
|
||||
- После успешного добавления или редактирования локальная копия карты сразу обновляется ответом сервера; загруженные картинки сохраняются локально без повторного скачивания. Если сохранение прервалось на середине (например, карта создана, а фото не загрузились), выполненные шаги сохраняются и «Save» повторяет только оставшиеся.
|
||||
- Приложение работает без интернета, кроме аутентификации, скачивания, добавления, редактирования и удаления карт и картинок. Эти функции без сети показывают сообщение об ошибке и прерываются (очереди отложенных операций нет).
|
||||
|
||||
## 5. Тесты
|
||||
|
||||
Unit- и интеграционные тесты сервера на Vitest: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`). E2E-тестов нет.
|
||||
Vitest. Сервер: шифрование, генерация ключей, yaml-хранилище, права доступа, маршруты API (через `fastify.inject`), интеграция с прокси. Клиент: пул загрузок, выбор карт для докачки, сортировка, PIN, распознаваемые форматы. E2E-тестов нет.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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>
|
||||
|
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>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
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);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'}`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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) };
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"test": "pnpm -r test",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ packages:
|
||||
- shared
|
||||
- server-be
|
||||
- server-fe
|
||||
- mobile-client
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
@@ -17,6 +17,7 @@ const cardSchema = z.object({
|
||||
color: z.string(),
|
||||
code: z.coerce.string(),
|
||||
codeType: z.enum(CODE_TYPES),
|
||||
comment: z.string().default(''),
|
||||
logo: imageRefSchema.nullable().default(null),
|
||||
images: z.array(imageRefSchema).default([]),
|
||||
imagesVersion: z.number().int().default(0),
|
||||
@@ -110,6 +111,7 @@ export class CardsService {
|
||||
const card: Card = {
|
||||
id,
|
||||
...fields,
|
||||
comment: fields.comment ?? '',
|
||||
logo: logoRef,
|
||||
images: [],
|
||||
imagesVersion: logoRef ? 1 : 0,
|
||||
|
||||
@@ -124,6 +124,34 @@ describe('client API', () => {
|
||||
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 () => {
|
||||
const { app, key } = await setup();
|
||||
const res = await client(app, key)('GET', '/nope');
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
BWIP_BCID,
|
||||
TWO_DIMENSIONAL_CODE_TYPES,
|
||||
bwipRenderOptions,
|
||||
cardAbbreviation,
|
||||
textColorOn,
|
||||
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 (!bwipjs) return { error: 'Loading…' };
|
||||
try {
|
||||
const svg = bwipjs.toSVG({
|
||||
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 };
|
||||
return { svg: bwipjs.toSVG(bwipRenderOptions(codeType, code)) };
|
||||
} catch (e) {
|
||||
return { error: String((e as Error).message ?? e).replace(/^Error:\s*/, '') };
|
||||
}
|
||||
|
||||
@@ -27,15 +27,21 @@ function Editor({ initial }: { initial?: Card }) {
|
||||
const [card, setCard] = useState<Card | undefined>(initial);
|
||||
const [fields, setFields] = useState<CardFields>(
|
||||
initial
|
||||
? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType }
|
||||
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13' },
|
||||
? { name: initial.name, color: initial.color, code: initial.code, codeType: initial.codeType, comment: initial.comment ?? '' }
|
||||
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
|
||||
);
|
||||
const action = useAction();
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
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 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 />
|
||||
</div>
|
||||
<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">
|
||||
<button type="submit" disabled={action.busy || (!dirty && !!card)}>
|
||||
{card ? 'Save' : 'Create card'}
|
||||
|
||||
@@ -47,3 +47,22 @@ export const BWIP_BCID: Record<CodeType, string> = {
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface Card {
|
||||
color: string;
|
||||
code: string;
|
||||
codeType: CodeType;
|
||||
/** Free text, may be multiline; '' when empty. */
|
||||
comment: string;
|
||||
logo: ImageRef | null;
|
||||
images: ImageRef[];
|
||||
imagesVersion: number;
|
||||
|
||||
@@ -9,6 +9,13 @@ export const cardFieldsSchema = z.object({
|
||||
color: colorSchema,
|
||||
code: z.string().min(1, 'Code is required').max(4000),
|
||||
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>;
|
||||
|
||||
|
||||