Feat/notes init (#40)
* feat: notes init * Feat/notes page (#39) * wip: notes page ui added * notes page basic ui done * wip: page UI * minor cleanup * todo comments added for easy ref for backend wiring --------- Co-authored-by: amadeus-x1 <45001978+amadeus-x1@users.noreply.github.com> * feat: wire up notes --------- Co-authored-by: amadeus-x1 <45001978+amadeus-x1@users.noreply.github.com>
This commit is contained in:
parent
674092f1b7
commit
a128ec7972
39 changed files with 2980 additions and 10 deletions
|
|
@ -82,6 +82,7 @@
|
|||
"@amical/eslint-config": "workspace:*",
|
||||
"@amical/smart-whisper": "workspace:*",
|
||||
"@amical/types": "workspace:*",
|
||||
"@amical/y-libsql": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
|
@ -141,6 +142,7 @@
|
|||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-trpc-experimental": "1.0.0-alpha.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emoji-picker-react": "^4.13.3",
|
||||
"framer-motion": "^12.10.5",
|
||||
"input-otp": "^1.4.2",
|
||||
"jest-worker": "^29.7.0",
|
||||
|
|
@ -148,6 +150,7 @@
|
|||
"libsql": "^0.5.13",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"onnxruntime-node": "^1.20.1",
|
||||
"openai": "^4.98.0",
|
||||
|
|
@ -168,6 +171,7 @@
|
|||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"workerpool": "^9.3.3",
|
||||
"yjs": "^13.6.27",
|
||||
"zod": "^3.25.24"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
apps/desktop/public/assets/meet.svg
Normal file
20
apps/desktop/public/assets/meet.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 -22.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<polygon fill="#00832D" points="144.822496 105.321856 169.778926 133.848796 203.341343 155.294133 209.178931 105.50196 203.341343 56.8331137 169.136495 75.6715889">
|
||||
</polygon>
|
||||
<path d="M0.000557021739,150.659712 L0.000557021739,193.089915 C0.000557021739,202.77838 7.86384724,210.643527 17.5541688,210.643527 L59.9843714,210.643527 L68.7704609,178.585069 L59.9843714,150.659712 L30.8744153,141.873623 L0.000557021739,150.659712 Z" fill="#0066DA">
|
||||
</path>
|
||||
<polygon fill="#E94235" points="59.9838143 9.9475983e-14 0 59.9838143 30.875715 68.7494798 59.9838143 59.9838143 68.6102243 32.4390893">
|
||||
</polygon>
|
||||
<polygon fill="#2684FC" points="0.000557021739 150.679394 59.9843714 150.679394 59.9843714 59.9832573 0.000557021739 59.9832573">
|
||||
</polygon>
|
||||
<path d="M241.658683,25.3977775 L203.341157,56.8342278 L203.341157,155.29339 L241.818362,186.852385 C247.577967,191.364261 256.003849,187.251584 256.003849,179.930462 L256.003849,32.1785888 C256.003849,24.7757699 247.377439,20.6835169 241.658683,25.3977775" fill="#00AC47">
|
||||
</path>
|
||||
<path d="M144.822496,105.321856 L144.822496,150.659712 L59.9843714,150.659712 L59.9843714,210.643527 L185.787731,210.643527 C195.478053,210.643527 203.341343,202.77838 203.341343,193.089915 L203.341343,155.294133 L144.822496,105.321856 Z" fill="#00AC47">
|
||||
</path>
|
||||
<path d="M185.787731,0 L59.9843714,0 L59.9843714,59.9838143 L144.822496,59.9838143 L144.822496,105.32167 L203.341343,56.832928 L203.341343,17.5536117 C203.341343,7.86329022 195.478053,0 185.787731,0" fill="#FFBA00">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
apps/desktop/public/assets/zoom.svg
Normal file
1
apps/desktop/public/assets/zoom.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid" viewBox="0 0 256 256"><defs><linearGradient id="zoom__a" x1="23.666%" x2="76.334%" y1="95.6118%" y2="4.3882%"><stop offset=".00006%" stop-color="#0845BF"/><stop offset="19.11%" stop-color="#0950DE"/><stop offset="38.23%" stop-color="#0B59F6"/><stop offset="50%" stop-color="#0B5CFF"/><stop offset="67.32%" stop-color="#0E5EFE"/><stop offset="77.74%" stop-color="#1665FC"/><stop offset="86.33%" stop-color="#246FF9"/><stop offset="93.88%" stop-color="#387FF4"/><stop offset="100%" stop-color="#4F90EE"/></linearGradient></defs><path fill="url(#zoom__a)" d="M256 128c0 13.568-1.024 27.136-3.328 40.192-6.912 43.264-41.216 77.568-84.48 84.48C155.136 254.976 141.568 256 128 256c-13.568 0-27.136-1.024-40.192-3.328-43.264-6.912-77.568-41.216-84.48-84.48C1.024 155.136 0 141.568 0 128c0-13.568 1.024-27.136 3.328-40.192 6.912-43.264 41.216-77.568 84.48-84.48C100.864 1.024 114.432 0 128 0c13.568 0 27.136 1.024 40.192 3.328 43.264 6.912 77.568 41.216 84.48 84.48C254.976 100.864 256 114.432 256 128Z"/><path fill="#FFF" d="M204.032 207.872H75.008c-8.448 0-16.64-4.608-20.48-12.032-4.608-8.704-2.816-19.2 4.096-26.112l89.856-89.856H83.968c-17.664 0-32-14.336-32-32h118.784c8.448 0 16.64 4.608 20.48 12.032 4.608 8.704 2.816 19.2-4.096 26.112l-89.6 90.112h74.496c17.664 0 32 14.08 32 31.744Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
63
apps/desktop/scripts/migrate-yjs-to-blob.ts
Normal file
63
apps/desktop/scripts/migrate-yjs-to-blob.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Migration script to convert base64 encoded yjs_updates to blob format
|
||||
* Run this after applying the schema migration but before running the app
|
||||
*/
|
||||
|
||||
import { db } from "../src/db";
|
||||
import { yjsUpdates } from "../src/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function migrateYjsUpdatesToBlob() {
|
||||
console.log("Starting migration of yjs_updates from base64 to blob...");
|
||||
|
||||
try {
|
||||
// Get all updates
|
||||
const updates = await db.select().from(yjsUpdates);
|
||||
console.log(`Found ${updates.length} updates to migrate`);
|
||||
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const update of updates) {
|
||||
try {
|
||||
// Check if data is already a Buffer or still base64 string
|
||||
if (typeof update.updateData === "string") {
|
||||
// Convert base64 string to Buffer
|
||||
const buffer = Buffer.from(update.updateData, "base64");
|
||||
|
||||
// Update the record with the Buffer
|
||||
await db
|
||||
.update(yjsUpdates)
|
||||
.set({ updateData: buffer })
|
||||
.where(eq(yjsUpdates.id, update.id));
|
||||
|
||||
migrated++;
|
||||
} else {
|
||||
// Already migrated
|
||||
skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to migrate update ${update.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration complete!`);
|
||||
console.log(`- Migrated: ${migrated} updates`);
|
||||
console.log(`- Skipped (already migrated): ${skipped} updates`);
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the migration
|
||||
migrateYjsUpdatesToBlob()
|
||||
.then(() => {
|
||||
console.log("Migration successful!");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Migration error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
18
apps/desktop/src/db/migrations/0002_cheerful_betty_brant.sql
Normal file
18
apps/desktop/src/db/migrations/0002_cheerful_betty_brant.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
CREATE TABLE `notes` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`content` text DEFAULT '',
|
||||
`icon` text,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `yjs_updates` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`note_id` integer NOT NULL,
|
||||
`update_data` blob NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `yjs_updates_note_id_idx` ON `yjs_updates` (`note_id`);
|
||||
491
apps/desktop/src/db/migrations/meta/0002_snapshot.json
Normal file
491
apps/desktop/src/db/migrations/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "95846ee3-06b7-46d3-97d3-511cee036d5e",
|
||||
"prevId": "ee7c367e-1078-491c-bcfe-5cca9efbd92f",
|
||||
"tables": {
|
||||
"app_settings": {
|
||||
"name": "app_settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"models": {
|
||||
"name": "models",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"context": {
|
||||
"name": "context",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"local_path": {
|
||||
"name": "local_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"checksum": {
|
||||
"name": "checksum",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"downloaded_at": {
|
||||
"name": "downloaded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"original_model": {
|
||||
"name": "original_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"speed": {
|
||||
"name": "speed",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accuracy": {
|
||||
"name": "accuracy",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"models_provider_idx": {
|
||||
"name": "models_provider_idx",
|
||||
"columns": ["provider"],
|
||||
"isUnique": false
|
||||
},
|
||||
"models_type_idx": {
|
||||
"name": "models_type_idx",
|
||||
"columns": ["type"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"models_provider_id_pk": {
|
||||
"columns": ["provider", "id"],
|
||||
"name": "models_provider_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"transcriptions": {
|
||||
"name": "transcriptions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"text": {
|
||||
"name": "text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"language": {
|
||||
"name": "language",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'en'"
|
||||
},
|
||||
"audio_file": {
|
||||
"name": "audio_file",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"confidence": {
|
||||
"name": "confidence",
|
||||
"type": "real",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"speech_model": {
|
||||
"name": "speech_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"formatting_model": {
|
||||
"name": "formatting_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"meta": {
|
||||
"name": "meta",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"vocabulary": {
|
||||
"name": "vocabulary",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"word": {
|
||||
"name": "word",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"replacement_word": {
|
||||
"name": "replacement_word",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_replacement": {
|
||||
"name": "is_replacement",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"date_added": {
|
||||
"name": "date_added",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"usage_count": {
|
||||
"name": "usage_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"vocabulary_word_unique": {
|
||||
"name": "vocabulary_word_unique",
|
||||
"columns": ["word"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"yjs_updates": {
|
||||
"name": "yjs_updates",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"update_data": {
|
||||
"name": "update_data",
|
||||
"type": "blob",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch())"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"yjs_updates_note_id_idx": {
|
||||
"name": "yjs_updates_note_id_idx",
|
||||
"columns": ["note_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"yjs_updates_note_id_notes_id_fk": {
|
||||
"name": "yjs_updates_note_id_notes_id_fk",
|
||||
"tableFrom": "yjs_updates",
|
||||
"tableTo": "notes",
|
||||
"columnsFrom": ["note_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,13 @@
|
|||
"when": 1757443963743,
|
||||
"tag": "0001_short_betty_ross",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1757500655155,
|
||||
"tag": "0002_cheerful_betty_brant",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
167
apps/desktop/src/db/notes.ts
Normal file
167
apps/desktop/src/db/notes.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { eq, desc, asc, like, and, isNull } from "drizzle-orm";
|
||||
import { db } from "./index";
|
||||
import {
|
||||
notes,
|
||||
yjsUpdates,
|
||||
type Note,
|
||||
type NewNote,
|
||||
type YjsUpdate,
|
||||
} from "./schema";
|
||||
|
||||
// Create a new note
|
||||
export async function createNote(
|
||||
data: Omit<NewNote, "id" | "createdAt" | "updatedAt" | "lastAccessedAt">,
|
||||
) {
|
||||
const now = new Date();
|
||||
|
||||
const newNote: NewNote = {
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await db.insert(notes).values(newNote).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// Get all notes with optional filtering and sorting
|
||||
export async function getNotes(
|
||||
options: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: "title" | "updatedAt" | "createdAt";
|
||||
sortOrder?: "asc" | "desc";
|
||||
search?: string;
|
||||
} = {},
|
||||
) {
|
||||
const {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
sortBy = "updatedAt",
|
||||
sortOrder = "desc",
|
||||
search,
|
||||
} = options;
|
||||
|
||||
// Build query
|
||||
let query = db.select().from(notes);
|
||||
|
||||
// Apply filters
|
||||
const conditions = [];
|
||||
if (search) {
|
||||
conditions.push(like(notes.title, `%${search}%`));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions)) as any;
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
const sortColumn = notes[sortBy];
|
||||
const orderFn = sortOrder === "asc" ? asc : desc;
|
||||
query = query.orderBy(orderFn(sortColumn)) as any;
|
||||
|
||||
// Apply pagination
|
||||
query = query.limit(limit).offset(offset) as any;
|
||||
|
||||
return await query;
|
||||
}
|
||||
|
||||
// Get note by ID
|
||||
export async function getNoteById(id: number) {
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id));
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Update note
|
||||
export async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Omit<Note, "id" | "createdAt" | "docName">>,
|
||||
) {
|
||||
const updateData = {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await db
|
||||
.update(notes)
|
||||
.set(updateData)
|
||||
.where(eq(notes.id, id))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// Delete note
|
||||
export async function deleteNote(id: number) {
|
||||
// Delete the note (yjs updates and metadata will be cascade deleted)
|
||||
const result = await db.delete(notes).where(eq(notes.id, id)).returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
// YJS Updates operations
|
||||
|
||||
// Save a YJS update to the database
|
||||
export async function saveYjsUpdate(noteId: number, update: Uint8Array) {
|
||||
// Convert Uint8Array to Buffer for storage
|
||||
const bufferUpdate = Buffer.from(update);
|
||||
|
||||
// Insert into yjs_updates table
|
||||
await db.insert(yjsUpdates).values({
|
||||
noteId,
|
||||
updateData: bufferUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
// Load all YJS updates for a note
|
||||
export async function loadYjsUpdates(noteId: number): Promise<Uint8Array[]> {
|
||||
const updates = await db
|
||||
.select()
|
||||
.from(yjsUpdates)
|
||||
.where(eq(yjsUpdates.noteId, noteId))
|
||||
.orderBy(asc(yjsUpdates.id));
|
||||
|
||||
// Convert Buffer to Uint8Array
|
||||
return updates.map((u: YjsUpdate) => {
|
||||
return new Uint8Array(u.updateData as Buffer);
|
||||
});
|
||||
}
|
||||
|
||||
// Get all unique note IDs that have updates
|
||||
export async function getUniqueNoteIds(): Promise<number[]> {
|
||||
const result = await db
|
||||
.select({ noteId: yjsUpdates.noteId })
|
||||
.from(yjsUpdates)
|
||||
.groupBy(yjsUpdates.noteId);
|
||||
|
||||
return result.map((r: { noteId: number }) => r.noteId);
|
||||
}
|
||||
|
||||
// Get all YJS updates for a specific note
|
||||
export async function getYjsUpdatesByNoteId(
|
||||
noteId: number,
|
||||
): Promise<YjsUpdate[]> {
|
||||
return await db
|
||||
.select()
|
||||
.from(yjsUpdates)
|
||||
.where(eq(yjsUpdates.noteId, noteId))
|
||||
.orderBy(asc(yjsUpdates.id));
|
||||
}
|
||||
|
||||
// Replace all YJS updates with a compacted one (transactional)
|
||||
export async function replaceYjsUpdates(
|
||||
noteId: number,
|
||||
compactedUpdate: Uint8Array,
|
||||
): Promise<void> {
|
||||
const bufferUpdate = Buffer.from(compactedUpdate);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Delete all existing updates
|
||||
await tx.delete(yjsUpdates).where(eq(yjsUpdates.noteId, noteId));
|
||||
|
||||
// Insert the compacted update
|
||||
await tx.insert(yjsUpdates).values({
|
||||
noteId,
|
||||
updateData: bufferUpdate,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
real,
|
||||
index,
|
||||
primaryKey,
|
||||
blob,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Transcriptions table
|
||||
|
|
@ -163,6 +164,39 @@ export interface AppSettingsData {
|
|||
};
|
||||
}
|
||||
|
||||
// Notes table
|
||||
export const notes = sqliteTable("notes", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
title: text("title").notNull(),
|
||||
content: text("content").default(""), // Store the actual text content
|
||||
icon: text("icon"), // Store the icon (emoji) associated with the note
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
// Yjs updates table for persistence
|
||||
export const yjsUpdates = sqliteTable(
|
||||
"yjs_updates",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
noteId: integer("note_id")
|
||||
.notNull()
|
||||
.references(() => notes.id, { onDelete: "cascade" }),
|
||||
updateData: blob("update_data", { mode: "buffer" }).notNull(), // Binary data stored as Buffer
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
},
|
||||
(table) => [
|
||||
// Index for efficient foreign key lookups
|
||||
index("yjs_updates_note_id_idx").on(table.noteId),
|
||||
],
|
||||
);
|
||||
|
||||
// Export types for TypeScript
|
||||
export type Transcription = typeof transcriptions.$inferSelect;
|
||||
export type NewTranscription = typeof transcriptions.$inferInsert;
|
||||
|
|
@ -172,3 +206,7 @@ export type Model = typeof models.$inferSelect;
|
|||
export type NewModel = typeof models.$inferInsert;
|
||||
export type AppSettings = typeof appSettings.$inferSelect;
|
||||
export type NewAppSettings = typeof appSettings.$inferInsert;
|
||||
export type Note = typeof notes.$inferSelect;
|
||||
export type NewNote = typeof notes.$inferInsert;
|
||||
export type YjsUpdate = typeof yjsUpdates.$inferSelect;
|
||||
export type NewYjsUpdate = typeof yjsUpdates.$inferInsert;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { HelperEvent } from "@amical/types";
|
|||
import { AppManager } from "./app-manager";
|
||||
import { logger } from "../logger";
|
||||
import { ipcMain, shell, systemPreferences, app } from "electron";
|
||||
import NotesService from "../../services/notes-service";
|
||||
|
||||
export class EventHandlers {
|
||||
private appManager: AppManager;
|
||||
|
|
@ -14,6 +15,7 @@ export class EventHandlers {
|
|||
this.setupNativeBridgeEventHandlers();
|
||||
this.setupGeneralIPCHandlers();
|
||||
this.setupOnboardingIPCHandlers();
|
||||
this.setupNotesIPCHandlers();
|
||||
// Note: Audio IPC handlers are now managed by RecordingService
|
||||
}
|
||||
|
||||
|
|
@ -100,4 +102,43 @@ export class EventHandlers {
|
|||
app.quit();
|
||||
});
|
||||
}
|
||||
|
||||
private setupNotesIPCHandlers(): void {
|
||||
const notesService = NotesService.getInstance();
|
||||
|
||||
// Save yjs update
|
||||
ipcMain.handle(
|
||||
"notes:saveYjsUpdate",
|
||||
async (event, noteId: number, update: ArrayBuffer) => {
|
||||
try {
|
||||
// Convert ArrayBuffer to Uint8Array
|
||||
const updateArray = new Uint8Array(update);
|
||||
await notesService.saveYjsUpdate(noteId, updateArray);
|
||||
logger.main.debug("Saved yjs update", {
|
||||
noteId,
|
||||
updateSize: updateArray.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.main.error("Failed to save yjs update", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Load all yjs updates for a note
|
||||
ipcMain.handle("notes:loadYjsUpdates", async (event, noteId: number) => {
|
||||
try {
|
||||
const updates = await notesService.loadYjsUpdates(noteId);
|
||||
logger.main.debug("Loaded yjs updates", {
|
||||
noteId,
|
||||
count: updates.length,
|
||||
});
|
||||
// Convert Uint8Array[] to ArrayBuffer[] for IPC transfer
|
||||
return updates.map((u) => u.buffer);
|
||||
} catch (error) {
|
||||
logger.main.error("Failed to load yjs updates", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,9 +121,9 @@ export class ServiceManager {
|
|||
|
||||
this.transcriptionService = new TranscriptionService(
|
||||
this.modelManagerService,
|
||||
this.vadService,
|
||||
this.vadService!,
|
||||
this.settingsService,
|
||||
this.telemetryService,
|
||||
this.telemetryService!,
|
||||
);
|
||||
await this.transcriptionService.initialize();
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,15 @@ const api: ElectronAPI = {
|
|||
|
||||
// External link handling
|
||||
openExternal: (url: string) => ipcRenderer.invoke("open-external", url),
|
||||
|
||||
// Notes API - Yjs synchronization only
|
||||
notes: {
|
||||
saveYjsUpdate: (noteId: number, update: ArrayBuffer) =>
|
||||
ipcRenderer.invoke("notes:saveYjsUpdate", noteId, update),
|
||||
|
||||
loadYjsUpdates: (noteId: number) =>
|
||||
ipcRenderer.invoke("notes:loadYjsUpdates", noteId),
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", api);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
IconBookFilled,
|
||||
IconKeyboard,
|
||||
IconAdjustments,
|
||||
IconNotes,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { NavMain } from "@/components/nav-main";
|
||||
|
|
@ -49,6 +50,11 @@ const data = {
|
|||
url: "/settings/shortcuts",
|
||||
icon: IconKeyboard,
|
||||
},
|
||||
{
|
||||
title: "Notes",
|
||||
url: "/settings/notes",
|
||||
icon: IconNotes,
|
||||
},
|
||||
{
|
||||
title: "Vocabulary",
|
||||
url: "/settings/vocabulary",
|
||||
|
|
|
|||
17
apps/desktop/src/renderer/main/hooks/useDebounce.ts
Normal file
17
apps/desktop/src/renderer/main/hooks/useDebounce.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, Calendar } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Note } from "../types";
|
||||
|
||||
interface RecentNoteCardProps {
|
||||
note: Note;
|
||||
onNoteClick: (noteId: number) => void;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffInDays = Math.floor(
|
||||
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (diffInDays === 0) return "Today";
|
||||
if (diffInDays === 1) return "Yesterday";
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onNoteClick }: RecentNoteCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onNoteClick(note.id)}
|
||||
className={cn(
|
||||
"flex items-start gap-3 py-2 px-3 rounded-lg transition-colors group",
|
||||
"hover:bg-accent/50 hover:text-accent-foreground",
|
||||
)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onNoteClick(note.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Note Icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{note.icon ? (
|
||||
<span className="text-lg">{note.icon}</span>
|
||||
) : (
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Note Name */}
|
||||
<div className="font-medium text-foreground text-sm leading-tight">
|
||||
{note.title}
|
||||
</div>
|
||||
|
||||
{/* Date and Meeting Info */}
|
||||
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
|
||||
<span>{formatDate(note.updatedAt)}</span>
|
||||
|
||||
{note.meetingEvent && (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-muted-foreground rounded-full"></span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar
|
||||
className="w-3 h-3"
|
||||
style={{ color: note.meetingEvent.calendarColor }}
|
||||
/>
|
||||
<span className="">{note.meetingEvent.title}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import * as Y from "yjs";
|
||||
import { api } from "@/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { debounce } from "../../../utils/debounce";
|
||||
import Note from "./note";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type NotePageProps = {
|
||||
noteId: string;
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
export default function NotePage({ noteId, onBack }: NotePageProps) {
|
||||
const navigate = useNavigate();
|
||||
const utils = api.useUtils();
|
||||
|
||||
// State
|
||||
const [noteTitle, setNoteTitle] = useState("");
|
||||
const [noteBody, setNoteBody] = useState("");
|
||||
const [isSyncing, setIsSyncing] = useState(true);
|
||||
const [noteIcon, setNoteIcon] = useState<string | null>(null);
|
||||
|
||||
// Refs
|
||||
const ydocRef = useRef<Y.Doc | null>(null);
|
||||
const textRef = useRef<Y.Text | null>(null);
|
||||
const noteRef = useRef<typeof note>(null);
|
||||
|
||||
// Fetch note data
|
||||
const { data: note, isLoading } = api.notes.getNoteById.useQuery(
|
||||
{ id: parseInt(noteId) },
|
||||
{
|
||||
enabled: !!noteId,
|
||||
},
|
||||
);
|
||||
|
||||
// Update title mutation
|
||||
const updateTitleMutation = api.notes.updateNoteTitle.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.notes.getNotes.invalidate();
|
||||
utils.notes.getNoteById.invalidate({ id: parseInt(noteId) });
|
||||
},
|
||||
});
|
||||
|
||||
// Update emoji mutation
|
||||
const updateNoteIconMutation = api.notes.updateNoteIcon.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.notes.getNotes.invalidate();
|
||||
utils.notes.getNoteById.invalidate({ id: parseInt(noteId) });
|
||||
toast.success("Emoji updated");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update emoji: " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = api.notes.deleteNote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.notes.getNotes.invalidate();
|
||||
// Use onBack if provided, otherwise navigate
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate({ to: "/settings/notes" });
|
||||
}
|
||||
toast.success("Note deleted");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete note: " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Debounced title update
|
||||
const debouncedUpdateTitle = useMemo(
|
||||
() =>
|
||||
debounce((title: string) => {
|
||||
const currentNote = noteRef.current;
|
||||
if (currentNote && title !== currentNote.title) {
|
||||
updateTitleMutation.mutate({ id: currentNote.id, title });
|
||||
}
|
||||
}, 500),
|
||||
[], // No dependencies - function should remain stable
|
||||
);
|
||||
|
||||
// Debounced YJS update
|
||||
const debouncedYjsUpdate = useMemo(
|
||||
() =>
|
||||
debounce((newContent: string) => {
|
||||
if (textRef.current && ydocRef.current) {
|
||||
ydocRef.current.transact(() => {
|
||||
const oldLength = textRef.current!.length;
|
||||
textRef.current!.delete(0, oldLength);
|
||||
textRef.current!.insert(0, newContent);
|
||||
}, "user-input-debounced");
|
||||
}
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
// Initialize YJS document
|
||||
useEffect(() => {
|
||||
if (!note) return;
|
||||
|
||||
// Cancel any pending updates
|
||||
debouncedYjsUpdate.cancel();
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const initializeYjs = async () => {
|
||||
try {
|
||||
// Create YJS document
|
||||
const ydoc = new Y.Doc();
|
||||
const text = ydoc.getText("content");
|
||||
|
||||
// Store references
|
||||
ydocRef.current = ydoc;
|
||||
textRef.current = text;
|
||||
|
||||
// Load existing updates from backend
|
||||
try {
|
||||
const updates = await window.electronAPI.notes.loadYjsUpdates(
|
||||
note.id,
|
||||
);
|
||||
|
||||
if (updates.length > 0) {
|
||||
// Apply all updates to reconstruct the document
|
||||
updates.forEach((update: ArrayBuffer) => {
|
||||
Y.applyUpdate(ydoc, new Uint8Array(update));
|
||||
});
|
||||
|
||||
// Set content from the reconstructed document
|
||||
const reconstructedContent = text.toString();
|
||||
setNoteBody(reconstructedContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load yjs updates:", error);
|
||||
}
|
||||
|
||||
setIsSyncing(false);
|
||||
|
||||
// Listen for changes from YJS
|
||||
const observer = () => {
|
||||
if (!mounted) return;
|
||||
const newContent = text.toString();
|
||||
setNoteBody(newContent);
|
||||
};
|
||||
|
||||
text.observe(observer);
|
||||
|
||||
// Save YJS updates to backend
|
||||
ydoc.on("update", async (update: Uint8Array) => {
|
||||
try {
|
||||
// Convert Uint8Array to ArrayBuffer for IPC
|
||||
const buffer = update.buffer.slice(
|
||||
update.byteOffset,
|
||||
update.byteOffset + update.byteLength,
|
||||
);
|
||||
|
||||
await window.electronAPI.notes.saveYjsUpdate(
|
||||
note.id,
|
||||
buffer as ArrayBuffer,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save yjs update:", error);
|
||||
toast.error("Failed to save changes");
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
text.unobserve(observer);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize yjs:", error);
|
||||
setIsSyncing(false);
|
||||
toast.error("Failed to load note");
|
||||
}
|
||||
};
|
||||
|
||||
initializeYjs();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
debouncedYjsUpdate.cancel();
|
||||
};
|
||||
}, [note, debouncedYjsUpdate]);
|
||||
|
||||
// Update note ref and set initial title and emoji
|
||||
useEffect(() => {
|
||||
noteRef.current = note;
|
||||
if (note) {
|
||||
setNoteTitle(note.title);
|
||||
setNoteIcon(note.icon || null);
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
// Handle content changes
|
||||
const handleContentChange = useCallback(
|
||||
(newContent: string) => {
|
||||
setNoteBody(newContent);
|
||||
debouncedYjsUpdate(newContent);
|
||||
},
|
||||
[debouncedYjsUpdate],
|
||||
);
|
||||
|
||||
// Handle title change
|
||||
const handleTitleChange = useCallback(
|
||||
(newTitle: string) => {
|
||||
setNoteTitle(newTitle);
|
||||
debouncedUpdateTitle(newTitle);
|
||||
},
|
||||
[debouncedUpdateTitle],
|
||||
);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(() => {
|
||||
deleteMutation.mutate({ id: parseInt(noteId) });
|
||||
}, [noteId, deleteMutation]);
|
||||
|
||||
// Handle emoji change
|
||||
const handleEmojiChange = useCallback(
|
||||
(emoji: string | null) => {
|
||||
setNoteIcon(emoji);
|
||||
updateNoteIconMutation.mutate({ id: parseInt(noteId), icon: emoji });
|
||||
},
|
||||
[noteId, updateNoteIconMutation],
|
||||
);
|
||||
|
||||
// Note not found state
|
||||
if (!isLoading && !note) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<FileTextIcon className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Note not found</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate({ to: "/settings/notes" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Back to notes
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lastEditDate = note ? new Date(note.updatedAt) : new Date();
|
||||
|
||||
// Use the presentational component
|
||||
return (
|
||||
<Note
|
||||
noteId={noteId}
|
||||
noteTitle={noteTitle}
|
||||
noteBody={noteBody}
|
||||
noteEmoji={noteIcon}
|
||||
isLoading={isLoading}
|
||||
isSyncing={isSyncing}
|
||||
lastEditDate={lastEditDate}
|
||||
onTitleChange={handleTitleChange}
|
||||
onBodyChange={handleContentChange}
|
||||
onDelete={handleDelete}
|
||||
onEmojiChange={handleEmojiChange}
|
||||
onBack={onBack}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
517
apps/desktop/src/renderer/main/pages/notes/components/note.tsx
Normal file
517
apps/desktop/src/renderer/main/pages/notes/components/note.tsx
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Sparkles,
|
||||
Share,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Copy,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Check,
|
||||
Star,
|
||||
FileTextIcon,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import EmojiPicker, { Theme } from "emoji-picker-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type InvitedUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
email: string;
|
||||
access: string;
|
||||
status: "active" | "invited";
|
||||
};
|
||||
|
||||
export type NotePageUIProps = {
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
noteBody: string;
|
||||
noteEmoji: string | null;
|
||||
isLoading: boolean;
|
||||
isSyncing: boolean;
|
||||
lastEditDate: Date;
|
||||
onTitleChange: (value: string) => void;
|
||||
onBodyChange: (value: string) => void;
|
||||
onDelete: () => void;
|
||||
onEmojiChange: (emoji: string | null) => void;
|
||||
onBack?: () => void;
|
||||
isDeleting?: boolean;
|
||||
};
|
||||
|
||||
export default function Note({
|
||||
noteTitle,
|
||||
noteBody,
|
||||
noteEmoji,
|
||||
isLoading,
|
||||
isSyncing,
|
||||
lastEditDate,
|
||||
onTitleChange,
|
||||
onBodyChange,
|
||||
onDelete,
|
||||
onEmojiChange,
|
||||
isDeleting = false,
|
||||
}: NotePageUIProps) {
|
||||
// Local UI state
|
||||
const [shareEmail, setShareEmail] = useState("");
|
||||
const [accessLevel, setAccessLevel] = useState("can-read");
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [invitedUsers, setInvitedUsers] = useState<Array<InvitedUser>>([]);
|
||||
const [starred, setStarred] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
// Mock shared users data
|
||||
/* const sharedUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Alice Johnson",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1588516903720-8ceb67f9ef84?q=80&w=1344&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
email: "alice@example.com",
|
||||
access: "can-write",
|
||||
status: "active" as const,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Bob Smith",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1564564321837-a57b7070ac4f?q=80&w=2676&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
email: "bob@example.com",
|
||||
access: "can-write",
|
||||
status: "active" as const,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Carol Davis",
|
||||
avatar:
|
||||
"https://images.unsplash.com/photo-1560087637-bf797bc7796a?q=80&w=1287&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
email: "carol@example.com",
|
||||
access: "can-read",
|
||||
status: "active" as const,
|
||||
},
|
||||
]; */
|
||||
|
||||
// TODO: implement actual share functionality
|
||||
/* const handleShare = () => {
|
||||
if (shareEmail) {
|
||||
const newInvite = {
|
||||
id: Date.now(),
|
||||
name: shareEmail.split("@")[0],
|
||||
email: shareEmail,
|
||||
access: accessLevel,
|
||||
status: "invited" as const,
|
||||
};
|
||||
setInvitedUsers((prev) => [...prev, newInvite]);
|
||||
setShowConfirmation(true);
|
||||
setShareEmail("");
|
||||
|
||||
// Hide confirmation after 3 seconds
|
||||
setTimeout(() => setShowConfirmation(false), 3000);
|
||||
}
|
||||
}; */
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDelete();
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emojiData: { emoji: string }) => {
|
||||
onEmojiChange(emojiData.emoji);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleEmojiRemove = () => {
|
||||
onEmojiChange(null);
|
||||
};
|
||||
|
||||
/* const allUsers = [...sharedUsers, ...invitedUsers]; */
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="">
|
||||
{/* Note Content */}
|
||||
<div className="mt-0 space-y-2">
|
||||
{/* Note Title with Emoji Picker */}
|
||||
<div className="flex items-center">
|
||||
{/* Emoji Picker */}
|
||||
<Popover open={showEmojiPicker} onOpenChange={setShowEmojiPicker}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-12 w-12 p-0 hover:bg-muted/50"
|
||||
>
|
||||
{noteEmoji ? (
|
||||
<span className="text-2xl">{noteEmoji}</span>
|
||||
) : (
|
||||
<FileTextIcon className="!h-6 !w-6 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div>
|
||||
{noteEmoji && (
|
||||
<div className="flex justify-end p-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleEmojiRemove}
|
||||
className="text-xs"
|
||||
>
|
||||
Remove emoji
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<EmojiPicker
|
||||
onEmojiClick={handleEmojiSelect}
|
||||
autoFocusSearch={false}
|
||||
theme={Theme.DARK}
|
||||
lazyLoadEmojis={false}
|
||||
height={400}
|
||||
width={400}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Note Title Input */}
|
||||
<Input
|
||||
value={noteTitle}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
className="!text-4xl font-semibold border-none px-4 py-2 focus-visible:ring-0 placeholder:text-muted-foreground flex-1"
|
||||
style={{ backgroundColor: "transparent" }}
|
||||
placeholder="Note title..."
|
||||
disabled={isSyncing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top Bar */}
|
||||
<div className="flex items-center justify-start pl-4 pb-0.5 bg-card">
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center ">
|
||||
{/* Last edited date */}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Edited{" "}
|
||||
{lastEditDate.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year:
|
||||
lastEditDate.getFullYear() !== new Date().getFullYear()
|
||||
? "numeric"
|
||||
: undefined,
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* {console.log("[v0] Note ID:", noteId)} */}
|
||||
|
||||
{/* <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
className="gap-1 mx-1.5"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI Notes
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>AI Notes coming soon</TooltipContent>
|
||||
</Tooltip> */}
|
||||
|
||||
{/* Shared users avatars */}
|
||||
{/* <div className="flex -space-x-2">
|
||||
{sharedUsers.map((user) => (
|
||||
<Tooltip key={user.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Avatar className="h-8 w-8 border-2 border-background cursor-pointer">
|
||||
<AvatarImage
|
||||
className="object-cover"
|
||||
src={user.avatar || "/placeholder.svg"}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div> */}
|
||||
|
||||
{/* Star the note */}
|
||||
{/* <Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-2"
|
||||
onClick={() => setStarred(!starred)}
|
||||
>
|
||||
<Star
|
||||
fill={starred ? "orange" : "none"}
|
||||
stroke={starred ? "orange" : "currentColor"}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</Button> */}
|
||||
|
||||
{/* Share button with popover */}
|
||||
{/* <Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Share className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<div className="space-y-4">
|
||||
{showConfirmation ? (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300">
|
||||
Invitation sent successfully!
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-2">
|
||||
Share this note
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Invite others to collaborate on this note
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Enter email address"
|
||||
value={shareEmail}
|
||||
onChange={(e) => setShareEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={accessLevel}
|
||||
onValueChange={setAccessLevel}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full-access">
|
||||
Full access
|
||||
</SelectItem>
|
||||
<SelectItem value="can-write">
|
||||
Can write
|
||||
</SelectItem>
|
||||
<SelectItem value="can-read">Can read</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
Send invitation
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allUsers.length > 0 && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
<h5 className="text-xs font-medium mb-2 text-muted-foreground">
|
||||
People with access
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{allUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={user.avatar || "/placeholder.svg"}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<span className="text-xs">{user.name}</span>
|
||||
{user.status === "invited" && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
(invited)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{user.access.replace("-", " ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover> */}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" disabled>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Transcription coming soon</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* More actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <DropdownMenuItem className="gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Move to
|
||||
</DropdownMenuItem> */}
|
||||
<AlertDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
</AlertDialog>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note Body */}
|
||||
<Textarea
|
||||
value={noteBody}
|
||||
onChange={(e) => onBodyChange(e.target.value)}
|
||||
placeholder="Start writing your note..."
|
||||
className="min-h-[500px] resize-none border-none bg-transparent px-4 py-2 focus-visible:ring-0 text-base leading-relaxed placeholder:text-muted-foreground"
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
disabled={isSyncing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Note</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{noteTitle}"? This action
|
||||
cannot be undone and the note will be permanently removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteClick}
|
||||
className="bg-destructive text-foreground hover:bg-destructive/90"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
"Delete Note"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { NotebookText, Plus } from "lucide-react";
|
||||
import { NoteCard } from "./note-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NotesList() {
|
||||
const navigate = useNavigate();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: notes, isLoading } = api.notes.getNotes.useQuery({
|
||||
sortBy: "updatedAt",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
|
||||
// Create note mutation
|
||||
const createNoteMutation = api.notes.createNote.useMutation({
|
||||
onSuccess: (newNote) => {
|
||||
// Invalidate notes list to refetch
|
||||
utils.notes.getNotes.invalidate();
|
||||
// Navigate to the new note
|
||||
navigate({
|
||||
to: "/settings/notes/$noteId",
|
||||
params: { noteId: String(newNote.id) },
|
||||
});
|
||||
toast.success("Note created");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to create note: " + error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateNote = () => {
|
||||
createNoteMutation.mutate({
|
||||
title: "Untitled Note",
|
||||
initialContent: "",
|
||||
});
|
||||
};
|
||||
|
||||
const onNoteClick = (noteId: number) => {
|
||||
navigate({
|
||||
to: "/settings/notes/$noteId",
|
||||
params: { noteId: String(noteId) },
|
||||
});
|
||||
};
|
||||
|
||||
// Convert database notes to UI format
|
||||
const formattedNotes = notes || [];
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<NotebookText className="w-4 h-4" />
|
||||
<h2 className="text-sm font-medium">Notes</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-start gap-3 py-2 px-3">
|
||||
<Skeleton className="w-5 h-5 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<NotebookText className="w-4 h-4" />
|
||||
<h2 className="text-sm font-medium">Notes</h2>
|
||||
</div>
|
||||
|
||||
{formattedNotes.length > 0 && (
|
||||
<div>
|
||||
{formattedNotes.map((note) => (
|
||||
<NoteCard key={note.id} note={note} onNoteClick={onNoteClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formattedNotes.length === 0 && (
|
||||
<div className="border border-dashed rounded-lg p-6 text-center space-y-4">
|
||||
<NotebookText className="w-8 h-8 text-muted-foreground mx-auto" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create your first note to get started
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
onClick={onCreateNote}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { NotebookPen } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { getMeetingIcon } from "@/utils/meeting-icons";
|
||||
|
||||
interface UpcomingEvent {
|
||||
title: string;
|
||||
time: string;
|
||||
url: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface UpcomingEventCardProps {
|
||||
event: UpcomingEvent;
|
||||
onTakeNotes?: (event: UpcomingEvent) => void;
|
||||
}
|
||||
|
||||
const UpcomingEventCard = ({ event, onTakeNotes }: UpcomingEventCardProps) => {
|
||||
const handleLinkClick = () => {
|
||||
if (event.url) {
|
||||
// Open external link - adjust this based on your Electron setup
|
||||
window.electronAPI.openExternal(event.url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakeNotes = () => {
|
||||
onTakeNotes?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-transparent border-none group hover:bg-accent/60 transition-colors relative py-3 px-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Leading icon */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getMeetingIcon(event.url, {
|
||||
className: "w-5 h-5 text-muted-foreground",
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* Event title */}
|
||||
<h3 className="text-foreground text-sm font-medium leading-tight line-clamp-1">
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
{/* Time and meeting url */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span className="whitespace-nowrap">
|
||||
{event.date} {event.time}
|
||||
</span>
|
||||
|{" "}
|
||||
{
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
className="text-muted-foreground text-xs line-clamp-1 hover:text-foreground cursor-pointer transition-colors"
|
||||
>
|
||||
{event.url}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* take notes button - visible only on hover */}
|
||||
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex-shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleTakeNotes}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<NotebookPen className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Take notes</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpcomingEventCard;
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Calendar, CalendarX, CalendarPlus } from "lucide-react";
|
||||
import UpcomingEventCard from "./upcoming-event-card";
|
||||
import { UpcomingEvent } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CalendarState = "with-events" | "no-events" | "no-calendar";
|
||||
|
||||
// TOOD: add calendar connection and sync logic
|
||||
|
||||
export function UpcomingEvents() {
|
||||
// Switch this variable to test different states:
|
||||
// "with-events" - shows upcoming events
|
||||
// "no-events" - calendar connected but no events
|
||||
// "no-calendar" - no calendar connected
|
||||
const calendarState: CalendarState = "with-events";
|
||||
|
||||
// TODO: replace mock data with actual data from backend
|
||||
// Mock events data
|
||||
const mockEvents: UpcomingEvent[] = [
|
||||
{
|
||||
title: "Product Review: Q3 Feature Planning",
|
||||
date: "Tomorrow",
|
||||
time: "2:00 – 3:00 PM",
|
||||
url: "https://zoom.us/j/123456789",
|
||||
},
|
||||
{
|
||||
title: "1:1 with Sarah - Engineering Sync",
|
||||
date: "Sep 8th",
|
||||
time: "10:00 – 10:30 AM",
|
||||
url: "https://meet.google.com/abc-defg-hij",
|
||||
},
|
||||
];
|
||||
|
||||
// Determine events based on state
|
||||
const upcomingEvents = calendarState === "with-events" ? mockEvents : [];
|
||||
|
||||
const handleTakeNotes = (event: UpcomingEvent) => {
|
||||
// Handle taking notes for the event
|
||||
console.log("Taking notes for:", event.title);
|
||||
// TODO: navigate to a notes editor, open a modal, etc.
|
||||
// You can implement your note-taking logic here
|
||||
// For example: navigate to a notes editor, open a modal, etc.
|
||||
};
|
||||
|
||||
const handleConnectCalendar = () => {
|
||||
// Handle connecting calendar
|
||||
console.log("Connecting calendar...");
|
||||
// TODO: implement your calendar connection logic here
|
||||
// You can implement your calendar connection logic here
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<h2 className="text-sm font-medium">Upcoming events</h2>
|
||||
</div>
|
||||
|
||||
{calendarState === "with-events" ? (
|
||||
<div className="bg-accent/40 rounded-xl overflow-clip">
|
||||
{upcomingEvents.map((event, index) => (
|
||||
<div key={index}>
|
||||
<UpcomingEventCard event={event} onTakeNotes={handleTakeNotes} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : calendarState === "no-events" ? (
|
||||
<div className="border border-dashed rounded-lg p-6 text-center">
|
||||
<CalendarX className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No upcoming events</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed rounded-lg p-6 text-center space-y-4">
|
||||
<CalendarPlus className="w-8 h-8 text-muted-foreground mx-auto" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect your calendar
|
||||
<br />
|
||||
Connect your calendar to get started
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleConnectCalendar}
|
||||
className="mt-2"
|
||||
size={"sm"}
|
||||
variant={"outline"}
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4" />
|
||||
Connect calendar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/desktop/src/renderer/main/pages/notes/index.tsx
Normal file
13
apps/desktop/src/renderer/main/pages/notes/index.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { NotesList } from "./components/notes-list";
|
||||
|
||||
export default function Notes() {
|
||||
return (
|
||||
<div className="space-y-6 p-2">
|
||||
{/* Upcoming events section */}
|
||||
{/* <UpcomingEvents /> */}
|
||||
|
||||
{/* Recent notes section */}
|
||||
<NotesList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/desktop/src/renderer/main/pages/notes/types.ts
Normal file
18
apps/desktop/src/renderer/main/pages/notes/types.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type UpcomingEvent = {
|
||||
title: string;
|
||||
time: string;
|
||||
url: string;
|
||||
date?: string;
|
||||
calendarColor?: string;
|
||||
};
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
title: string;
|
||||
icon?: string | null;
|
||||
updatedAt: Date;
|
||||
meetingEvent?: {
|
||||
title: string;
|
||||
calendarColor: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -15,11 +15,14 @@ import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
|||
import { Route as SettingsVocabularyRouteImport } from './routes/settings/vocabulary'
|
||||
import { Route as SettingsShortcutsRouteImport } from './routes/settings/shortcuts'
|
||||
import { Route as SettingsPreferencesRouteImport } from './routes/settings/preferences'
|
||||
import { Route as SettingsNotesRouteImport } from './routes/settings/notes'
|
||||
import { Route as SettingsHistoryRouteImport } from './routes/settings/history'
|
||||
import { Route as SettingsDictationRouteImport } from './routes/settings/dictation'
|
||||
import { Route as SettingsAiModelsRouteImport } from './routes/settings/ai-models'
|
||||
import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced'
|
||||
import { Route as SettingsAboutRouteImport } from './routes/settings/about'
|
||||
import { Route as SettingsNotesIndexRouteImport } from './routes/settings/notes.index'
|
||||
import { Route as SettingsNotesNoteIdRouteImport } from './routes/settings/notes.$noteId'
|
||||
|
||||
const SettingsRouteRoute = SettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
|
|
@ -51,6 +54,11 @@ const SettingsPreferencesRoute = SettingsPreferencesRouteImport.update({
|
|||
path: '/preferences',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const SettingsNotesRoute = SettingsNotesRouteImport.update({
|
||||
id: '/notes',
|
||||
path: '/notes',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const SettingsHistoryRoute = SettingsHistoryRouteImport.update({
|
||||
id: '/history',
|
||||
path: '/history',
|
||||
|
|
@ -76,6 +84,16 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({
|
|||
path: '/about',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const SettingsNotesIndexRoute = SettingsNotesIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => SettingsNotesRoute,
|
||||
} as any)
|
||||
const SettingsNotesNoteIdRoute = SettingsNotesNoteIdRouteImport.update({
|
||||
id: '/$noteId',
|
||||
path: '/$noteId',
|
||||
getParentRoute: () => SettingsNotesRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -85,10 +103,13 @@ export interface FileRoutesByFullPath {
|
|||
'/settings/ai-models': typeof SettingsAiModelsRoute
|
||||
'/settings/dictation': typeof SettingsDictationRoute
|
||||
'/settings/history': typeof SettingsHistoryRoute
|
||||
'/settings/notes': typeof SettingsNotesRouteWithChildren
|
||||
'/settings/preferences': typeof SettingsPreferencesRoute
|
||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||
'/settings/vocabulary': typeof SettingsVocabularyRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
|
||||
'/settings/notes/': typeof SettingsNotesIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -101,6 +122,8 @@ export interface FileRoutesByTo {
|
|||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||
'/settings/vocabulary': typeof SettingsVocabularyRoute
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
|
||||
'/settings/notes': typeof SettingsNotesIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
|
|
@ -111,10 +134,13 @@ export interface FileRoutesById {
|
|||
'/settings/ai-models': typeof SettingsAiModelsRoute
|
||||
'/settings/dictation': typeof SettingsDictationRoute
|
||||
'/settings/history': typeof SettingsHistoryRoute
|
||||
'/settings/notes': typeof SettingsNotesRouteWithChildren
|
||||
'/settings/preferences': typeof SettingsPreferencesRoute
|
||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||
'/settings/vocabulary': typeof SettingsVocabularyRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
|
||||
'/settings/notes/': typeof SettingsNotesIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
|
|
@ -126,10 +152,13 @@ export interface FileRouteTypes {
|
|||
| '/settings/ai-models'
|
||||
| '/settings/dictation'
|
||||
| '/settings/history'
|
||||
| '/settings/notes'
|
||||
| '/settings/preferences'
|
||||
| '/settings/shortcuts'
|
||||
| '/settings/vocabulary'
|
||||
| '/settings/'
|
||||
| '/settings/notes/$noteId'
|
||||
| '/settings/notes/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -142,6 +171,8 @@ export interface FileRouteTypes {
|
|||
| '/settings/shortcuts'
|
||||
| '/settings/vocabulary'
|
||||
| '/settings'
|
||||
| '/settings/notes/$noteId'
|
||||
| '/settings/notes'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
|
|
@ -151,10 +182,13 @@ export interface FileRouteTypes {
|
|||
| '/settings/ai-models'
|
||||
| '/settings/dictation'
|
||||
| '/settings/history'
|
||||
| '/settings/notes'
|
||||
| '/settings/preferences'
|
||||
| '/settings/shortcuts'
|
||||
| '/settings/vocabulary'
|
||||
| '/settings/'
|
||||
| '/settings/notes/$noteId'
|
||||
| '/settings/notes/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
|
|
@ -206,6 +240,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof SettingsPreferencesRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/settings/notes': {
|
||||
id: '/settings/notes'
|
||||
path: '/notes'
|
||||
fullPath: '/settings/notes'
|
||||
preLoaderRoute: typeof SettingsNotesRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/settings/history': {
|
||||
id: '/settings/history'
|
||||
path: '/history'
|
||||
|
|
@ -241,15 +282,44 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof SettingsAboutRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/settings/notes/': {
|
||||
id: '/settings/notes/'
|
||||
path: '/'
|
||||
fullPath: '/settings/notes/'
|
||||
preLoaderRoute: typeof SettingsNotesIndexRouteImport
|
||||
parentRoute: typeof SettingsNotesRoute
|
||||
}
|
||||
'/settings/notes/$noteId': {
|
||||
id: '/settings/notes/$noteId'
|
||||
path: '/$noteId'
|
||||
fullPath: '/settings/notes/$noteId'
|
||||
preLoaderRoute: typeof SettingsNotesNoteIdRouteImport
|
||||
parentRoute: typeof SettingsNotesRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SettingsNotesRouteChildren {
|
||||
SettingsNotesNoteIdRoute: typeof SettingsNotesNoteIdRoute
|
||||
SettingsNotesIndexRoute: typeof SettingsNotesIndexRoute
|
||||
}
|
||||
|
||||
const SettingsNotesRouteChildren: SettingsNotesRouteChildren = {
|
||||
SettingsNotesNoteIdRoute: SettingsNotesNoteIdRoute,
|
||||
SettingsNotesIndexRoute: SettingsNotesIndexRoute,
|
||||
}
|
||||
|
||||
const SettingsNotesRouteWithChildren = SettingsNotesRoute._addFileChildren(
|
||||
SettingsNotesRouteChildren,
|
||||
)
|
||||
|
||||
interface SettingsRouteRouteChildren {
|
||||
SettingsAboutRoute: typeof SettingsAboutRoute
|
||||
SettingsAdvancedRoute: typeof SettingsAdvancedRoute
|
||||
SettingsAiModelsRoute: typeof SettingsAiModelsRoute
|
||||
SettingsDictationRoute: typeof SettingsDictationRoute
|
||||
SettingsHistoryRoute: typeof SettingsHistoryRoute
|
||||
SettingsNotesRoute: typeof SettingsNotesRouteWithChildren
|
||||
SettingsPreferencesRoute: typeof SettingsPreferencesRoute
|
||||
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
|
||||
SettingsVocabularyRoute: typeof SettingsVocabularyRoute
|
||||
|
|
@ -262,6 +332,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
|||
SettingsAiModelsRoute: SettingsAiModelsRoute,
|
||||
SettingsDictationRoute: SettingsDictationRoute,
|
||||
SettingsHistoryRoute: SettingsHistoryRoute,
|
||||
SettingsNotesRoute: SettingsNotesRouteWithChildren,
|
||||
SettingsPreferencesRoute: SettingsPreferencesRoute,
|
||||
SettingsShortcutsRoute: SettingsShortcutsRoute,
|
||||
SettingsVocabularyRoute: SettingsVocabularyRoute,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import NotePage from "../../pages/notes/components/note-wrapper";
|
||||
|
||||
export const Route = createFileRoute("/settings/notes/$noteId")({
|
||||
component: NotePageWrapper,
|
||||
});
|
||||
|
||||
function NotePageWrapper() {
|
||||
const { noteId } = Route.useParams();
|
||||
|
||||
return <NotePage noteId={noteId} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import NotesPage from "../../pages/notes";
|
||||
|
||||
export const Route = createFileRoute("/settings/notes/")({
|
||||
component: NotesPage,
|
||||
});
|
||||
5
apps/desktop/src/renderer/main/routes/settings/notes.tsx
Normal file
5
apps/desktop/src/renderer/main/routes/settings/notes.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/settings/notes")({
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
|
|
@ -12,6 +12,11 @@ function SettingsLayout() {
|
|||
const location = useLocation();
|
||||
|
||||
const getSettingsPageTitle = (pathname: string): string => {
|
||||
// Check for dynamic routes first
|
||||
if (pathname.startsWith("/settings/notes")) {
|
||||
return "Notes";
|
||||
}
|
||||
|
||||
const routes: Record<string, string> = {
|
||||
"/settings/preferences": "Preferences",
|
||||
"/settings/dictation": "Dictation",
|
||||
|
|
|
|||
25
apps/desktop/src/renderer/main/utils/debounce.ts
Normal file
25
apps/desktop/src/renderer/main/utils/debounce.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const debounced = function (this: any, ...args: Parameters<T>) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
timeout = null;
|
||||
}, wait);
|
||||
};
|
||||
|
||||
debounced.cancel = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
|
@ -117,11 +117,7 @@ export function UnifiedPermissionsStep({
|
|||
<div className="max-w-lg w-full space-y-6">
|
||||
{/* Header with logo */}
|
||||
<div className="text-center space-y-4">
|
||||
<img
|
||||
src="assets/logo.svg"
|
||||
alt="Amical"
|
||||
className="w-20 h-20 mx-auto"
|
||||
/>
|
||||
<img src="assets/logo.svg" alt="Amical" className="w-20 h-20 mx-auto" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Permissions Required</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
|
|
|
|||
193
apps/desktop/src/services/notes-service.ts
Normal file
193
apps/desktop/src/services/notes-service.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import * as cron from "node-cron";
|
||||
import {
|
||||
createNote,
|
||||
getNotes,
|
||||
getNoteById,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
saveYjsUpdate as saveYjsUpdateToDB,
|
||||
loadYjsUpdates as loadYjsUpdatesFromDB,
|
||||
getUniqueNoteIds,
|
||||
getYjsUpdatesByNoteId,
|
||||
replaceYjsUpdates,
|
||||
} from "../db/notes";
|
||||
import * as Y from "yjs";
|
||||
import { logger } from "../main/logger";
|
||||
|
||||
export interface NoteCreateOptions {
|
||||
title: string;
|
||||
initialContent?: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface NoteUpdateOptions {
|
||||
title?: string;
|
||||
transcriptionId?: number | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
class NotesService {
|
||||
private static instance: NotesService;
|
||||
private compactionTask: cron.ScheduledTask | null = null;
|
||||
|
||||
private constructor() {
|
||||
// Set up cron job for daily compaction
|
||||
this.setupCompactionCron();
|
||||
}
|
||||
|
||||
public static getInstance(): NotesService {
|
||||
if (!NotesService.instance) {
|
||||
NotesService.instance = new NotesService();
|
||||
}
|
||||
return NotesService.instance;
|
||||
}
|
||||
|
||||
async createNote(options: NoteCreateOptions) {
|
||||
// Create the note in the database
|
||||
const note = await createNote({
|
||||
title: options.title,
|
||||
icon: options.icon,
|
||||
});
|
||||
|
||||
// Initialize yjs document with initial content if provided
|
||||
if (options.initialContent) {
|
||||
const ydoc = new Y.Doc();
|
||||
const text = ydoc.getText("content");
|
||||
text.insert(0, options.initialContent);
|
||||
|
||||
// Save initial content as a YJS update
|
||||
const initialUpdate = Y.encodeStateAsUpdate(ydoc);
|
||||
await saveYjsUpdateToDB(note.id, initialUpdate);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async getNote(id: number) {
|
||||
const note = await getNoteById(id);
|
||||
return note;
|
||||
}
|
||||
|
||||
async listNotes(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: "title" | "updatedAt" | "createdAt";
|
||||
sortOrder?: "asc" | "desc";
|
||||
search?: string;
|
||||
transcriptionId?: number | null;
|
||||
}) {
|
||||
return await getNotes(options);
|
||||
}
|
||||
|
||||
async updateNote(id: number, options: NoteUpdateOptions) {
|
||||
return await updateNote(id, options);
|
||||
}
|
||||
|
||||
async deleteNote(id: number) {
|
||||
const note = await getNoteById(id);
|
||||
if (!note) return null;
|
||||
|
||||
return await deleteNote(id);
|
||||
}
|
||||
|
||||
// Save yjs update to database
|
||||
async saveYjsUpdate(noteId: number, update: Uint8Array) {
|
||||
await saveYjsUpdateToDB(noteId, update);
|
||||
}
|
||||
|
||||
// Load all yjs updates for a note
|
||||
async loadYjsUpdates(noteId: number): Promise<Uint8Array[]> {
|
||||
return await loadYjsUpdatesFromDB(noteId);
|
||||
}
|
||||
|
||||
// Compact all note documents
|
||||
async compactAllNotes(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
logger.main.info("Starting yjs compaction for all notes");
|
||||
|
||||
try {
|
||||
// Get all unique note IDs that have updates
|
||||
const noteIds = await getUniqueNoteIds();
|
||||
logger.main.info(`Found ${noteIds.length} notes to compact`);
|
||||
|
||||
let totalUpdatesBefore = 0;
|
||||
let totalUpdatesAfter = 0;
|
||||
|
||||
for (const noteId of noteIds) {
|
||||
const compactResult = await this.compactNote(noteId);
|
||||
totalUpdatesBefore += compactResult.updatesBefore;
|
||||
totalUpdatesAfter += compactResult.updatesAfter;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.main.info(`Compaction completed in ${duration}ms`, {
|
||||
notesCompacted: noteIds.length,
|
||||
totalUpdatesBefore,
|
||||
totalUpdatesAfter,
|
||||
updatesReduced: totalUpdatesBefore - totalUpdatesAfter,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.main.error("Failed to compact notes:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Compact a specific note
|
||||
async compactNote(
|
||||
noteId: number,
|
||||
): Promise<{ updatesBefore: number; updatesAfter: number }> {
|
||||
// Get all updates for this note
|
||||
const updates = await getYjsUpdatesByNoteId(noteId);
|
||||
const updatesBefore = updates.length;
|
||||
|
||||
if (updatesBefore <= 1) {
|
||||
// No need to compact if there's only one update or none
|
||||
return { updatesBefore, updatesAfter: updatesBefore };
|
||||
}
|
||||
|
||||
// Create a new Y.Doc and apply all updates
|
||||
const ydoc = new Y.Doc();
|
||||
for (const update of updates) {
|
||||
const updateArray = new Uint8Array(update.updateData as Buffer);
|
||||
Y.applyUpdate(ydoc, updateArray);
|
||||
}
|
||||
|
||||
// Encode the current state as a single update
|
||||
const stateUpdate = Y.encodeStateAsUpdate(ydoc);
|
||||
|
||||
// Replace all updates with the compacted one
|
||||
await replaceYjsUpdates(noteId, stateUpdate);
|
||||
|
||||
logger.main.debug(
|
||||
`Compacted note ${noteId}: ${updatesBefore} updates -> 1 update`,
|
||||
);
|
||||
|
||||
return { updatesBefore, updatesAfter: 1 };
|
||||
}
|
||||
|
||||
// Set up cron job for scheduled compaction
|
||||
private setupCompactionCron() {
|
||||
// Schedule for daily at 2 AM in production, every 5 minutes in development
|
||||
const schedule =
|
||||
process.env.NODE_ENV === "development" ? "*/5 * * * *" : "0 2 * * *";
|
||||
|
||||
this.compactionTask = cron.schedule(schedule, async () => {
|
||||
logger.main.info(
|
||||
`Running scheduled yjs compaction (schedule: ${schedule})`,
|
||||
);
|
||||
await this.compactAllNotes();
|
||||
});
|
||||
|
||||
logger.main.info(`Yjs compaction cron job scheduled: ${schedule}`);
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
cleanup() {
|
||||
// Stop the cron job
|
||||
if (this.compactionTask) {
|
||||
this.compactionTask.stop();
|
||||
this.compactionTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default NotesService;
|
||||
|
|
@ -413,14 +413,13 @@ export class TranscriptionService {
|
|||
? completionTime - session.recordingStartedAt
|
||||
: undefined;
|
||||
|
||||
const selectedModel =
|
||||
this.modelManagerService.getSelectedModel() || "unknown";
|
||||
const selectedModel = await this.modelManagerService.getSelectedModel();
|
||||
const audioDurationSeconds =
|
||||
session.context.sharedData.audioMetadata?.duration;
|
||||
|
||||
this.telemetryService.trackTranscriptionCompleted({
|
||||
session_id: sessionId,
|
||||
model_id: selectedModel,
|
||||
model_id: selectedModel!,
|
||||
model_preloaded: this.modelWasPreloaded,
|
||||
total_duration_ms: totalDuration || 0,
|
||||
recording_duration_ms: recordingDuration,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { settingsRouter } from "./routers/settings";
|
|||
import { updaterRouter } from "./routers/updater";
|
||||
import { recordingRouter } from "./routers/recording";
|
||||
import { widgetRouter } from "./routers/widget";
|
||||
import { notesRouter } from "./routers/notes";
|
||||
import { createRouter, procedure } from "./trpc";
|
||||
|
||||
export const router = createRouter({
|
||||
|
|
@ -53,6 +54,9 @@ export const router = createRouter({
|
|||
|
||||
// Widget router
|
||||
widget: widgetRouter,
|
||||
|
||||
// Notes router
|
||||
notes: notesRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof router;
|
||||
|
|
|
|||
104
apps/desktop/src/trpc/routers/notes.ts
Normal file
104
apps/desktop/src/trpc/routers/notes.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { z } from "zod";
|
||||
import { createRouter, procedure } from "../trpc";
|
||||
import NotesService from "../../services/notes-service";
|
||||
|
||||
const notesService = NotesService.getInstance();
|
||||
|
||||
// Input schemas
|
||||
const GetNotesSchema = z.object({
|
||||
limit: z.number().optional().default(50),
|
||||
offset: z.number().optional().default(0),
|
||||
sortBy: z
|
||||
.enum(["title", "updatedAt", "createdAt"])
|
||||
.optional()
|
||||
.default("updatedAt"),
|
||||
sortOrder: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
search: z.string().optional(),
|
||||
transcriptionId: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
const CreateNoteSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
initialContent: z.string().optional(),
|
||||
icon: z.string().nullish(),
|
||||
});
|
||||
|
||||
const UpdateNoteTitleSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string().min(1),
|
||||
});
|
||||
|
||||
const UpdateNoteIconSchema = z.object({
|
||||
id: z.number(),
|
||||
icon: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const notesRouter = createRouter({
|
||||
// Get all notes
|
||||
getNotes: procedure.input(GetNotesSchema).query(async ({ input }) => {
|
||||
return await notesService.listNotes({
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
sortBy: input.sortBy,
|
||||
sortOrder: input.sortOrder,
|
||||
search: input.search,
|
||||
transcriptionId: input.transcriptionId,
|
||||
});
|
||||
}),
|
||||
|
||||
// Get note by ID
|
||||
getNoteById: procedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const note = await notesService.getNote(input.id);
|
||||
if (!note) {
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
return note;
|
||||
}),
|
||||
|
||||
// Create new note
|
||||
createNote: procedure.input(CreateNoteSchema).mutation(async ({ input }) => {
|
||||
return await notesService.createNote({
|
||||
title: input.title,
|
||||
initialContent: input.initialContent || "",
|
||||
icon: input.icon,
|
||||
});
|
||||
}),
|
||||
|
||||
// Update note title
|
||||
updateNoteTitle: procedure
|
||||
.input(UpdateNoteTitleSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const updated = await notesService.updateNote(input.id, {
|
||||
title: input.title,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update note");
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
|
||||
updateNoteIcon: procedure
|
||||
.input(UpdateNoteIconSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const updated = await notesService.updateNote(input.id, {
|
||||
icon: input.icon,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update note");
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
|
||||
// Delete note
|
||||
deleteNote: procedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const deleted = await notesService.deleteNote(input.id);
|
||||
if (!deleted) {
|
||||
throw new Error("Note not found");
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
|
@ -37,4 +37,10 @@ export interface ElectronAPI {
|
|||
|
||||
// External link handling
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
|
||||
// Notes API - Yjs synchronization only
|
||||
notes: {
|
||||
saveYjsUpdate: (noteId: number, update: ArrayBuffer) => Promise<void>;
|
||||
loadYjsUpdates: (noteId: number) => Promise<ArrayBuffer[]>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
98
apps/desktop/src/utils/meeting-icons.tsx
Normal file
98
apps/desktop/src/utils/meeting-icons.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React from "react";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
interface MeetingIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Helper function to determine meeting platform from URL
|
||||
export function getMeetingPlatform(url: string): string {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
|
||||
// Remove www. prefix if present
|
||||
const cleanHostname = hostname.replace(/^www\./, "");
|
||||
|
||||
switch (true) {
|
||||
case cleanHostname.includes("zoom.us"):
|
||||
case cleanHostname.includes("zoom.com"):
|
||||
return "zoom";
|
||||
|
||||
case cleanHostname.includes("meet.google.com"):
|
||||
case cleanHostname.includes("meets.google.com"):
|
||||
return "google-meet";
|
||||
|
||||
case cleanHostname.includes("teams.microsoft.com"):
|
||||
case cleanHostname.includes("teams.live.com"):
|
||||
return "microsoft-teams";
|
||||
|
||||
case cleanHostname.includes("discord.com"):
|
||||
case cleanHostname.includes("discord.gg"):
|
||||
return "discord";
|
||||
|
||||
case cleanHostname.includes("webex.com"):
|
||||
return "webex";
|
||||
|
||||
case cleanHostname.includes("gotomeeting.com"):
|
||||
return "gotomeeting";
|
||||
|
||||
case cleanHostname.includes("bluejeans.com"):
|
||||
return "bluejeans";
|
||||
|
||||
case cleanHostname.includes("whereby.com"):
|
||||
return "whereby";
|
||||
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
// Component to render the appropriate meeting icon
|
||||
export function getMeetingIcon(
|
||||
url: string,
|
||||
props: MeetingIconProps = {},
|
||||
): React.ReactElement {
|
||||
const { className = "w-4 h-4" } = props;
|
||||
const platform = getMeetingPlatform(url);
|
||||
|
||||
switch (platform) {
|
||||
case "zoom":
|
||||
return <img src="/assets/zoom.svg" alt="Zoom" className={className} />;
|
||||
|
||||
case "google-meet":
|
||||
return (
|
||||
<img src="/assets/meet.svg" alt="Google Meet" className={className} />
|
||||
);
|
||||
|
||||
case "discord":
|
||||
return (
|
||||
<img
|
||||
src="/assets/discord-icon.svg"
|
||||
alt="Discord"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
// Add more platforms as needed when you have their icons
|
||||
case "microsoft-teams":
|
||||
case "webex":
|
||||
case "gotomeeting":
|
||||
case "bluejeans":
|
||||
case "whereby":
|
||||
default:
|
||||
// Fallback to calendar icon for unknown platforms
|
||||
return <Calendar className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Export the platforms for potential use elsewhere
|
||||
export const SUPPORTED_PLATFORMS = {
|
||||
ZOOM: "zoom",
|
||||
GOOGLE_MEET: "google-meet",
|
||||
MICROSOFT_TEAMS: "microsoft-teams",
|
||||
DISCORD: "discord",
|
||||
WEBEX: "webex",
|
||||
GOTOMEETING: "gotomeeting",
|
||||
BLUEJEANS: "bluejeans",
|
||||
WHEREBY: "whereby",
|
||||
DEFAULT: "default",
|
||||
} as const;
|
||||
24
packages/y-libsql/package.json
Normal file
24
packages/y-libsql/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@amical/y-libsql",
|
||||
"version": "0.1.0",
|
||||
"description": "LibSQL persistence provider for Yjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.15.9",
|
||||
"yjs": "^13.6.18",
|
||||
"minimatch": "10.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.9",
|
||||
"typescript": "^5.5.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.6.0"
|
||||
}
|
||||
}
|
||||
240
packages/y-libsql/src/index.ts
Normal file
240
packages/y-libsql/src/index.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import * as Y from "yjs";
|
||||
import { Client, createClient } from "@libsql/client";
|
||||
|
||||
export interface LibSQLPersistenceOptions {
|
||||
url?: string;
|
||||
authToken?: string;
|
||||
client?: Client;
|
||||
}
|
||||
|
||||
export class LibSQLPersistence {
|
||||
private doc: Y.Doc;
|
||||
private docName: string;
|
||||
private client: Client;
|
||||
private _synced: boolean = false;
|
||||
private _destroyed: boolean = false;
|
||||
private whenSynced: Promise<void>;
|
||||
private _resolveSynced!: () => void;
|
||||
private _storeUpdateHandler: (update: Uint8Array, origin: any) => void;
|
||||
private meta: Map<string, any> = new Map();
|
||||
|
||||
constructor(
|
||||
docName: string,
|
||||
ydoc: Y.Doc,
|
||||
options: LibSQLPersistenceOptions = {},
|
||||
) {
|
||||
this.doc = ydoc;
|
||||
this.docName = docName;
|
||||
|
||||
// Initialize client
|
||||
if (options.client) {
|
||||
this.client = options.client;
|
||||
} else if (options.url) {
|
||||
this.client = createClient({
|
||||
url: options.url,
|
||||
authToken: options.authToken,
|
||||
});
|
||||
} else {
|
||||
// Default to local file
|
||||
this.client = createClient({
|
||||
url: "file:local.db",
|
||||
});
|
||||
}
|
||||
|
||||
// Create promise for sync status
|
||||
this.whenSynced = new Promise((resolve) => {
|
||||
this._resolveSynced = resolve;
|
||||
});
|
||||
|
||||
// Bind the update handler
|
||||
this._storeUpdateHandler = this._storeUpdate.bind(this);
|
||||
|
||||
// Initialize the database and load existing data
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
private async _initialize() {
|
||||
try {
|
||||
// Create tables if they don't exist
|
||||
await this._createTables();
|
||||
|
||||
// Load existing updates
|
||||
await this._loadUpdates();
|
||||
|
||||
// Listen for document updates
|
||||
this.doc.on("update", this._storeUpdateHandler);
|
||||
|
||||
// Mark as synced
|
||||
this._synced = true;
|
||||
this._resolveSynced();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize LibSQLPersistence:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async _createTables() {
|
||||
// Create updates table
|
||||
await this.client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS yjs_updates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
doc_name TEXT NOT NULL,
|
||||
update_data BLOB NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
INDEX idx_doc_name (doc_name)
|
||||
)
|
||||
`);
|
||||
|
||||
// Create metadata table
|
||||
await this.client.execute(`
|
||||
CREATE TABLE IF NOT EXISTS yjs_metadata (
|
||||
doc_name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
PRIMARY KEY (doc_name, key)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
private async _loadUpdates() {
|
||||
// Fetch all updates for this document
|
||||
const result = await this.client.execute({
|
||||
sql: "SELECT update_data FROM yjs_updates WHERE doc_name = ? ORDER BY id",
|
||||
args: [this.docName],
|
||||
});
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
// Apply updates to the document
|
||||
Y.transact(
|
||||
this.doc,
|
||||
() => {
|
||||
for (const row of result.rows) {
|
||||
const updateData = row.update_data;
|
||||
if (updateData instanceof ArrayBuffer) {
|
||||
Y.applyUpdate(this.doc, new Uint8Array(updateData), this);
|
||||
} else if (typeof updateData === "string") {
|
||||
// Handle base64 encoded data
|
||||
const binaryString = atob(updateData);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
Y.applyUpdate(this.doc, bytes, this);
|
||||
}
|
||||
}
|
||||
},
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
// Load metadata
|
||||
const metaResult = await this.client.execute({
|
||||
sql: "SELECT key, value FROM yjs_metadata WHERE doc_name = ?",
|
||||
args: [this.docName],
|
||||
});
|
||||
|
||||
for (const row of metaResult.rows) {
|
||||
const key = row.key as string;
|
||||
const value = row.value as string;
|
||||
try {
|
||||
this.meta.set(key, JSON.parse(value));
|
||||
} catch {
|
||||
this.meta.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _storeUpdate(update: Uint8Array, origin: any) {
|
||||
// Don't store updates that originated from this provider
|
||||
if (origin === this || this._destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert Uint8Array to base64 for storage
|
||||
const base64Update = btoa(String.fromCharCode(...update));
|
||||
|
||||
await this.client.execute({
|
||||
sql: "INSERT INTO yjs_updates (doc_name, update_data) VALUES (?, ?)",
|
||||
args: [this.docName, base64Update],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to store update:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: any): Promise<void> {
|
||||
this.meta.set(key, value);
|
||||
const jsonValue = JSON.stringify(value);
|
||||
|
||||
await this.client.execute({
|
||||
sql: `
|
||||
INSERT INTO yjs_metadata (doc_name, key, value)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(doc_name, key)
|
||||
DO UPDATE SET value = excluded.value
|
||||
`,
|
||||
args: [this.docName, key, jsonValue],
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any {
|
||||
return this.meta.get(key);
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
this.meta.delete(key);
|
||||
|
||||
await this.client.execute({
|
||||
sql: "DELETE FROM yjs_metadata WHERE doc_name = ? AND key = ?",
|
||||
args: [this.docName, key],
|
||||
});
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
// Clear all data for this document
|
||||
await this.client.execute({
|
||||
sql: "DELETE FROM yjs_updates WHERE doc_name = ?",
|
||||
args: [this.docName],
|
||||
});
|
||||
|
||||
await this.client.execute({
|
||||
sql: "DELETE FROM yjs_metadata WHERE doc_name = ?",
|
||||
args: [this.docName],
|
||||
});
|
||||
|
||||
this.meta.clear();
|
||||
}
|
||||
|
||||
async compactUpdates(): Promise<void> {
|
||||
// Get the current state as a single update
|
||||
const stateUpdate = Y.encodeStateAsUpdate(this.doc);
|
||||
|
||||
// Clear old updates
|
||||
await this.client.execute({
|
||||
sql: "DELETE FROM yjs_updates WHERE doc_name = ?",
|
||||
args: [this.docName],
|
||||
});
|
||||
|
||||
// Store the compacted update
|
||||
const base64Update = btoa(String.fromCharCode(...stateUpdate));
|
||||
await this.client.execute({
|
||||
sql: "INSERT INTO yjs_updates (doc_name, update_data) VALUES (?, ?)",
|
||||
args: [this.docName, base64Update],
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this._destroyed) return;
|
||||
|
||||
this._destroyed = true;
|
||||
this.doc.off("update", this._storeUpdateHandler);
|
||||
}
|
||||
|
||||
get synced(): boolean {
|
||||
return this._synced;
|
||||
}
|
||||
}
|
||||
|
||||
// Export for convenience
|
||||
export { Y };
|
||||
19
packages/y-libsql/tsconfig.json
Normal file
19
packages/y-libsql/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
82
pnpm-lock.yaml
generated
82
pnpm-lock.yaml
generated
|
|
@ -35,6 +35,9 @@ importers:
|
|||
'@amical/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@amical/y-libsql':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/y-libsql
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
|
|
@ -212,6 +215,9 @@ importers:
|
|||
embla-carousel-react:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(react@19.1.1)
|
||||
emoji-picker-react:
|
||||
specifier: ^4.13.3
|
||||
version: 4.13.3(react@19.1.1)
|
||||
framer-motion:
|
||||
specifier: ^12.10.5
|
||||
version: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
|
|
@ -233,6 +239,9 @@ importers:
|
|||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
node-machine-id:
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12
|
||||
|
|
@ -293,6 +302,9 @@ importers:
|
|||
workerpool:
|
||||
specifier: ^9.3.3
|
||||
version: 9.3.3
|
||||
yjs:
|
||||
specifier: ^13.6.27
|
||||
version: 13.6.27
|
||||
zod:
|
||||
specifier: ^3.25.24
|
||||
version: 3.25.76
|
||||
|
|
@ -665,6 +677,25 @@ importers:
|
|||
specifier: 5.8.2
|
||||
version: 5.8.2
|
||||
|
||||
packages/y-libsql:
|
||||
dependencies:
|
||||
'@libsql/client':
|
||||
specifier: ^0.15.9
|
||||
version: 0.15.12
|
||||
minimatch:
|
||||
specifier: 10.0.3
|
||||
version: 10.0.3
|
||||
yjs:
|
||||
specifier: ^13.6.18
|
||||
version: 13.6.27
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.14.9
|
||||
version: 20.19.11
|
||||
typescript:
|
||||
specifier: ^5.5.2
|
||||
version: 5.8.3
|
||||
|
||||
packages:
|
||||
|
||||
'@ai-sdk/openai@1.3.24':
|
||||
|
|
@ -3996,6 +4027,9 @@ packages:
|
|||
'@types/node@18.19.123':
|
||||
resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==}
|
||||
|
||||
'@types/node@20.19.11':
|
||||
resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==}
|
||||
|
||||
'@types/node@22.15.12':
|
||||
resolution: {integrity: sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==}
|
||||
|
||||
|
|
@ -5477,6 +5511,12 @@ packages:
|
|||
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
emoji-picker-react@4.13.3:
|
||||
resolution: {integrity: sha512-aZaxCI72oUQfvZtYuQ9RaYLEwmH3GVgAr5SEeB97Y7gWL06zJ4VTuSl8rAMVY7GNmd0tf/EQ1W2SDuXTl0q9AA==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16'
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
|
|
@ -6057,6 +6097,9 @@ packages:
|
|||
fix-dts-default-cjs-exports@1.0.1:
|
||||
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
|
||||
|
||||
flairup@1.0.0:
|
||||
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
|
||||
|
||||
flat-cache@1.3.4:
|
||||
resolution: {integrity: sha512-VwyB3Lkgacfik2vhqR4uv2rvebqmDvFu4jlN/C1RzWoJEo8I7z4Q404oiqYCkq41mni8EzQnm95emU9seckwtg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -6903,6 +6946,9 @@ packages:
|
|||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isomorphic.js@0.2.5:
|
||||
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -7209,6 +7255,11 @@ packages:
|
|||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lib0@0.2.114:
|
||||
resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
libsql@0.5.17:
|
||||
resolution: {integrity: sha512-RRlj5XQI9+Wq+/5UY8EnugSWfRmHEw4hn3DKlPrkUgZONsge1PwTtHcpStP6MSNi8ohcbsRgEHJaymA33a8cBw==}
|
||||
cpu: [x64, arm64, wasm32, arm]
|
||||
|
|
@ -7868,6 +7919,10 @@ packages:
|
|||
node-api-version@0.2.1:
|
||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||
|
||||
node-cron@4.2.1:
|
||||
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
|
|
@ -10180,6 +10235,10 @@ packages:
|
|||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
yjs@13.6.27:
|
||||
resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
|
||||
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
||||
|
||||
yn@3.1.1:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -14119,6 +14178,10 @@ snapshots:
|
|||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@20.19.11':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.15.12':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
|
@ -15646,6 +15709,11 @@ snapshots:
|
|||
|
||||
emittery@0.13.1: {}
|
||||
|
||||
emoji-picker-react@4.13.3(react@19.1.1):
|
||||
dependencies:
|
||||
flairup: 1.0.0
|
||||
react: 19.1.1
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
|
@ -16588,6 +16656,8 @@ snapshots:
|
|||
mlly: 1.7.4
|
||||
rollup: 4.47.1
|
||||
|
||||
flairup@1.0.0: {}
|
||||
|
||||
flat-cache@1.3.4:
|
||||
dependencies:
|
||||
circular-json: 0.3.3
|
||||
|
|
@ -17624,6 +17694,8 @@ snapshots:
|
|||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic.js@0.2.5: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-instrument@5.2.1:
|
||||
|
|
@ -18117,6 +18189,10 @@ snapshots:
|
|||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lib0@0.2.114:
|
||||
dependencies:
|
||||
isomorphic.js: 0.2.5
|
||||
|
||||
libsql@0.5.17:
|
||||
dependencies:
|
||||
'@neon-rs/load': 0.0.4
|
||||
|
|
@ -19029,6 +19105,8 @@ snapshots:
|
|||
dependencies:
|
||||
semver: 7.7.2
|
||||
|
||||
node-cron@4.2.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
|
@ -21736,6 +21814,10 @@ snapshots:
|
|||
buffer-crc32: 0.2.13
|
||||
fd-slicer: 1.1.0
|
||||
|
||||
yjs@13.6.27:
|
||||
dependencies:
|
||||
lib0: 0.2.114
|
||||
|
||||
yn@3.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue