From 722306ff637f507bf86ce5d34a823582461216ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alvaro=20Amor=C3=B3s?= <39102625+ElMaxter99@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:23:53 +0100 Subject: [PATCH 1/5] feat: sync annotation templates with cloud workspaces --- docs/cloud-templates-api.md | 306 +++++++++++++++++ src/app/annotation-templates.service.ts | 309 ++++++++++++++---- src/app/app.config.ts | 2 + src/app/i18n/translations/ca.json | 20 ++ src/app/i18n/translations/en.json | 20 ++ src/app/i18n/translations/es-ES.json | 20 ++ src/app/models/annotation-template.model.ts | 18 + .../sidebar/workspace-sidebar.component.html | 89 +++++ .../sidebar/workspace-sidebar.component.scss | 141 ++++++++ .../sidebar/workspace-sidebar.component.ts | 4 +- src/app/pages/workspace/workspace.page.ts | 110 ++++++- src/app/services/cloud-templates.service.ts | 117 +++++++ src/app/services/session.service.ts | 288 ++++++++++++++++ 13 files changed, 1376 insertions(+), 68 deletions(-) create mode 100644 docs/cloud-templates-api.md create mode 100644 src/app/models/annotation-template.model.ts create mode 100644 src/app/services/cloud-templates.service.ts create mode 100644 src/app/services/session.service.ts diff --git a/docs/cloud-templates-api.md b/docs/cloud-templates-api.md new file mode 100644 index 0000000..0418245 --- /dev/null +++ b/docs/cloud-templates-api.md @@ -0,0 +1,306 @@ +# API REST de plantillas en la nube + +## Visión general + +- **Base URL**: `https://api.pdf-annotator.example.com/api/v1` +- **Versionado**: todas las rutas se versionan con el prefijo `/v1`. Cambios incompatibles crearán nuevas versiones (`/v2`, `/v3`, ...). +- **Formato**: JSON UTF-8. Las marcas de tiempo se serializan en ISO 8601 (`2025-01-10T12:00:00Z`). +- **Autenticación**: Bearer JWT generado por el endpoint de sesión. Todos los endpoints protegidos exigen `Authorization: Bearer `. +- **Seguridad**: los tokens expiran en 15 minutos. Se entrega un `refreshToken` con caducidad de 30 días para renovar la sesión. + +## Autenticación + +### POST `/auth/sessions` + +Inicia una sesión de usuario. + +**Request** +```json +{ + "email": "editor@acme.com", + "password": "StrongPassw0rd!", + "projectKey": "pdf-annotator" +} +``` + +**Response** `201 Created` +```json +{ + "accessToken": "jwt-access-token", + "refreshToken": "jwt-refresh-token", + "expiresIn": 900, + "user": { + "id": "usr_123", + "name": "Ana Campos", + "email": "editor@acme.com", + "avatarUrl": "https://cdn.example.com/avatars/usr_123.png" + }, + "workspaces": [ + { + "id": "wrk_001", + "name": "Demo", + "role": "editor" + } + ], + "defaultWorkspaceId": "wrk_001" +} +``` + +### POST `/auth/refresh` + +Renueva el `accessToken` usando un `refreshToken` válido. + +**Request** +```json +{ "refreshToken": "jwt-refresh-token" } +``` + +**Response** `200 OK` +```json +{ + "accessToken": "new-access-token", + "refreshToken": "new-refresh-token", + "expiresIn": 900 +} +``` + +### DELETE `/auth/sessions/current` + +Revoca la sesión activa. Invalida el `refreshToken` asociado. + +**Response** `204 No Content` + +## Espacios de trabajo / Proyectos + +Los espacios de trabajo representan proyectos colaborativos. Cada plantilla pertenece a un único workspace. + +### GET `/workspaces` + +Lista los espacios accesibles para el usuario autenticado. + +**Response** `200 OK` +```json +[ + { + "id": "wrk_001", + "name": "Demo", + "role": "owner", + "updatedAt": "2025-01-05T10:15:00Z" + }, + { + "id": "wrk_002", + "name": "Marketing", + "role": "viewer", + "updatedAt": "2024-12-18T09:02:00Z" + } +] +``` + +### POST `/workspaces` + +Crea un nuevo workspace. Solo disponible para usuarios con rol `owner`. + +**Request** +```json +{ + "name": "Cliente ACME", + "slug": "cliente-acme" +} +``` + +**Response** `201 Created` +```json +{ + "id": "wrk_010", + "name": "Cliente ACME", + "slug": "cliente-acme", + "role": "owner" +} +``` + +### POST `/workspaces/{workspaceId}/members` + +Invita a un miembro utilizando su correo electrónico y rol deseado. + +```json +{ + "email": "designer@acme.com", + "role": "editor" +} +``` + +## Plantillas de anotación + +### Modelo `AnnotationTemplate` + +```json +{ + "id": "tpl_001", + "name": "Facturas", + "version": 3, + "workspaceId": "wrk_001", + "createdAt": "2024-11-30T08:00:00Z", + "updatedAt": "2025-01-09T17:41:00Z", + "guidesEnabled": true, + "guideSettings": { + "showGrid": true, + "snapToGrid": true, + "gridSize": 12 + }, + "pages": [ + { + "num": 1, + "fields": [ + { + "id": "fld_1", + "type": "text", + "x": 120, + "y": 250, + "width": 320, + "height": 48, + "rotation": 0, + "fontFamily": "Helvetica", + "fontSize": 14, + "opacity": 1, + "textAlign": "left", + "content": "Nombre del cliente" + } + ] + } + ] +} +``` + +### GET `/workspaces/{workspaceId}/templates` + +Devuelve todas las plantillas accesibles para el workspace. El servidor siempre devuelve la última versión persistida. + +**Response** `200 OK` +```json +[ + { "id": "tpl_001", "name": "Facturas", "version": 3, "workspaceId": "wrk_001", "createdAt": "2024-11-30T08:00:00Z", "updatedAt": "2025-01-09T17:41:00Z", "guidesEnabled": true, "guideSettings": {"showGrid": true, "snapToGrid": true, "gridSize": 12}, "pages": [] } +] +``` + +### PUT `/workspaces/{workspaceId}/templates/{templateId}` + +Crea o sustituye una plantilla. El cliente debe enviar siempre la versión esperada. + +**Headers** +- `If-Match: W/"3"` para realizar control de concurrencia optimista (el valor debe coincidir con `version`). + +**Request** +```json +{ + "name": "Facturas", + "version": 3, + "guidesEnabled": true, + "guideSettings": { + "showGrid": true, + "snapToGrid": true, + "gridSize": 12 + }, + "pages": [ + { + "num": 1, + "fields": [] + } + ] +} +``` + +**Response** `200 OK` +```json +{ + "id": "tpl_001", + "name": "Facturas", + "version": 4, + "workspaceId": "wrk_001", + "createdAt": "2024-11-30T08:00:00Z", + "updatedAt": "2025-01-09T17:42:10Z", + "guidesEnabled": true, + "guideSettings": { + "showGrid": true, + "snapToGrid": true, + "gridSize": 12 + }, + "pages": [ + { + "num": 1, + "fields": [] + } + ] +} +``` + +Si el `If-Match` no coincide el servidor responderá `409 Conflict` con la versión actual: +```json +{ + "error": "version_conflict", + "message": "La plantilla fue modificada por otro usuario.", + "currentVersion": 5 +} +``` + +### DELETE `/workspaces/{workspaceId}/templates/{templateId}` + +Elimina la plantilla indicada. Devuelve `204 No Content` si la operación tiene éxito. + +### POST `/workspaces/{workspaceId}/templates/{templateId}/versions` + +Crea una rama histórica adicional sin afectar a la versión publicada. Útil para auditoría o restauración. + +```json +{ + "sourceVersion": 5, + "label": "Pre-ajustes 2025-01" +} +``` + +**Response** `201 Created` +```json +{ + "id": "tpl_001:v6", + "templateId": "tpl_001", + "version": 6, + "label": "Pre-ajustes 2025-01", + "createdAt": "2025-01-10T09:10:00Z" +} +``` + +### Sincronización en tiempo real (opcional) + +Para colaboración simultánea puede abrirse un canal WebSocket en `wss://api.pdf-annotator.example.com/ws/templates`. El cliente debe enviar el token JWT en la query (`?token=...`). Eventos relevantes: + +- `template.updated`: notifica cambios en una plantilla (`id`, `workspaceId`, `version`). +- `template.deleted`: notifica eliminaciones. +- `workspace.joined` / `workspace.left`: eventos de miembros. + +## Esquema `PageAnnotations` + +Un `PageAnnotations` replica el modelo de la app: + +```json +{ + "num": 1, + "fields": [ + { + "id": "fld_1", + "type": "text", + "x": 120, + "y": 250, + "width": 320, + "height": 48, + "rotation": 0, + "fontFamily": "Helvetica", + "fontSize": 14, + "opacity": 1, + "textAlign": "left", + "content": "Nombre del cliente", + "backgroundColor": "rgba(255,255,255,0)" + } + ] +} +``` + +Los clientes deben enviar la estructura completa para garantizar consistencia entre dispositivos. Cada actualización incrementa el campo `version` en el backend. diff --git a/src/app/annotation-templates.service.ts b/src/app/annotation-templates.service.ts index cd75238..804bf9e 100644 --- a/src/app/annotation-templates.service.ts +++ b/src/app/annotation-templates.service.ts @@ -1,24 +1,26 @@ -import { Injectable } from '@angular/core'; +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { BehaviorSubject, distinctUntilChanged, take } from 'rxjs'; + import { PageAnnotations, PageField } from './models/annotation.model'; +import { AnnotationTemplate } from './models/annotation-template.model'; import { DEFAULT_GUIDE_SETTINGS, GuideSettings, cloneGuideSettings, } from './models/guide-settings.model'; - -export interface AnnotationTemplate { - id: string; - name: string; - createdAt: number; - pages: PageAnnotations[]; - guidesEnabled: boolean; - guideSettings: GuideSettings; -} +import { CloudTemplatesService } from './services/cloud-templates.service'; +import { SessionService } from './services/session.service'; type StoredAnnotationTemplate = { id: string; name: string; createdAt: number; + updatedAt?: number; + version?: number; + workspaceId?: string | null; + origin?: AnnotationTemplate['origin']; + syncedAt?: number | null; pages?: PageAnnotations[]; guidesEnabled?: boolean; guideSettings?: Partial | null; @@ -30,10 +32,37 @@ export class AnnotationTemplatesService { private readonly lastCoordsKey = 'pdf-annotator.last-coords'; readonly defaultTemplateId = '__default-template__'; private readonly defaultTemplateName = 'Predeterminada'; + private readonly destroyRef = inject(DestroyRef); + + private cachedTemplates: AnnotationTemplate[] = []; + private activeWorkspaceId: string | null = null; + private readonly templatesSubject = new BehaviorSubject([]); + + readonly templates$ = this.templatesSubject.asObservable(); + + constructor( + private readonly cloudTemplatesService: CloudTemplatesService, + private readonly sessionService: SessionService, + ) { + this.cachedTemplates = this.getStoredTemplates(); + this.activeWorkspaceId = this.sessionService.getActiveWorkspaceId(); + this.templatesSubject.next(this.buildVisibleTemplates()); + + this.sessionService.activeWorkspace$ + .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((workspaceId) => { + this.activeWorkspaceId = workspaceId; + this.templatesSubject.next(this.buildVisibleTemplates()); + this.syncWithRemote(workspaceId); + }); + + if (this.activeWorkspaceId) { + this.syncWithRemote(this.activeWorkspaceId); + } + } getTemplates(): AnnotationTemplate[] { - const stored = this.getStoredTemplates(); - return [this.createDefaultTemplate(), ...stored]; + return this.templatesSubject.value.map((template) => this.cloneTemplate(template)); } saveTemplate( @@ -51,37 +80,51 @@ export class AnnotationTemplatesService { const sanitizedPages = this.clonePages(data.pages); const sanitizedGuideSettings = cloneGuideSettings(data.guideSettings); - const templates = this.getStoredTemplates(); const normalizedName = name.trim(); const now = Date.now(); - const existingIndex = templates.findIndex( + const workspaceId = this.activeWorkspaceId; + const templatesByWorkspace = this.filterTemplatesByWorkspace(workspaceId); + const existingIndex = templatesByWorkspace.findIndex( (template) => template.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase() ); + let template: AnnotationTemplate; + if (existingIndex >= 0) { - const updatedTemplate: AnnotationTemplate = { - ...templates[existingIndex], + const existing = templatesByWorkspace[existingIndex]; + template = { + ...existing, + name: normalizedName, + pages: sanitizedPages, + guidesEnabled: data.guidesEnabled, + guideSettings: sanitizedGuideSettings, + updatedAt: now, + version: existing.version ?? (workspaceId ? 1 : undefined), + }; + this.replaceTemplate(existing.id, template); + } else { + template = { + id: this.createId(), name: normalizedName, createdAt: now, + updatedAt: now, + version: workspaceId ? 1 : undefined, + workspaceId: workspaceId ?? null, + origin: workspaceId ? 'hybrid' : 'local', + syncedAt: null, pages: sanitizedPages, guidesEnabled: data.guidesEnabled, guideSettings: sanitizedGuideSettings, }; - templates.splice(existingIndex, 1, updatedTemplate); - this.persistTemplates(templates); - return this.cloneTemplate(updatedTemplate); - } - - const template: AnnotationTemplate = { - id: this.createId(), - name: normalizedName, - createdAt: now, - pages: sanitizedPages, - guidesEnabled: data.guidesEnabled, - guideSettings: sanitizedGuideSettings, - }; + this.cachedTemplates = [template, ...this.removeTemplatesByName(normalizedName, workspaceId)]; + } + + this.emitAndPersist(); + + if (workspaceId) { + this.pushTemplateToCloud(template, workspaceId); + } - this.persistTemplates([template, ...templates]); return this.cloneTemplate(template); } @@ -89,9 +132,24 @@ export class AnnotationTemplatesService { if (id === this.defaultTemplateId) { return; } - const storedTemplates = this.getStoredTemplates(); - const nextTemplates = storedTemplates.filter((template) => template.id !== id); - this.persistTemplates(nextTemplates); + + const template = this.cachedTemplates.find((item) => item.id === id); + if (!template) { + return; + } + + this.cachedTemplates = this.cachedTemplates.filter((item) => item.id !== id); + this.emitAndPersist(); + + if (template.workspaceId) { + this.cloudTemplatesService + .deleteTemplate(template.id, template.workspaceId) + .pipe(take(1)) + .subscribe({ + error: (error) => + console.warn('No se pudo eliminar la plantilla en la nube.', error), + }); + } } storeLastCoords(pages: readonly PageAnnotations[]) { @@ -103,47 +161,125 @@ export class AnnotationTemplatesService { return stored ? this.clonePages(stored) : null; } - private persistTemplates(templates: readonly AnnotationTemplate[]) { - this.writeToStorage( - this.templatesKey, - templates.map((template) => this.cloneTemplate(template)) + refreshFromCloud(): void { + this.syncWithRemote(this.activeWorkspaceId); + } + + private pushTemplateToCloud(template: AnnotationTemplate, workspaceId: string) { + this.cloudTemplatesService + .saveTemplate(template, workspaceId) + .pipe(take(1)) + .subscribe({ + next: (remoteTemplate) => { + this.mergeRemoteTemplates([remoteTemplate], workspaceId); + }, + error: (error) => + console.warn('No se pudo sincronizar la plantilla con la nube.', error), + }); + } + + private replaceTemplate(id: string, replacement: AnnotationTemplate) { + this.cachedTemplates = this.cachedTemplates.map((template) => + template.id === id ? replacement : template ); } - private normalizeGuideSettings( - settings: Partial | null | undefined - ): GuideSettings { - if (!settings) { - return cloneGuideSettings(DEFAULT_GUIDE_SETTINGS); + private removeTemplatesByName(name: string, workspaceId: string | null): AnnotationTemplate[] { + return this.cachedTemplates.filter((template) => { + const sameWorkspace = (template.workspaceId ?? null) === (workspaceId ?? null); + return !sameWorkspace || template.name.toLocaleLowerCase() !== name.toLocaleLowerCase(); + }); + } + + private filterTemplatesByWorkspace(workspaceId: string | null): AnnotationTemplate[] { + return this.cachedTemplates.filter( + (template) => (template.workspaceId ?? null) === (workspaceId ?? null) + ); + } + + private syncWithRemote(workspaceId: string | null) { + if (!workspaceId) { + this.emitAndPersist(); + return; } - const merged: GuideSettings = { - ...DEFAULT_GUIDE_SETTINGS, - ...settings, - snapPointsX: Array.isArray(settings.snapPointsX) - ? settings.snapPointsX - : DEFAULT_GUIDE_SETTINGS.snapPointsX, - snapPointsY: Array.isArray(settings.snapPointsY) - ? settings.snapPointsY - : DEFAULT_GUIDE_SETTINGS.snapPointsY, - }; + this.cloudTemplatesService + .listTemplates(workspaceId) + .pipe(take(1)) + .subscribe({ + next: (remoteTemplates) => { + this.mergeRemoteTemplates(remoteTemplates, workspaceId); + }, + error: (error) => + console.warn('No se pudo sincronizar las plantillas con la nube.', error), + }); + } - return cloneGuideSettings(merged); + private mergeRemoteTemplates(templates: readonly AnnotationTemplate[], workspaceId: string) { + const localTemplates = this.cachedTemplates.filter( + (template) => template.workspaceId === workspaceId + ); + const otherTemplates = this.cachedTemplates.filter( + (template) => template.workspaceId !== workspaceId + ); + + const mergedMap = new Map(); + for (const template of localTemplates) { + mergedMap.set(template.id, template); + } + + const now = Date.now(); + + for (const remote of templates) { + const existing = mergedMap.get(remote.id); + if (!existing) { + mergedMap.set(remote.id, { + ...remote, + workspaceId, + origin: 'remote', + syncedAt: now, + }); + continue; + } + + const localTimestamp = existing.updatedAt ?? existing.createdAt; + const remoteTimestamp = remote.updatedAt ?? remote.createdAt; + + if (remoteTimestamp >= localTimestamp) { + mergedMap.set(remote.id, { + ...remote, + workspaceId, + origin: 'remote', + syncedAt: now, + }); + } + } + + this.cachedTemplates = [...otherTemplates, ...mergedMap.values()]; + this.emitAndPersist(); } - private cloneTemplate(template: AnnotationTemplate): AnnotationTemplate { - return { - ...template, - pages: this.clonePages(template.pages), - guideSettings: cloneGuideSettings(template.guideSettings), - }; + private emitAndPersist() { + this.templatesSubject.next(this.buildVisibleTemplates()); + this.persistStoredTemplates(); } - private clonePages(pages: readonly PageAnnotations[]): PageAnnotations[] { - return pages.map((page) => ({ - num: page.num, - fields: page.fields.map((field): PageField => ({ ...field })), - })); + private buildVisibleTemplates(): AnnotationTemplate[] { + const visible = this.cachedTemplates.filter((template) => { + const workspaceMatch = + !template.workspaceId || template.workspaceId === this.activeWorkspaceId; + return workspaceMatch; + }); + + return [ + this.createDefaultTemplate(), + ...visible.map((template) => this.cloneTemplate(template)), + ]; + } + + private persistStoredTemplates() { + const serialized = this.cachedTemplates.map((template) => this.cloneTemplate(template)); + this.writeToStorage(this.templatesKey, serialized); } private getStoredTemplates(): AnnotationTemplate[] { @@ -152,6 +288,11 @@ export class AnnotationTemplatesService { id: template.id, name: template.name, createdAt: template.createdAt, + updatedAt: template.updatedAt, + version: template.version, + workspaceId: template.workspaceId ?? null, + origin: template.origin ?? (template.workspaceId ? 'remote' : 'local'), + syncedAt: template.syncedAt ?? null, pages: this.clonePages(template.pages ?? []), guidesEnabled: template.guidesEnabled ?? false, guideSettings: this.normalizeGuideSettings(template.guideSettings), @@ -163,6 +304,10 @@ export class AnnotationTemplatesService { id: this.defaultTemplateId, name: this.defaultTemplateName, createdAt: 0, + updatedAt: 0, + workspaceId: null, + origin: 'system', + syncedAt: null, pages: [], guidesEnabled: false, guideSettings: cloneGuideSettings(DEFAULT_GUIDE_SETTINGS), @@ -236,4 +381,40 @@ export class AnnotationTemplatesService { error.code === 22) ); } + + private cloneTemplate(template: AnnotationTemplate): AnnotationTemplate { + return { + ...template, + pages: this.clonePages(template.pages), + guideSettings: cloneGuideSettings(template.guideSettings), + }; + } + + private clonePages(pages: readonly PageAnnotations[]): PageAnnotations[] { + return pages.map((page) => ({ + num: page.num, + fields: page.fields.map((field): PageField => ({ ...field })), + })); + } + + private normalizeGuideSettings( + settings: Partial | null | undefined + ): GuideSettings { + if (!settings) { + return cloneGuideSettings(DEFAULT_GUIDE_SETTINGS); + } + + const merged: GuideSettings = { + ...DEFAULT_GUIDE_SETTINGS, + ...settings, + snapPointsX: Array.isArray(settings.snapPointsX) + ? settings.snapPointsX + : DEFAULT_GUIDE_SETTINGS.snapPointsX, + snapPointsY: Array.isArray(settings.snapPointsY) + ? settings.snapPointsY + : DEFAULT_GUIDE_SETTINGS.snapPointsY, + }; + + return cloneGuideSettings(merged); + } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e164989..fedb6f4 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,3 +1,4 @@ +import { provideHttpClient, withFetch } from '@angular/common/http'; import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; @@ -10,5 +11,6 @@ export const appConfig: ApplicationConfig = { provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideClientHydration(withEventReplay()), + provideHttpClient(withFetch()), ] }; diff --git a/src/app/i18n/translations/ca.json b/src/app/i18n/translations/ca.json index ad80dfb..acf3515 100644 --- a/src/app/i18n/translations/ca.json +++ b/src/app/i18n/translations/ca.json @@ -50,6 +50,26 @@ "exportGroup": "Opcions d'exportació" }, "sidebar": { + "session": { + "title": "Sessions al núvol", + "description": "Inicia sessió per sincronitzar plantilles i compartir-les amb el teu equip.", + "email": "Correu electrònic", + "password": "Contrasenya", + "login": "Inicia sessió", + "loading": "Connectant...", + "logout": "Tanca la sessió", + "workspaceLabel": "Espais compartits", + "workspacePlaceholder": "Tria un espai", + "refresh": "Actualitza els espais", + "emptyWorkspaces": "Encara no formes part de cap espai compartit.", + "emailError": "Introdueix un correu electrònic vàlid.", + "passwordError": "La contrasenya és obligatòria.", + "role": { + "owner": "Propietari", + "editor": "Editor", + "viewer": "Lector" + } + }, "title": "Anotacions (JSON)", "importJsonFile": "Importa des d'un fitxer", "applyJson": "Aplica el JSON", diff --git a/src/app/i18n/translations/en.json b/src/app/i18n/translations/en.json index 82917e7..8a4adaf 100644 --- a/src/app/i18n/translations/en.json +++ b/src/app/i18n/translations/en.json @@ -50,6 +50,26 @@ "exportGroup": "Export options" }, "sidebar": { + "session": { + "title": "Cloud sessions", + "description": "Sign in to sync templates and collaborate with your team.", + "email": "Email", + "password": "Password", + "login": "Sign in", + "loading": "Signing in...", + "logout": "Sign out", + "workspaceLabel": "Shared workspaces", + "workspacePlaceholder": "Choose a workspace", + "refresh": "Refresh workspaces", + "emptyWorkspaces": "You haven't joined any shared workspace yet.", + "emailError": "Please enter a valid email address.", + "passwordError": "Password is required.", + "role": { + "owner": "Owner", + "editor": "Editor", + "viewer": "Viewer" + } + }, "title": "Annotations (JSON)", "importJsonFile": "Import from file", "applyJson": "Apply JSON", diff --git a/src/app/i18n/translations/es-ES.json b/src/app/i18n/translations/es-ES.json index f3f38d9..b55dfb2 100644 --- a/src/app/i18n/translations/es-ES.json +++ b/src/app/i18n/translations/es-ES.json @@ -50,6 +50,26 @@ "exportGroup": "Opciones de exportación" }, "sidebar": { + "session": { + "title": "Sesiones en la nube", + "description": "Inicia sesión para sincronizar plantillas y compartirlas con tu equipo.", + "email": "Correo electrónico", + "password": "Contraseña", + "login": "Iniciar sesión", + "loading": "Conectando...", + "logout": "Cerrar sesión", + "workspaceLabel": "Espacios compartidos", + "workspacePlaceholder": "Selecciona un espacio", + "refresh": "Actualizar espacios", + "emptyWorkspaces": "Todavía no perteneces a ningún espacio compartido.", + "emailError": "Introduce un correo válido.", + "passwordError": "La contraseña es obligatoria.", + "role": { + "owner": "Propietario", + "editor": "Editor", + "viewer": "Lector" + } + }, "title": "Anotaciones (JSON)", "importJsonFile": "Importar desde archivo", "applyJson": "Aplicar JSON", diff --git a/src/app/models/annotation-template.model.ts b/src/app/models/annotation-template.model.ts new file mode 100644 index 0000000..93606b5 --- /dev/null +++ b/src/app/models/annotation-template.model.ts @@ -0,0 +1,18 @@ +import { PageAnnotations } from './annotation.model'; +import { GuideSettings } from './guide-settings.model'; + +export type AnnotationTemplateOrigin = 'local' | 'remote' | 'hybrid' | 'system'; + +export interface AnnotationTemplate { + id: string; + name: string; + createdAt: number; + updatedAt?: number; + version?: number; + workspaceId?: string | null; + origin?: AnnotationTemplateOrigin; + syncedAt?: number | null; + pages: PageAnnotations[]; + guidesEnabled: boolean; + guideSettings: GuideSettings; +} diff --git a/src/app/pages/workspace/components/sidebar/workspace-sidebar.component.html b/src/app/pages/workspace/components/sidebar/workspace-sidebar.component.html index 9eb8370..709e896 100644 --- a/src/app/pages/workspace/components/sidebar/workspace-sidebar.component.html +++ b/src/app/pages/workspace/components/sidebar/workspace-sidebar.component.html @@ -1,4 +1,93 @@