feat(arc-011,arc-010a): Implement Add Link and Minimal Reader

ARC-011 Add Link & Content Ingestion:
- Add saveUrl GraphQL mutation with URL validation and duplicate detection
- Create AddLinkModal component with folder selection and content type tabs
- Implement comprehensive E2E test suite (17 tests, 614 lines)
- Add URL validation, error handling, and success feedback
- Generate unique slugs and set CONTENT_NOT_FETCHED state
- Content extraction deferred to ARC-012/013 (queue processing)

ARC-010A Minimal Reader:
- Create ReaderPage component with clean typography and responsive design
- Implement DOMPurify HTML sanitization for security
- Add loading, error, and empty states for content handling
- Support navigation from library to reader via title clicks
- Handle CONTENT_NOT_FETCHED state gracefully
- Add comprehensive CSS styling for reading experience

Backend changes:
- Add SaveUrlInput DTO with validation
- Update LibraryItem entity and GraphQL schema
- Implement saveUrl service method with duplicate detection
- Add content field to GraphQL type for reader display

Frontend changes:
- AddLinkModal with URL input, folder selection, content type tabs
- ReaderPage with article header, content display, back navigation
- GraphQL client hooks for saveUrl and libraryItem queries
- Complete CSS styling for both components
- Integration with LibraryPage for seamless UX

Resolves ARC-011 and ARC-010A from unified migration backlog
This commit is contained in:
Timothy Atapagra 2025-10-05 17:18:50 -04:00
parent 7c79be2e2a
commit cf1da4d90f
18 changed files with 1968 additions and 94 deletions

View file

@ -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:

View file

@ -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

View file

@ -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
}

View file

@ -62,6 +62,9 @@ export class LibraryItem {
@Field(() => [Label], { nullable: true })
labels?: Label[] | null
@Field({ nullable: true })
content?: string | null
}
@ObjectType()

View file

@ -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[]
}

View file

@ -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<BulkActionResult> {
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<LibraryItem> {
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
}
}

View file

@ -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<LibraryItemEntity>,
@ -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<LibraryItemEntity> {
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()}`
}
}
}

View file

@ -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<boolean>(
'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<string, any> = {},
) => {
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,
})
})
})
})

View file

@ -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",

View file

@ -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;
}

View file

@ -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<AddLinkModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const [contentType, setContentType] = useState<ContentType>('link')
const [url, setUrl] = useState('')
const [folder, setFolder] = useState<'inbox' | 'archive'>('inbox')
const [validationError, setValidationError] = useState<string | null>(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 (
<div className="modal-overlay" onClick={handleClose}>
<div className="modal-content add-link-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{getTitle()}</h2>
<button className="close-btn" onClick={handleClose} aria-label="Close">
×
</button>
</div>
<div className="content-type-tabs">
<button
type="button"
className={`content-type-tab ${contentType === 'link' ? 'active' : ''}`}
onClick={() => {
setContentType('link')
setUrl('')
setValidationError(null)
}}
>
🔗 Link
</button>
<button
type="button"
className={`content-type-tab ${contentType === 'pdf' ? 'active' : ''}`}
onClick={() => {
setContentType('pdf')
setUrl('')
setValidationError(null)
}}
title="Coming soon in ARC-013"
>
📄 PDF
</button>
<button
type="button"
className={`content-type-tab ${contentType === 'rss' ? 'active' : ''}`}
onClick={() => {
setContentType('rss')
setUrl('')
setValidationError(null)
}}
title="Coming soon in future release"
>
📡 RSS
</button>
</div>
{contentType !== 'link' && (
<div className="coming-soon-notice">
<p>
📋 {contentType === 'pdf' ? 'PDF' : 'RSS'} support is coming soon!
For now, please use the Link tab to add web articles.
</p>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="url">
{contentType === 'link' ? 'URL' : contentType === 'pdf' ? 'PDF URL' : 'RSS Feed URL'}
</label>
<input
id="url"
type="text"
value={url}
onChange={(e) => {
setUrl(e.target.value)
setValidationError(null)
}}
placeholder={getPlaceholder()}
className={`url-input ${validationError || error ? 'error' : ''}`}
disabled={loading || contentType !== 'link'}
autoFocus
/>
{validationError && (
<span className="error-message">{validationError}</span>
)}
{error && !validationError && (
<span className="error-message">{error.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="folder">Save to</label>
<select
id="folder"
value={folder}
onChange={(e) => setFolder(e.target.value as 'inbox' | 'archive')}
className="folder-select"
disabled={loading || contentType !== 'link'}
>
<option value="inbox">Inbox</option>
<option value="archive">Archive</option>
</select>
</div>
<div className="modal-actions">
<button
type="button"
onClick={handleClose}
className="btn btn-secondary"
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading || !url.trim() || contentType !== 'link'}
>
{loading ? 'Saving...' : contentType === 'link' ? 'Add Link' : 'Add'}
</button>
</div>
</form>
{loading && (
<div className="loading-indicator">
<div className="spinner-small"></div>
<span>Saving link...</span>
</div>
)}
</div>
</div>
)
}
export default AddLinkModal

View file

@ -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<T> {
@ -411,6 +433,35 @@ export function useBulkMarkAsRead() {
return { ...state, bulkMarkAsRead }
}
export function useSaveUrl() {
const [state, setState] = useState<MutationState<any>>({
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 }
}

View file

@ -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<string[]>([])
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 (
<div className="loading-spinner">
@ -550,10 +583,21 @@ const LibraryPage: React.FC = () => {
>
{isMultiSelectMode ? 'Exit Multi-Select' : 'Multi-Select'}
</button>
<button className="add-article-btn">+ Add Article</button>
<button
className="add-article-btn"
onClick={() => setShowAddLinkModal(true)}
>
+ Add Article
</button>
</div>
</div>
<AddLinkModal
isOpen={showAddLinkModal}
onClose={() => setShowAddLinkModal(false)}
onSuccess={handleAddLinkSuccess}
/>
{isMultiSelectMode && (
<div className="bulk-actions-bar">
<div className="bulk-select-controls">
@ -690,7 +734,10 @@ const LibraryPage: React.FC = () => {
: 'Your library is empty. Add some articles to get started!'}
</p>
{!searchQuery && (
<button className="add-article-btn">
<button
className="add-article-btn"
onClick={() => setShowAddLinkModal(true)}
>
+ Add Your First Article
</button>
)}
@ -726,14 +773,12 @@ const LibraryPage: React.FC = () => {
</div>
<h3 className="article-title">
<a
href={item.originalUrl}
target="_blank"
rel="noopener noreferrer"
className="article-link"
<button
onClick={() => handleRead(item.id)}
className="article-title-btn"
>
{item.title}
</a>
</button>
</h3>
<div className="article-meta">

View file

@ -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 = () => (
<div className="reader-page">
<h1>Article Reader</h1>
<p>Reader functionality coming soon...</p>
</div>
)
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 (
<div className="reader-page">
<div className="reader-loading">
<div className="spinner"></div>
<p>Loading article...</p>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="reader-page">
<div className="reader-error">
<h2>Error Loading Article</h2>
<p>{error.message}</p>
<button onClick={handleBack} className="back-button">
Back to Library
</button>
</div>
</div>
)
}
// Not found state
if (!item) {
return (
<div className="reader-page">
<div className="reader-error">
<h2>Article Not Found</h2>
<p>The article you're looking for doesn't exist or has been deleted.</p>
<button onClick={handleBack} className="back-button">
Back to Library
</button>
</div>
</div>
)
}
// Content not fetched state
if (item.state === 'CONTENT_NOT_FETCHED' || !item.content) {
return (
<div className="reader-page">
<div className="reader-header">
<button onClick={handleBack} className="back-button">
Back to Library
</button>
<h1>{item.title}</h1>
{item.author && <p className="author">By {item.author}</p>}
{item.publishedAt && (
<p className="publish-date">{formatDate(item.publishedAt)}</p>
)}
{item.originalUrl && (
<a
href={item.originalUrl}
target="_blank"
rel="noopener noreferrer"
className="original-link"
>
View Original
</a>
)}
</div>
<div className="reader-content-empty">
<div className="empty-state">
<h2>Content Not Available</h2>
<p>This article's content is being processed. Please check back in a moment.</p>
<p className="state-info">Status: {item.state}</p>
<a
href={item.originalUrl}
target="_blank"
rel="noopener noreferrer"
className="view-original-button"
>
View Original Article
</a>
</div>
</div>
</div>
)
}
// Sanitize HTML content
const sanitizedContent = DOMPurify.sanitize(item.content, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'],
})
return (
<div className="reader-page">
<div className="reader-header">
<button onClick={handleBack} className="back-button">
Back to Library
</button>
<h1>{item.title}</h1>
{item.author && <p className="author">By {item.author}</p>}
{item.publishedAt && (
<p className="publish-date">{formatDate(item.publishedAt)}</p>
)}
{item.originalUrl && (
<a
href={item.originalUrl}
target="_blank"
rel="noopener noreferrer"
className="original-link"
>
View Original
</a>
)}
</div>
<div
className="reader-content"
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
</div>
)
}
export default ReaderPage

View file

@ -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%;
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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"