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:
commit
186d651862
|
|
@ -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/
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.`);
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue