feat: initial mobile app extracted from bill-tracker repo

- Capacitor + React + Vite app for Android
- SetupScreen for server URL connection or local mode
- LoadingScreen with nodejs-mobile health polling
- sync-nodejs-project.sh: --source flag (defaults to ../bill-tracker)
- transpile-node12.js: uses own esbuild devDependency
- prepare-android-assets.js: nodejs-mobile cordova asset packaging
This commit is contained in:
null 2026-06-13 20:00:05 -05:00
commit 186d651862
18 changed files with 4287 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
node_modules/
dist/
android/
ios/
# Generated by sync-nodejs-project.sh
nodejs-assets/nodejs-project/server/
nodejs-assets/nodejs-project/node_modules/
# OS junk
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/

17
capacitor.config.ts Normal file
View File

@ -0,0 +1,17 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.billtracker.app',
appName: 'Bill Tracker',
webDir: 'dist',
android: {
// Allow HTTP (cleartext) for local network servers
allowMixedContent: true,
},
server: {
// androidScheme must be https for cookies to work correctly
androidScheme: 'https',
},
};
export default config;

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#09090b" />
<title>Bill Tracker</title>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3356
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "bill-tracker-mobile",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"sync": "npm run build && npx cap sync",
"sync:server": "./scripts/sync-nodejs-project.sh",
"sync:android-assets": "node scripts/prepare-android-assets.js",
"android": "npm run sync && npm run sync:server && npm run sync:android-assets && npx cap open android",
"android:run": "npm run sync && npm run sync:server && npm run sync:android-assets && npx cap run android --no-sync"
},
"dependencies": {
"@capacitor/android": "^8.4.0",
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.4.0",
"@capacitor/ios": "^8.4.0",
"@capacitor/preferences": "^8.0.1",
"nodejs-mobile-cordova": "^0.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@capacitor/cli": "^8.4.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.2",
"esbuild": "^0.25.0",
"typescript": "^5.6.2",
"vite": "^8.0.0"
}
}

View File

@ -0,0 +1,171 @@
#!/usr/bin/env node
// Copies mobile/nodejs-assets/nodejs-project into the Android project's assets
// so the nodejs-mobile-cordova plugin can find/copy it, and generates
// dir.list/file.list for the plugin's runtime copy step.
//
// nodejs-mobile-cordova's build.gradle is `apply from`'d into BOTH the `app`
// and `capacitor-cordova-android-plugins` modules, and each checks for
// `<module>/src/main/assets/www/` at *configuration* time — the build fails
// with "couldn't find the www folder" if either is missing. So we place a
// copy in both.
//
// IMPORTANT: `npx cap sync` regenerates capacitor-cordova-android-plugins'
// assets folder (wiping anything placed here), so this script must run AFTER
// `npx cap sync` and the build must run with `--no-sync`
// (npm run android does this in the right order).
//
// Run after `./scripts/sync-nodejs-project.sh` and (optionally) `npm install`
// inside nodejs-assets/nodejs-project.
'use strict';
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const MOBILE_DIR = path.join(__dirname, '..');
const SRC = path.join(MOBILE_DIR, 'nodejs-assets', 'nodejs-project');
if (!fs.existsSync(SRC)) {
console.error(`Source not found: ${SRC}\nRun ./scripts/sync-nodejs-project.sh first.`);
process.exit(1);
}
function rmrf(p) {
fs.rmSync(p, { recursive: true, force: true });
}
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (entry.name === '.bin') continue; // symlinks under node_modules/.bin break the asset copy
// Android's asset merger treats foo.js + foo.js.gz as duplicate resources.
if (entry.name.endsWith('.gz')) continue;
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isSymbolicLink()) {
const real = fs.realpathSync(srcPath);
if (fs.statSync(real).isDirectory()) copyDir(real, destPath);
else fs.copyFileSync(real, destPath);
} else if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
function enumerate(absDir, relDir, dirs, files) {
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
if (entry.name.endsWith('.gz') || entry.name.endsWith('~')) continue;
const absPath = path.join(absDir, entry.name);
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
dirs.push(relPath);
enumerate(absPath, relPath, dirs, files);
} else {
files.push(relPath);
}
}
}
// Patch a known issue in nodejs-mobile-cordova 0.4.3: NodeJS.java references
// `BuildConfig.DEBUG`, which doesn't resolve in Capacitor's cordova-plugins
// library module (no matching BuildConfig in that package). This file is
// re-copied verbatim from node_modules by `cap sync`, so patch it each time.
const nodeJsJava = path.join(
MOBILE_DIR, 'android', 'capacitor-cordova-android-plugins', 'src', 'main', 'java',
'com', 'janeasystems', 'cdvnodejsmobile', 'NodeJS.java'
);
if (fs.existsSync(nodeJsJava)) {
const src = fs.readFileSync(nodeJsJava, 'utf8');
const patched = src.replace('if (BuildConfig.DEBUG) {', 'if (false) {');
if (patched !== src) {
fs.writeFileSync(nodeJsJava, patched);
console.log(`Patched ${path.relative(MOBILE_DIR, nodeJsJava)} (BuildConfig.DEBUG -> false)`);
}
}
// nodejs-mobile-cordova's build.gradle sets up a CMake build with
// `cmake.path = "libs/cdvnodejsmobile/CMakeLists.txt"` (relative to each
// module's projectDir) for both `app` and `capacitor-cordova-android-plugins`.
// `cap sync` doesn't replicate the plugin.xml `<source-file target-dir="libs/cdvnodejsmobile">`
// copies, so populate that directory ourselves in both modules.
const NODEJS_MOBILE = path.join(MOBILE_DIR, 'node_modules', 'nodejs-mobile-cordova');
const ABIS = ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'];
function gunzipFile(src, dest) {
const data = fs.readFileSync(src);
fs.writeFileSync(dest, zlib.gunzipSync(data));
}
function populateNativeLibs(moduleDir) {
const dest = path.join(moduleDir, 'libs', 'cdvnodejsmobile');
rmrf(dest);
fs.mkdirSync(dest, { recursive: true });
for (const f of ['CMakeLists.txt']) {
fs.copyFileSync(path.join(NODEJS_MOBILE, 'src', 'android', f), path.join(dest, f));
}
for (const f of ['native-lib.cpp']) {
fs.copyFileSync(path.join(NODEJS_MOBILE, 'src', 'android', 'jni', f), path.join(dest, f));
}
for (const f of ['cordova-bridge.cpp', 'cordova-bridge.h']) {
fs.copyFileSync(path.join(NODEJS_MOBILE, 'src', 'common', 'cordova-bridge', f), path.join(dest, f));
}
const libnodeSrc = path.join(NODEJS_MOBILE, 'libs', 'android', 'libnode');
const libnodeDest = path.join(dest, 'libnode');
copyDir(path.join(libnodeSrc, 'include'), path.join(libnodeDest, 'include'));
for (const abi of ABIS) {
const binDest = path.join(libnodeDest, 'bin', abi);
fs.mkdirSync(binDest, { recursive: true });
gunzipFile(
path.join(libnodeSrc, 'bin', abi, 'libnode.so.gz'),
path.join(binDest, 'libnode.so')
);
}
console.log(`Populated ${path.relative(MOBILE_DIR, dest)} (native CMake build sources + libnode)`);
}
for (const moduleDir of [
path.join(MOBILE_DIR, 'android', 'app'),
path.join(MOBILE_DIR, 'android', 'capacitor-cordova-android-plugins'),
]) {
populateNativeLibs(moduleDir);
}
const targets = [
path.join(MOBILE_DIR, 'android', 'capacitor-cordova-android-plugins', 'src', 'main', 'assets'),
path.join(MOBILE_DIR, 'android', 'app', 'src', 'main', 'assets'),
];
// The plugin's copyNodeJSAssets() also expects a `nodejs-mobile-cordova-assets`
// folder at the root of the assets (builtin modules like cordova-bridge);
// without it, assetManager.list() returns empty and the fallback
// assetManager.open() throws FileNotFoundException.
const BUILTIN_ASSETS_SRC = path.join(NODEJS_MOBILE, 'install', 'nodejs-mobile-cordova-assets');
for (const assetsRoot of targets) {
const builtinDest = path.join(assetsRoot, 'nodejs-mobile-cordova-assets');
rmrf(builtinDest);
copyDir(BUILTIN_ASSETS_SRC, builtinDest);
const dest = path.join(assetsRoot, 'www', 'nodejs-project');
console.log(`Copying ${SRC} -> ${dest}`);
rmrf(dest);
copyDir(SRC, dest);
// dir.list/file.list, paths relative to assetsRoot (e.g. "www/nodejs-project/main.js"),
// matching the format produced by the cordova after_prepare hook.
const dirs = [];
const files = [];
enumerate(dest, 'www/nodejs-project', dirs, files);
fs.writeFileSync(path.join(assetsRoot, 'dir.list'), dirs.join('\n') + '\n');
fs.writeFileSync(path.join(assetsRoot, 'file.list'), files.join('\n') + '\n');
console.log(`Wrote ${dirs.length} dirs / ${files.length} files to ${path.relative(MOBILE_DIR, assetsRoot)}/{dir,file}.list`);
}

76
scripts/sync-nodejs-project.sh Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Copies the bill-tracker backend source into mobile/nodejs-assets/nodejs-project/server/
# so it can be bundled into the Android app and run by nodejs-mobile in local mode.
#
# Usage: ./scripts/sync-nodejs-project.sh [--source /path/to/bill-tracker]
# Run from the mobile project root.
#
# --source defaults to ../bill-tracker (sibling directory).
set -euo pipefail
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Parse --source flag
REPO_ROOT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--source) REPO_ROOT="$(cd "$2" && pwd)"; shift 2 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
if [[ -z "$REPO_ROOT" ]]; then
REPO_ROOT="$(cd "$MOBILE_DIR/../bill-tracker" && pwd)"
fi
if [[ ! -f "$REPO_ROOT/server.js" ]]; then
echo "Error: Bill Tracker source not found at $REPO_ROOT" >&2
echo "Use --source /path/to/bill-tracker to specify the Bill Tracker project root." >&2
exit 1
fi
DEST="$MOBILE_DIR/nodejs-assets/nodejs-project/server"
echo "Syncing backend source from $REPO_ROOT -> $DEST"
rm -rf "$DEST"
mkdir -p "$DEST"
cp "$REPO_ROOT/server.js" "$DEST/"
for dir in db routes services middleware utils workers; do
cp -R "$REPO_ROOT/$dir" "$DEST/$dir"
done
# routes/user.js requires ../scripts/seedDemoData (relative to server/).
mkdir -p "$DEST/scripts"
cp "$REPO_ROOT/scripts/seedDemoData.js" "$DEST/scripts/"
# Drop dev SQLite DB files and node_modules if they were copied along with db/.
rm -f "$DEST"/db/*.db "$DEST"/db/*.db-* "$DEST"/db/*.db-shm "$DEST"/db/*.db-wal
rm -rf "$DEST/db/node_modules"
# Seed-data JSON files read by db/database.js during first-run initialization.
mkdir -p "$DEST/docs"
cp "$REPO_ROOT/docs/advisory_non_bill_transaction_filters_us_ms_5000.json" "$DEST/docs/"
cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/"
# Built frontend, served as static files by server.js (path.join(__dirname, 'dist')).
cp -R "$REPO_ROOT/dist" "$DEST/dist"
# nodejs-mobile-cordova 0.4.3 embeds Node 12.19, which doesn't support
# optional chaining (?.) / nullish coalescing (??) used in this project and
# in some npm dependencies (e.g. express-rate-limit). Transpile server-side
# .js files down to Node 12-compatible syntax in place (excludes the
# prebuilt frontend bundle in dist/).
node "$MOBILE_DIR/scripts/transpile-node12.js" "$DEST"
# Also transpile already-installed dependencies, if present (run
# `npm install` in nodejs-assets/nodejs-project after this script to pick up
# new/updated deps, then re-run this script to transpile them).
NODE_MODULES="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules"
if [ -d "$NODE_MODULES" ]; then
node "$MOBILE_DIR/scripts/transpile-node12.js" "$NODE_MODULES"
fi
echo "Done. Next: cd \"$MOBILE_DIR/nodejs-assets/nodejs-project\" && npm install"

View File

@ -0,0 +1,69 @@
#!/usr/bin/env node
// Transpiles all .js files under a directory (in place) to syntax supported
// by nodejs-mobile-cordova's embedded Node 12.19 — primarily optional
// chaining (?.) and nullish coalescing (??) used by some npm dependencies
// and by this project's own source.
//
// Usage: node transpile-node12.js <dir> [<dir> ...]
'use strict';
const fs = require('fs');
const path = require('path');
// Resolve esbuild from this project's own node_modules (no longer parent repo)
const esbuild = require('esbuild');
const SKIP_DIRS = new Set(['.bin']);
function walk(dir, cb) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
walk(full, cb);
} else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs') || entry.name === 'package.json') {
cb(full);
}
}
}
let count = 0;
let pkgCount = 0;
for (const dir of process.argv.slice(2)) {
if (!fs.existsSync(dir)) continue;
walk(dir, (file) => {
if (path.basename(file) === 'package.json') {
// esbuild converts ESM (import/export) to CJS (module.exports) below,
// but Node still treats .js files as ES modules if the nearest
// package.json has "type": "module" — drop that so the converted
// CJS output is loaded correctly under Node 12.
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
if (pkg.type === 'module') {
delete pkg.type;
fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');
pkgCount++;
}
return;
}
const src = fs.readFileSync(file, 'utf8');
let out;
try {
out = esbuild.transformSync(src, { target: 'node12', format: 'cjs', loader: 'js' }).code;
} catch (err) {
console.warn(`Skipping ${file}: ${err.message.split('\n')[0]}`);
return;
}
// Node 12 doesn't understand the "node:" builtin-module prefix
// (added in Node 14.18/16), used by some dependencies (e.g. express-rate-limit).
out = out.replace(/require\((['"])node:([a-z/_-]+)\1\)/g, "require($1$2$1)");
if (out !== src) {
fs.writeFileSync(file, out);
count++;
}
});
}
console.log(`Transpiled ${count} file(s) and dropped "type": "module" from ${pkgCount} package.json file(s) for Node 12 compatibility.`);

50
src/App.tsx Normal file
View File

@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { Preferences } from '@capacitor/preferences';
import SetupScreen from './SetupScreen';
import LoadingScreen from './LoadingScreen';
const LOCAL_URL = 'http://localhost:3000';
export default function App() {
const [ready, setReady] = useState(false);
const [localMode, setLocalMode] = useState(false);
useEffect(() => {
Preferences.get({ key: 'serverUrl' }).then(({ value }) => {
if (value === 'local') {
setLocalMode(true);
} else if (value) {
// Navigate the WebView to the saved server URL.
// From this point the remote server's UI takes over entirely.
window.location.replace(value);
} else {
setReady(true);
}
});
}, []);
async function handleConnect(url: string) {
await Preferences.set({ key: 'serverUrl', value: url });
window.location.replace(url);
}
async function handleLocalMode() {
await Preferences.set({ key: 'serverUrl', value: 'local' });
setLocalMode(true);
}
if (localMode) {
return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
}
if (!ready) {
// Brief blank while we check preferences before redirecting
return (
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
</div>
);
}
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
}

73
src/LoadingScreen.tsx Normal file
View File

@ -0,0 +1,73 @@
import { useEffect, useState } from 'react';
const LOCAL_URL = 'http://localhost:3000';
const HEALTH_URL = `${LOCAL_URL}/api/health`;
const POLL_INTERVAL_MS = 250;
interface Props {
/** Called once the embedded server responds to a health check. */
onReady: () => void;
}
/**
* Shown while the embedded Node.js server (nodejs-mobile) boots in local mode.
* Starts the engine on mount, then polls /api/health until it responds.
*/
export default function LoadingScreen({ onReady }: Props) {
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
let pollTimer: ReturnType<typeof setTimeout>;
function pollHealth() {
fetch(HEALTH_URL)
.then(res => {
if (!cancelled && res.ok) {
onReady();
} else if (!cancelled) {
pollTimer = setTimeout(pollHealth, POLL_INTERVAL_MS);
}
})
.catch(() => {
if (!cancelled) pollTimer = setTimeout(pollHealth, POLL_INTERVAL_MS);
});
}
if (!window.nodejs) {
setError('Local server engine is unavailable on this platform.');
return;
}
window.nodejs.start('main.js', err => {
if (err && !cancelled) {
setError('Failed to start local server: ' + String(err));
return;
}
pollHealth();
});
return () => {
cancelled = true;
clearTimeout(pollTimer);
};
}, [onReady]);
return (
<div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
{error ? (
<>
<h1 className="setup-title">Couldn't start local server</h1>
<p className="setup-subtitle">{error}</p>
</>
) : (
<>
<div className="spinner" />
<p className="setup-subtitle">Starting Bill Tracker</p>
</>
)}
</div>
</div>
);
}

