diff --git a/packages/api-nest/schema.graphql b/packages/api-nest/schema.graphql index 2944a856f..3de230af4 100644 --- a/packages/api-nest/schema.graphql +++ b/packages/api-nest/schema.graphql @@ -119,19 +119,43 @@ type LibraryItem { description: String folder: String! id: ID! + + """Legacy alias for thumbnail""" + image: String + + """Item type (ARTICLE, FILE, VIDEO, etc.)""" + itemType: String! labels: [Label!] note: String noteUpdatedAt: DateTime originalUrl: String! + + """Legacy alias for itemType""" + pageType: String! publishedAt: DateTime readAt: DateTime readingProgressBottomPercent: Float readingProgressTopPercent: Float savedAt: DateTime! + + """Site favicon/icon URL""" + siteIcon: String + + """Site name (e.g., "Medium", "New York Times")""" + siteName: String slug: String! state: LibraryItemState! + + """Thumbnail/cover image URL for the library item""" + thumbnail: String title: String! updatedAt: DateTime! + + """Estimated word count for reading time calculation""" + wordCount: Float + + """Legacy alias for wordCount""" + wordsCount: Float } enum LibraryItemState { @@ -272,6 +296,15 @@ type Mutation { """Update an existing label""" updateLabel(id: String!, input: UpdateLabelInput!): Label! + """Update library item metadata (title, author, description)""" + updateLibraryItem( + """Library item ID""" + id: String! + + """Updated library item metadata""" + input: UpdateLibraryItemInput! + ): LibraryItem! + """Update notebook content for a library item""" updateNotebook( """Library item ID""" @@ -312,6 +345,9 @@ type Query { libraryItem(id: String!): LibraryItem libraryItems(after: String, first: Int = 20, search: LibrarySearchInput): LibraryItemsConnection! me: User! + + """Legacy search query for backward compatibility""" + search(after: String, first: Int = 20, includeContent: Boolean, query: String): SearchResult! session: AuthPayload viewer: User! } @@ -353,6 +389,30 @@ input SaveUrlInput { url: String! } +type SearchError { + errorCodes: [String!]! +} + +type SearchItemEdge { + cursor: String! + node: LibraryItem! +} + +type SearchPageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + totalCount: Int +} + +union SearchResult = SearchError | SearchSuccess + +type SearchSuccess { + edges: [SearchItemEdge!]! + pageInfo: SearchPageInfo! +} + """Sort order direction""" enum SortOrder { ASC @@ -379,6 +439,17 @@ input UpdateLabelInput { name: String } +input UpdateLibraryItemInput { + """Updated author name for the library item""" + author: String + + """Updated description for the library item""" + description: String + + """Updated title for the library item""" + title: String +} + input UpdateNotebookInput { """Notebook content (supports markdown)""" note: String! 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 11cdace7f..aede3bcc1 100644 --- a/packages/api-nest/src/library/dto/library-inputs.type.ts +++ b/packages/api-nest/src/library/dto/library-inputs.type.ts @@ -216,3 +216,33 @@ export class UpdateNotebookInput { @IsString() note: string } + +/** + * Input type for updating library item metadata (title, author, description) + */ +@InputType() +export class UpdateLibraryItemInput { + @Field(() => String, { + nullable: true, + description: 'Updated title for the library item', + }) + @IsOptional() + @IsString() + title?: string + + @Field(() => String, { + nullable: true, + description: 'Updated author name for the library item', + }) + @IsOptional() + @IsString() + author?: string + + @Field(() => String, { + nullable: true, + description: 'Updated description for the library item', + }) + @IsOptional() + @IsString() + description?: 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 fd3da37d6..7c1597f86 100644 --- a/packages/api-nest/src/library/dto/library-item.type.ts +++ b/packages/api-nest/src/library/dto/library-item.type.ts @@ -1,4 +1,4 @@ -import { Field, Float, ID, ObjectType, registerEnumType } from '@nestjs/graphql' +import { Field, Float, ID, Int, ObjectType, registerEnumType, createUnionType } from '@nestjs/graphql' import { LibraryItemState, ContentReaderType } from '../entities/library-item.entity' import { Label } from '../../label/dto/label.type' @@ -71,6 +71,37 @@ export class LibraryItem { @Field(() => Date, { nullable: true }) noteUpdatedAt?: Date | null + + @Field({ nullable: true, description: 'Thumbnail/cover image URL for the library item' }) + thumbnail?: string | null + + @Field(() => Float, { nullable: true, description: 'Estimated word count for reading time calculation' }) + wordCount?: number | null + + @Field({ nullable: true, description: 'Site name (e.g., "Medium", "New York Times")' }) + siteName?: string | null + + @Field({ nullable: true, description: 'Site favicon/icon URL' }) + siteIcon?: string | null + + @Field({ description: 'Item type (ARTICLE, FILE, VIDEO, etc.)', defaultValue: 'ARTICLE' }) + itemType!: string + + // Legacy field aliases for backward compatibility with frontend + @Field({ nullable: true, name: 'image', description: 'Legacy alias for thumbnail' }) + get image(): string | null { + return this.thumbnail + } + + @Field(() => Float, { nullable: true, name: 'wordsCount', description: 'Legacy alias for wordCount' }) + get wordsCount(): number | null { + return this.wordCount + } + + @Field({ name: 'pageType', description: 'Legacy alias for itemType' }) + get pageType(): string { + return this.itemType + } } @ObjectType() @@ -99,3 +130,61 @@ export class BulkActionResult { @Field({ nullable: true }) message?: string | null } + +// Legacy search result types for backward compatibility +@ObjectType() +export class SearchItemEdge { + @Field() + cursor!: string + + @Field(() => LibraryItem) + node!: LibraryItem +} + +@ObjectType() +export class SearchPageInfo { + @Field() + hasNextPage!: boolean + + @Field() + hasPreviousPage!: boolean + + @Field({ nullable: true }) + startCursor?: string | null + + @Field({ nullable: true }) + endCursor?: string | null + + @Field(() => Int, { nullable: true }) + totalCount?: number | null +} + +@ObjectType() +export class SearchSuccess { + @Field(() => [SearchItemEdge]) + edges!: SearchItemEdge[] + + @Field(() => SearchPageInfo) + pageInfo!: SearchPageInfo +} + +@ObjectType() +export class SearchError { + @Field(() => [String]) + errorCodes!: string[] +} + +// Union type for search result (legacy compatibility) +export const SearchResult = createUnionType({ + name: 'SearchResult', + types: () => [SearchSuccess, SearchError] as const, + resolveType(value) { + if ('edges' in value) { + return SearchSuccess + } + if ('errorCodes' in value) { + return SearchError + } + return null + }, +}) diff --git a/packages/api-nest/src/library/library.resolver.ts b/packages/api-nest/src/library/library.resolver.ts index 9f8f7a0b6..4f58e1c05 100644 --- a/packages/api-nest/src/library/library.resolver.ts +++ b/packages/api-nest/src/library/library.resolver.ts @@ -4,13 +4,22 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' import { CurrentUser } from '../user/decorators/current-user.decorator' import { User } from '../user/entities/user.entity' import { LibraryService } from './library.service' -import { LibraryItem, LibraryItemsConnection, BulkActionResult } from './dto/library-item.type' +import { + LibraryItem, + LibraryItemsConnection, + BulkActionResult, + SearchResult, + SearchSuccess, + SearchItemEdge, + SearchPageInfo, +} from './dto/library-item.type' import { ReadingProgressInput, DeleteResult, LibrarySearchInput, SaveUrlInput, UpdateNotebookInput, + UpdateLibraryItemInput, } from './dto/library-inputs.type' import { LabelService } from '../label/label.service' import { Label } from '../label/dto/label.type' @@ -68,6 +77,61 @@ export class LibraryResolver { return entity ? mapEntityToGraph(entity) : null } + /** + * Legacy search query for backward compatibility with frontend + * Maps to libraryItems but returns the expected edges/pageInfo structure + */ + @Query(() => SearchResult, { + name: 'search', + description: 'Legacy search query for backward compatibility', + }) + @UseGuards(JwtAuthGuard) + async search( + @CurrentUser() user: User, + @Args('first', { type: () => Int, nullable: true, defaultValue: 20 }) + first = 20, + @Args('after', { type: () => String, nullable: true }) after?: string, + @Args('query', { type: () => String, nullable: true }) query?: string, + @Args('includeContent', { type: () => Boolean, nullable: true }) includeContent?: boolean, + ): Promise { + try { + // Convert query string to search input format + const searchInput: LibrarySearchInput | undefined = query + ? { query } + : undefined + + const { items, nextCursor } = await this.libraryService.listForUser( + user.id, + first, + after, + searchInput, + ) + + // Transform to legacy format with edges and pageInfo + const edges: SearchItemEdge[] = items.map((item, index) => ({ + cursor: nextCursor && index === items.length - 1 ? nextCursor : item.id, + node: mapEntityToGraph(item), + })) + + const pageInfo: SearchPageInfo = { + hasNextPage: !!nextCursor, + hasPreviousPage: !!after, + startCursor: items.length > 0 ? items[0].id : null, + endCursor: nextCursor, + totalCount: null, // Not currently tracked + } + + return { + edges, + pageInfo, + } as SearchSuccess + } catch (error) { + return { + errorCodes: ['SEARCH_ERROR'], + } + } + } + // ==================== MUTATIONS ==================== @Mutation(() => LibraryItem, { @@ -145,6 +209,28 @@ export class LibraryResolver { return mapEntityToGraph(entity) } + @Mutation(() => LibraryItem, { + description: 'Update library item metadata (title, author, description)', + }) + @UseGuards(JwtAuthGuard) + async updateLibraryItem( + @CurrentUser() user: User, + @Args('id', { type: () => String, description: 'Library item ID' }) + id: string, + @Args('input', { + type: () => UpdateLibraryItemInput, + description: 'Updated library item metadata', + }) + input: UpdateLibraryItemInput, + ): Promise { + const entity = await this.libraryService.updateLibraryItemMetadata( + user.id, + id, + input, + ) + return mapEntityToGraph(entity) + } + @Mutation(() => LibraryItem, { description: 'Move a library item to a different folder', }) @@ -251,7 +337,15 @@ export class LibraryResolver { } } +/** + * Map LibraryItemEntity to GraphQL LibraryItem type + * Handles field name differences and null coalescing + */ function mapEntityToGraph(entity: any): LibraryItem { + const thumbnail = entity.thumbnail ?? null + const wordCount = entity.wordCount ?? null + const itemType = entity.itemType ?? 'ARTICLE' + return { id: entity.id, title: entity.title, @@ -273,5 +367,15 @@ function mapEntityToGraph(entity: any): LibraryItem { note: entity.note ?? null, noteUpdatedAt: entity.noteUpdatedAt ?? null, labels: null, // Labels will be resolved by the field resolver - } + // ARC-009: Add fields for frontend library feature parity + thumbnail, + wordCount, + siteName: entity.siteName ?? null, + siteIcon: entity.siteIcon ?? null, + itemType, + // Legacy field aliases (TypeScript doesn't know about getters, so we set them directly) + image: thumbnail, + wordsCount: wordCount, + pageType: itemType, + } as LibraryItem } diff --git a/packages/api-nest/src/library/library.service.ts b/packages/api-nest/src/library/library.service.ts index 41b7cad99..d8550ee68 100644 --- a/packages/api-nest/src/library/library.service.ts +++ b/packages/api-nest/src/library/library.service.ts @@ -14,6 +14,7 @@ import { ReadingProgressInput, LibrarySearchInput, SaveUrlInput, + UpdateLibraryItemInput, } from './dto/library-inputs.type' import { EventBusService } from '../queue/event-bus.service' import { EVENT_NAMES } from '../queue/events.constants' @@ -458,6 +459,47 @@ export class LibraryService { return item } + /** + * Update library item metadata (title, author, description) + * @param userId - User ID who owns the item + * @param itemId - Library item ID + * @param input - Updated metadata fields + * @returns Updated library item + */ + async updateLibraryItemMetadata( + userId: string, + itemId: string, + input: UpdateLibraryItemInput, + ): Promise { + const item = await this.findById(userId, itemId) + + if (!item) { + throw new NotFoundException(`Library item with ID ${itemId} not found`) + } + + // Update only the fields that are provided + if (input.title !== undefined) { + if (!input.title.trim()) { + throw new BadRequestException('Title cannot be empty') + } + item.title = input.title + } + + if (input.author !== undefined) { + item.author = input.author || null + } + + if (input.description !== undefined) { + item.description = input.description || null + } + + // Update the updatedAt timestamp + item.updatedAt = new Date() + + await this.libraryRepository.save(item) + return item + } + /** * Generate a unique, URL-safe slug from a URL * Extracts meaningful parts from the URL pathname and adds a timestamp diff --git a/packages/api-nest/src/queue/processors/content-processor.service.ts b/packages/api-nest/src/queue/processors/content-processor.service.ts index 42d200cf7..e6880de7e 100644 --- a/packages/api-nest/src/queue/processors/content-processor.service.ts +++ b/packages/api-nest/src/queue/processors/content-processor.service.ts @@ -275,9 +275,12 @@ export class ContentProcessorService extends WorkerHost implements OnModuleInit, } } - // Phase 5: Combine Open Graph + Readability results + // Phase 5: Calculate accurate word count from content (strips HTML) + const actualWordCount = this.calculateWordCount(article.content || '') + + // Phase 6: Combine Open Graph + Readability results this.logger.log( - `Successfully extracted content from ${url}: ${article.length} words` + `Successfully extracted content from ${url}: ${actualWordCount} words` ) return { @@ -291,7 +294,7 @@ export class ContentProcessorService extends WorkerHost implements OnModuleInit, siteName: article.siteName || ogData.siteName, siteIcon: ogData.favicon, publishedDate: ogData.publishedTime ? new Date(ogData.publishedTime) : undefined, - wordCount: article.length || 0, + wordCount: actualWordCount, } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) @@ -304,6 +307,26 @@ export class ContentProcessorService extends WorkerHost implements OnModuleInit, } } + /** + * Calculate word count from HTML content (strips tags and counts actual words) + * @param htmlContent - HTML content to count words from + * @returns Actual word count + */ + private calculateWordCount(htmlContent: string): number { + if (!htmlContent) return 0 + + // Strip HTML tags + const textOnly = htmlContent.replace(/<[^>]*>/g, ' ') + + // Remove extra whitespace and normalize + const normalized = textOnly.replace(/\s+/g, ' ').trim() + + // Split by whitespace and count non-empty words + const words = normalized.split(' ').filter(word => word.length > 0) + + return words.length + } + /** * Extract Open Graph metadata from HTML document */ diff --git a/packages/api-nest/test/factories/library-item.factory.ts b/packages/api-nest/test/factories/library-item.factory.ts index 87e6f30f5..93571fea0 100644 --- a/packages/api-nest/test/factories/library-item.factory.ts +++ b/packages/api-nest/test/factories/library-item.factory.ts @@ -28,6 +28,7 @@ class LibraryItemFactoryClass extends BaseFactory { protected generateDefaults() { const timestamp = Date.now() const title = faker.lorem.sentence() + const domain = faker.internet.domainName() return { id: faker.string.uuid(), @@ -41,6 +42,23 @@ class LibraryItemFactoryClass extends BaseFactory { itemType: 'ARTICLE', createdAt: new Date(), updatedAt: new Date(), + // ARC-009: Add metadata fields for frontend library feature parity + author: faker.person.fullName(), + description: faker.lorem.paragraph(), + thumbnail: faker.image.url({ width: 640, height: 480, category: 'tech' }), + wordCount: faker.number.int({ min: 300, max: 5000 }), + siteName: faker.company.name(), + siteIcon: `https://${domain}/favicon.ico`, + publishedAt: faker.date.past({ years: 1 }), + readingProgressTopPercent: 0, + readingProgressBottomPercent: 0, + readingProgressLastReadAnchor: 0, + readingProgressHighestReadAnchor: 0, + readableContent: faker.lorem.paragraphs(5), + labelNames: [], + note: null, + noteUpdatedAt: null, + readAt: null, // These will be set by the caller userId: '', // Must be provided user: undefined, @@ -54,7 +72,10 @@ class LibraryItemFactoryClass extends BaseFactory { /** * Create an archived library item */ - async archived(userId: string, overrides: Partial = {}): Promise { + async archived( + userId: string, + overrides: Partial = {}, + ): Promise { return this.create({ userId, folder: FOLDERS.ARCHIVE, @@ -66,7 +87,10 @@ class LibraryItemFactoryClass extends BaseFactory { /** * Create a deleted library item (in trash) */ - async deleted(userId: string, overrides: Partial = {}): Promise { + async deleted( + userId: string, + overrides: Partial = {}, + ): Promise { return this.create({ userId, folder: FOLDERS.TRASH, @@ -97,7 +121,10 @@ class LibraryItemFactoryClass extends BaseFactory { /** * Create an item that's still being processed */ - async processing(userId: string, overrides: Partial = {}): Promise { + async processing( + userId: string, + overrides: Partial = {}, + ): Promise { return this.create({ userId, state: LibraryItemState.CONTENT_NOT_FETCHED, @@ -125,7 +152,10 @@ class LibraryItemFactoryClass extends BaseFactory { /** * Create a PDF library item */ - async pdf(userId: string, overrides: Partial = {}): Promise { + async pdf( + userId: string, + overrides: Partial = {}, + ): Promise { return this.create({ userId, contentReader: ContentReaderType.PDF, @@ -137,7 +167,10 @@ class LibraryItemFactoryClass extends BaseFactory { /** * Build archived item (in memory) */ - buildArchived(userId: string, overrides: Partial = {}): LibraryItemEntity { + buildArchived( + userId: string, + overrides: Partial = {}, + ): LibraryItemEntity { return this.build({ userId, folder: FOLDERS.ARCHIVE, @@ -162,6 +195,50 @@ class LibraryItemFactoryClass extends BaseFactory { ...overrides, }) } + + /** + * Create a library item with complete metadata (ARC-009) + * Useful for testing frontend display of thumbnails, site info, etc. + */ + async withFullMetadata( + userId: string, + overrides: Partial = {}, + ): Promise { + const siteName = faker.company.name() + const domain = faker.internet.domainName() + + return this.create({ + userId, + author: faker.person.fullName(), + description: faker.lorem.sentences(2), + thumbnail: faker.image.urlLoremFlickr({ + width: 1200, + height: 630, + category: 'business', + }), + wordCount: faker.number.int({ min: 1000, max: 3000 }), + siteName, + siteIcon: `https://${domain}/favicon.ico`, + publishedAt: faker.date.past({ years: 1 }), + readableContent: faker.lorem.paragraphs(15), + ...overrides, + }) + } + + /** + * Build deleted item (in memory) + */ + buildDeleted( + userId: string, + overrides: Partial = {}, + ): LibraryItemEntity { + return this.build({ + userId, + folder: FOLDERS.TRASH, + state: LibraryItemState.DELETED, + ...overrides, + }) + } } // Export singleton instance diff --git a/packages/api-nest/test/library-arc009.e2e-spec.ts b/packages/api-nest/test/library-arc009.e2e-spec.ts new file mode 100644 index 000000000..82df6e155 --- /dev/null +++ b/packages/api-nest/test/library-arc009.e2e-spec.ts @@ -0,0 +1,185 @@ +/** + * ARC-009: Frontend Library Feature Parity - E2E Tests + * + * Tests the new GraphQL fields added for ARC-009 to support + * frontend library UI features (thumbnails, metadata, etc.) + * + * Run with: yarn test:e2e --testPathPattern=library-arc009 + */ + +import { UserFactory, LibraryItemFactory } from './factories' +import { getTestDataSource } from './setup/test-datasource' + +describe('ARC-009: Frontend Library Feature Parity (e2e)', () => { + it('should include thumbnail field in library items', async () => { + // Verify testcontainer datasource is available + const dataSource = getTestDataSource() + expect(dataSource.isInitialized).toBe(true) + + // Create a user + const user = await UserFactory.create() + + // Create a library item with full metadata + const item = await LibraryItemFactory.withFullMetadata(user.id, { + title: 'Article with Thumbnail', + }) + + expect(item.id).toBeDefined() + expect(item.thumbnail).toBeDefined() + expect(item.thumbnail).toMatch(/https?:\/\//) + expect(item.title).toBe('Article with Thumbnail') + + console.log('✅ Thumbnail field available:', item.thumbnail) + }) + + it('should include metadata fields (wordCount, siteName, siteIcon)', async () => { + const user = await UserFactory.create() + + const item = await LibraryItemFactory.withFullMetadata(user.id, { + wordCount: 1500, + siteName: 'Tech Blog', + siteIcon: 'https://techblog.com/favicon.ico', + }) + + expect(item.wordCount).toBe(1500) + expect(item.siteName).toBe('Tech Blog') + expect(item.siteIcon).toBe('https://techblog.com/favicon.ico') + + console.log('✅ Metadata fields available:') + console.log(' - Word count:', item.wordCount) + console.log(' - Site name:', item.siteName) + console.log(' - Site icon:', item.siteIcon) + }) + + it('should include itemType field for different content types', async () => { + const user = await UserFactory.create() + + // Test article + const article = await LibraryItemFactory.create({ + userId: user.id, + itemType: 'ARTICLE', + }) + expect(article.itemType).toBe('ARTICLE') + + // Test PDF + const pdf = await LibraryItemFactory.pdf(user.id) + expect(pdf.itemType).toBe('FILE') + expect(pdf.contentReader).toBe('PDF') + + console.log('✅ Item type field available') + console.log(' - Article type:', article.itemType) + console.log(' - PDF type:', pdf.itemType) + }) + + it('should generate realistic metadata with faker', async () => { + const user = await UserFactory.create() + + // Create multiple items to verify faker generates varied data + const items = await Promise.all([ + LibraryItemFactory.withFullMetadata(user.id), + LibraryItemFactory.withFullMetadata(user.id), + LibraryItemFactory.withFullMetadata(user.id), + ]) + + // Check that each item has unique values (faker randomness) + const titles = items.map((i) => i.title) + const thumbnails = items.map((i) => i.thumbnail) + const siteNames = items.map((i) => i.siteName) + + expect(new Set(titles).size).toBe(3) // All different + expect(new Set(thumbnails).size).toBe(3) // All different + expect(new Set(siteNames).size).toBe(3) // All different + + // Check word count is in reasonable range + items.forEach((item) => { + expect(item.wordCount).toBeGreaterThanOrEqual(300) + expect(item.wordCount).toBeLessThanOrEqual(5000) + }) + + console.log('✅ Faker generates realistic, varied metadata') + }) + + it('should support items with reading progress and metadata', async () => { + const user = await UserFactory.create() + + // Create item with both progress and metadata + const item = await LibraryItemFactory.withFullMetadata(user.id, { + readingProgressTopPercent: 65, + title: 'Partially Read Article', + }) + + expect(item.title).toBe('Partially Read Article') + expect(item.readingProgressTopPercent).toBe(65) + expect(item.thumbnail).toBeDefined() + expect(item.wordCount).toBeDefined() + expect(item.siteName).toBeDefined() + + console.log('✅ Progress + metadata work together') + console.log(' - Progress:', item.readingProgressTopPercent, '%') + console.log(' - Word count:', item.wordCount) + }) + + it('should handle items without metadata gracefully', async () => { + const user = await UserFactory.create() + + // Create minimal item (processing state, no content yet) + const processing = await LibraryItemFactory.processing(user.id) + + expect(processing.id).toBeDefined() + expect(processing.state).toBe('CONTENT_NOT_FETCHED') + + // Metadata fields should exist but may be null or default + expect(processing).toHaveProperty('thumbnail') + expect(processing).toHaveProperty('wordCount') + expect(processing).toHaveProperty('siteName') + expect(processing).toHaveProperty('siteIcon') + expect(processing).toHaveProperty('itemType') + + console.log('✅ Items without metadata handle gracefully') + }) + + it('should support all library item states with metadata', async () => { + const user = await UserFactory.create() + + const succeeded = await LibraryItemFactory.withFullMetadata(user.id) + const archived = await LibraryItemFactory.archived(user.id) + const deleted = await LibraryItemFactory.deleted(user.id) + + expect(succeeded.state).toBe('SUCCEEDED') + expect(succeeded.thumbnail).toBeDefined() + + expect(archived.state).toBe('ARCHIVED') + expect(archived.folder).toBe('archive') + + expect(deleted.state).toBe('DELETED') + expect(deleted.folder).toBe('trash') + + console.log('✅ All states support metadata fields') + console.log(' - Succeeded:', succeeded.state) + console.log(' - Archived:', archived.state) + console.log(' - Deleted:', deleted.state) + }) + + it('should calculate reading time from word count', async () => { + const user = await UserFactory.create() + + const shortArticle = await LibraryItemFactory.withFullMetadata(user.id, { + wordCount: 500, + }) + + const longArticle = await LibraryItemFactory.withFullMetadata(user.id, { + wordCount: 3000, + }) + + // Average reading speed: 200-250 words/minute + const shortReadingTime = Math.ceil(shortArticle.wordCount! / 200) + const longReadingTime = Math.ceil(longArticle.wordCount! / 200) + + expect(shortReadingTime).toBeGreaterThanOrEqual(2) // ~2-3 minutes + expect(longReadingTime).toBeGreaterThanOrEqual(15) // ~15 minutes + + console.log('✅ Word count enables reading time estimates') + console.log(' - 500 words ≈', shortReadingTime, 'min') + console.log(' - 3000 words ≈', longReadingTime, 'min') + }) +}) diff --git a/packages/web-vite/src/App.css b/packages/web-vite/src/App.css index ad862515d..538a0f045 100644 --- a/packages/web-vite/src/App.css +++ b/packages/web-vite/src/App.css @@ -7,6 +7,108 @@ .app { min-height: 100vh; width: 100%; + background: #1a1a1a; + color: #fff; +} + +/* New layout with left navigation */ +.app-layout { + display: flex; + min-height: 100vh; + background: #1a1a1a; +} + +.main-content-wrapper { + flex: 1; + margin-left: 250px; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Top bar */ +.top-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + background: #2a2a2a; + border-bottom: 1px solid #3a3a3a; + position: sticky; + top: 0; + z-index: 50; +} + +.top-bar-left { + flex: 1; +} + +.top-bar-right { + display: flex; + align-items: center; + gap: 1rem; +} + +.top-bar-link { + color: #898989; + text-decoration: none; + font-size: 0.9rem; + transition: color 0.2s; +} + +.top-bar-link:hover { + color: #fff; +} + +.logout-btn { + background: #333; + border: 1px solid #444; + color: #898989; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.2s, color 0.2s; +} + +.logout-btn:hover { + background: #444; + color: #fff; +} + +/* Main content area */ +.app-main { + flex: 1; + background: #1a1a1a; + overflow-y: auto; +} + +/* View toggle button */ +.view-toggle-btn { + background: #333; + border: 1px solid #444; + color: #898989; + padding: 0.5rem 1rem; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.85rem; + transition: background 0.2s, color 0.2s; +} + +.view-toggle-btn:hover { + background: #444; + color: #fff; +} + +/* Responsive - Mobile */ +@media (max-width: 768px) { + .main-content-wrapper { + margin-left: 0; + } + + .top-bar { + padding: 1rem; + } } /* Login page styles - matching legacy web package */ diff --git a/packages/web-vite/src/components/AddLinkModal.tsx b/packages/web-vite/src/components/AddLinkModal.tsx index 59ceb676f..6bc468f68 100644 --- a/packages/web-vite/src/components/AddLinkModal.tsx +++ b/packages/web-vite/src/components/AddLinkModal.tsx @@ -29,17 +29,19 @@ const AddLinkModal: React.FC = ({ 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 + // Add protocol if missing for URL validation + let testUrl = urlString.trim() + if (!testUrl.startsWith('http://') && !testUrl.startsWith('https://')) { + testUrl = `https://${testUrl}` } + // Use built-in URL constructor for validation + new URL(testUrl) + setValidationError(null) return true } catch { - setValidationError('Invalid URL format') + setValidationError('Please enter a valid URL (e.g., https://example.com/article)') return false } } diff --git a/packages/web-vite/src/components/CardSkeleton.tsx b/packages/web-vite/src/components/CardSkeleton.tsx new file mode 100644 index 000000000..c3a41c3e1 --- /dev/null +++ b/packages/web-vite/src/components/CardSkeleton.tsx @@ -0,0 +1,68 @@ +/** + * CardSkeleton - Loading placeholder for LibraryItemCard + * + * Shows a shimmer/skeleton state when content is being processed. + * Per ARC-009B Design System, this indicates the item is in "PROCESSING" state + * and content extraction is in progress. + * + * Features: + * - Shimmer animation for visual feedback + * - Matches LibraryCard dimensions + * - Respects density modes + */ + +import React from 'react' +import type { CardDensity } from './LibraryItemCard' +import '../styles/CardSkeleton.css' + +interface CardSkeletonProps { + density?: CardDensity +} + +const CardSkeleton: React.FC = ({ density = 'comfortable' }) => { + const showThumbnail = density !== 'compact' + + return ( +
+ {/* Thumbnail skeleton */} + {showThumbnail && ( +
+
+
+ )} + + {/* Metadata bar skeleton */} +
+
+
+
+
+
+ + {/* Title skeleton */} +
+
+ {density !== 'compact' && ( +
+ )} + {density === 'spacious' && ( +
+ )} +
+ + {/* Tags skeleton */} +
+
+
+
+
+ + {/* Footer skeleton */} +
+
+
+
+ ) +} + +export default CardSkeleton diff --git a/packages/web-vite/src/components/EditInfoModal.tsx b/packages/web-vite/src/components/EditInfoModal.tsx new file mode 100644 index 000000000..204c6812a --- /dev/null +++ b/packages/web-vite/src/components/EditInfoModal.tsx @@ -0,0 +1,161 @@ +import { useState, useEffect } from 'react' +import { useUpdateLibraryItem, type UpdateLibraryItemInput } from '../lib/graphql-client' +import '../styles/EditInfoModal.css' + +interface EditInfoModalProps { + itemId: string + currentTitle: string + currentAuthor?: string | null + currentDescription?: string | null + onUpdate: (data: UpdateLibraryItemInput) => void + onClose: () => void +} + +export function EditInfoModal({ + itemId, + currentTitle, + currentAuthor, + currentDescription, + onUpdate, + onClose, +}: EditInfoModalProps) { + const { updateLibraryItem, loading } = useUpdateLibraryItem() + const [title, setTitle] = useState(currentTitle) + const [author, setAuthor] = useState(currentAuthor || '') + const [description, setDescription] = useState(currentDescription || '') + const [error, setError] = useState(null) + + // Update local state when props change + useEffect(() => { + setTitle(currentTitle) + setAuthor(currentAuthor || '') + setDescription(currentDescription || '') + }, [currentTitle, currentAuthor, currentDescription]) + + const handleSave = async () => { + try { + setError(null) + const input: UpdateLibraryItemInput = {} + + // Only include fields that changed + if (title !== currentTitle) input.title = title + if (author !== (currentAuthor || '')) input.author = author + if (description !== (currentDescription || '')) input.description = description + + // If nothing changed, just close + if (Object.keys(input).length === 0) { + onClose() + return + } + + await updateLibraryItem(itemId, input) + onUpdate(input) + onClose() + } catch (err) { + console.error('Failed to update item:', err) + const errorMessage = err instanceof Error ? err.message : 'Failed to update item info' + setError(errorMessage) + } + } + + const handleCancel = () => { + setTitle(currentTitle) + setAuthor(currentAuthor || '') + setDescription(currentDescription || '') + onClose() + } + + return ( +
+
e.stopPropagation()}> +
+

Edit Info

+ +
+ +
+ {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Title Field */} +
+ + setTitle(e.target.value)} + disabled={loading} + placeholder="Enter article title..." + /> +
+ + {/* Author Field */} +
+ + setAuthor(e.target.value)} + disabled={loading} + placeholder="Enter author name..." + /> +
+ + {/* Description Field */} +
+ +