mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
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:
parent
7c79be2e2a
commit
cf1da4d90f
18 changed files with 1968 additions and 94 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export class LibraryItem {
|
|||
|
||||
@Field(() => [Label], { nullable: true })
|
||||
labels?: Label[] | null
|
||||
|
||||
@Field({ nullable: true })
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}`
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
614
packages/api-nest/test/save-url.e2e-spec.ts
Normal file
614
packages/api-nest/test/save-url.e2e-spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
238
packages/web-vite/src/components/AddLinkModal.tsx
Normal file
238
packages/web-vite/src/components/AddLinkModal.tsx
Normal 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
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
288
packages/web-vite/src/styles/AddLinkModal.css
Normal file
288
packages/web-vite/src/styles/AddLinkModal.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
269
packages/web-vite/src/styles/ReaderPage.css
Normal file
269
packages/web-vite/src/styles/ReaderPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue