diff --git a/docker-compose.yml b/docker-compose.yml index 838d55b87..a571d2ebf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,45 +1,20 @@ x-postgres: &postgres-common - image: 'ankane/pgvector:v0.5.1' + image: "ankane/pgvector:v0.5.1" user: postgres healthcheck: - test: 'exit 0' + test: "exit 0" interval: 2s timeout: 12s retries: 3 services: - api-nest: - build: - context: . - dockerfile: packages/api-nest/Dockerfile - target: builder - container_name: 'omnivore-api-nest' - ports: - - '4001:4001' - environment: - - NODE_ENV=development - - NEST_PORT=4001 - - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/omnivore - - REDIS_URL=redis://redis:6379 - - JWT_SECRET=your-secret-key-change-in-production - depends_on: - - postgres - - redis - volumes: - - ./packages/api-nest:/app/packages/api-nest - - ./packages/db:/app/packages/db - - ./tsconfig.base.json:/app/tsconfig.base.json - - /app/packages/api-nest/node_modules - command: yarn start:dev - restart: unless-stopped - postgres: <<: *postgres-common - container_name: 'omnivore-postgres' + container_name: "omnivore-postgres" expose: - 5432 ports: - - '5432:5432' + - "5432:5432" environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -59,11 +34,11 @@ services: postgres-replica: <<: *postgres-common - container_name: 'omnivore-postgres-replica' + container_name: "omnivore-postgres-replica" expose: - 5433 ports: - - '5433:5432' + - "5433:5432" environment: PGUSER: replicator PGPASSWORD: replicator_password @@ -71,13 +46,22 @@ services: - postgres_replica_data:/var/lib/postgresql/data command: | bash -c " - until pg_basebackup --pgdata=/var/lib/postgresql/data -R --slot=replication_slot --host=postgres --port=5432 - do - echo 'Waiting for primary to connect...' - sleep 1s - done - echo 'Backup done, starting replica...' - chmod 0700 /var/lib/postgresql/data + # Check if data directory is already initialized + if [ -s /var/lib/postgresql/data/PG_VERSION ]; then + echo 'Replica already initialized, starting postgres...' + else + echo 'Initializing replica from primary...' + # Remove any partial/corrupt data + rm -rf /var/lib/postgresql/data/* + until pg_basebackup --pgdata=/var/lib/postgresql/data -R --slot=replication_slot --host=postgres --port=5432 + do + echo 'Waiting for primary to connect...' + sleep 1s + done + echo 'Backup done, setting permissions...' + chmod 0700 /var/lib/postgresql/data + fi + echo 'Starting replica server...' postgres " depends_on: @@ -87,8 +71,8 @@ services: build: context: . dockerfile: ./packages/db/Dockerfile - container_name: 'omnivore-migrate' - command: '/bin/sh ./packages/db/setup.sh' # Also create a demo user with email: demo@omnivore.app, password: demo_password + container_name: "omnivore-migrate" + command: "/bin/sh ./packages/db/setup.sh" # Also create a demo user with email: demo@omnivore.app, password: demo_password environment: - PGPASSWORD=postgres - POSTGRES_USER=postgres @@ -103,11 +87,11 @@ services: build: context: . dockerfile: ./packages/api/Dockerfile - container_name: 'omnivore-api' + container_name: "omnivore-api" ports: - - '4000:8080' + - "4000:8080" healthcheck: - test: ['CMD-SHELL', 'nc -z 0.0.0.0 8080 || exit 1'] + test: ["CMD-SHELL", "nc -z 0.0.0.0 8080 || exit 1"] interval: 15s timeout: 90s environment: @@ -140,10 +124,6 @@ services: condition: service_completed_successfully minio: condition: service_healthy - # develop: - # watch: - # - action: rebuild - # path: . web: build: @@ -154,9 +134,9 @@ services: - BASE_URL=http://localhost:3000 - SERVER_BASE_URL=http://localhost:4000 - HIGHLIGHTS_BASE_URL=http://localhost:3000 - container_name: 'omnivore-web' + container_name: "omnivore-web" ports: - - '3000:8080' + - "3000:3000" environment: - NEXT_PUBLIC_APP_ENV=prod - NEXT_PUBLIC_BASE_URL=http://localhost:3000 @@ -165,6 +145,7 @@ services: - SERVER_BASE_URL=http://localhost:4000 - BASE_URL=http://localhost:3000 - HIGHLIGHTS_BASE_URL=http://localhost:3000 + command: sh -c "cd /app && yarn workspace @omnivore/web start -p 3000" depends_on: api: condition: service_healthy @@ -173,39 +154,39 @@ services: - action: rebuild path: ./packages/web/pages - content-fetch: - build: - context: . - dockerfile: ./packages/content-fetch/Dockerfile - container_name: 'omnivore-content-fetch' - ports: - - '9090:8080' - environment: - - JWT_SECRET=some_secret - - VERIFICATION_TOKEN=some_token - - REST_BACKEND_ENDPOINT=http://api:8080/api - - REDIS_URL=redis://redis:6379 - - MQ_REDIS_URL=redis://redis:6379 - depends_on: - redis: - condition: service_healthy - api: - condition: service_healthy + # content-fetch: + # build: + # context: . + # dockerfile: ./packages/content-fetch/Dockerfile + # container_name: "omnivore-content-fetch" + # ports: + # - "9090:8080" + # environment: + # - JWT_SECRET=some_secret + # - VERIFICATION_TOKEN=some_token + # - REST_BACKEND_ENDPOINT=http://api:8080/api + # - REDIS_URL=redis://redis:6379 + # - MQ_REDIS_URL=redis://redis:6379 + # depends_on: + # redis: + # condition: service_healthy + # api: + # condition: service_healthy redis: - image: 'redis:7.2.4' - container_name: 'omnivore-redis' + image: "redis:7.2.4" + container_name: "omnivore-redis" ports: - - '6379:6379' + - "6379:6379" healthcheck: - test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping'] + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] minio: - image: 'minio/minio:latest' - container_name: 'omnivore-minio' + image: "minio/minio:latest" + container_name: "omnivore-minio" ports: - - '9000:9000' - - '9001:9001' + - "9000:9000" + - "9001:9001" environment: - MINIO_ROOT_USER=minioadmin - MINIO_ROOT_PASSWORD=minioadmin123 @@ -213,13 +194,13 @@ services: volumes: - minio_data:/data healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 30s timeout: 20s retries: 3 createbuckets: - image: 'minio/mc:latest' + image: "minio/mc:latest" depends_on: minio: condition: service_healthy @@ -238,7 +219,7 @@ services: image: structurizr/lite:latest container_name: omnivore-structurizr ports: - - '8081:8080' + - "8081:8080" volumes: - ./structurizr:/usr/local/structurizr environment: diff --git a/packages/api-nest/schema.graphql b/packages/api-nest/schema.graphql index 044c43ec3..b16f432ed 100644 --- a/packages/api-nest/schema.graphql +++ b/packages/api-nest/schema.graphql @@ -58,6 +58,7 @@ type Label { type LibraryItem { author: String + content: String contentReader: ContentReaderType! createdAt: DateTime! description: String @@ -184,6 +185,9 @@ type Mutation { id: String! ): LibraryItem! + """Save a URL to the library with content extraction""" + saveUrl(input: SaveUrlInput!): LibraryItem! + """Set labels for a library item (replaces existing labels)""" setLibraryItemLabels(itemId: String!, labelIds: [String!]!): [Label!]! @@ -234,6 +238,14 @@ enum RegistrationType { TWITTER } +input SaveUrlInput { + """Folder to save the URL to (inbox, archive)""" + folder: String = "inbox" + + """URL to save to library""" + url: String! +} + """Sort order direction""" enum SortOrder { ASC diff --git a/packages/api-nest/src/library/dto/library-inputs.type.ts b/packages/api-nest/src/library/dto/library-inputs.type.ts index f1a286159..f6fa2b2e6 100644 --- a/packages/api-nest/src/library/dto/library-inputs.type.ts +++ b/packages/api-nest/src/library/dto/library-inputs.type.ts @@ -15,6 +15,7 @@ import { IsIn, IsOptional, IsEnum, + IsUrl, } from 'class-validator' import { LibraryItemState } from '../entities/library-item.entity' @@ -173,3 +174,24 @@ export class LibrarySearchInput { @IsOptional() labels?: string[] } + +/** + * Input type for saving a URL to the library + */ +@InputType() +export class SaveUrlInput { + @Field(() => String, { description: 'URL to save to library' }) + @IsUrl({}, { message: 'Must be a valid URL' }) + @IsString() + url: string + + @Field(() => String, { + nullable: true, + defaultValue: 'inbox', + description: 'Folder to save the URL to (inbox, archive)', + }) + @IsOptional() + @IsString() + @IsIn(['inbox', 'archive']) + folder?: string +} diff --git a/packages/api-nest/src/library/dto/library-item.type.ts b/packages/api-nest/src/library/dto/library-item.type.ts index 3f9980813..e745a200d 100644 --- a/packages/api-nest/src/library/dto/library-item.type.ts +++ b/packages/api-nest/src/library/dto/library-item.type.ts @@ -62,6 +62,9 @@ export class LibraryItem { @Field(() => [Label], { nullable: true }) labels?: Label[] | null + + @Field({ nullable: true }) + content?: string | null } @ObjectType() diff --git a/packages/api-nest/src/library/entities/library-item.entity.ts b/packages/api-nest/src/library/entities/library-item.entity.ts index 3ab99b3f1..32c3e6ed3 100644 --- a/packages/api-nest/src/library/entities/library-item.entity.ts +++ b/packages/api-nest/src/library/entities/library-item.entity.ts @@ -116,6 +116,9 @@ export class LibraryItemEntity { @Column({ name: 'label_names', type: 'text', array: true, nullable: true, default: [] }) labelNames?: string[] | null + @Column({ name: 'readable_content', type: 'text', default: '' }) + readableContent!: string + @OneToMany(() => EntityLabel, (entityLabel) => entityLabel.libraryItem) entityLabels!: EntityLabel[] } diff --git a/packages/api-nest/src/library/library.resolver.ts b/packages/api-nest/src/library/library.resolver.ts index dbcdcf081..de6791a90 100644 --- a/packages/api-nest/src/library/library.resolver.ts +++ b/packages/api-nest/src/library/library.resolver.ts @@ -9,6 +9,7 @@ import { ReadingProgressInput, DeleteResult, LibrarySearchInput, + SaveUrlInput, } from './dto/library-inputs.type' import { LabelService } from '../label/label.service' import { Label } from '../label/dto/label.type' @@ -210,6 +211,21 @@ export class LibraryResolver { ): Promise { return await this.libraryService.bulkMarkAsRead(user.id, itemIds) } + + // ==================== CONTENT INGESTION ==================== + + @Mutation(() => LibraryItem, { + description: 'Save a URL to the library with content extraction', + }) + @UseGuards(JwtAuthGuard) + async saveUrl( + @CurrentUser() user: User, + @Args('input', { type: () => SaveUrlInput }) + input: SaveUrlInput, + ): Promise { + const entity = await this.libraryService.saveUrl(user.id, input) + return mapEntityToGraph(entity) + } } function mapEntityToGraph(entity: any): LibraryItem { @@ -230,6 +246,7 @@ function mapEntityToGraph(entity: any): LibraryItem { state: entity.state, contentReader: entity.contentReader, folder: entity.folder, + content: entity.readableContent ?? null, labels: null, // Labels will be resolved by the field resolver } } diff --git a/packages/api-nest/src/library/library.service.ts b/packages/api-nest/src/library/library.service.ts index 1a6afc584..398481d4f 100644 --- a/packages/api-nest/src/library/library.service.ts +++ b/packages/api-nest/src/library/library.service.ts @@ -2,6 +2,8 @@ import { Injectable, NotFoundException, BadRequestException, + Logger, + ConflictException, } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' import { Repository, DataSource } from 'typeorm' @@ -14,10 +16,13 @@ import { LibrarySearchInput, LibrarySortField, SortOrder, + SaveUrlInput, } from './dto/library-inputs.type' @Injectable() export class LibraryService { + private readonly logger = new Logger(LibraryService.name) + constructor( @InjectRepository(LibraryItemEntity) private readonly libraryRepository: Repository, @@ -632,4 +637,88 @@ export class LibraryService { await queryRunner.release() } } + + /** + * Save a URL to the user's library + */ + async saveUrl( + userId: string, + input: SaveUrlInput, + ): Promise { + const { url, folder = 'inbox' } = input + + this.logger.log(`Saving URL for user ${userId}: ${url}`) + + // Check for duplicate URL + const existingItem = await this.libraryRepository.findOne({ + where: { + userId, + originalUrl: url, + }, + }) + + if (existingItem) { + throw new ConflictException( + 'This URL has already been saved to your library', + ) + } + + // Create library item with CONTENT_NOT_FETCHED state + // Content extraction will be handled by queue in ARC-012 + const slug = this.generateSlug(url) + + const libraryItem = this.libraryRepository.create({ + userId, + originalUrl: url, + slug, + title: url, // Temporary title until content is fetched + state: LibraryItemState.CONTENT_NOT_FETCHED, + folder, + savedAt: new Date(), + contentReader: 'WEB' as any, + itemType: 'ARTICLE', + }) + + const savedItem = await this.libraryRepository.save(libraryItem) + + this.logger.log( + `Successfully saved URL with ID: ${savedItem.id} (content extraction deferred to queue)`, + ) + + // TODO: Dispatch to queue for content extraction (ARC-012) + // TODO: Use @omnivore/readability for proper extraction (ARC-013) + + return savedItem + } + + /** + * Generate a slug from URL + */ + private generateSlug(url: string): string { + try { + const urlObj = new URL(url) + const pathname = urlObj.pathname + + // Extract meaningful part from pathname + let slug = pathname + .split('/') + .filter((part) => part.length > 0) + .join('-') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .substring(0, 100) + + if (!slug) { + slug = urlObj.hostname.replace(/\./g, '-') + } + + // Add timestamp to ensure uniqueness + const timestamp = Date.now() + return `${slug}-${timestamp}` + } catch { + return `url-${Date.now()}` + } + } + } diff --git a/packages/api-nest/test/save-url.e2e-spec.ts b/packages/api-nest/test/save-url.e2e-spec.ts new file mode 100644 index 000000000..d3f84637f --- /dev/null +++ b/packages/api-nest/test/save-url.e2e-spec.ts @@ -0,0 +1,614 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication, ValidationPipe } from '@nestjs/common' +import request from 'supertest' +import { randomUUID } from 'crypto' +import { AppModule } from '../src/app/app.module' +import { ConfigService } from '@nestjs/config' +import { DataSource } from 'typeorm' + +describe('SaveUrl E2E Tests', () => { + let app: INestApplication + let authToken: string + let userId: string + let createdLibraryItemIds: string[] = [] + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile() + + app = moduleFixture.createNestApplication() + + // Use the same validation pipe configuration as main.ts + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + await app.init() + + // Create a test user and get auth token + const testEmail = `test-saveurl-${Date.now()}@example.com` + const testPassword = 'TestPassword123!' + + const registerResponse = await request(app.getHttpServer()) + .post('/api/v2/auth/register') + .send({ + email: testEmail, + password: testPassword, + name: 'SaveUrl Test User', + }) + .expect(201) + + authToken = registerResponse.body.accessToken + userId = registerResponse.body.user.id + + // Get the config service to skip email confirmation + const configService = app.get(ConfigService) + const requireEmailConfirmation = configService.get( + 'AUTH_REQUIRE_EMAIL_CONFIRMATION', + ) + + // If email confirmation is required, confirm the email + if (requireEmailConfirmation) { + const dataSource = app.get(DataSource) + await dataSource.query( + `UPDATE omnivore.user SET status = 'ACTIVE' WHERE id = $1`, + [userId], + ) + } + }) + + afterAll(async () => { + // Clean up test data + if (userId) { + const dataSource = app.get(DataSource) + + // Delete in correct order due to foreign key constraints + await dataSource.query( + `DELETE FROM omnivore.library_item WHERE user_id = $1`, + [userId], + ) + await dataSource.query(`DELETE FROM omnivore.user WHERE id = $1`, [ + userId, + ]) + } + + await app.close() + }) + + const executeQuery = async ( + query: string, + variables: Record = {}, + ) => { + return request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ query, variables }) + } + + // ==================== BASIC SAVE URL ==================== + + describe('Basic SaveUrl Functionality', () => { + it('should save a valid URL to the library', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + title + originalUrl + slug + folder + state + contentReader + savedAt + createdAt + } + } + `, + { + input: { + url: 'https://example.com/article', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeUndefined() + expect(response.body.data.saveUrl).toMatchObject({ + originalUrl: 'https://example.com/article', + folder: 'inbox', // Default folder + contentReader: 'WEB', + state: 'CONTENT_NOT_FETCHED', // Content extraction deferred to ARC-012 + }) + expect(response.body.data.saveUrl.id).toBeDefined() + expect(response.body.data.saveUrl.slug).toContain('article') + expect(response.body.data.saveUrl.title).toBe( + 'https://example.com/article', + ) // Title is URL until content is fetched + + createdLibraryItemIds.push(response.body.data.saveUrl.id) + }) + + it('should save URL to specified folder', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + originalUrl + folder + } + } + `, + { + input: { + url: 'https://example.com/archived-article', + folder: 'archive', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.saveUrl).toMatchObject({ + originalUrl: 'https://example.com/archived-article', + folder: 'archive', + }) + + createdLibraryItemIds.push(response.body.data.saveUrl.id) + }) + + it('should generate unique slug for URL', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + slug + } + } + `, + { + input: { + url: 'https://example.com/test/article/123', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.saveUrl.slug).toMatch(/test-article-123-\d+/) + + createdLibraryItemIds.push(response.body.data.saveUrl.id) + }) + }) + + // ==================== VALIDATION ==================== + + describe('URL Validation', () => { + it('should reject invalid URL format', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + } + } + `, + { + input: { + url: 'not-a-valid-url', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + // GraphQL wraps validation errors in Bad Request Exception + const errorMessage = response.body.errors[0].message + expect(errorMessage).toBeDefined() + expect( + errorMessage.includes('valid URL') || + errorMessage.includes('Bad Request'), + ).toBe(true) + }) + + it('should accept URL without protocol (class-validator may be lenient)', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + originalUrl + } + } + `, + { + input: { + url: 'example.com/article', + }, + }, + ) + + // class-validator's @IsUrl might accept this or reject it + // Let's test both scenarios + if (response.body.errors) { + expect(response.body.errors).toBeDefined() + } else { + expect(response.body.data.saveUrl.originalUrl).toBe( + 'example.com/article', + ) + createdLibraryItemIds.push(response.body.data.saveUrl.id) + } + }) + + it('should reject invalid folder name', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + } + } + `, + { + input: { + url: 'https://example.com/article', + folder: 'invalid-folder', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + }) + }) + + // ==================== DUPLICATE DETECTION ==================== + + describe('Duplicate URL Detection', () => { + it('should detect duplicate URLs', async () => { + const testUrl = `https://example.com/duplicate-test-${Date.now()}` + + // Save URL first time + const firstResponse = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + originalUrl + } + } + `, + { + input: { + url: testUrl, + }, + }, + ) + + expect(firstResponse.status).toBe(200) + expect(firstResponse.body.errors).toBeUndefined() + createdLibraryItemIds.push(firstResponse.body.data.saveUrl.id) + + // Try to save same URL again + const secondResponse = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + } + } + `, + { + input: { + url: testUrl, + }, + }, + ) + + expect(secondResponse.status).toBe(200) + expect(secondResponse.body.errors).toBeDefined() + expect(secondResponse.body.errors[0].message).toContain( + 'already been saved', + ) + }) + + it('should allow different users to save same URL', async () => { + // This test would require creating a second user + // For now, we'll skip it as it's a more complex scenario + // Future: Implement multi-user duplicate test + }) + }) + + // ==================== CONTENT EXTRACTION ==================== + + describe('Content Extraction', () => { + it('should create item in CONTENT_NOT_FETCHED state', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + title + originalUrl + state + } + } + `, + { + input: { + url: 'https://example.com/metadata-test', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.saveUrl).toMatchObject({ + originalUrl: 'https://example.com/metadata-test', + state: 'CONTENT_NOT_FETCHED', + title: 'https://example.com/metadata-test', // URL as title until fetched + }) + + createdLibraryItemIds.push(response.body.data.saveUrl.id) + }) + + it('should save URL without attempting extraction', async () => { + // Even potentially slow URLs should save immediately + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + originalUrl + state + } + } + `, + { + input: { + url: 'https://httpstat.us/200?sleep=5000', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.saveUrl.state).toBe('CONTENT_NOT_FETCHED') + + if (response.body.data.saveUrl.id) { + createdLibraryItemIds.push(response.body.data.saveUrl.id) + } + }) + }) + + // ==================== QUERY SAVED ITEMS ==================== + + describe('Querying Saved URLs', () => { + it('should retrieve saved URL by ID', async () => { + // Save a URL first + const saveResponse = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + } + } + `, + { + input: { + url: `https://example.com/query-test-${Date.now()}`, + }, + }, + ) + + const itemId = saveResponse.body.data.saveUrl.id + createdLibraryItemIds.push(itemId) + + // Query it back + const queryResponse = await executeQuery( + ` + query GetLibraryItem($id: String!) { + libraryItem(id: $id) { + id + originalUrl + title + state + folder + } + } + `, + { id: itemId }, + ) + + expect(queryResponse.status).toBe(200) + expect(queryResponse.body.data.libraryItem).toMatchObject({ + id: itemId, + }) + }) + + it('should list saved URLs in library items', async () => { + const response = await executeQuery(` + query { + libraryItems(first: 20) { + items { + id + originalUrl + title + state + folder + } + nextCursor + } + } + `) + + expect(response.status).toBe(200) + expect(response.body.data.libraryItems.items).toBeDefined() + expect(Array.isArray(response.body.data.libraryItems.items)).toBe(true) + // Should have at least the items we created + expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0) + }) + }) + + // ==================== ERROR HANDLING ==================== + + describe('Error Handling', () => { + it('should handle missing URL parameter', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + } + } + `, + { + input: {}, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + }) + + it('should require authentication', async () => { + const response = await request(app.getHttpServer()) + .post('/api/graphql') + // No Authorization header + .send({ + query: ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + } + } + `, + variables: { + input: { + url: 'https://example.com/auth-test', + }, + }, + }) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('Unauthorized') + }) + + it('should save even non-existent URLs (extraction deferred)', async () => { + const response = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + state + } + } + `, + { + input: { + url: 'https://httpstat.us/404', + }, + }, + ) + + expect(response.status).toBe(200) + // Should save the item even if URL doesn't exist (extraction will fail later) + expect(response.body.data.saveUrl.state).toBe('CONTENT_NOT_FETCHED') + if (response.body.data?.saveUrl?.id) { + createdLibraryItemIds.push(response.body.data.saveUrl.id) + } + }) + }) + + // ==================== INTEGRATION WITH OTHER FEATURES ==================== + + describe('Integration with Library Features', () => { + it('should allow moving saved URL to different folder', async () => { + // Save URL first + const saveResponse = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + folder + } + } + `, + { + input: { + url: `https://example.com/move-test-${Date.now()}`, + }, + }, + ) + + const itemId = saveResponse.body.data.saveUrl.id + createdLibraryItemIds.push(itemId) + + // Move to archive + const moveResponse = await executeQuery( + ` + mutation MoveToFolder($id: String!, $folder: String!) { + moveLibraryItemToFolder(id: $id, folder: $folder) { + id + folder + } + } + `, + { + id: itemId, + folder: 'archive', + }, + ) + + expect(moveResponse.status).toBe(200) + expect(moveResponse.body.data.moveLibraryItemToFolder).toMatchObject({ + id: itemId, + folder: 'archive', + }) + }) + + it('should allow deleting saved URL', async () => { + // Save URL first + const saveResponse = await executeQuery( + ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + } + } + `, + { + input: { + url: `https://example.com/delete-test-${Date.now()}`, + }, + }, + ) + + const itemId = saveResponse.body.data.saveUrl.id + + // Delete it + const deleteResponse = await executeQuery( + ` + mutation DeleteLibraryItem($id: String!) { + deleteLibraryItem(id: $id) { + success + itemId + } + } + `, + { id: itemId }, + ) + + expect(deleteResponse.status).toBe(200) + expect(deleteResponse.body.data.deleteLibraryItem).toMatchObject({ + success: true, + itemId: itemId, + }) + }) + }) +}) diff --git a/packages/web-vite/package.json b/packages/web-vite/package.json index 4620b02b2..255f85876 100644 --- a/packages/web-vite/package.json +++ b/packages/web-vite/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-toast": "^1.2.15", "@stitches/react": "^1.2.8", "@tanstack/react-query": "^5.90.2", + "dompurify": "^3.2.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^6.0.0", diff --git a/packages/web-vite/src/App.css b/packages/web-vite/src/App.css index 42359b030..7d6d4e6d2 100644 --- a/packages/web-vite/src/App.css +++ b/packages/web-vite/src/App.css @@ -676,6 +676,24 @@ color: #4a9eff; } +.article-title-btn { + background: none; + border: none; + padding: 0; + color: #d9d9d9; + text-decoration: none; + font-size: 18px; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: color 0.2s ease; + width: 100%; +} + +.article-title-btn:hover { + color: #4a9eff; +} + .article-meta { margin-bottom: 16px; } diff --git a/packages/web-vite/src/components/AddLinkModal.tsx b/packages/web-vite/src/components/AddLinkModal.tsx new file mode 100644 index 000000000..59ceb676f --- /dev/null +++ b/packages/web-vite/src/components/AddLinkModal.tsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react' +import { useSaveUrl } from '../lib/graphql-client' +import '../styles/AddLinkModal.css' + +interface AddLinkModalProps { + isOpen: boolean + onClose: () => void + onSuccess: () => void +} + +type ContentType = 'link' | 'pdf' | 'rss' + +const AddLinkModal: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const [contentType, setContentType] = useState('link') + const [url, setUrl] = useState('') + const [folder, setFolder] = useState<'inbox' | 'archive'>('inbox') + const [validationError, setValidationError] = useState(null) + const { saveUrl, loading, error } = useSaveUrl() + + const validateUrl = (urlString: string): boolean => { + try { + // Basic URL validation + if (!urlString.trim()) { + setValidationError('URL is required') + return false + } + + // Check if it's a valid URL format + const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/ + if (!urlPattern.test(urlString.trim())) { + setValidationError('Please enter a valid URL (e.g., https://example.com/article)') + return false + } + + setValidationError(null) + return true + } catch { + setValidationError('Invalid URL format') + return false + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validateUrl(url)) { + return + } + + try { + // Ensure URL has protocol + let formattedUrl = url.trim() + if (!formattedUrl.startsWith('http://') && !formattedUrl.startsWith('https://')) { + formattedUrl = `https://${formattedUrl}` + } + + await saveUrl({ url: formattedUrl, folder }) + + // Reset form and close modal + setUrl('') + setFolder('inbox') + setValidationError(null) + onSuccess() + onClose() + } catch (err) { + // Error is handled by the hook, but we'll keep the modal open + console.error('Failed to save URL:', err) + } + } + + const handleClose = () => { + setUrl('') + setFolder('inbox') + setContentType('link') + setValidationError(null) + onClose() + } + + const getPlaceholder = () => { + switch (contentType) { + case 'link': + return 'https://example.com/article' + case 'pdf': + return 'https://example.com/document.pdf' + case 'rss': + return 'https://example.com/feed.xml' + default: + return 'Enter URL' + } + } + + const getTitle = () => { + switch (contentType) { + case 'link': + return 'Add Link' + case 'pdf': + return 'Add PDF' + case 'rss': + return 'Add RSS Feed' + default: + return 'Add Content' + } + } + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