109
src/SetupScreen.tsx Normal file
View File

@ -0,0 +1,109 @@
import { useState } from 'react';
interface Props {
onConnect: (url: string) => void;
onLocalMode: () => void;
}
function normalizeUrl(raw: string): string {
const trimmed = raw.trim().replace(/\/$/, '');
if (!trimmed) return '';
if (!/^https?:\/\//i.test(trimmed)) return 'https://' + trimmed;
return trimmed;
}
function isValidUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
export default function SetupScreen({ onConnect, onLocalMode }: Props) {
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const [connecting, setConnecting] = useState(false);
function handleConnect() {
const normalized = normalizeUrl(url);
if (!isValidUrl(normalized)) {
setError('Enter a valid URL, e.g. https://bills.yourdomain.com');
return;
}
setError('');
setConnecting(true);
onConnect(normalized);
}
function handleKey(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleConnect();
}
return (
<div className="setup-root">
<div className="setup-card">
{/* Logo mark */}
<div className="logo-mark" aria-hidden="true">
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="10" fill="#6366f1" fillOpacity="0.15" />
<path d="M20 8v4M20 28v4M8 20h4M28 20h4" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" />
<rect x="13" y="13" width="14" height="14" rx="3" stroke="#6366f1" strokeWidth="2" />
<path d="M16 20h8M20 16v8" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div>
<h1 className="setup-title">Bill Tracker</h1>
<p className="setup-subtitle">Connect to your server to get started.</p>
<div className="form-group">
<label className="form-label" htmlFor="server-url">Server URL</label>
<input
id="server-url"
className="form-input"
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="https://bills.yourdomain.com"
value={url}
onChange={e => { setUrl(e.target.value); setError(''); }}
onKeyDown={handleKey}
disabled={connecting}
/>
{error && <p className="form-error">{error}</p>}
<p className="form-hint">
The address of your Bill Tracker server. Must be reachable from this device.
</p>
</div>
<button
className="btn-primary"
onClick={handleConnect}
disabled={connecting || !url.trim()}
>
{connecting ? 'Connecting…' : 'Connect to Server'}
</button>
<div className="divider">
<span>or</span>
</div>
<button
className="btn-secondary"
style={{ cursor: 'pointer' }}
onClick={onLocalMode}
disabled={connecting}
>
<span>Run on this phone</span>
</button>
<p className="setup-footer">
To change your server later, go to <strong>Android Settings Apps Bill Tracker Clear Storage</strong>.
</p>
</div>
</div>
);
}

227
src/index.css Normal file
View File

@ -0,0 +1,227 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ── Setup screen ──────────────────────────────────────────────── */
.setup-root {
min-height: 100dvh;
background: #09090b;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.setup-card {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.logo-mark {
width: 56px;
height: 56px;
margin: 0 auto 0.5rem;
}
.logo-mark svg {
width: 100%;
height: 100%;
}
.setup-title {
text-align: center;
font-size: 1.5rem;
font-weight: 700;
color: #fafafa;
letter-spacing: -0.02em;
}
.setup-subtitle {
text-align: center;
font-size: 0.875rem;
color: #71717a;
margin-top: -0.75rem;
}
/* ── Form ───────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #a1a1aa;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: #18181b;
border: 1px solid #27272a;
border-radius: 0.625rem;
color: #fafafa;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.15s;
-webkit-appearance: none;
}
.form-input::placeholder {
color: #52525b;
}
.form-input:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.form-input:disabled {
opacity: 0.5;
}
.form-error {
font-size: 0.8125rem;
color: #f87171;
}
.form-hint {
font-size: 0.75rem;
color: #52525b;
line-height: 1.5;
}
/* ── Buttons ─────────────────────────────────────────────────────── */
.btn-primary {
width: 100%;
padding: 0.8125rem 1rem;
background: #6366f1;
color: #fff;
border: none;
border-radius: 0.625rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
}
.btn-primary:active:not(:disabled) {
background: #4338ca;
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
width: 100%;
padding: 0.8125rem 1rem;
background: transparent;
color: #a1a1aa;
border: 1px solid #27272a;
border-radius: 0.625rem;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
transition: border-color 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.btn-secondary:hover:not(:disabled) {
border-color: #3f3f46;
color: #d4d4d8;
}
.btn-secondary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.badge {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #27272a;
color: #71717a;
padding: 0.1875rem 0.5rem;
border-radius: 9999px;
}
/* ── Divider ─────────────────────────────────────────────────────── */
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
color: #3f3f46;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #27272a;
}
/* ── Footer note ─────────────────────────────────────────────────── */
.setup-footer {
font-size: 0.75rem;
color: #3f3f46;
text-align: center;
line-height: 1.6;
padding: 0 0.25rem;
}
.setup-footer strong {
color: #52525b;
font-weight: 500;
}
/* ── Spinner (shown briefly while checking preferences) ─────────── */
.spinner {
width: 28px;
height: 28px;
border: 2px solid #27272a;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

8
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
// Injected by the nodejs-mobile-cordova plugin once `cordova.js` has loaded.
interface Window {
nodejs?: {
start: (filename: string, callback?: (err: unknown) => void) => void;
};
}

22
tsconfig.app.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

20
tsconfig.node.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "capacitor.config.ts"]
}

9
vite.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
},
});