diff --git a/adapter/adapter.ts b/adapter/adapter.ts index 332c831..92da579 100644 --- a/adapter/adapter.ts +++ b/adapter/adapter.ts @@ -1,4 +1,4 @@ -import type { FolderType, NoteType } from "~/repositories/storage.type"; +import type { FolderType, NoteType, TagType, NoteTagType } from "~/repositories/storage.type"; class NotasAdapter { async pullFolders(lastSync: number): Promise { @@ -17,6 +17,23 @@ class NotasAdapter { throw new Error("Not implemented"); } + // tags + async pullTags(lastSync: number): Promise { + throw new Error("Not implemented"); + } + + async pushTag(tag: any): Promise { + throw new Error("Not implemented"); + } + + async pullNoteTags(lastSync: number): Promise { + throw new Error("Not implemented"); + } + + async pushNoteTag(noteTag: any): Promise { + throw new Error("Not implemented"); + } + // settings async pushSettings(setting: any): Promise { throw new Error("Not implemented"); diff --git a/adapter/localforage/index.ts b/adapter/localforage/index.ts index 6c91475..f925d5c 100644 --- a/adapter/localforage/index.ts +++ b/adapter/localforage/index.ts @@ -1,4 +1,4 @@ -import type { FolderType, NoteType } from "~/repositories/storage.type"; +import type { FolderType, NoteType, TagType, NoteTagType } from "~/repositories/storage.type"; import type NotasAdapter from "../adapter"; class LocalForageAdapter implements NotasAdapter { @@ -20,6 +20,23 @@ class LocalForageAdapter implements NotasAdapter { return {}; } + // tags + async pullTags(lastSync: number): Promise { + return []; + } + + async pushTag(tag: any): Promise { + return {}; + } + + async pullNoteTags(lastSync: number): Promise { + return []; + } + + async pushNoteTag(noteTag: any): Promise { + return {}; + } + // settings async pushSettings(setting: any): Promise { return {}; diff --git a/adapter/turso/index.ts b/adapter/turso/index.ts index 234ad66..9fcf21b 100644 --- a/adapter/turso/index.ts +++ b/adapter/turso/index.ts @@ -3,21 +3,27 @@ import { LibSQLDatabase, drizzle } from "drizzle-orm/libsql"; import { gt } from "drizzle-orm"; import type NotasAdapter from "../adapter"; -import type { FolderType, NoteType } from "~/repositories/storage.type"; +import type { FolderType, NoteType, TagType, NoteTagType } from "~/repositories/storage.type"; +import { applySchemaMigrations } from "~/migrate/scripts/apply-migrations"; import noteModel from "./models/note"; import folderModel from "./models/folder"; import settingModel from "./models/setting"; +import tagModel from "./models/tag"; +import noteTagModel from "./models/note-tag"; class TursoAdapter implements NotasAdapter { private turso: LibSQLDatabase; + private ready: Promise; constructor(config: string) { const configJSON = JSON.parse(config); const client = createClient(configJSON); this.turso = drizzle(client); + this.ready = applySchemaMigrations(client); } async pullFolders(lastSync: number): Promise { + await this.ready; const folders = await this.turso .select() .from(folderModel) @@ -35,6 +41,7 @@ class TursoAdapter implements NotasAdapter { } async pullNotes(lastSync: number): Promise { + await this.ready; const notes = await this.turso .select() .from(noteModel) @@ -56,6 +63,7 @@ class TursoAdapter implements NotasAdapter { } async pushFolder(folder: any): Promise { + await this.ready; return this.turso .insert(folderModel) .values({ @@ -90,6 +98,7 @@ class TursoAdapter implements NotasAdapter { } async pushNote(note: any): Promise { + await this.ready; return this.turso .insert(noteModel) .values({ @@ -135,8 +144,124 @@ class TursoAdapter implements NotasAdapter { }); } + // tags + async pullTags(lastSync: number): Promise { + await this.ready; + const tags = await this.turso + .select() + .from(tagModel) + .where(gt(tagModel.updatedAt, lastSync)) + .all(); + + return tags.map((tag: any) => ({ + id: tag.id, + name: tag.name, + color: tag.color, + lastSync: tag.lastSync, + createdAt: tag.createdAt, + updatedAt: tag.updatedAt, + deletedAt: tag.deletedAt || null, + })); + } + + async pushTag(tag: any): Promise { + await this.ready; + return this.turso + .insert(tagModel) + .values({ + id: tag.id, + name: tag.name, + color: tag.color, + lastSync: tag.lastSync, + createdAt: tag.createdAt, + updatedAt: tag.updatedAt, + deletedAt: tag.deletedAt, + }) + .onConflictDoUpdate({ + target: tagModel.id, + set: { + name: tag.name, + color: tag.color, + lastSync: tag.lastSync, + createdAt: tag.createdAt, + updatedAt: tag.updatedAt, + deletedAt: tag.deletedAt, + }, + }) + .returning({ + id: tagModel.id, + name: tagModel.name, + color: tagModel.color, + lastSync: tagModel.lastSync, + createdAt: tagModel.createdAt, + updatedAt: tagModel.updatedAt, + deletedAt: tagModel.deletedAt, + }) + .catch((err) => { + console.log("pushTag error", err); + }); + } + + async pullNoteTags(lastSync: number): Promise { + await this.ready; + const noteTags = await this.turso + .select() + .from(noteTagModel) + .where(gt(noteTagModel.updatedAt, lastSync)) + .all(); + + return noteTags.map((noteTag: any) => ({ + id: noteTag.id, + noteId: noteTag.noteId, + tagId: noteTag.tagId, + lastSync: noteTag.lastSync, + createdAt: noteTag.createdAt, + updatedAt: noteTag.updatedAt, + deletedAt: noteTag.deletedAt || null, + })); + } + + async pushNoteTag(noteTag: any): Promise { + await this.ready; + return this.turso + .insert(noteTagModel) + .values({ + id: noteTag.id, + noteId: noteTag.noteId, + tagId: noteTag.tagId, + lastSync: noteTag.lastSync, + createdAt: noteTag.createdAt, + updatedAt: noteTag.updatedAt, + deletedAt: noteTag.deletedAt, + }) + .onConflictDoUpdate({ + target: noteTagModel.id, + set: { + noteId: noteTag.noteId, + tagId: noteTag.tagId, + lastSync: noteTag.lastSync, + createdAt: noteTag.createdAt, + updatedAt: noteTag.updatedAt, + deletedAt: noteTag.deletedAt, + }, + }) + .returning({ + id: noteTagModel.id, + noteId: noteTagModel.noteId, + tagId: noteTagModel.tagId, + lastSync: noteTagModel.lastSync, + createdAt: noteTagModel.createdAt, + updatedAt: noteTagModel.updatedAt, + deletedAt: noteTagModel.deletedAt, + }) + .catch((err) => { + console.log("pushNoteTag error", err); + }); + } + // settings async pushSettings(setting: any): Promise { + await this.ready; return this.turso .insert(settingModel) .values({ @@ -164,6 +289,7 @@ class TursoAdapter implements NotasAdapter { }); } async pullSettings(lastSync: number): Promise { + await this.ready; const setting = await this.turso .select() .from(settingModel) diff --git a/adapter/turso/migrations/0005_tired_king_bedlam.sql b/adapter/turso/migrations/0005_tired_king_bedlam.sql new file mode 100644 index 0000000..fbb3026 --- /dev/null +++ b/adapter/turso/migrations/0005_tired_king_bedlam.sql @@ -0,0 +1,21 @@ +CREATE TABLE `note_tags` ( + `id` text PRIMARY KEY NOT NULL, + `noteId` text, + `tagId` text, + `lastSync` integer, + `createdAt` integer, + `updatedAt` integer, + `deletedAt` integer +); +--> statement-breakpoint +CREATE TABLE `tags` ( + `id` text PRIMARY KEY NOT NULL, + `name` text, + `color` text, + `lastSync` integer, + `createdAt` integer, + `updatedAt` integer, + `deletedAt` integer +); +--> statement-breakpoint +ALTER TABLE settings ADD `schemaVersion` integer; \ No newline at end of file diff --git a/adapter/turso/migrations/meta/0005_snapshot.json b/adapter/turso/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..26b5797 --- /dev/null +++ b/adapter/turso/migrations/meta/0005_snapshot.json @@ -0,0 +1,304 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "ef267c27-e8b1-471c-be5f-b351c6fbc83b", + "prevId": "fd40e6ed-735b-4ce9-b691-1a1328bbd735", + "tables": { + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSync": { + "name": "lastSync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "note_tags": { + "name": "note_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "noteId": { + "name": "noteId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSync": { + "name": "lastSync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "notes": { + "name": "notes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "folderId": { + "name": "folderId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isPinned": { + "name": "isPinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isLocked": { + "name": "isLocked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSync": { + "name": "lastSync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleteCompletelyAt": { + "name": "deleteCompletelyAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSync": { + "name": "lastSync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schemaVersion": { + "name": "schemaVersion", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastSync": { + "name": "lastSync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/adapter/turso/migrations/meta/_journal.json b/adapter/turso/migrations/meta/_journal.json index d06e391..b90b18b 100644 --- a/adapter/turso/migrations/meta/_journal.json +++ b/adapter/turso/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1715500976113, "tag": "0004_legal_black_tom", "breakpoints": true + }, + { + "idx": 5, + "version": "5", + "when": 1784646172086, + "tag": "0005_tired_king_bedlam", + "breakpoints": true } ] } \ No newline at end of file diff --git a/adapter/turso/models/index.ts b/adapter/turso/models/index.ts index 13e627f..4e15d60 100644 --- a/adapter/turso/models/index.ts +++ b/adapter/turso/models/index.ts @@ -1,3 +1,5 @@ export * from './folder'; export * from './note'; export * from './setting'; +export * from './tag'; +export * from './note-tag'; diff --git a/adapter/turso/models/note-tag.ts b/adapter/turso/models/note-tag.ts new file mode 100644 index 0000000..b240672 --- /dev/null +++ b/adapter/turso/models/note-tag.ts @@ -0,0 +1,13 @@ +import { text, sqliteTable, integer } from "drizzle-orm/sqlite-core"; + +const noteTagModel = sqliteTable("note_tags", { + id: text("id").primaryKey(), // `${noteId}:${tagId}` + noteId: text("noteId"), + tagId: text("tagId"), + lastSync: integer("lastSync"), + createdAt: integer("createdAt"), + updatedAt: integer("updatedAt"), + deletedAt: integer("deletedAt"), +}); + +export default noteTagModel; diff --git a/adapter/turso/models/setting.ts b/adapter/turso/models/setting.ts index c975f55..e5b2a38 100644 --- a/adapter/turso/models/setting.ts +++ b/adapter/turso/models/setting.ts @@ -5,6 +5,7 @@ const settingModel = sqliteTable("settings", { password: text("password"), lastSync: integer("lastSync"), updatedAt: integer("updatedAt"), + schemaVersion: integer("schemaVersion"), }); export default settingModel; diff --git a/adapter/turso/models/tag.ts b/adapter/turso/models/tag.ts new file mode 100644 index 0000000..480c3a7 --- /dev/null +++ b/adapter/turso/models/tag.ts @@ -0,0 +1,13 @@ +import { text, sqliteTable, integer } from "drizzle-orm/sqlite-core"; + +const tagModel = sqliteTable("tags", { + id: text("id").primaryKey(), + name: text("name"), + color: text("color"), + lastSync: integer("lastSync"), + createdAt: integer("createdAt"), + updatedAt: integer("updatedAt"), + deletedAt: integer("deletedAt"), +}); + +export default tagModel; diff --git a/app.vue b/app.vue index e09851b..23d91b7 100644 --- a/app.vue +++ b/app.vue @@ -12,4 +12,5 @@ onMounted(() => { diff --git a/assets/css/crepe.css b/assets/css/crepe.css index d2a3e82..563817c 100644 --- a/assets/css/crepe.css +++ b/assets/css/crepe.css @@ -22,12 +22,14 @@ --crepe-color-selected: oklch(var(--p)); --crepe-color-inline-area: #2b2b2b; - --crepe-font-title: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; - --crepe-font-default: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", - Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; - --crepe-font-code: "Space Mono", Fira Code, Menlo, Monaco, "Courier New", - Courier, monospace; + --crepe-font-title: + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --crepe-font-default: + system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --crepe-font-code: + "Space Mono", Fira Code, Menlo, Monaco, "Courier New", Courier, monospace; /* --crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), @@ -122,6 +124,59 @@ height: 100px !important; } +/* image sync badge */ +.milkdown .milkdown-image-block { + position: relative; +} +.image-sync-badge { + position: absolute; + top: 8px; + right: 8px; + z-index: 20; + cursor: pointer; + width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: + transform 0.15s, + opacity 0.15s; + opacity: 0.9; + border: none; + padding: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} +.image-sync-badge:hover { + opacity: 1; + transform: scale(1.12); +} +.image-sync-badge--pending { + background: rgba(0, 0, 0, 0.55); +} +.image-sync-badge--failed { + background: rgba(0, 0, 0, 0.55); +} +.image-sync-badge--syncing { + background: rgba(0, 0, 0, 0.55); + cursor: default; + animation: sync-badge-spin 1s linear infinite; +} +.image-sync-badge svg { + width: 18px; + height: 18px; + color: #f59e0b; +} +@keyframes sync-badge-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + /* image */ .milkdown .milkdown-image-block .image-edit { border-radius: 0.5rem !important; @@ -141,11 +196,44 @@ align-items: center; justify-content: center; } +.milkdown .milkdown-image-block .caption-input { + font-size: 0.875rem !important; + font-weight: 600 !important; + font-style: italic !important; +} /* Prevent scroll when resizing image on mobile */ .milkdown .milkdown-image-block .image-resize-handle { touch-action: none; } +/* Image loading placeholder — hide broken icon while blob URL is resolving */ +.milkdown .milkdown-image-block img { + transition: opacity 0.3s ease; +} +.milkdown .milkdown-image-block img[src^="opennotas://"] { + opacity: 0; +} +.milkdown .milkdown-image-block > .image-wrapper:has(img[src^="opennotas://"]) { + min-height: 120px; + border-radius: 8px; + background-image: linear-gradient( + 90deg, + oklch(var(--b2)) 25%, + oklch(var(--b3)) 50%, + oklch(var(--b2)) 75% + ); + background-size: 200% 100%; + animation: img-skeleton-shimmer 1.5s ease-in-out infinite; +} +@keyframes img-skeleton-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + /* Link preview */ .milkdown .milkdown-link-preview .link-preview { height: auto !important; @@ -153,7 +241,8 @@ border: 1px solid var(--neutral) !important; } .milkdown .milkdown-link-edit .link-edit { - height: auto !important; + height: 42px !important; + /* width: 250px !important; */ padding: 0.5rem !important; border: 1px solid var(--neutral) !important; } diff --git a/assets/svg/alert-square-rounded.svg b/assets/svg/alert-square-rounded.svg new file mode 100644 index 0000000..1726515 --- /dev/null +++ b/assets/svg/alert-square-rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/cloud-upload.svg b/assets/svg/cloud-upload.svg new file mode 100644 index 0000000..300583e --- /dev/null +++ b/assets/svg/cloud-upload.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/cloud.svg b/assets/svg/cloud.svg new file mode 100644 index 0000000..06fb5f1 --- /dev/null +++ b/assets/svg/cloud.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/file-arrow-right.svg b/assets/svg/file-arrow-right.svg new file mode 100644 index 0000000..09b720a --- /dev/null +++ b/assets/svg/file-arrow-right.svg @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/assets/svg/fingerprint.svg b/assets/svg/fingerprint.svg new file mode 100644 index 0000000..4ea0b87 --- /dev/null +++ b/assets/svg/fingerprint.svg @@ -0,0 +1 @@ + diff --git a/assets/svg/history.svg b/assets/svg/history.svg new file mode 100644 index 0000000..6b61a36 --- /dev/null +++ b/assets/svg/history.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/assets/svg/tag.svg b/assets/svg/tag.svg new file mode 100644 index 0000000..a750df5 --- /dev/null +++ b/assets/svg/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/AppToast.vue b/components/AppToast.vue new file mode 100644 index 0000000..0904a4a --- /dev/null +++ b/components/AppToast.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/components/ColsPersonal.vue b/components/ColsPersonal.vue index a76e150..11ffd70 100644 --- a/components/ColsPersonal.vue +++ b/components/ColsPersonal.vue @@ -1,5 +1,6 @@