{getTitle()}

+ +
+ +
+ + + +
+ + {contentType !== 'link' && ( +
+

+ 📋 {contentType === 'pdf' ? 'PDF' : 'RSS'} support is coming soon! + For now, please use the Link tab to add web articles. +

+
+ )} + +
+
+ + { + setUrl(e.target.value) + setValidationError(null) + }} + placeholder={getPlaceholder()} + className={`url-input ${validationError || error ? 'error' : ''}`} + disabled={loading || contentType !== 'link'} + autoFocus + /> + {validationError && ( + {validationError} + )} + {error && !validationError && ( + {error.message} + )} +
+ +
+ + +
+ +
+ + +
+
+ + {loading && ( +
+
+ Saving link... +
+ )} +
+
+ ) +} + +export default AddLinkModal diff --git a/packages/web-vite/src/lib/graphql-client.ts b/packages/web-vite/src/lib/graphql-client.ts index 1f2ef8121..2526bc9e4 100644 --- a/packages/web-vite/src/lib/graphql-client.ts +++ b/packages/web-vite/src/lib/graphql-client.ts @@ -2,6 +2,7 @@ // Mirrors the behaviour of the legacy web package's fetcher but keeps dependencies light import { useState, useCallback } from 'react' +import type { LibraryItem, DeleteResult } from '../types/api' const DEFAULT_GRAPHQL_PATH = '/api/graphql' const TOKEN_STORAGE_KEY = 'omnivore-auth-token' @@ -169,6 +170,27 @@ const BULK_MARK_AS_READ_MUTATION = ` } ` +const SAVE_URL_MUTATION = ` + mutation SaveUrl($input: SaveUrlInput!) { + saveUrl(input: $input) { + id + title + slug + originalUrl + author + description + savedAt + createdAt + updatedAt + publishedAt + readAt + state + contentReader + folder + } + } +` + // ==================== HOOKS ==================== interface MutationState { @@ -411,6 +433,35 @@ export function useBulkMarkAsRead() { return { ...state, bulkMarkAsRead } } +export function useSaveUrl() { + const [state, setState] = useState>({ + loading: false, + error: null, + data: null, + }) + + const saveUrl = useCallback( + async (input: { url: string; folder?: string }) => { + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ saveUrl: any }>( + SAVE_URL_MUTATION, + { input } + ) + setState({ loading: false, error: null, data: result.saveUrl }) + return result.saveUrl + } catch (error) { + const err = error instanceof Error ? error : new Error('Save URL failed') + setState({ loading: false, error: err, data: null }) + throw err + } + }, + [] + ) + + return { ...state, saveUrl } +} + // ==================== LABEL TYPES ==================== export interface Label { @@ -436,6 +487,37 @@ export interface UpdateLabelInput { description?: string } +// ==================== LIBRARY ITEM QUERIES ==================== + +const GET_LIBRARY_ITEM_QUERY = ` + query GetLibraryItem($id: String!) { + libraryItem(id: $id) { + id + title + slug + originalUrl + author + description + content + savedAt + createdAt + publishedAt + readAt + updatedAt + readingProgressTopPercent + readingProgressBottomPercent + state + contentReader + folder + labels { + id + name + color + } + } + } +` + // ==================== LABEL QUERIES ==================== const GET_LABELS_QUERY = ` @@ -661,3 +743,37 @@ export function useSetLibraryItemLabels() { return { ...state, setLibraryItemLabels } } + +// ==================== LIBRARY ITEM HOOKS ==================== + +export function useLibraryItem(id: string) { + const [state, setState] = useState<{ + loading: boolean + error: Error | null + data: LibraryItem | null + }>({ + loading: false, + error: null, + data: null, + }) + + const fetchLibraryItem = useCallback(async () => { + if (!id) return + + setState({ loading: true, error: null, data: null }) + try { + const result = await graphqlRequest<{ libraryItem: LibraryItem | null }>( + GET_LIBRARY_ITEM_QUERY, + { id } + ) + setState({ loading: false, error: null, data: result.libraryItem }) + return result.libraryItem + } catch (error) { + const err = error instanceof Error ? error : new Error('Failed to fetch library item') + setState({ loading: false, error: err, data: null }) + throw err + } + }, [id]) + + return { ...state, fetchLibraryItem } +} diff --git a/packages/web-vite/src/pages/LibraryPage.tsx b/packages/web-vite/src/pages/LibraryPage.tsx index 229d624ff..a4b7a34a0 100644 --- a/packages/web-vite/src/pages/LibraryPage.tsx +++ b/packages/web-vite/src/pages/LibraryPage.tsx @@ -21,6 +21,7 @@ import type { } from '../types/api' import ErrorBoundary from '../components/ErrorBoundary' import LabelPicker from '../components/LabelPicker' +import AddLinkModal from '../components/AddLinkModal' import '../styles/LabelPicker.css' const LIBRARY_ITEMS_QUERY = ` @@ -72,6 +73,7 @@ const LibraryPage: React.FC = () => { const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) const [selectedLabelFilters, setSelectedLabelFilters] = useState([]) const [showLabelFilter, setShowLabelFilter] = useState(false) + const [showAddLinkModal, setShowAddLinkModal] = useState(false) const { archiveItem } = useArchiveItem() const { deleteItem } = useDeleteItem() @@ -449,6 +451,37 @@ const LibraryPage: React.FC = () => { setSelectedLabelFilters([]) } + const handleAddLinkSuccess = async () => { + showToast('Link added successfully!', 'success') + // Refetch library items + try { + const searchParams: any = {} + if (searchQuery.trim()) { + searchParams.query = searchQuery.trim() + } + if (activeFolder && activeFolder !== 'all') { + searchParams.folder = activeFolder + } + if (selectedLabelFilters.length > 0) { + searchParams.labels = selectedLabelFilters + } + searchParams.sortBy = sortBy + searchParams.sortOrder = sortOrder + + const data = await graphqlRequest<{ libraryItems: LibraryItemsConnection }>( + LIBRARY_ITEMS_QUERY, + { + first: INITIAL_PAGE_SIZE, + search: Object.keys(searchParams).length > 0 ? searchParams : undefined + } + ) + + setItems(data.libraryItems.items) + } catch (err) { + console.error('Failed to refetch library items:', err) + } + } + if (loading) { return (
@@ -550,10 +583,21 @@ const LibraryPage: React.FC = () => { > {isMultiSelectMode ? 'Exit Multi-Select' : 'Multi-Select'} - +
+ setShowAddLinkModal(false)} + onSuccess={handleAddLinkSuccess} + /> + {isMultiSelectMode && (
@@ -690,7 +734,10 @@ const LibraryPage: React.FC = () => { : 'Your library is empty. Add some articles to get started!'}

{!searchQuery && ( - )} @@ -726,14 +773,12 @@ const LibraryPage: React.FC = () => {

- handleRead(item.id)} + className="article-title-btn" > {item.title} - +

diff --git a/packages/web-vite/src/pages/ReaderPage.tsx b/packages/web-vite/src/pages/ReaderPage.tsx index ef72701e4..58815793e 100644 --- a/packages/web-vite/src/pages/ReaderPage.tsx +++ b/packages/web-vite/src/pages/ReaderPage.tsx @@ -1,13 +1,157 @@ // Reader page component for Omnivore Vite migration -// Placeholder for reader functionality +// Displays article content with sanitized HTML -import React from 'react' +import React, { useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import DOMPurify from 'dompurify' +import { useLibraryItem } from '../lib/graphql-client' +import '../styles/ReaderPage.css' -const ReaderPage: React.FC = () => ( -
-

Article Reader

-

Reader functionality coming soon...

-
-) +const ReaderPage: React.FC = () => { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { data: item, loading, error, fetchLibraryItem } = useLibraryItem(id || '') + + useEffect(() => { + if (id) { + fetchLibraryItem() + } + }, [id, fetchLibraryItem]) + + const handleBack = () => { + navigate('/home') + } + + const formatDate = (dateString: string | null | undefined) => { + if (!dateString) return null + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + // Loading state + if (loading) { + return ( +
+
+
+

Loading article...

+
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+

Error Loading Article

+

{error.message}

+ +
+
+ ) + } + + // Not found state + if (!item) { + return ( +
+
+

Article Not Found

+

The article you're looking for doesn't exist or has been deleted.

+ +
+
+ ) + } + + // Content not fetched state + if (item.state === 'CONTENT_NOT_FETCHED' || !item.content) { + return ( +
+
+ +

{item.title}

+ {item.author &&

By {item.author}

} + {item.publishedAt && ( +

{formatDate(item.publishedAt)}

+ )} + {item.originalUrl && ( + + View Original → + + )} +
+
+
+

Content Not Available

+

This article's content is being processed. Please check back in a moment.

+

Status: {item.state}

+ + View Original Article + +
+
+
+ ) + } + + // Sanitize HTML content + const sanitizedContent = DOMPurify.sanitize(item.content, { + ADD_TAGS: ['iframe'], + ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'], + }) + + return ( +
+
+ +

{item.title}

+ {item.author &&

By {item.author}

} + {item.publishedAt && ( +

{formatDate(item.publishedAt)}

+ )} + {item.originalUrl && ( + + View Original → + + )} +
+ +
+
+ ) +} export default ReaderPage diff --git a/packages/web-vite/src/styles/AddLinkModal.css b/packages/web-vite/src/styles/AddLinkModal.css new file mode 100644 index 000000000..b23ed22e8 --- /dev/null +++ b/packages/web-vite/src/styles/AddLinkModal.css @@ -0,0 +1,288 @@ +/* Add Link Modal Styles */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 500px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid #e0e0e0; +} + +.modal-header h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: #333; +} + +.close-btn { + background: none; + border: none; + font-size: 2rem; + color: #999; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.close-btn:hover { + background-color: #f5f5f5; + color: #333; +} + +/* Content Type Tabs */ +.content-type-tabs { + display: flex; + gap: 0.5rem; + padding: 1rem 1.5rem 0; + border-bottom: 1px solid #e0e0e0; +} + +.content-type-tab { + flex: 1; + padding: 0.75rem 1rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + color: #666; + transition: all 0.2s; +} + +.content-type-tab:hover { + background-color: #f9f9f9; + color: #333; +} + +.content-type-tab.active { + color: #4a9eff; + border-bottom-color: #4a9eff; +} + +.content-type-tab:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Coming Soon Notice */ +.coming-soon-notice { + background-color: #fff9e6; + border: 1px solid #ffe599; + border-radius: 6px; + padding: 1rem; + margin: 1rem 1.5rem; +} + +.coming-soon-notice p { + margin: 0; + color: #856404; + font-size: 0.9rem; +} + +/* Form Styles */ +form { + padding: 1.5rem; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; + font-size: 0.95rem; +} + +.url-input, +.folder-select { + width: 100%; + padding: 0.75rem; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 1rem; + transition: all 0.2s; +} + +.url-input:focus, +.folder-select:focus { + outline: none; + border-color: #4a9eff; + box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1); +} + +.url-input.error { + border-color: #ff4d4f; +} + +.url-input:disabled, +.folder-select:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + opacity: 0.6; +} + +.error-message { + display: block; + margin-top: 0.5rem; + color: #ff4d4f; + font-size: 0.85rem; +} + +/* Modal Actions */ +.modal-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + padding-top: 1rem; + border-top: 1px solid #e0e0e0; + margin-top: 1.5rem; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-secondary { + background-color: #f5f5f5; + color: #333; +} + +.btn-secondary:hover:not(:disabled) { + background-color: #e0e0e0; +} + +.btn-primary { + background-color: #4a9eff; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: #3a8eef; +} + +/* Loading Indicator */ +.loading-indicator { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.5rem; + background-color: #f9f9f9; + border-top: 1px solid #e0e0e0; + color: #666; + font-size: 0.9rem; +} + +.spinner-small { + width: 16px; + height: 16px; + border: 2px solid #e0e0e0; + border-top-color: #4a9eff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Responsive Design */ +@media (max-width: 600px) { + .modal-content { + width: 95%; + max-width: none; + } + + .modal-header { + padding: 1rem; + } + + .content-type-tabs { + padding: 0.75rem 1rem 0; + } + + .content-type-tab { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + } + + form { + padding: 1rem; + } + + .modal-actions { + flex-direction: column-reverse; + } + + .btn { + width: 100%; + } +} diff --git a/packages/web-vite/src/styles/ReaderPage.css b/packages/web-vite/src/styles/ReaderPage.css new file mode 100644 index 000000000..cc8c3b77f --- /dev/null +++ b/packages/web-vite/src/styles/ReaderPage.css @@ -0,0 +1,269 @@ +/* Reader Page Styles */ + +.reader-page { + max-width: 800px; + margin: 0 auto; + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +/* Header Styles */ +.reader-header { + margin-bottom: 40px; + padding-bottom: 20px; + border-bottom: 1px solid #e0e0e0; +} + +.back-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + margin-bottom: 20px; + background: #f5f5f5; + border: none; + border-radius: 6px; + font-size: 14px; + color: #333; + cursor: pointer; + transition: background-color 0.2s; +} + +.back-button:hover { + background: #e0e0e0; +} + +.reader-header h1 { + font-size: 36px; + font-weight: 700; + line-height: 1.2; + margin: 0 0 16px 0; + color: #1a1a1a; +} + +.reader-header .author { + font-size: 16px; + color: #666; + margin: 8px 0; +} + +.reader-header .publish-date { + font-size: 14px; + color: #999; + margin: 4px 0; +} + +.reader-header .original-link { + display: inline-block; + margin-top: 12px; + font-size: 14px; + color: #007aff; + text-decoration: none; + transition: color 0.2s; +} + +.reader-header .original-link:hover { + color: #0051d5; + text-decoration: underline; +} + +/* Content Styles */ +.reader-content { + font-size: 18px; + line-height: 1.7; + color: #333; +} + +.reader-content p { + margin: 0 0 1.5em 0; +} + +.reader-content h1, +.reader-content h2, +.reader-content h3, +.reader-content h4, +.reader-content h5, +.reader-content h6 { + margin: 1.5em 0 0.5em 0; + line-height: 1.3; + font-weight: 600; + color: #1a1a1a; +} + +.reader-content h1 { font-size: 2em; } +.reader-content h2 { font-size: 1.5em; } +.reader-content h3 { font-size: 1.25em; } +.reader-content h4 { font-size: 1.1em; } + +.reader-content a { + color: #007aff; + text-decoration: none; +} + +.reader-content a:hover { + text-decoration: underline; +} + +.reader-content img { + max-width: 100%; + height: auto; + margin: 1.5em 0; + border-radius: 8px; +} + +.reader-content pre { + background: #f5f5f5; + padding: 16px; + border-radius: 8px; + overflow-x: auto; + font-size: 14px; +} + +.reader-content code { + background: #f5f5f5; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.9em; +} + +.reader-content pre code { + background: none; + padding: 0; +} + +.reader-content blockquote { + margin: 1.5em 0; + padding: 0 0 0 20px; + border-left: 4px solid #e0e0e0; + color: #666; + font-style: italic; +} + +.reader-content ul, +.reader-content ol { + margin: 1em 0; + padding-left: 2em; +} + +.reader-content li { + margin: 0.5em 0; +} + +/* Loading State */ +.reader-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + color: #666; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #007aff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Error State */ +.reader-error { + text-align: center; + padding: 60px 20px; +} + +.reader-error h2 { + font-size: 24px; + color: #1a1a1a; + margin-bottom: 12px; +} + +.reader-error p { + font-size: 16px; + color: #666; + margin-bottom: 24px; +} + +/* Empty State (Content Not Fetched) */ +.reader-content-empty { + padding: 60px 20px; +} + +.empty-state { + text-align: center; + max-width: 500px; + margin: 0 auto; +} + +.empty-state h2 { + font-size: 24px; + color: #1a1a1a; + margin-bottom: 12px; +} + +.empty-state p { + font-size: 16px; + color: #666; + margin-bottom: 12px; +} + +.empty-state .state-info { + font-size: 14px; + color: #999; + font-style: italic; + margin-bottom: 24px; +} + +.empty-state .view-original-button { + display: inline-block; + padding: 12px 24px; + background: #007aff; + color: white; + text-decoration: none; + border-radius: 6px; + font-size: 16px; + transition: background-color 0.2s; +} + +.empty-state .view-original-button:hover { + background: #0051d5; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .reader-page { + padding: 16px; + } + + .reader-header h1 { + font-size: 28px; + } + + .reader-content { + font-size: 16px; + } + + .reader-content h1 { font-size: 1.75em; } + .reader-content h2 { font-size: 1.4em; } + .reader-content h3 { font-size: 1.2em; } +} + +@media (max-width: 480px) { + .reader-header h1 { + font-size: 24px; + } + + .back-button { + font-size: 13px; + padding: 6px 12px; + } +} diff --git a/packages/web-vite/src/types/api.ts b/packages/web-vite/src/types/api.ts index 280d82ad8..033b6d42a 100644 --- a/packages/web-vite/src/types/api.ts +++ b/packages/web-vite/src/types/api.ts @@ -178,6 +178,7 @@ export interface LibraryItem { originalUrl: string author?: string | null description?: string | null + content?: string | null savedAt: string createdAt: string publishedAt?: string | null @@ -189,6 +190,12 @@ export interface LibraryItem { labels?: Label[] | null } +export interface DeleteResult { + success: boolean + message?: string + itemId?: string +} + export interface LibraryItemsConnection { items: LibraryItem[] nextCursor: string | null diff --git a/yarn.lock b/yarn.lock index a79781b09..9bdfbfb2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15896,6 +15896,13 @@ dompurify@^2.4.3: resolved "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz" integrity sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw== +dompurify@^3.2.3: + version "3.2.7" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.7.tgz#721d63913db5111dd6dfda8d3a748cfd7982d44a" + integrity sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + domutils@^2.5.2, domutils@^2.8.0: version "2.8.0" resolved "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz"