pacatui 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/assets/paca-icon.png +0 -0
- package/package.json +1 -1
- package/src/App.tsx +194 -7
- package/src/components/DatabaseSelectModal.tsx +124 -0
- package/src/components/SettingsView.tsx +75 -38
- package/src/components/index.ts +1 -0
- package/src/db-path.ts +98 -0
- package/src/db.ts +21 -18
- package/src/index.tsx +28 -5
- package/src/menubar/db-lite.ts +16 -11
- package/src/menubar/index.ts +172 -141
- package/src/menubar/swift-source.ts +291 -0
- package/src/types.ts +4 -1
- package/src/menubar/action.ts +0 -80
- package/src/menubar/plugin.ts +0 -88
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A simple TUI app for task, timer and invoicing for projects.
|
|
|
16
16
|
- **Stripe Invoicing** - Create draft invoices directly from time entries
|
|
17
17
|
- **Invoice Management** - View and manage all your Stripe invoices
|
|
18
18
|
- **Dashboard** - Overview of projects, tasks, and time stats
|
|
19
|
+
- **Menu Bar** - Native macOS menu bar companion for quick timer control
|
|
19
20
|
- **Offline-first** - All data stored locally in SQLite
|
|
20
21
|
- **Vim-style Navigation** - Keyboard-driven interface
|
|
21
22
|
|
|
@@ -120,6 +121,7 @@ Access settings by pressing `5`:
|
|
|
120
121
|
- **Business Name** - Your business name for invoices
|
|
121
122
|
- **Stripe API Key** - Enable invoicing features
|
|
122
123
|
- **Timezone** - Set display timezone (or auto-detect)
|
|
124
|
+
- **Menu Bar** - Toggle the macOS menu bar companion
|
|
123
125
|
- **Export/Import** - Backup and restore your data
|
|
124
126
|
|
|
125
127
|
### Data Location
|
|
@@ -127,6 +129,27 @@ Access settings by pressing `5`:
|
|
|
127
129
|
- Database: `~/.paca/paca.db`
|
|
128
130
|
- Backups: `~/.paca/backups/`
|
|
129
131
|
|
|
132
|
+
## Menu Bar (macOS)
|
|
133
|
+
|
|
134
|
+
Paca includes a native macOS menu bar companion that shows your running timer and lets you start/stop timers without opening the TUI.
|
|
135
|
+
|
|
136
|
+
### Enable via Settings
|
|
137
|
+
|
|
138
|
+
1. Press `5` to open Settings
|
|
139
|
+
2. Select **Menu Bar** and press `Enter` to toggle it on
|
|
140
|
+
|
|
141
|
+
### Enable via CLI
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
paca menubar enable # Compile & launch
|
|
145
|
+
paca menubar disable # Stop & remove
|
|
146
|
+
paca menubar status # Show current status
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The first time you enable it, Paca compiles a small native Swift helper binary (requires Xcode Command Line Tools). The paca mascot icon appears in your menu bar with live timer status.
|
|
150
|
+
|
|
151
|
+
**Requires**: Xcode Command Line Tools (`xcode-select --install`)
|
|
152
|
+
|
|
130
153
|
## Stripe Integration
|
|
131
154
|
|
|
132
155
|
To enable invoicing:
|
|
Binary file
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
EditTimeEntryModal,
|
|
21
21
|
CreateInvoiceModal,
|
|
22
22
|
ThemeSelectModal,
|
|
23
|
+
DatabaseSelectModal,
|
|
23
24
|
} from "./components/index.ts";
|
|
24
25
|
import { InvoicesView } from "./components/InvoicesView.tsx";
|
|
25
26
|
import {
|
|
@@ -31,7 +32,16 @@ import {
|
|
|
31
32
|
database,
|
|
32
33
|
customers,
|
|
33
34
|
invoices,
|
|
35
|
+
switchDatabase,
|
|
34
36
|
} from "./db.ts";
|
|
37
|
+
import {
|
|
38
|
+
getActiveDbFilename,
|
|
39
|
+
setActiveDbFilename,
|
|
40
|
+
getActiveDbPath,
|
|
41
|
+
listDatabases,
|
|
42
|
+
sanitizeDbName,
|
|
43
|
+
getPacaDir,
|
|
44
|
+
} from "./db-path.ts";
|
|
35
45
|
import { getOrCreateStripeCustomer, createDraftInvoice, listInvoices, clearInvoiceCache, type StripeInvoiceItem } from "./stripe.ts";
|
|
36
46
|
import {
|
|
37
47
|
getEffectiveTimezone,
|
|
@@ -109,6 +119,7 @@ export function App() {
|
|
|
109
119
|
stripeApiKey: "",
|
|
110
120
|
timezone: "auto",
|
|
111
121
|
theme: "catppuccin-mocha",
|
|
122
|
+
menuBar: "disabled",
|
|
112
123
|
});
|
|
113
124
|
|
|
114
125
|
// Timesheet State
|
|
@@ -151,6 +162,9 @@ export function App() {
|
|
|
151
162
|
const [invoicesHasMore, setInvoicesHasMore] = useState(false);
|
|
152
163
|
const [invoicesCursors, setInvoicesCursors] = useState<string[]>([]); // Stack of cursors for pagination
|
|
153
164
|
|
|
165
|
+
// Database State
|
|
166
|
+
const [currentDbFilename, setCurrentDbFilename] = useState(getActiveDbFilename());
|
|
167
|
+
|
|
154
168
|
// Modal State
|
|
155
169
|
const [inputMode, setInputMode] = useState<InputMode | null>(null);
|
|
156
170
|
const [statusMessage, setStatusMessage] =
|
|
@@ -364,6 +378,30 @@ export function App() {
|
|
|
364
378
|
loadCustomers();
|
|
365
379
|
}, []);
|
|
366
380
|
|
|
381
|
+
// Poll for external timer changes (e.g. from menu bar)
|
|
382
|
+
// Uses bun:sqlite directly to bypass Prisma's connection cache
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
const poll = setInterval(async () => {
|
|
385
|
+
const { getRunningTimer: getRunningTimerLite } = await import("./menubar/db-lite.ts");
|
|
386
|
+
const row = getRunningTimerLite();
|
|
387
|
+
if (row) {
|
|
388
|
+
setRunningTimer({
|
|
389
|
+
id: row.id,
|
|
390
|
+
startTime: new Date(row.startTime.includes("T") ? row.startTime : row.startTime + "Z"),
|
|
391
|
+
project: {
|
|
392
|
+
id: row.projectId,
|
|
393
|
+
name: row.projectName,
|
|
394
|
+
color: row.projectColor,
|
|
395
|
+
hourlyRate: row.projectHourlyRate,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
} else {
|
|
399
|
+
setRunningTimer(null);
|
|
400
|
+
}
|
|
401
|
+
}, 5000);
|
|
402
|
+
return () => clearInterval(poll);
|
|
403
|
+
}, []);
|
|
404
|
+
|
|
367
405
|
// Reload tasks when project selection changes
|
|
368
406
|
useEffect(() => {
|
|
369
407
|
loadTasks();
|
|
@@ -473,14 +511,19 @@ export function App() {
|
|
|
473
511
|
return;
|
|
474
512
|
}
|
|
475
513
|
|
|
476
|
-
// Handle input mode escape (except
|
|
477
|
-
if (inputMode && inputMode !== "select_timer_project") {
|
|
514
|
+
// Handle input mode escape (except modals that handle their own escape)
|
|
515
|
+
if (inputMode && inputMode !== "select_timer_project" && inputMode !== "select_database" && inputMode !== "create_database_name") {
|
|
478
516
|
if (key.name === "escape") {
|
|
479
517
|
setInputMode(null);
|
|
480
518
|
}
|
|
481
519
|
return;
|
|
482
520
|
}
|
|
483
521
|
|
|
522
|
+
// Handle select_database and create_database_name modals (they have their own handlers)
|
|
523
|
+
if (inputMode === "select_database" || inputMode === "create_database_name") {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
484
527
|
// Handle select timer project modal
|
|
485
528
|
if (inputMode === "select_timer_project") {
|
|
486
529
|
// The modal has its own keyboard handler
|
|
@@ -846,6 +889,7 @@ export function App() {
|
|
|
846
889
|
}
|
|
847
890
|
|
|
848
891
|
// Select/activate setting
|
|
892
|
+
// Order matches: Business (0: businessName, 1: stripeApiKey) + App (2: database, 3: theme, 4: timezone, 5: menuBar, 6: export, 7: import)
|
|
849
893
|
if (key.name === "return") {
|
|
850
894
|
switch (selectedSettingsIndex) {
|
|
851
895
|
case 0: // Business Name
|
|
@@ -854,16 +898,22 @@ export function App() {
|
|
|
854
898
|
case 1: // Stripe API Key
|
|
855
899
|
setInputMode("edit_stripe_key");
|
|
856
900
|
break;
|
|
857
|
-
case 2: //
|
|
901
|
+
case 2: // Database
|
|
902
|
+
setInputMode("select_database");
|
|
903
|
+
break;
|
|
904
|
+
case 3: // Theme
|
|
858
905
|
setInputMode("select_theme");
|
|
859
906
|
break;
|
|
860
|
-
case
|
|
907
|
+
case 4: // Timezone
|
|
861
908
|
setInputMode("edit_timezone");
|
|
862
909
|
break;
|
|
863
|
-
case
|
|
910
|
+
case 5: // Menu Bar
|
|
911
|
+
handleToggleMenuBar();
|
|
912
|
+
break;
|
|
913
|
+
case 6: // Export Database
|
|
864
914
|
handleExportDatabase();
|
|
865
915
|
break;
|
|
866
|
-
case
|
|
916
|
+
case 7: // Import Database
|
|
867
917
|
setConfirmMessage("Import will replace all data. Continue?");
|
|
868
918
|
setConfirmAction(() => () => handleImportDatabase());
|
|
869
919
|
break;
|
|
@@ -1132,7 +1182,8 @@ export function App() {
|
|
|
1132
1182
|
}
|
|
1133
1183
|
|
|
1134
1184
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1135
|
-
const
|
|
1185
|
+
const dbName = currentDbFilename.replace(/\.db$/, "");
|
|
1186
|
+
const exportPath = `${backupDir}/${dbName}-backup-${timestamp}.db`;
|
|
1136
1187
|
try {
|
|
1137
1188
|
await database.exportToFile(exportPath);
|
|
1138
1189
|
showMessage(`Exported to ${exportPath}`);
|
|
@@ -1177,6 +1228,117 @@ export function App() {
|
|
|
1177
1228
|
showMessage(`Theme: ${theme.displayName}`);
|
|
1178
1229
|
};
|
|
1179
1230
|
|
|
1231
|
+
const handleToggleMenuBar = async () => {
|
|
1232
|
+
const newValue = appSettings.menuBar === "enabled" ? "disabled" : "enabled";
|
|
1233
|
+
await settings.set("menuBar", newValue);
|
|
1234
|
+
setAppSettings((prev) => ({ ...prev, menuBar: newValue }));
|
|
1235
|
+
|
|
1236
|
+
const { enableMenuBar, disableMenuBar } = await import("./menubar/index.ts");
|
|
1237
|
+
const msg = newValue === "enabled"
|
|
1238
|
+
? await enableMenuBar()
|
|
1239
|
+
: await disableMenuBar();
|
|
1240
|
+
showMessage(msg);
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
// Database handlers
|
|
1244
|
+
const handleSwitchDatabase = async (filename: string) => {
|
|
1245
|
+
if (filename === currentDbFilename) {
|
|
1246
|
+
setInputMode(null);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
try {
|
|
1251
|
+
const { join } = await import("path");
|
|
1252
|
+
const { existsSync } = await import("fs");
|
|
1253
|
+
const dbPath = join(getPacaDir(), filename);
|
|
1254
|
+
|
|
1255
|
+
// Run migration if database file doesn't exist yet
|
|
1256
|
+
if (!existsSync(dbPath)) {
|
|
1257
|
+
const { execSync } = await import("child_process");
|
|
1258
|
+
const { dirname } = await import("path");
|
|
1259
|
+
const projectDir = dirname(dirname(import.meta.path));
|
|
1260
|
+
execSync(`cd "${projectDir}" && bunx prisma migrate deploy`, {
|
|
1261
|
+
stdio: "pipe",
|
|
1262
|
+
env: {
|
|
1263
|
+
...process.env,
|
|
1264
|
+
DATABASE_URL: `file:${dbPath}`,
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Disable menu bar before switching (it points at the old db)
|
|
1270
|
+
if (appSettings.menuBar === "enabled") {
|
|
1271
|
+
const { disableMenuBar } = await import("./menubar/index.ts");
|
|
1272
|
+
await disableMenuBar();
|
|
1273
|
+
await settings.set("menuBar", "disabled");
|
|
1274
|
+
setAppSettings((prev) => ({ ...prev, menuBar: "disabled" }));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Write .active file
|
|
1278
|
+
setActiveDbFilename(filename);
|
|
1279
|
+
|
|
1280
|
+
// Swap PrismaClient
|
|
1281
|
+
await switchDatabase(dbPath);
|
|
1282
|
+
|
|
1283
|
+
// Update state
|
|
1284
|
+
setCurrentDbFilename(filename);
|
|
1285
|
+
|
|
1286
|
+
// Reload all app state
|
|
1287
|
+
const [settingsData, projectData, dashboardData, recentData, weeklyData, runningData, customerData] = await Promise.all([
|
|
1288
|
+
settings.getAppSettings(),
|
|
1289
|
+
projects.getAll(false),
|
|
1290
|
+
stats.getDashboardStats(),
|
|
1291
|
+
stats.getRecentActivity(10),
|
|
1292
|
+
stats.getWeeklyTimeStats(6),
|
|
1293
|
+
timeEntries.getRunning(),
|
|
1294
|
+
customers.getAll(),
|
|
1295
|
+
]);
|
|
1296
|
+
|
|
1297
|
+
setAppSettings(settingsData);
|
|
1298
|
+
setProjectList(projectData);
|
|
1299
|
+
setDashboardStats(dashboardData);
|
|
1300
|
+
setRecentTasks(recentData as (Task & { project: { name: string; color: string } })[]);
|
|
1301
|
+
setWeeklyTimeData(weeklyData);
|
|
1302
|
+
setCustomerList(customerData);
|
|
1303
|
+
|
|
1304
|
+
if (runningData) {
|
|
1305
|
+
setRunningTimer({
|
|
1306
|
+
id: runningData.id,
|
|
1307
|
+
startTime: runningData.startTime,
|
|
1308
|
+
project: runningData.project,
|
|
1309
|
+
});
|
|
1310
|
+
} else {
|
|
1311
|
+
setRunningTimer(null);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Reset selection indices
|
|
1315
|
+
setSelectedProjectIndex(0);
|
|
1316
|
+
setSelectedTaskIndex(0);
|
|
1317
|
+
setSelectedDashboardTaskIndex(0);
|
|
1318
|
+
setSelectedSettingsIndex(0);
|
|
1319
|
+
|
|
1320
|
+
setInputMode(null);
|
|
1321
|
+
showMessage(`Switched to database: ${filename.replace(/\.db$/, "")}`);
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
showMessage(`Failed to switch database: ${error}`);
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const handleCreateDatabase = async (name: string) => {
|
|
1328
|
+
const filename = sanitizeDbName(name);
|
|
1329
|
+
const { join } = await import("path");
|
|
1330
|
+
const { existsSync } = await import("fs");
|
|
1331
|
+
const dbPath = join(getPacaDir(), filename);
|
|
1332
|
+
|
|
1333
|
+
if (existsSync(dbPath)) {
|
|
1334
|
+
showMessage(`Database "${filename.replace(/\.db$/, "")}" already exists`);
|
|
1335
|
+
setInputMode("select_database");
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
await handleSwitchDatabase(filename);
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1180
1342
|
// Modal handlers
|
|
1181
1343
|
const handleCreateProject = async (name: string, rate: number | null) => {
|
|
1182
1344
|
await projects.create({ name, hourlyRate: rate });
|
|
@@ -1433,10 +1595,13 @@ export function App() {
|
|
|
1433
1595
|
settings={appSettings}
|
|
1434
1596
|
selectedIndex={selectedSettingsIndex}
|
|
1435
1597
|
theme={getTheme(appSettings.theme)}
|
|
1598
|
+
currentDbFilename={currentDbFilename}
|
|
1599
|
+
onSelectDatabase={() => setInputMode("select_database")}
|
|
1436
1600
|
onEditBusinessName={() => setInputMode("edit_business_name")}
|
|
1437
1601
|
onEditStripeKey={() => setInputMode("edit_stripe_key")}
|
|
1438
1602
|
onEditTimezone={() => setInputMode("edit_timezone")}
|
|
1439
1603
|
onSelectTheme={() => setInputMode("select_theme")}
|
|
1604
|
+
onToggleMenuBar={handleToggleMenuBar}
|
|
1440
1605
|
onExportDatabase={handleExportDatabase}
|
|
1441
1606
|
onImportDatabase={() => {
|
|
1442
1607
|
setConfirmMessage("Import will replace all data. Continue?");
|
|
@@ -1626,6 +1791,28 @@ export function App() {
|
|
|
1626
1791
|
/>
|
|
1627
1792
|
)}
|
|
1628
1793
|
|
|
1794
|
+
{inputMode === "select_database" && (
|
|
1795
|
+
<DatabaseSelectModal
|
|
1796
|
+
databases={listDatabases()}
|
|
1797
|
+
currentDatabase={currentDbFilename}
|
|
1798
|
+
onSelect={handleSwitchDatabase}
|
|
1799
|
+
onCreateNew={() => setInputMode("create_database_name")}
|
|
1800
|
+
onCancel={() => setInputMode(null)}
|
|
1801
|
+
theme={theme}
|
|
1802
|
+
/>
|
|
1803
|
+
)}
|
|
1804
|
+
|
|
1805
|
+
{inputMode === "create_database_name" && (
|
|
1806
|
+
<InputModal
|
|
1807
|
+
mode={inputMode}
|
|
1808
|
+
title="Create New Database"
|
|
1809
|
+
placeholder="Database name (e.g. work, personal)..."
|
|
1810
|
+
onSubmit={handleCreateDatabase}
|
|
1811
|
+
onCancel={() => setInputMode("select_database")}
|
|
1812
|
+
theme={theme}
|
|
1813
|
+
/>
|
|
1814
|
+
)}
|
|
1815
|
+
|
|
1629
1816
|
{inputMode === "select_timer_project" && (
|
|
1630
1817
|
<ProjectSelectModal
|
|
1631
1818
|
projects={projectList.filter((p) => !p.archived)}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import type { Theme } from "../types.ts";
|
|
4
|
+
import Modal from "./Modal.tsx";
|
|
5
|
+
|
|
6
|
+
interface DatabaseSelectModalProps {
|
|
7
|
+
databases: string[];
|
|
8
|
+
currentDatabase: string;
|
|
9
|
+
onSelect: (filename: string) => void;
|
|
10
|
+
onCreateNew: () => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
theme: Theme;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function DatabaseSelectModal({
|
|
16
|
+
databases,
|
|
17
|
+
currentDatabase,
|
|
18
|
+
onSelect,
|
|
19
|
+
onCreateNew,
|
|
20
|
+
onCancel,
|
|
21
|
+
theme,
|
|
22
|
+
}: DatabaseSelectModalProps) {
|
|
23
|
+
const currentIndex = databases.indexOf(currentDatabase);
|
|
24
|
+
const [selectedIndex, setSelectedIndex] = useState(
|
|
25
|
+
currentIndex >= 0 ? currentIndex : 0,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const colors = theme.colors;
|
|
29
|
+
|
|
30
|
+
// Total items = databases + "Create new" option
|
|
31
|
+
const totalItems = databases.length + 1;
|
|
32
|
+
|
|
33
|
+
useKeyboard((key) => {
|
|
34
|
+
if (key.name === "escape") {
|
|
35
|
+
onCancel();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.name === "return") {
|
|
39
|
+
if (selectedIndex === databases.length) {
|
|
40
|
+
onCreateNew();
|
|
41
|
+
} else {
|
|
42
|
+
const selected = databases[selectedIndex];
|
|
43
|
+
if (selected) {
|
|
44
|
+
onSelect(selected);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (key.name === "down" || key.name === "j") {
|
|
50
|
+
setSelectedIndex((i) => Math.min(i + 1, totalItems - 1));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key.name === "up" || key.name === "k") {
|
|
54
|
+
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// border(2) + padding(2) + title(1) + marginTop(1) + items + spacer(1) + create_new(1) + marginTop(1) + help(1)
|
|
60
|
+
const height = Math.min(totalItems + 10, 20);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Modal title="Select Database" height={height} theme={theme}>
|
|
64
|
+
<box style={{ flexGrow: 1, marginTop: 1 }}>
|
|
65
|
+
{databases.map((db, index) => {
|
|
66
|
+
const isSelected = index === selectedIndex;
|
|
67
|
+
const isCurrent = db === currentDatabase;
|
|
68
|
+
const displayName = db.replace(/\.db$/, "");
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<box
|
|
72
|
+
key={db}
|
|
73
|
+
style={{
|
|
74
|
+
paddingLeft: 1,
|
|
75
|
+
paddingRight: 1,
|
|
76
|
+
backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<text>
|
|
80
|
+
<span fg={isSelected ? colors.selectedText : colors.textPrimary}>
|
|
81
|
+
{isSelected ? "▸ " : " "}
|
|
82
|
+
</span>
|
|
83
|
+
<span
|
|
84
|
+
fg={isSelected ? colors.selectedText : colors.textPrimary}
|
|
85
|
+
attributes={isSelected ? "bold" : undefined}
|
|
86
|
+
>
|
|
87
|
+
{displayName}
|
|
88
|
+
</span>
|
|
89
|
+
{isCurrent && (
|
|
90
|
+
<span fg={colors.success}> (active)</span>
|
|
91
|
+
)}
|
|
92
|
+
</text>
|
|
93
|
+
</box>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
|
|
97
|
+
{/* Create new option */}
|
|
98
|
+
<box
|
|
99
|
+
style={{
|
|
100
|
+
paddingLeft: 1,
|
|
101
|
+
paddingRight: 1,
|
|
102
|
+
marginTop: databases.length > 0 ? 1 : 0,
|
|
103
|
+
backgroundColor: selectedIndex === databases.length ? colors.selectedRowBg : "transparent",
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<text>
|
|
107
|
+
<span fg={selectedIndex === databases.length ? colors.selectedText : colors.accent}>
|
|
108
|
+
{selectedIndex === databases.length ? "▸ " : " "}
|
|
109
|
+
</span>
|
|
110
|
+
<span
|
|
111
|
+
fg={selectedIndex === databases.length ? colors.selectedText : colors.accent}
|
|
112
|
+
attributes={selectedIndex === databases.length ? "bold" : undefined}
|
|
113
|
+
>
|
|
114
|
+
+ Create new database...
|
|
115
|
+
</span>
|
|
116
|
+
</text>
|
|
117
|
+
</box>
|
|
118
|
+
</box>
|
|
119
|
+
<text fg={colors.textMuted} style={{ marginTop: 1 }}>
|
|
120
|
+
↑/↓ to navigate, Enter to select, Esc to cancel
|
|
121
|
+
</text>
|
|
122
|
+
</Modal>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -5,31 +5,46 @@ interface SettingsViewProps {
|
|
|
5
5
|
settings: AppSettings;
|
|
6
6
|
selectedIndex: number;
|
|
7
7
|
theme: Theme;
|
|
8
|
+
currentDbFilename: string;
|
|
9
|
+
onSelectDatabase: () => void;
|
|
8
10
|
onEditBusinessName: () => void;
|
|
9
11
|
onEditStripeKey: () => void;
|
|
10
12
|
onEditTimezone: () => void;
|
|
11
13
|
onSelectTheme: () => void;
|
|
14
|
+
onToggleMenuBar: () => void;
|
|
12
15
|
onExportDatabase: () => void;
|
|
13
16
|
onImportDatabase: () => void;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
type SettingsItem = { key: string; label: string; type: string };
|
|
20
|
+
|
|
21
|
+
const BUSINESS_SETTINGS: SettingsItem[] = [
|
|
17
22
|
{ key: "businessName", label: "Business Name", type: "text" },
|
|
18
23
|
{ key: "stripeApiKey", label: "Stripe API Key", type: "secret" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const APP_SETTINGS: SettingsItem[] = [
|
|
27
|
+
{ key: "database", label: "Database", type: "select" },
|
|
19
28
|
{ key: "theme", label: "Theme", type: "select" },
|
|
20
29
|
{ key: "timezone", label: "Timezone", type: "text" },
|
|
30
|
+
{ key: "menuBar", label: "Menu Bar", type: "toggle" },
|
|
21
31
|
{ key: "exportDatabase", label: "Export Database", type: "action" },
|
|
22
32
|
{ key: "importDatabase", label: "Import Database", type: "action" },
|
|
23
|
-
]
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const ALL_SETTINGS = [...BUSINESS_SETTINGS, ...APP_SETTINGS];
|
|
24
36
|
|
|
25
37
|
export function SettingsView({
|
|
26
38
|
settings,
|
|
27
39
|
selectedIndex,
|
|
28
40
|
theme,
|
|
41
|
+
currentDbFilename,
|
|
42
|
+
onSelectDatabase,
|
|
29
43
|
onEditBusinessName,
|
|
30
44
|
onEditStripeKey,
|
|
31
45
|
onEditTimezone,
|
|
32
46
|
onSelectTheme,
|
|
47
|
+
onToggleMenuBar,
|
|
33
48
|
onExportDatabase,
|
|
34
49
|
onImportDatabase,
|
|
35
50
|
}: SettingsViewProps) {
|
|
@@ -55,6 +70,8 @@ export function SettingsView({
|
|
|
55
70
|
|
|
56
71
|
const getValue = (key: string) => {
|
|
57
72
|
switch (key) {
|
|
73
|
+
case "database":
|
|
74
|
+
return currentDbFilename.replace(/\.db$/, "");
|
|
58
75
|
case "theme":
|
|
59
76
|
return getThemeDisplay();
|
|
60
77
|
case "businessName":
|
|
@@ -63,6 +80,8 @@ export function SettingsView({
|
|
|
63
80
|
return maskSecret(settings.stripeApiKey);
|
|
64
81
|
case "timezone":
|
|
65
82
|
return formatTimezone(settings.timezone);
|
|
83
|
+
case "menuBar":
|
|
84
|
+
return settings.menuBar === "enabled" ? "Enabled" : "Disabled";
|
|
66
85
|
case "exportDatabase":
|
|
67
86
|
return "Export to file...";
|
|
68
87
|
case "importDatabase":
|
|
@@ -74,6 +93,8 @@ export function SettingsView({
|
|
|
74
93
|
|
|
75
94
|
const getAction = (key: string) => {
|
|
76
95
|
switch (key) {
|
|
96
|
+
case "database":
|
|
97
|
+
return onSelectDatabase;
|
|
77
98
|
case "theme":
|
|
78
99
|
return onSelectTheme;
|
|
79
100
|
case "businessName":
|
|
@@ -82,6 +103,8 @@ export function SettingsView({
|
|
|
82
103
|
return onEditStripeKey;
|
|
83
104
|
case "timezone":
|
|
84
105
|
return onEditTimezone;
|
|
106
|
+
case "menuBar":
|
|
107
|
+
return onToggleMenuBar;
|
|
85
108
|
case "exportDatabase":
|
|
86
109
|
return onExportDatabase;
|
|
87
110
|
case "importDatabase":
|
|
@@ -94,9 +117,42 @@ export function SettingsView({
|
|
|
94
117
|
const getActionLabel = (type: string) => {
|
|
95
118
|
if (type === "action") return "run";
|
|
96
119
|
if (type === "select") return "select";
|
|
120
|
+
if (type === "toggle") return "toggle";
|
|
97
121
|
return "edit";
|
|
98
122
|
};
|
|
99
123
|
|
|
124
|
+
const renderItem = (item: SettingsItem, globalIndex: number) => {
|
|
125
|
+
const isSelected = globalIndex === selectedIndex;
|
|
126
|
+
return (
|
|
127
|
+
<box
|
|
128
|
+
key={item.key}
|
|
129
|
+
style={{
|
|
130
|
+
flexDirection: "row",
|
|
131
|
+
paddingLeft: 1,
|
|
132
|
+
paddingRight: 1,
|
|
133
|
+
backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<box style={{ width: 20 }}>
|
|
137
|
+
<text
|
|
138
|
+
fg={isSelected ? colors.selectedText : colors.textPrimary}
|
|
139
|
+
attributes={isSelected ? "bold" : undefined}
|
|
140
|
+
>
|
|
141
|
+
{item.label}
|
|
142
|
+
</text>
|
|
143
|
+
</box>
|
|
144
|
+
<box style={{ flexGrow: 1 }}>
|
|
145
|
+
<text fg={isSelected ? colors.selectedText : colors.textSecondary}>
|
|
146
|
+
{getValue(item.key)}
|
|
147
|
+
</text>
|
|
148
|
+
</box>
|
|
149
|
+
{isSelected && (
|
|
150
|
+
<text fg={colors.accent}>[Enter to {getActionLabel(item.type)}]</text>
|
|
151
|
+
)}
|
|
152
|
+
</box>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
100
156
|
return (
|
|
101
157
|
<box
|
|
102
158
|
style={{
|
|
@@ -105,50 +161,31 @@ export function SettingsView({
|
|
|
105
161
|
padding: 1,
|
|
106
162
|
}}
|
|
107
163
|
>
|
|
108
|
-
<box
|
|
109
|
-
|
|
164
|
+
<box
|
|
165
|
+
title="Business"
|
|
166
|
+
style={{
|
|
167
|
+
border: true,
|
|
168
|
+
borderColor: colors.borderSubtle,
|
|
169
|
+
padding: 1,
|
|
170
|
+
flexDirection: "column",
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{BUSINESS_SETTINGS.map((item, i) => renderItem(item, i))}
|
|
110
174
|
</box>
|
|
111
175
|
|
|
112
176
|
<box
|
|
113
|
-
title="
|
|
177
|
+
title="App"
|
|
114
178
|
style={{
|
|
115
179
|
border: true,
|
|
116
180
|
borderColor: colors.borderSubtle,
|
|
117
181
|
padding: 1,
|
|
118
182
|
flexDirection: "column",
|
|
183
|
+
marginTop: 1,
|
|
119
184
|
}}
|
|
120
185
|
>
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<box
|
|
125
|
-
key={item.key}
|
|
126
|
-
style={{
|
|
127
|
-
flexDirection: "row",
|
|
128
|
-
paddingLeft: 1,
|
|
129
|
-
paddingRight: 1,
|
|
130
|
-
backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
|
|
131
|
-
}}
|
|
132
|
-
>
|
|
133
|
-
<box style={{ width: 20 }}>
|
|
134
|
-
<text
|
|
135
|
-
fg={isSelected ? colors.selectedText : colors.textPrimary}
|
|
136
|
-
attributes={isSelected ? "bold" : undefined}
|
|
137
|
-
>
|
|
138
|
-
{item.label}
|
|
139
|
-
</text>
|
|
140
|
-
</box>
|
|
141
|
-
<box style={{ flexGrow: 1 }}>
|
|
142
|
-
<text fg={isSelected ? colors.selectedText : colors.textSecondary}>
|
|
143
|
-
{getValue(item.key)}
|
|
144
|
-
</text>
|
|
145
|
-
</box>
|
|
146
|
-
{isSelected && (
|
|
147
|
-
<text fg={colors.accent}>[Enter to {getActionLabel(item.type)}]</text>
|
|
148
|
-
)}
|
|
149
|
-
</box>
|
|
150
|
-
);
|
|
151
|
-
})}
|
|
186
|
+
{APP_SETTINGS.map((item, i) =>
|
|
187
|
+
renderItem(item, BUSINESS_SETTINGS.length + i),
|
|
188
|
+
)}
|
|
152
189
|
</box>
|
|
153
190
|
|
|
154
191
|
<box style={{ marginTop: 2 }}>
|
|
@@ -158,10 +195,10 @@ export function SettingsView({
|
|
|
158
195
|
</box>
|
|
159
196
|
|
|
160
197
|
<box style={{ marginTop: 1 }}>
|
|
161
|
-
<text fg={colors.textMuted}>Database
|
|
198
|
+
<text fg={colors.textMuted}>Database: ~/.paca/{currentDbFilename}</text>
|
|
162
199
|
</box>
|
|
163
200
|
</box>
|
|
164
201
|
);
|
|
165
202
|
}
|
|
166
203
|
|
|
167
|
-
export const SETTINGS_COUNT =
|
|
204
|
+
export const SETTINGS_COUNT = ALL_SETTINGS.length;
|
package/src/components/index.ts
CHANGED
|
@@ -15,3 +15,4 @@ export { CustomerSelectModal } from "./CustomerSelectModal.tsx";
|
|
|
15
15
|
export { EditTimeEntryModal } from "./EditTimeEntryModal.tsx";
|
|
16
16
|
export { CreateInvoiceModal } from "./CreateInvoiceModal.tsx";
|
|
17
17
|
export { ThemeSelectModal } from "./ThemeSelectModal.tsx";
|
|
18
|
+
export { DatabaseSelectModal } from "./DatabaseSelectModal.tsx";
|