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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/generated/prisma/browser.ts +59 -0
  4. package/generated/prisma/client.ts +81 -0
  5. package/generated/prisma/commonInputTypes.ts +402 -0
  6. package/generated/prisma/enums.ts +15 -0
  7. package/generated/prisma/internal/class.ts +260 -0
  8. package/generated/prisma/internal/prismaNamespace.ts +1362 -0
  9. package/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  10. package/generated/prisma/models/Customer.ts +1489 -0
  11. package/generated/prisma/models/Invoice.ts +1837 -0
  12. package/generated/prisma/models/Project.ts +1981 -0
  13. package/generated/prisma/models/Setting.ts +1086 -0
  14. package/generated/prisma/models/Tag.ts +1288 -0
  15. package/generated/prisma/models/Task.ts +1669 -0
  16. package/generated/prisma/models/TaskTag.ts +1340 -0
  17. package/generated/prisma/models/TimeEntry.ts +1602 -0
  18. package/generated/prisma/models.ts +19 -0
  19. package/package.json +71 -0
  20. package/prisma/migrations/20260115051911_init/migration.sql +71 -0
  21. package/prisma/migrations/20260115062427_add_time_tracking/migration.sql +20 -0
  22. package/prisma/migrations/20260117233250_add_customers_invoices/migration.sql +81 -0
  23. package/prisma/migrations/migration_lock.toml +3 -0
  24. package/prisma/schema.prisma +162 -0
  25. package/src/App.tsx +1492 -0
  26. package/src/components/CreateInvoiceModal.tsx +222 -0
  27. package/src/components/CustomerModal.tsx +158 -0
  28. package/src/components/CustomerSelectModal.tsx +142 -0
  29. package/src/components/Dashboard.tsx +242 -0
  30. package/src/components/DateTimePicker.tsx +335 -0
  31. package/src/components/EditTimeEntryModal.tsx +293 -0
  32. package/src/components/Header.tsx +65 -0
  33. package/src/components/HelpView.tsx +109 -0
  34. package/src/components/InputModal.tsx +79 -0
  35. package/src/components/InvoicesView.tsx +297 -0
  36. package/src/components/Modal.tsx +38 -0
  37. package/src/components/ProjectList.tsx +114 -0
  38. package/src/components/ProjectModal.tsx +116 -0
  39. package/src/components/SettingsView.tsx +145 -0
  40. package/src/components/SplashScreen.tsx +25 -0
  41. package/src/components/StatusBar.tsx +93 -0
  42. package/src/components/TaskList.tsx +143 -0
  43. package/src/components/Timer.tsx +95 -0
  44. package/src/components/TimerModals.tsx +120 -0
  45. package/src/components/TimesheetView.tsx +218 -0
  46. package/src/components/index.ts +17 -0
  47. package/src/db.ts +629 -0
  48. package/src/hooks/usePaste.ts +69 -0
  49. package/src/index.tsx +75 -0
  50. package/src/stripe.ts +163 -0
  51. package/src/types.ts +361 -0
@@ -0,0 +1,17 @@
1
+ export { Header } from "./Header.tsx";
2
+ export { StatusBar } from "./StatusBar.tsx";
3
+ export { ProjectList } from "./ProjectList.tsx";
4
+ export { TaskList } from "./TaskList.tsx";
5
+ export { Dashboard } from "./Dashboard.tsx";
6
+ export { HelpView } from "./HelpView.tsx";
7
+ export { SettingsView, SETTINGS_COUNT } from "./SettingsView.tsx";
8
+ export { InputModal, ConfirmModal } from "./InputModal.tsx";
9
+ export { ProjectModal } from "./ProjectModal.tsx";
10
+ export { Timer, formatDurationHuman } from "./Timer.tsx";
11
+ export { ProjectSelectModal, StopTimerModal } from "./TimerModals.tsx";
12
+ export { SplashScreen } from "./SplashScreen.tsx";
13
+ export { TimesheetView } from "./TimesheetView.tsx";
14
+ export { CustomerModal } from "./CustomerModal.tsx";
15
+ export { CustomerSelectModal } from "./CustomerSelectModal.tsx";
16
+ export { EditTimeEntryModal } from "./EditTimeEntryModal.tsx";
17
+ export { CreateInvoiceModal } from "./CreateInvoiceModal.tsx";
package/src/db.ts ADDED
@@ -0,0 +1,629 @@
1
+ import { PrismaClient } from "../generated/prisma/client.ts";
2
+ import { PrismaLibSql } from "@prisma/adapter-libsql";
3
+ import { existsSync, mkdirSync } from "fs";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+
7
+ // Ensure the .paca directory exists in user's home
8
+ const pacaDir = join(homedir(), ".paca");
9
+ if (!existsSync(pacaDir)) {
10
+ mkdirSync(pacaDir, { recursive: true });
11
+ }
12
+
13
+ // Database path
14
+ export const DB_PATH = join(pacaDir, "paca.db");
15
+
16
+ // Create adapter factory
17
+ const adapterFactory = new PrismaLibSql({
18
+ url: `file:${DB_PATH}`,
19
+ });
20
+
21
+ // Create Prisma client with adapter
22
+ export const db = new PrismaClient({ adapter: adapterFactory });
23
+
24
+ // Initialize database connection
25
+ export async function initDatabase() {
26
+ try {
27
+ await db.$connect();
28
+ return true;
29
+ } catch (error) {
30
+ console.error("Failed to connect to database:", error);
31
+ return false;
32
+ }
33
+ }
34
+
35
+ // Disconnect from database
36
+ export async function closeDatabase() {
37
+ await db.$disconnect();
38
+ }
39
+
40
+ // Status sort order: in_progress first, then todo, then done
41
+ const STATUS_ORDER: Record<string, number> = {
42
+ in_progress: 0,
43
+ todo: 1,
44
+ done: 2,
45
+ };
46
+
47
+ const sortByStatus = <T extends { status: string; priority: string; createdAt: Date }>(tasks: T[]): T[] => {
48
+ const priorityOrder: Record<string, number> = { urgent: 0, high: 1, medium: 2, low: 3 };
49
+ return [...tasks].sort((a, b) => {
50
+ const statusDiff = (STATUS_ORDER[a.status] ?? 99) - (STATUS_ORDER[b.status] ?? 99);
51
+ if (statusDiff !== 0) return statusDiff;
52
+ const priorityDiff = (priorityOrder[a.priority] ?? 99) - (priorityOrder[b.priority] ?? 99);
53
+ if (priorityDiff !== 0) return priorityDiff;
54
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
55
+ });
56
+ };
57
+
58
+ // Clean up old completed tasks (completed more than N days ago)
59
+ export async function cleanupOldCompletedTasks(daysOld = 3) {
60
+ const cutoffDate = new Date();
61
+ cutoffDate.setDate(cutoffDate.getDate() - daysOld);
62
+
63
+ const result = await db.task.deleteMany({
64
+ where: {
65
+ status: "done",
66
+ completedAt: { lt: cutoffDate },
67
+ },
68
+ });
69
+
70
+ return result.count;
71
+ }
72
+
73
+ // Project operations
74
+ export const projects = {
75
+ async getAll(includeArchived = false) {
76
+ return db.project.findMany({
77
+ where: includeArchived ? {} : { archived: false },
78
+ orderBy: { name: "asc" },
79
+ include: {
80
+ tasks: {
81
+ select: {
82
+ id: true,
83
+ status: true,
84
+ },
85
+ },
86
+ customer: true,
87
+ },
88
+ });
89
+ },
90
+
91
+ async getById(id: string) {
92
+ const project = await db.project.findUnique({
93
+ where: { id },
94
+ include: {
95
+ tasks: true,
96
+ customer: true,
97
+ },
98
+ });
99
+ if (project) {
100
+ project.tasks = sortByStatus(project.tasks);
101
+ }
102
+ return project;
103
+ },
104
+
105
+ async create(data: {
106
+ name: string;
107
+ description?: string;
108
+ color?: string;
109
+ hourlyRate?: number;
110
+ }) {
111
+ return db.project.create({ data });
112
+ },
113
+
114
+ async update(
115
+ id: string,
116
+ data: {
117
+ name?: string;
118
+ description?: string;
119
+ color?: string;
120
+ hourlyRate?: number | null;
121
+ archived?: boolean;
122
+ },
123
+ ) {
124
+ return db.project.update({ where: { id }, data });
125
+ },
126
+
127
+ async archive(id: string) {
128
+ return db.project.update({ where: { id }, data: { archived: true } });
129
+ },
130
+
131
+ async unarchive(id: string) {
132
+ return db.project.update({ where: { id }, data: { archived: false } });
133
+ },
134
+
135
+ async delete(id: string) {
136
+ return db.project.delete({ where: { id } });
137
+ },
138
+
139
+ async setCustomer(projectId: string, customerId: string | null) {
140
+ return db.project.update({
141
+ where: { id: projectId },
142
+ data: { customerId },
143
+ include: { customer: true },
144
+ });
145
+ },
146
+ };
147
+
148
+ // Task operations
149
+ export const tasks = {
150
+ async getByProject(projectId: string) {
151
+ const result = await db.task.findMany({
152
+ where: { projectId },
153
+ include: {
154
+ tags: {
155
+ include: { tag: true },
156
+ },
157
+ },
158
+ });
159
+ return sortByStatus(result);
160
+ },
161
+
162
+ async getAll() {
163
+ const result = await db.task.findMany({
164
+ include: {
165
+ project: true,
166
+ tags: {
167
+ include: { tag: true },
168
+ },
169
+ },
170
+ });
171
+ return sortByStatus(result);
172
+ },
173
+
174
+ async create(data: {
175
+ title: string;
176
+ description?: string;
177
+ projectId: string;
178
+ priority?: string;
179
+ dueDate?: Date;
180
+ }) {
181
+ return db.task.create({ data });
182
+ },
183
+
184
+ async update(
185
+ id: string,
186
+ data: {
187
+ title?: string;
188
+ description?: string;
189
+ status?: string;
190
+ priority?: string;
191
+ dueDate?: Date | null;
192
+ },
193
+ ) {
194
+ const updateData: Record<string, unknown> = { ...data };
195
+ if (data.status === "done") {
196
+ updateData.completedAt = new Date();
197
+ } else if (data.status && data.status !== "done") {
198
+ updateData.completedAt = null;
199
+ }
200
+ return db.task.update({ where: { id }, data: updateData });
201
+ },
202
+
203
+ async toggleStatus(id: string) {
204
+ const task = await db.task.findUnique({ where: { id } });
205
+ if (!task) return null;
206
+
207
+ const statusCycle: Record<string, string> = {
208
+ todo: "in_progress",
209
+ in_progress: "done",
210
+ done: "todo",
211
+ };
212
+
213
+ const newStatus = statusCycle[task.status] || "todo";
214
+ return this.update(id, { status: newStatus });
215
+ },
216
+
217
+ async delete(id: string) {
218
+ return db.task.delete({ where: { id } });
219
+ },
220
+ };
221
+
222
+ // Tag operations
223
+ export const tags = {
224
+ async getAll() {
225
+ return db.tag.findMany({
226
+ orderBy: { name: "asc" },
227
+ });
228
+ },
229
+
230
+ async create(data: { name: string; color?: string }) {
231
+ return db.tag.create({ data });
232
+ },
233
+
234
+ async addToTask(taskId: string, tagId: string) {
235
+ return db.taskTag.create({
236
+ data: { taskId, tagId },
237
+ });
238
+ },
239
+
240
+ async removeFromTask(taskId: string, tagId: string) {
241
+ return db.taskTag.delete({
242
+ where: { taskId_tagId: { taskId, tagId } },
243
+ });
244
+ },
245
+ };
246
+
247
+ // Stats for dashboard
248
+ export const stats = {
249
+ async getDashboardStats() {
250
+ const [
251
+ totalProjects,
252
+ activeProjects,
253
+ totalTasks,
254
+ todoTasks,
255
+ inProgressTasks,
256
+ doneTasks,
257
+ overdueTasks,
258
+ ] = await Promise.all([
259
+ db.project.count(),
260
+ db.project.count({ where: { archived: false } }),
261
+ db.task.count(),
262
+ db.task.count({ where: { status: "todo" } }),
263
+ db.task.count({ where: { status: "in_progress" } }),
264
+ db.task.count({ where: { status: "done" } }),
265
+ db.task.count({
266
+ where: {
267
+ status: { not: "done" },
268
+ dueDate: { lt: new Date() },
269
+ },
270
+ }),
271
+ ]);
272
+
273
+ return {
274
+ totalProjects,
275
+ activeProjects,
276
+ archivedProjects: totalProjects - activeProjects,
277
+ totalTasks,
278
+ todoTasks,
279
+ inProgressTasks,
280
+ doneTasks,
281
+ overdueTasks,
282
+ completionRate:
283
+ totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0,
284
+ };
285
+ },
286
+
287
+ async getRecentActivity(limit = 10) {
288
+ const tasks = await db.task.findMany({
289
+ take: limit * 2, // Fetch more to ensure good coverage after sorting
290
+ orderBy: { updatedAt: "desc" },
291
+ include: {
292
+ project: {
293
+ select: { name: true, color: true },
294
+ },
295
+ },
296
+ });
297
+
298
+ // Sort by status (in_progress -> todo -> done), then by updatedAt within each group
299
+ return tasks
300
+ .sort((a, b) => {
301
+ const statusDiff = (STATUS_ORDER[a.status] ?? 99) - (STATUS_ORDER[b.status] ?? 99);
302
+ if (statusDiff !== 0) return statusDiff;
303
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
304
+ })
305
+ .slice(0, limit);
306
+ },
307
+
308
+ async getTimeStats() {
309
+ const today = new Date();
310
+ today.setHours(0, 0, 0, 0);
311
+
312
+ const weekStart = new Date(today);
313
+ weekStart.setDate(weekStart.getDate() - weekStart.getDay());
314
+
315
+ const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
316
+
317
+ const [todayEntries, weekEntries, monthEntries] = await Promise.all([
318
+ db.timeEntry.findMany({
319
+ where: { startTime: { gte: today }, endTime: { not: null } },
320
+ }),
321
+ db.timeEntry.findMany({
322
+ where: { startTime: { gte: weekStart }, endTime: { not: null } },
323
+ }),
324
+ db.timeEntry.findMany({
325
+ where: { startTime: { gte: monthStart }, endTime: { not: null } },
326
+ }),
327
+ ]);
328
+
329
+ const calcDuration = (
330
+ entries: { startTime: Date; endTime: Date | null }[],
331
+ ) =>
332
+ entries.reduce((sum, e) => {
333
+ if (!e.endTime) return sum;
334
+ return (
335
+ sum +
336
+ (new Date(e.endTime).getTime() - new Date(e.startTime).getTime())
337
+ );
338
+ }, 0);
339
+
340
+ return {
341
+ todayMs: calcDuration(todayEntries),
342
+ weekMs: calcDuration(weekEntries),
343
+ monthMs: calcDuration(monthEntries),
344
+ };
345
+ },
346
+ };
347
+
348
+ // Time entry operations
349
+ export const timeEntries = {
350
+ async getRunning() {
351
+ return db.timeEntry.findFirst({
352
+ where: { endTime: null },
353
+ include: {
354
+ project: {
355
+ select: { id: true, name: true, color: true, hourlyRate: true },
356
+ },
357
+ },
358
+ });
359
+ },
360
+
361
+ async start(projectId: string) {
362
+ // Stop any existing running timer first
363
+ const running = await this.getRunning();
364
+ if (running) {
365
+ await this.stop(running.id, "Timer stopped automatically");
366
+ }
367
+
368
+ return db.timeEntry.create({
369
+ data: {
370
+ projectId,
371
+ startTime: new Date(),
372
+ },
373
+ include: {
374
+ project: {
375
+ select: { id: true, name: true, color: true, hourlyRate: true },
376
+ },
377
+ },
378
+ });
379
+ },
380
+
381
+ async stop(id: string, description?: string) {
382
+ return db.timeEntry.update({
383
+ where: { id },
384
+ data: {
385
+ endTime: new Date(),
386
+ description,
387
+ },
388
+ include: {
389
+ project: {
390
+ select: { id: true, name: true, color: true, hourlyRate: true },
391
+ },
392
+ },
393
+ });
394
+ },
395
+
396
+ async getByProject(projectId: string, limit = 50) {
397
+ return db.timeEntry.findMany({
398
+ where: { projectId, endTime: { not: null } },
399
+ orderBy: { startTime: "desc" },
400
+ take: limit,
401
+ });
402
+ },
403
+
404
+ async getRecent(limit = 10) {
405
+ return db.timeEntry.findMany({
406
+ where: { endTime: { not: null } },
407
+ orderBy: { startTime: "desc" },
408
+ take: limit,
409
+ include: {
410
+ project: {
411
+ select: { id: true, name: true, color: true, hourlyRate: true },
412
+ },
413
+ },
414
+ });
415
+ },
416
+
417
+ async delete(id: string) {
418
+ return db.timeEntry.delete({ where: { id } });
419
+ },
420
+
421
+ async getProjectTotalTime(projectId: string) {
422
+ const entries = await db.timeEntry.findMany({
423
+ where: { projectId, endTime: { not: null } },
424
+ });
425
+
426
+ return entries.reduce((sum, e) => {
427
+ if (!e.endTime) return sum;
428
+ return (
429
+ sum + (new Date(e.endTime).getTime() - new Date(e.startTime).getTime())
430
+ );
431
+ }, 0);
432
+ },
433
+
434
+ async getUninvoiced() {
435
+ return db.timeEntry.findMany({
436
+ where: {
437
+ invoiceId: null,
438
+ endTime: { not: null },
439
+ },
440
+ orderBy: { startTime: "desc" },
441
+ include: {
442
+ project: {
443
+ select: {
444
+ id: true,
445
+ name: true,
446
+ color: true,
447
+ hourlyRate: true,
448
+ customer: true,
449
+ },
450
+ },
451
+ },
452
+ });
453
+ },
454
+
455
+ async update(
456
+ id: string,
457
+ data: { startTime?: Date; endTime?: Date; description?: string },
458
+ ) {
459
+ return db.timeEntry.update({
460
+ where: { id },
461
+ data,
462
+ include: {
463
+ project: {
464
+ select: {
465
+ id: true,
466
+ name: true,
467
+ color: true,
468
+ hourlyRate: true,
469
+ customer: true,
470
+ },
471
+ },
472
+ },
473
+ });
474
+ },
475
+
476
+ async markInvoiced(ids: string[], invoiceId: string) {
477
+ return db.timeEntry.updateMany({
478
+ where: { id: { in: ids } },
479
+ data: { invoiceId },
480
+ });
481
+ },
482
+ };
483
+
484
+ // Settings operations
485
+ export const settings = {
486
+ async get(key: string): Promise<string | null> {
487
+ const setting = await db.setting.findUnique({ where: { key } });
488
+ return setting?.value ?? null;
489
+ },
490
+
491
+ async set(key: string, value: string): Promise<void> {
492
+ await db.setting.upsert({
493
+ where: { key },
494
+ update: { value },
495
+ create: { key, value },
496
+ });
497
+ },
498
+
499
+ async getAll(): Promise<Record<string, string>> {
500
+ const allSettings = await db.setting.findMany();
501
+ return Object.fromEntries(allSettings.map((s) => [s.key, s.value]));
502
+ },
503
+
504
+ async getAppSettings() {
505
+ const all = await this.getAll();
506
+ return {
507
+ businessName: all.businessName ?? "",
508
+ stripeApiKey: all.stripeApiKey ?? "",
509
+ timezone: all.timezone ?? "auto",
510
+ };
511
+ },
512
+ };
513
+
514
+ // Customer operations
515
+ export const customers = {
516
+ async getAll() {
517
+ return db.customer.findMany({
518
+ orderBy: { name: "asc" },
519
+ });
520
+ },
521
+
522
+ async getById(id: string) {
523
+ return db.customer.findUnique({ where: { id } });
524
+ },
525
+
526
+ async create(data: { name: string; email: string }) {
527
+ return db.customer.create({ data });
528
+ },
529
+
530
+ async update(id: string, data: { name?: string; email?: string; stripeCustomerId?: string | null }) {
531
+ return db.customer.update({ where: { id }, data });
532
+ },
533
+
534
+ async delete(id: string) {
535
+ return db.customer.delete({ where: { id } });
536
+ },
537
+
538
+ async updateStripeId(id: string, stripeCustomerId: string) {
539
+ return db.customer.update({
540
+ where: { id },
541
+ data: { stripeCustomerId },
542
+ });
543
+ },
544
+ };
545
+
546
+ // Invoice operations
547
+ export const invoices = {
548
+ async create(data: {
549
+ projectId: string;
550
+ customerId: string;
551
+ totalHours: number;
552
+ totalAmount: number;
553
+ timeEntryIds: string[];
554
+ }) {
555
+ const { timeEntryIds, ...invoiceData } = data;
556
+
557
+ // Create invoice and link time entries in a transaction
558
+ return db.$transaction(async (tx) => {
559
+ const invoice = await tx.invoice.create({ data: invoiceData });
560
+
561
+ // Link time entries to this invoice
562
+ await tx.timeEntry.updateMany({
563
+ where: { id: { in: timeEntryIds } },
564
+ data: { invoiceId: invoice.id },
565
+ });
566
+
567
+ return invoice;
568
+ });
569
+ },
570
+
571
+ async updateStripeId(id: string, stripeInvoiceId: string) {
572
+ return db.invoice.update({
573
+ where: { id },
574
+ data: { stripeInvoiceId },
575
+ });
576
+ },
577
+
578
+ async getByProject(projectId: string) {
579
+ return db.invoice.findMany({
580
+ where: { projectId },
581
+ orderBy: { createdAt: "desc" },
582
+ include: {
583
+ customer: true,
584
+ timeEntries: true,
585
+ },
586
+ });
587
+ },
588
+
589
+ async getByCustomer(customerId: string) {
590
+ return db.invoice.findMany({
591
+ where: { customerId },
592
+ orderBy: { createdAt: "desc" },
593
+ include: {
594
+ project: true,
595
+ timeEntries: true,
596
+ },
597
+ });
598
+ },
599
+
600
+ async getAll() {
601
+ return db.invoice.findMany({
602
+ orderBy: { createdAt: "desc" },
603
+ include: {
604
+ project: true,
605
+ customer: true,
606
+ timeEntries: true,
607
+ },
608
+ });
609
+ },
610
+ };
611
+
612
+ // Database import/export operations
613
+ export const database = {
614
+ async exportToFile(targetPath: string): Promise<void> {
615
+ const { copyFileSync } = await import("fs");
616
+ copyFileSync(DB_PATH, targetPath);
617
+ },
618
+
619
+ async importFromFile(sourcePath: string): Promise<void> {
620
+ const { copyFileSync, existsSync } = await import("fs");
621
+ if (!existsSync(sourcePath)) {
622
+ throw new Error("Source file does not exist");
623
+ }
624
+ // Close connection, copy file, reconnect
625
+ await db.$disconnect();
626
+ copyFileSync(sourcePath, DB_PATH);
627
+ await db.$connect();
628
+ },
629
+ };
@@ -0,0 +1,69 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useRenderer } from "@opentui/react";
3
+ import type { InputRenderable, PasteEvent } from "@opentui/core";
4
+
5
+ /**
6
+ * Hook to enable paste support for input components.
7
+ * Returns a ref that should be attached to the input element.
8
+ * When the input is focused and a paste event occurs, the text is inserted.
9
+ */
10
+ export function usePaste() {
11
+ const renderer = useRenderer();
12
+ const inputRef = useRef<InputRenderable | null>(null);
13
+
14
+ useEffect(() => {
15
+ const handlePaste = (event: PasteEvent) => {
16
+ const input = inputRef.current;
17
+ if (input && input.focused) {
18
+ input.insertText(event.text);
19
+ event.preventDefault();
20
+ }
21
+ };
22
+
23
+ renderer.keyInput.on("paste", handlePaste);
24
+ return () => {
25
+ renderer.keyInput.off("paste", handlePaste);
26
+ };
27
+ }, [renderer]);
28
+
29
+ return inputRef;
30
+ }
31
+
32
+ /**
33
+ * Hook to enable paste support for multiple inputs.
34
+ * Call registerInput(name) to get a ref for each input.
35
+ * When a paste event occurs, it inserts text into the focused input.
36
+ */
37
+ export function useMultiPaste() {
38
+ const renderer = useRenderer();
39
+ const inputRefs = useRef<Map<string, InputRenderable | null>>(new Map());
40
+
41
+ useEffect(() => {
42
+ const handlePaste = (event: PasteEvent) => {
43
+ for (const input of inputRefs.current.values()) {
44
+ if (input && input.focused) {
45
+ input.insertText(event.text);
46
+ event.preventDefault();
47
+ return;
48
+ }
49
+ }
50
+ };
51
+
52
+ renderer.keyInput.on("paste", handlePaste);
53
+ return () => {
54
+ renderer.keyInput.off("paste", handlePaste);
55
+ };
56
+ }, [renderer]);
57
+
58
+ const registerInput = (name: string) => {
59
+ return (ref: InputRenderable | null) => {
60
+ if (ref) {
61
+ inputRefs.current.set(name, ref);
62
+ } else {
63
+ inputRefs.current.delete(name);
64
+ }
65
+ };
66
+ };
67
+
68
+ return { registerInput };
69
+ }