pacatui 0.1.0
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/LICENSE +21 -0
- package/README.md +153 -0
- package/generated/prisma/browser.ts +59 -0
- package/generated/prisma/client.ts +81 -0
- package/generated/prisma/commonInputTypes.ts +402 -0
- package/generated/prisma/enums.ts +15 -0
- package/generated/prisma/internal/class.ts +260 -0
- package/generated/prisma/internal/prismaNamespace.ts +1362 -0
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/generated/prisma/models/Customer.ts +1489 -0
- package/generated/prisma/models/Invoice.ts +1837 -0
- package/generated/prisma/models/Project.ts +1981 -0
- package/generated/prisma/models/Setting.ts +1086 -0
- package/generated/prisma/models/Tag.ts +1288 -0
- package/generated/prisma/models/Task.ts +1669 -0
- package/generated/prisma/models/TaskTag.ts +1340 -0
- package/generated/prisma/models/TimeEntry.ts +1602 -0
- package/generated/prisma/models.ts +19 -0
- package/package.json +71 -0
- package/prisma/migrations/20260115051911_init/migration.sql +71 -0
- package/prisma/migrations/20260115062427_add_time_tracking/migration.sql +20 -0
- package/prisma/migrations/20260117233250_add_customers_invoices/migration.sql +81 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +162 -0
- package/src/App.tsx +1492 -0
- package/src/components/CreateInvoiceModal.tsx +222 -0
- package/src/components/CustomerModal.tsx +158 -0
- package/src/components/CustomerSelectModal.tsx +142 -0
- package/src/components/Dashboard.tsx +242 -0
- package/src/components/DateTimePicker.tsx +335 -0
- package/src/components/EditTimeEntryModal.tsx +293 -0
- package/src/components/Header.tsx +65 -0
- package/src/components/HelpView.tsx +109 -0
- package/src/components/InputModal.tsx +79 -0
- package/src/components/InvoicesView.tsx +297 -0
- package/src/components/Modal.tsx +38 -0
- package/src/components/ProjectList.tsx +114 -0
- package/src/components/ProjectModal.tsx +116 -0
- package/src/components/SettingsView.tsx +145 -0
- package/src/components/SplashScreen.tsx +25 -0
- package/src/components/StatusBar.tsx +93 -0
- package/src/components/TaskList.tsx +143 -0
- package/src/components/Timer.tsx +95 -0
- package/src/components/TimerModals.tsx +120 -0
- package/src/components/TimesheetView.tsx +218 -0
- package/src/components/index.ts +17 -0
- package/src/db.ts +629 -0
- package/src/hooks/usePaste.ts +69 -0
- package/src/index.tsx +75 -0
- package/src/stripe.ts +163 -0
- package/src/types.ts +361 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import { COLORS, type TimeEntry } from "../types";
|
|
4
|
+
import Modal from "./Modal";
|
|
5
|
+
import { DateTimePicker } from "./DateTimePicker";
|
|
6
|
+
import { usePaste } from "../hooks/usePaste";
|
|
7
|
+
|
|
8
|
+
interface EditTimeEntryModalProps {
|
|
9
|
+
entry: TimeEntry & {
|
|
10
|
+
project: { name: string; color: string; hourlyRate: number | null };
|
|
11
|
+
};
|
|
12
|
+
timezone: string;
|
|
13
|
+
onSubmit: (startTime: Date, endTime: Date, description: string) => void;
|
|
14
|
+
onCancel: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatDuration(ms: number): string {
|
|
18
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
19
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
20
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
21
|
+
|
|
22
|
+
if (hours > 0) {
|
|
23
|
+
return `${hours}h ${minutes}m`;
|
|
24
|
+
}
|
|
25
|
+
return `${minutes}m`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ActiveSection = "start" | "end" | "description";
|
|
29
|
+
|
|
30
|
+
export function EditTimeEntryModal({
|
|
31
|
+
entry,
|
|
32
|
+
timezone,
|
|
33
|
+
onSubmit,
|
|
34
|
+
onCancel,
|
|
35
|
+
}: EditTimeEntryModalProps) {
|
|
36
|
+
const startDate =
|
|
37
|
+
entry.startTime instanceof Date
|
|
38
|
+
? entry.startTime
|
|
39
|
+
: new Date(entry.startTime);
|
|
40
|
+
const endDate = entry.endTime
|
|
41
|
+
? entry.endTime instanceof Date
|
|
42
|
+
? entry.endTime
|
|
43
|
+
: new Date(entry.endTime)
|
|
44
|
+
: new Date();
|
|
45
|
+
|
|
46
|
+
const [startTime, setStartTime] = useState(startDate);
|
|
47
|
+
const [endTime, setEndTime] = useState(endDate);
|
|
48
|
+
const [description, setDescription] = useState(entry.description ?? "");
|
|
49
|
+
const [activeSection, setActiveSection] = useState<ActiveSection>("description");
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const descriptionInputRef = usePaste();
|
|
52
|
+
|
|
53
|
+
const startPicker = DateTimePicker({
|
|
54
|
+
value: startTime,
|
|
55
|
+
timezone,
|
|
56
|
+
focused: activeSection === "start",
|
|
57
|
+
onChange: (date) => {
|
|
58
|
+
setStartTime(date);
|
|
59
|
+
setError(null);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const endPicker = DateTimePicker({
|
|
64
|
+
value: endTime,
|
|
65
|
+
timezone,
|
|
66
|
+
focused: activeSection === "end",
|
|
67
|
+
onChange: (date) => {
|
|
68
|
+
setEndTime(date);
|
|
69
|
+
setError(null);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
useKeyboard((key) => {
|
|
74
|
+
if (key.name === "escape") {
|
|
75
|
+
onCancel();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Submit on Enter from any section
|
|
80
|
+
if (key.name === "return") {
|
|
81
|
+
handleSubmit();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Switch between sections with Shift+Tab or specific keys
|
|
86
|
+
if (key.shift && key.name === "tab") {
|
|
87
|
+
setActiveSection((s) => {
|
|
88
|
+
if (s === "description") return "end";
|
|
89
|
+
if (s === "end") return "start";
|
|
90
|
+
return "description";
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Handle date picker navigation when in start/end sections
|
|
96
|
+
if (activeSection === "start" || activeSection === "end") {
|
|
97
|
+
const picker = activeSection === "start" ? startPicker : endPicker;
|
|
98
|
+
|
|
99
|
+
// Navigate within picker
|
|
100
|
+
if (key.name === "tab" && !key.shift) {
|
|
101
|
+
// Check if we're at the last field of the picker
|
|
102
|
+
const currentIdx = picker.fields.indexOf(picker.activeField);
|
|
103
|
+
if (currentIdx === picker.fields.length - 1) {
|
|
104
|
+
// Move to next section
|
|
105
|
+
setActiveSection(activeSection === "start" ? "end" : "description");
|
|
106
|
+
} else {
|
|
107
|
+
picker.handleKey("tab");
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (key.name === "left" || key.name === "h") {
|
|
113
|
+
const currentIdx = picker.fields.indexOf(picker.activeField);
|
|
114
|
+
if (currentIdx === 0 && activeSection === "end") {
|
|
115
|
+
setActiveSection("start");
|
|
116
|
+
startPicker.setActiveField("ampm");
|
|
117
|
+
} else {
|
|
118
|
+
picker.handleKey("left");
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (key.name === "right" || key.name === "l") {
|
|
124
|
+
const currentIdx = picker.fields.indexOf(picker.activeField);
|
|
125
|
+
if (currentIdx === picker.fields.length - 1) {
|
|
126
|
+
if (activeSection === "start") {
|
|
127
|
+
setActiveSection("end");
|
|
128
|
+
endPicker.setActiveField("month");
|
|
129
|
+
} else {
|
|
130
|
+
setActiveSection("description");
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
picker.handleKey("right");
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
key.name === "up" ||
|
|
140
|
+
key.name === "k" ||
|
|
141
|
+
key.name === "down" ||
|
|
142
|
+
key.name === "j"
|
|
143
|
+
) {
|
|
144
|
+
picker.handleKey(key.name);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Handle description field navigation
|
|
150
|
+
if (activeSection === "description") {
|
|
151
|
+
if (key.name === "tab" && !key.shift) {
|
|
152
|
+
setActiveSection("start");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (key.name === "left" || key.name === "h") {
|
|
156
|
+
// Let the input handle cursor movement
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const handleSubmit = () => {
|
|
163
|
+
if (endTime <= startTime) {
|
|
164
|
+
setError("End time must be after start time");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onSubmit(startTime, endTime, description.trim());
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Calculate original duration
|
|
172
|
+
const originalEndDate = entry.endTime
|
|
173
|
+
? entry.endTime instanceof Date
|
|
174
|
+
? entry.endTime
|
|
175
|
+
: new Date(entry.endTime)
|
|
176
|
+
: new Date();
|
|
177
|
+
const originalStartDate =
|
|
178
|
+
entry.startTime instanceof Date
|
|
179
|
+
? entry.startTime
|
|
180
|
+
: new Date(entry.startTime);
|
|
181
|
+
const originalDurationMs =
|
|
182
|
+
originalEndDate.getTime() - originalStartDate.getTime();
|
|
183
|
+
|
|
184
|
+
// Calculate new duration
|
|
185
|
+
const newDurationMs = endTime.getTime() - startTime.getTime();
|
|
186
|
+
const durationChangeMs = newDurationMs - originalDurationMs;
|
|
187
|
+
|
|
188
|
+
// Format duration change
|
|
189
|
+
let changeText = "";
|
|
190
|
+
let changeColor = "#64748b";
|
|
191
|
+
if (durationChangeMs !== 0) {
|
|
192
|
+
const sign = durationChangeMs > 0 ? "+" : "-";
|
|
193
|
+
changeText = ` (${sign}${formatDuration(Math.abs(durationChangeMs))})`;
|
|
194
|
+
changeColor = durationChangeMs > 0 ? "#10b981" : "#ef4444";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Calculate amount if hourly rate exists
|
|
198
|
+
let amountPreview = "";
|
|
199
|
+
if (entry.project.hourlyRate && newDurationMs > 0) {
|
|
200
|
+
const amount = (newDurationMs / 3600000) * entry.project.hourlyRate;
|
|
201
|
+
amountPreview = `$${amount.toFixed(2)}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<Modal title="Edit Time Entry" height={23}>
|
|
206
|
+
<box style={{ flexDirection: "row", marginTop: 1, gap: 2 }}>
|
|
207
|
+
<text>
|
|
208
|
+
<span fg={entry.project.color}>{entry.project.name}</span>
|
|
209
|
+
</text>
|
|
210
|
+
<text>
|
|
211
|
+
<span fg={newDurationMs > 0 ? "#ffffff" : "#ef4444"}>
|
|
212
|
+
{newDurationMs > 0 ? formatDuration(newDurationMs) : "0m"}
|
|
213
|
+
</span>
|
|
214
|
+
<span fg={changeColor}>{changeText}</span>
|
|
215
|
+
{amountPreview && <span fg="#10b981"> ({amountPreview})</span>}
|
|
216
|
+
</text>
|
|
217
|
+
</box>
|
|
218
|
+
|
|
219
|
+
{error && (
|
|
220
|
+
<box style={{ marginTop: 1 }}>
|
|
221
|
+
<text fg="#ef4444">{error}</text>
|
|
222
|
+
</box>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
<box
|
|
226
|
+
onClick={() => setActiveSection("start")}
|
|
227
|
+
style={{
|
|
228
|
+
flexDirection: "row",
|
|
229
|
+
marginTop: 1,
|
|
230
|
+
alignItems: "center",
|
|
231
|
+
gap: 2,
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
<box style={{ width: 11 }}>
|
|
235
|
+
<text fg={activeSection === "start" ? "#ffffff" : "#94a3b8"}>
|
|
236
|
+
Start Time
|
|
237
|
+
</text>
|
|
238
|
+
</box>
|
|
239
|
+
<box>{startPicker.render}</box>
|
|
240
|
+
</box>
|
|
241
|
+
|
|
242
|
+
<box
|
|
243
|
+
onClick={() => setActiveSection("end")}
|
|
244
|
+
style={{
|
|
245
|
+
flexDirection: "row",
|
|
246
|
+
marginTop: 1,
|
|
247
|
+
alignItems: "center",
|
|
248
|
+
gap: 2,
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<box style={{ width: 11 }}>
|
|
252
|
+
<text fg={activeSection === "end" ? "#ffffff" : "#94a3b8"}>
|
|
253
|
+
End Time
|
|
254
|
+
</text>
|
|
255
|
+
</box>
|
|
256
|
+
<box>{endPicker.render}</box>
|
|
257
|
+
</box>
|
|
258
|
+
|
|
259
|
+
<box
|
|
260
|
+
onClick={() => setActiveSection("description")}
|
|
261
|
+
style={{ flexDirection: "column", marginTop: 1 }}
|
|
262
|
+
>
|
|
263
|
+
<box style={{ width: "100%" }}>
|
|
264
|
+
<text fg="#fff">Description</text>
|
|
265
|
+
</box>
|
|
266
|
+
<box
|
|
267
|
+
style={{
|
|
268
|
+
border: true,
|
|
269
|
+
borderColor:
|
|
270
|
+
activeSection === "description"
|
|
271
|
+
? COLORS.border
|
|
272
|
+
: COLORS.borderOff,
|
|
273
|
+
width: "100%",
|
|
274
|
+
height: 3,
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
<input
|
|
278
|
+
ref={descriptionInputRef}
|
|
279
|
+
placeholder="What did you work on?"
|
|
280
|
+
focused={activeSection === "description"}
|
|
281
|
+
onInput={setDescription}
|
|
282
|
+
onSubmit={handleSubmit}
|
|
283
|
+
value={description}
|
|
284
|
+
/>
|
|
285
|
+
</box>
|
|
286
|
+
</box>
|
|
287
|
+
|
|
288
|
+
<box style={{ marginTop: 1 }}>
|
|
289
|
+
<text fg="#64748b">Tab: next field | Enter: save | Esc: cancel</text>
|
|
290
|
+
</box>
|
|
291
|
+
</Modal>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import type { View, RunningTimer } from "../types.ts";
|
|
3
|
+
import { Timer } from "./Timer.tsx";
|
|
4
|
+
|
|
5
|
+
interface HeaderProps {
|
|
6
|
+
currentView: View;
|
|
7
|
+
onViewChange: (view: View) => void;
|
|
8
|
+
runningTimer: RunningTimer | null;
|
|
9
|
+
onStopTimer: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const VIEWS: { key: View; label: string; shortcut: string }[] = [
|
|
13
|
+
{ key: "dashboard", label: "dash", shortcut: "1" },
|
|
14
|
+
{ key: "tasks", label: "tasks", shortcut: "2" },
|
|
15
|
+
{ key: "timesheets", label: "sheets", shortcut: "3" },
|
|
16
|
+
{ key: "invoices", label: "invoices", shortcut: "4" },
|
|
17
|
+
{ key: "settings", label: "settings", shortcut: "5" },
|
|
18
|
+
{ key: "help", label: "help", shortcut: "?" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function Header({
|
|
22
|
+
currentView,
|
|
23
|
+
runningTimer,
|
|
24
|
+
onStopTimer,
|
|
25
|
+
}: HeaderProps) {
|
|
26
|
+
return (
|
|
27
|
+
<box
|
|
28
|
+
style={{
|
|
29
|
+
width: "100%",
|
|
30
|
+
flexDirection: "column",
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{/* Main header row */}
|
|
34
|
+
<box
|
|
35
|
+
style={{
|
|
36
|
+
width: "100%",
|
|
37
|
+
height: 1,
|
|
38
|
+
flexDirection: "row",
|
|
39
|
+
justifyContent: "space-between",
|
|
40
|
+
alignItems: "center",
|
|
41
|
+
paddingLeft: 1,
|
|
42
|
+
paddingRight: 1,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<box
|
|
46
|
+
style={{
|
|
47
|
+
flexDirection: "row",
|
|
48
|
+
gap: 2,
|
|
49
|
+
alignItems: "center",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{VIEWS.map((view) => (
|
|
53
|
+
<text key={view.key}>
|
|
54
|
+
<span fg="#3b82f6">{view.shortcut} </span>
|
|
55
|
+
<span fg={currentView === view.key ? "#ffffff" : "#64748b"}>
|
|
56
|
+
{view.label}
|
|
57
|
+
</span>
|
|
58
|
+
</text>
|
|
59
|
+
))}
|
|
60
|
+
</box>
|
|
61
|
+
<Timer runningTimer={runningTimer} onStop={onStopTimer} />
|
|
62
|
+
</box>
|
|
63
|
+
</box>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const KEYBINDINGS = [
|
|
2
|
+
{
|
|
3
|
+
category: "Navigation",
|
|
4
|
+
bindings: [
|
|
5
|
+
{ key: "1", action: "Go to Dashboard" },
|
|
6
|
+
{ key: "2", action: "Go to Projects" },
|
|
7
|
+
{ key: "3", action: "Go to Tasks" },
|
|
8
|
+
{ key: "4", action: "Go to Settings" },
|
|
9
|
+
{ key: "?", action: "Show Help" },
|
|
10
|
+
{ key: "Tab", action: "Toggle panels" },
|
|
11
|
+
{ key: "h/l or ←/→", action: "Switch panels" },
|
|
12
|
+
{ key: "j/k or ↑/↓", action: "Move up/down in list" },
|
|
13
|
+
{ key: "Enter", action: "Select / Confirm" },
|
|
14
|
+
{ key: "Esc", action: "Cancel / Go back" },
|
|
15
|
+
{ key: "q", action: "Quit Paca" },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
category: "Projects",
|
|
20
|
+
bindings: [
|
|
21
|
+
{ key: "n", action: "Create new project" },
|
|
22
|
+
{ key: "e", action: "Edit project" },
|
|
23
|
+
{ key: "a", action: "Archive/Unarchive" },
|
|
24
|
+
{ key: "d", action: "Delete project" },
|
|
25
|
+
{ key: "A", action: "Toggle show archived" },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
category: "Tasks",
|
|
30
|
+
bindings: [
|
|
31
|
+
{ key: "n", action: "Create new task" },
|
|
32
|
+
{ key: "e", action: "Edit selected task" },
|
|
33
|
+
{ key: "Space", action: "Toggle status (cycle)" },
|
|
34
|
+
{ key: "p", action: "Change priority" },
|
|
35
|
+
{ key: "d", action: "Delete task" },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
category: "Time Tracking",
|
|
40
|
+
bindings: [
|
|
41
|
+
{ key: "t", action: "Start timer" },
|
|
42
|
+
{ key: "s", action: "Stop running timer" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function HelpView() {
|
|
48
|
+
return (
|
|
49
|
+
<box
|
|
50
|
+
style={{
|
|
51
|
+
flexDirection: "column",
|
|
52
|
+
flexGrow: 1,
|
|
53
|
+
padding: 1,
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<box style={{ marginBottom: 1 }}>
|
|
57
|
+
<text fg="#64748b">A tui task management and timer</text>
|
|
58
|
+
</box>
|
|
59
|
+
|
|
60
|
+
<box
|
|
61
|
+
style={{
|
|
62
|
+
flexDirection: "row",
|
|
63
|
+
flexWrap: "wrap",
|
|
64
|
+
gap: 2,
|
|
65
|
+
flexGrow: 1,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{KEYBINDINGS.map((section) => (
|
|
69
|
+
<box
|
|
70
|
+
key={section.category}
|
|
71
|
+
title={section.category}
|
|
72
|
+
style={{
|
|
73
|
+
border: true,
|
|
74
|
+
borderColor: "#334155",
|
|
75
|
+
padding: 1,
|
|
76
|
+
minWidth: 35,
|
|
77
|
+
flexGrow: 1,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{section.bindings.map((binding) => (
|
|
81
|
+
<box
|
|
82
|
+
key={binding.key}
|
|
83
|
+
style={{
|
|
84
|
+
flexDirection: "row",
|
|
85
|
+
justifyContent: "space-between",
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
<text>
|
|
89
|
+
<span fg="#3b82f6" style={{ minWidth: 12 }}>
|
|
90
|
+
{binding.key}
|
|
91
|
+
</span>
|
|
92
|
+
</text>
|
|
93
|
+
<text fg="#94a3b8">{binding.action}</text>
|
|
94
|
+
</box>
|
|
95
|
+
))}
|
|
96
|
+
</box>
|
|
97
|
+
))}
|
|
98
|
+
</box>
|
|
99
|
+
|
|
100
|
+
<box
|
|
101
|
+
style={{
|
|
102
|
+
padding: 1,
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<text fg="#475569">Database location: ~/.paca/paca.db</text>
|
|
106
|
+
</box>
|
|
107
|
+
</box>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { InputMode } from "../types.ts";
|
|
3
|
+
import Modal from "./Modal";
|
|
4
|
+
import { usePaste } from "../hooks/usePaste";
|
|
5
|
+
|
|
6
|
+
interface InputModalProps {
|
|
7
|
+
mode: InputMode;
|
|
8
|
+
title: string;
|
|
9
|
+
initialValue?: string;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
onSubmit: (value: string) => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function InputModal({
|
|
16
|
+
title,
|
|
17
|
+
initialValue = "",
|
|
18
|
+
placeholder = "",
|
|
19
|
+
onSubmit,
|
|
20
|
+
}: InputModalProps) {
|
|
21
|
+
const [value, setValue] = useState(initialValue);
|
|
22
|
+
const inputRef = usePaste();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Modal title={title}>
|
|
26
|
+
<box
|
|
27
|
+
style={{
|
|
28
|
+
border: true,
|
|
29
|
+
borderColor: "#475569",
|
|
30
|
+
height: 3,
|
|
31
|
+
marginTop: 1,
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<input
|
|
35
|
+
ref={inputRef}
|
|
36
|
+
placeholder={placeholder}
|
|
37
|
+
focused
|
|
38
|
+
value={value}
|
|
39
|
+
onInput={setValue}
|
|
40
|
+
onSubmit={() => {
|
|
41
|
+
if (value.trim()) {
|
|
42
|
+
onSubmit(value.trim());
|
|
43
|
+
}
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
</box>
|
|
47
|
+
</Modal>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ConfirmModalProps {
|
|
52
|
+
title: string;
|
|
53
|
+
message: string;
|
|
54
|
+
onConfirm: () => void;
|
|
55
|
+
onCancel: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ConfirmModal({ title, message }: ConfirmModalProps) {
|
|
59
|
+
return (
|
|
60
|
+
<Modal title={title}>
|
|
61
|
+
<text fg="#ef4444" attributes="bold">
|
|
62
|
+
{title}
|
|
63
|
+
</text>
|
|
64
|
+
<text fg="#e2e8f0" style={{ marginTop: 1 }}>
|
|
65
|
+
{message}
|
|
66
|
+
</text>
|
|
67
|
+
<box style={{ flexDirection: "row", gap: 2, marginTop: 1 }}>
|
|
68
|
+
<text>
|
|
69
|
+
<span fg="#10b981">[y]</span>
|
|
70
|
+
<span fg="#94a3b8"> Yes</span>
|
|
71
|
+
</text>
|
|
72
|
+
<text>
|
|
73
|
+
<span fg="#ef4444">[n]</span>
|
|
74
|
+
<span fg="#94a3b8"> No</span>
|
|
75
|
+
</text>
|
|
76
|
+
</box>
|
|
77
|
+
</Modal>
|
|
78
|
+
);
|
|
79
|
+
}
|