Compare commits
27 Commits
142d8328c2
...
feature/em
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7b516be31 | ||
|
|
c92308c24a | ||
|
|
f92dcc5314 | ||
|
|
702fcb33c1 | ||
|
|
11b2a8b071 | ||
|
|
d1289bd616 | ||
|
|
15708b5ab2 | ||
|
|
a660056003 | ||
|
|
35a5825e76 | ||
|
|
87a4999ffe | ||
|
|
4ee5fa2330 | ||
|
|
6128d61349 | ||
|
|
5e347f2e2e | ||
|
|
8eb126a304 | ||
|
|
3dc02420fe | ||
|
|
241a15a174 | ||
|
|
6b97c0a70f | ||
|
|
e952241e3c | ||
|
|
65f1c6edc7 | ||
|
|
40fe3fdb80 | ||
|
|
95ce5fb532 | ||
|
|
1a99d9830d | ||
|
|
6b83783381 | ||
|
|
460ec358ca | ||
|
|
1d14d3d7aa | ||
|
|
4357351ec8 | ||
|
|
4ab03953ae |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,4 +46,5 @@ Thumbs.db
|
|||||||
|
|
||||||
# Application data
|
# Application data
|
||||||
images/
|
images/
|
||||||
data/
|
data/
|
||||||
|
backend/cookies.txt
|
||||||
|
|||||||
122
README.md
122
README.md
@@ -161,43 +161,75 @@ cd backend
|
|||||||
|
|
||||||
## 📖 Documentation
|
## 📖 Documentation
|
||||||
|
|
||||||
- **[API Documentation](docs/API.md)**: Complete REST API reference with examples
|
- **[Technical Specification](storycove-spec.md)**: Complete technical specification with API documentation, data models, and all feature specifications
|
||||||
- **[Data Model](docs/DATA_MODEL.md)**: Detailed database schema and relationships
|
- **[Web Scraper Specification](storycove-scraper-spec.md)**: URL content grabbing functionality
|
||||||
- **[Technical Specification](storycove-spec.md)**: Comprehensive technical specification
|
|
||||||
- **Environment Configuration**: Multi-environment deployment setup (see above)
|
- **Environment Configuration**: Multi-environment deployment setup (see above)
|
||||||
- **Development Setup**: Local development environment setup (see below)
|
- **Development Setup**: Local development environment setup (see below)
|
||||||
|
|
||||||
|
> **Note**: All feature specifications (Collections, Tag Enhancements, EPUB Import/Export) have been consolidated into the main technical specification for easier maintenance and reference.
|
||||||
|
|
||||||
## 🗄️ Data Model
|
## 🗄️ Data Model
|
||||||
|
|
||||||
StoryCove uses a PostgreSQL database with the following core entities:
|
StoryCove uses a PostgreSQL database with the following core entities:
|
||||||
|
|
||||||
### **Stories**
|
### **Stories**
|
||||||
- **Primary Key**: UUID
|
- **Primary Key**: UUID
|
||||||
- **Fields**: title, summary, description, content_html, content_plain, source_url, word_count, rating, volume, cover_path, reading_position, last_read_at
|
- **Fields**: title, summary, description, content_html, content_plain, source_url, word_count, rating, volume, cover_path, is_read, reading_position, last_read_at, created_at, updated_at
|
||||||
- **Relationships**: Many-to-One with Author, Many-to-One with Series, Many-to-Many with Tags
|
- **Relationships**: Many-to-One with Author, Many-to-One with Series, Many-to-Many with Tags, One-to-Many with ReadingPositions
|
||||||
- **Features**: Automatic word count calculation, HTML sanitization, plain text extraction, reading progress tracking
|
- **Features**: Automatic word count calculation, HTML sanitization, plain text extraction, reading progress tracking, duplicate detection
|
||||||
|
|
||||||
### **Authors**
|
### **Authors**
|
||||||
- **Primary Key**: UUID
|
- **Primary Key**: UUID
|
||||||
- **Fields**: name, notes, author_rating, avatar_image_path
|
- **Fields**: name, notes, author_rating, avatar_image_path, created_at, updated_at
|
||||||
- **Relationships**: One-to-Many with Stories, One-to-Many with Author URLs
|
- **Relationships**: One-to-Many with Stories, One-to-Many with Author URLs (via @ElementCollection)
|
||||||
- **Features**: URL collection storage, rating system, statistics calculation
|
- **Features**: URL collection storage, rating system, statistics calculation, average story rating calculation
|
||||||
|
|
||||||
|
### **Collections**
|
||||||
|
- **Primary Key**: UUID
|
||||||
|
- **Fields**: name, description, rating, cover_image_path, is_archived, created_at, updated_at
|
||||||
|
- **Relationships**: Many-to-Many with Tags, One-to-Many with CollectionStories
|
||||||
|
- **Features**: Story ordering with gap-based positioning, statistics calculation, EPUB export, Typesense search
|
||||||
|
|
||||||
|
### **CollectionStories** (Junction Table)
|
||||||
|
- **Composite Key**: collection_id, story_id
|
||||||
|
- **Fields**: position, added_at
|
||||||
|
- **Relationships**: Links Collections to Stories with ordering
|
||||||
|
- **Features**: Gap-based positioning for efficient reordering
|
||||||
|
|
||||||
### **Series**
|
### **Series**
|
||||||
- **Primary Key**: UUID
|
- **Primary Key**: UUID
|
||||||
- **Fields**: name, description
|
- **Fields**: name, description, created_at
|
||||||
- **Relationships**: One-to-Many with Stories (ordered by volume)
|
- **Relationships**: One-to-Many with Stories (ordered by volume)
|
||||||
- **Features**: Volume-based story ordering, navigation methods
|
- **Features**: Volume-based story ordering, navigation methods (next/previous story)
|
||||||
|
|
||||||
### **Tags**
|
### **Tags**
|
||||||
- **Primary Key**: UUID
|
- **Primary Key**: UUID
|
||||||
- **Fields**: name (unique)
|
- **Fields**: name (unique), color (hex), description, created_at
|
||||||
- **Relationships**: Many-to-Many with Stories
|
- **Relationships**: Many-to-Many with Stories, Many-to-Many with Collections, One-to-Many with TagAliases
|
||||||
- **Features**: Autocomplete support, usage statistics
|
- **Features**: Color coding, alias system, autocomplete support, usage statistics, AI-powered suggestions
|
||||||
|
|
||||||
### **Join Tables**
|
### **TagAliases**
|
||||||
- **story_tags**: Links stories to tags
|
- **Primary Key**: UUID
|
||||||
- **author_urls**: Stores multiple URLs per author
|
- **Fields**: alias_name (unique), canonical_tag_id, created_from_merge, created_at
|
||||||
|
- **Relationships**: Many-to-One with Tag (canonical)
|
||||||
|
- **Features**: Transparent alias resolution, merge tracking, autocomplete integration
|
||||||
|
|
||||||
|
### **ReadingPositions**
|
||||||
|
- **Primary Key**: UUID
|
||||||
|
- **Fields**: story_id, chapter_index, chapter_title, word_position, character_position, percentage_complete, epub_cfi, context_before, context_after, created_at, updated_at
|
||||||
|
- **Relationships**: Many-to-One with Story
|
||||||
|
- **Features**: Advanced reading position tracking, EPUB CFI support, context preservation, percentage calculation
|
||||||
|
|
||||||
|
### **Libraries**
|
||||||
|
- **Primary Key**: UUID
|
||||||
|
- **Fields**: name, description, is_default, created_at, updated_at
|
||||||
|
- **Features**: Multi-library support, library switching functionality
|
||||||
|
|
||||||
|
### **Core Join Tables**
|
||||||
|
- **story_tags**: Links stories to tags (Many-to-Many)
|
||||||
|
- **collection_tags**: Links collections to tags (Many-to-Many)
|
||||||
|
- **collection_stories**: Links collections to stories with ordering
|
||||||
|
- **author_urls**: Stores multiple URLs per author (@ElementCollection)
|
||||||
|
|
||||||
## 🔌 REST API Reference
|
## 🔌 REST API Reference
|
||||||
|
|
||||||
@@ -209,6 +241,7 @@ StoryCove uses a PostgreSQL database with the following core entities:
|
|||||||
### **Stories** (`/api/stories`)
|
### **Stories** (`/api/stories`)
|
||||||
- `GET /` - List stories (paginated)
|
- `GET /` - List stories (paginated)
|
||||||
- `GET /{id}` - Get specific story
|
- `GET /{id}` - Get specific story
|
||||||
|
- `GET /{id}/read` - Get story for reading interface
|
||||||
- `POST /` - Create new story
|
- `POST /` - Create new story
|
||||||
- `PUT /{id}` - Update story
|
- `PUT /{id}` - Update story
|
||||||
- `DELETE /{id}` - Delete story
|
- `DELETE /{id}` - Delete story
|
||||||
@@ -218,6 +251,10 @@ StoryCove uses a PostgreSQL database with the following core entities:
|
|||||||
- `POST /{id}/tags/{tagId}` - Add tag to story
|
- `POST /{id}/tags/{tagId}` - Add tag to story
|
||||||
- `DELETE /{id}/tags/{tagId}` - Remove tag from story
|
- `DELETE /{id}/tags/{tagId}` - Remove tag from story
|
||||||
- `POST /{id}/reading-progress` - Update reading position
|
- `POST /{id}/reading-progress` - Update reading position
|
||||||
|
- `POST /{id}/reading-status` - Mark story as read/unread
|
||||||
|
- `GET /{id}/collections` - Get collections containing story
|
||||||
|
- `GET /random` - Get random story with optional filters
|
||||||
|
- `GET /check-duplicate` - Check for duplicate stories
|
||||||
- `GET /search` - Search stories (Typesense with faceting)
|
- `GET /search` - Search stories (Typesense with faceting)
|
||||||
- `GET /search/suggestions` - Get search suggestions
|
- `GET /search/suggestions` - Get search suggestions
|
||||||
- `GET /author/{authorId}` - Stories by author
|
- `GET /author/{authorId}` - Stories by author
|
||||||
@@ -225,6 +262,16 @@ StoryCove uses a PostgreSQL database with the following core entities:
|
|||||||
- `GET /tags/{tagName}` - Stories with tag
|
- `GET /tags/{tagName}` - Stories with tag
|
||||||
- `GET /recent` - Recent stories
|
- `GET /recent` - Recent stories
|
||||||
- `GET /top-rated` - Top-rated stories
|
- `GET /top-rated` - Top-rated stories
|
||||||
|
- `POST /batch/add-to-collection` - Add multiple stories to collection
|
||||||
|
- `POST /reindex` - Manual Typesense reindex
|
||||||
|
- `POST /reindex-typesense` - Reindex stories in Typesense
|
||||||
|
- `POST /recreate-typesense-collection` - Recreate Typesense collection
|
||||||
|
|
||||||
|
#### **EPUB Import/Export** (`/api/stories/epub`)
|
||||||
|
- `POST /import` - Import story from EPUB file
|
||||||
|
- `POST /export` - Export story as EPUB with options
|
||||||
|
- `GET /{id}/epub` - Export story as EPUB (simple)
|
||||||
|
- `POST /validate` - Validate EPUB file structure
|
||||||
|
|
||||||
### **Authors** (`/api/authors`)
|
### **Authors** (`/api/authors`)
|
||||||
- `GET /` - List authors (paginated)
|
- `GET /` - List authors (paginated)
|
||||||
@@ -244,14 +291,49 @@ StoryCove uses a PostgreSQL database with the following core entities:
|
|||||||
### **Tags** (`/api/tags`)
|
### **Tags** (`/api/tags`)
|
||||||
- `GET /` - List tags (paginated)
|
- `GET /` - List tags (paginated)
|
||||||
- `GET /{id}` - Get specific tag
|
- `GET /{id}` - Get specific tag
|
||||||
- `POST /` - Create new tag
|
- `POST /` - Create new tag (with color and description)
|
||||||
- `PUT /{id}` - Update tag
|
- `PUT /{id}` - Update tag (name, color, description)
|
||||||
- `DELETE /{id}` - Delete tag
|
- `DELETE /{id}` - Delete tag
|
||||||
- `GET /search` - Search tags
|
- `GET /search` - Search tags
|
||||||
- `GET /autocomplete` - Tag autocomplete
|
- `GET /autocomplete` - Tag autocomplete with alias resolution
|
||||||
- `GET /popular` - Most used tags
|
- `GET /popular` - Most used tags
|
||||||
- `GET /unused` - Unused tags
|
- `GET /unused` - Unused tags
|
||||||
- `GET /stats` - Tag statistics
|
- `GET /stats` - Tag statistics
|
||||||
|
- `GET /collections` - Tags used by collections
|
||||||
|
- `GET /resolve/{name}` - Resolve tag name (handles aliases)
|
||||||
|
|
||||||
|
#### **Tag Aliases** (`/api/tags/{tagId}/aliases`)
|
||||||
|
- `POST /` - Add alias to tag
|
||||||
|
- `DELETE /{aliasId}` - Remove alias from tag
|
||||||
|
|
||||||
|
#### **Tag Management**
|
||||||
|
- `POST /merge` - Merge multiple tags into one
|
||||||
|
- `POST /merge/preview` - Preview tag merge operation
|
||||||
|
- `POST /suggest` - AI-powered tag suggestions for content
|
||||||
|
|
||||||
|
### **Collections** (`/api/collections`)
|
||||||
|
- `GET /` - Search and list collections (Typesense)
|
||||||
|
- `GET /{id}` - Get collection details
|
||||||
|
- `POST /` - Create new collection (JSON or multipart)
|
||||||
|
- `PUT /{id}` - Update collection metadata
|
||||||
|
- `DELETE /{id}` - Delete collection
|
||||||
|
- `PUT /{id}/archive` - Archive/unarchive collection
|
||||||
|
- `POST /{id}/cover` - Upload collection cover image
|
||||||
|
- `DELETE /{id}/cover` - Remove collection cover image
|
||||||
|
- `GET /{id}/stats` - Get collection statistics
|
||||||
|
|
||||||
|
#### **Collection Story Management**
|
||||||
|
- `POST /{id}/stories` - Add stories to collection
|
||||||
|
- `DELETE /{id}/stories/{storyId}` - Remove story from collection
|
||||||
|
- `PUT /{id}/stories/order` - Reorder stories in collection
|
||||||
|
- `GET /{id}/read/{storyId}` - Get story with collection context
|
||||||
|
|
||||||
|
#### **Collection EPUB Export**
|
||||||
|
- `GET /{id}/epub` - Export collection as EPUB
|
||||||
|
- `POST /{id}/epub` - Export collection as EPUB with options
|
||||||
|
|
||||||
|
#### **Collection Management**
|
||||||
|
- `POST /reindex-typesense` - Reindex collections in Typesense
|
||||||
|
|
||||||
### **Series** (`/api/series`)
|
### **Series** (`/api/series`)
|
||||||
- `GET /` - List series (paginated)
|
- `GET /` - List series (paginated)
|
||||||
|
|||||||
305
TAG_ENHANCEMENT_SPECIFICATION.md
Normal file
305
TAG_ENHANCEMENT_SPECIFICATION.md
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
# Tag Enhancement Specification
|
||||||
|
|
||||||
|
> **✅ Implementation Status: COMPLETED**
|
||||||
|
> This feature has been fully implemented and is available in the system.
|
||||||
|
> All tag enhancements including colors, aliases, merging, and AI suggestions are working.
|
||||||
|
> Last updated: January 2025
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the comprehensive enhancement of the tagging functionality in StoryCove, including color tags, tag deletion, merging, and aliases. These features will be accessible through a new "Tag Maintenance" page linked from the Settings page.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Color Tags
|
||||||
|
|
||||||
|
**Purpose**: Assign optional colors to tags for visual distinction and better organization.
|
||||||
|
|
||||||
|
**Implementation Details**:
|
||||||
|
- **Color Selection**: Predefined color palette that complements the app's theme
|
||||||
|
- **Custom Colors**: Fallback option with full color picker for advanced users
|
||||||
|
- **Default Behavior**: Tags without colors use consistent default styling
|
||||||
|
- **Accessibility**: All colors ensure sufficient contrast ratios
|
||||||
|
|
||||||
|
**UI Design**:
|
||||||
|
```
|
||||||
|
Color Selection Interface:
|
||||||
|
[Theme Blue] [Theme Green] [Theme Purple] [Theme Orange] ... [Custom ▼]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database Changes**:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tags ADD COLUMN color VARCHAR(7); -- hex colors like #3B82F6
|
||||||
|
ALTER TABLE tags ADD COLUMN description TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tag Deletion
|
||||||
|
|
||||||
|
**Purpose**: Remove unused or unwanted tags from the system.
|
||||||
|
|
||||||
|
**Safety Features**:
|
||||||
|
- Show impact: "This tag is used by X stories"
|
||||||
|
- Confirmation dialog with story count
|
||||||
|
- Option to reassign stories to different tag before deletion
|
||||||
|
- Simple workflow appropriate for single-user application
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- Display number of affected stories
|
||||||
|
- Require confirmation for deletion
|
||||||
|
- Optionally allow reassignment to another tag
|
||||||
|
|
||||||
|
### 3. Tag Merging
|
||||||
|
|
||||||
|
**Purpose**: Combine similar tags into a single canonical tag to reduce duplication.
|
||||||
|
|
||||||
|
**Workflow**:
|
||||||
|
1. User selects multiple tags to merge
|
||||||
|
2. User chooses which tag name becomes canonical
|
||||||
|
3. System shows merge preview with story counts
|
||||||
|
4. All story associations transfer to canonical tag
|
||||||
|
5. **Automatic Aliasing**: Merged tags automatically become aliases
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
Merge Preview:
|
||||||
|
• "magictf" (5 stories) → "magic tf" (12 stories)
|
||||||
|
• Result: "magic tf" (17 stories)
|
||||||
|
• "magictf" will become an alias for "magic tf"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Technical Implementation**:
|
||||||
|
```sql
|
||||||
|
-- Merge operation (atomic transaction)
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
UPDATE story_tags SET tag_id = target_tag_id WHERE tag_id = source_tag_id;
|
||||||
|
INSERT INTO tag_aliases (alias_name, canonical_tag_id, created_from_merge)
|
||||||
|
VALUES (source_tag_name, target_tag_id, TRUE);
|
||||||
|
DELETE FROM tags WHERE id = source_tag_id;
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Tag Aliases
|
||||||
|
|
||||||
|
**Purpose**: Prevent tag duplication by allowing alternative names that resolve to canonical tags.
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- **Transparent Resolution**: Users type "magictf" and automatically get "magic tf"
|
||||||
|
- **Hover Display**: Show aliases when hovering over tags
|
||||||
|
- **Import Integration**: Automatic alias resolution during story imports
|
||||||
|
- **Auto-Generation**: Created automatically during tag merges
|
||||||
|
|
||||||
|
**Database Schema**:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tag_aliases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
alias_name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
canonical_tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_from_merge BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tag_aliases_name ON tag_aliases(alias_name);
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Behavior**:
|
||||||
|
- Tags with aliases show subtle indicator (e.g., small "+" icon)
|
||||||
|
- Hover tooltip displays:
|
||||||
|
```
|
||||||
|
magic tf
|
||||||
|
────────────
|
||||||
|
Aliases: magictf, magic_tf, magic-transformation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tag Maintenance Page
|
||||||
|
|
||||||
|
### Access
|
||||||
|
- Reachable only through Settings page
|
||||||
|
- Button: "Tag Maintenance" or "Manage Tags"
|
||||||
|
|
||||||
|
### Main Interface
|
||||||
|
|
||||||
|
**Tag Management Table**:
|
||||||
|
```
|
||||||
|
┌─ Search: [____________] [Color Filter ▼] [Sort: Usage ▼]
|
||||||
|
├─
|
||||||
|
├─ ☐ magic tf 🔵 (17 stories) [+2 aliases] [Edit] [Delete]
|
||||||
|
├─ ☐ transformation 🟢 (34 stories) [+1 alias] [Edit] [Delete]
|
||||||
|
├─ ☐ sci-fi 🟣 (45 stories) [Edit] [Delete]
|
||||||
|
└─
|
||||||
|
[Merge Selected] [Bulk Delete] [Export/Import Tags]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Searchable and filterable tag list
|
||||||
|
- Sortable by name, usage count, creation date
|
||||||
|
- Bulk selection for merge/delete operations
|
||||||
|
- Visual indicators for color and alias count
|
||||||
|
|
||||||
|
### Tag Edit Modal
|
||||||
|
|
||||||
|
```
|
||||||
|
Edit Tag: "magic tf"
|
||||||
|
┌─ Name: [magic tf ]
|
||||||
|
├─ Color: [🔵] [Theme Colors...] [Custom...]
|
||||||
|
├─ Description: [Optional description]
|
||||||
|
├─
|
||||||
|
├─ Aliases (2):
|
||||||
|
│ • magictf [Remove]
|
||||||
|
│ • magic_tf [Remove]
|
||||||
|
│ [Add Alias: ____________] [Add]
|
||||||
|
├─
|
||||||
|
├─ Used by 17 stories [View Stories]
|
||||||
|
└─ [Save] [Cancel]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Functionality**:
|
||||||
|
- Edit tag name, color, and description
|
||||||
|
- Manage aliases (add/remove)
|
||||||
|
- View associated stories
|
||||||
|
- Prevent circular alias references
|
||||||
|
|
||||||
|
### Merge Interface
|
||||||
|
|
||||||
|
**Selection Process**:
|
||||||
|
1. Select multiple tags from main table
|
||||||
|
2. Click "Merge Selected"
|
||||||
|
3. Choose canonical tag name
|
||||||
|
4. Preview merge results
|
||||||
|
5. Confirm operation
|
||||||
|
|
||||||
|
**Preview Display**:
|
||||||
|
- Show before/after story counts
|
||||||
|
- List all aliases that will be created
|
||||||
|
- Highlight any conflicts or issues
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### 1. Import/Scraping Enhancement
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Tag resolution during imports
|
||||||
|
const resolveTagName = async (inputTag) => {
|
||||||
|
const alias = await tagApi.findAlias(inputTag);
|
||||||
|
return alias ? alias.canonicalTag : inputTag;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tag Input Components
|
||||||
|
|
||||||
|
**Enhanced Autocomplete**:
|
||||||
|
- Include both canonical names and aliases in suggestions
|
||||||
|
- Show resolution: "magictf → magic tf" in dropdown
|
||||||
|
- Always save canonical name to database
|
||||||
|
|
||||||
|
### 3. Search Functionality
|
||||||
|
|
||||||
|
**Transparent Alias Search**:
|
||||||
|
- Search for "magictf" includes stories tagged with "magic tf"
|
||||||
|
- User doesn't need to know about canonical/alias distinction
|
||||||
|
- Expand search queries to include all aliases
|
||||||
|
|
||||||
|
### 4. Display Components
|
||||||
|
|
||||||
|
**Tag Rendering**:
|
||||||
|
- Apply colors consistently across all tag displays
|
||||||
|
- Show alias indicator where appropriate
|
||||||
|
- Implement hover tooltips for alias information
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure
|
||||||
|
- [ ] Database schema updates (tags.color, tag_aliases table)
|
||||||
|
- [ ] Basic tag editing functionality (name, color, description)
|
||||||
|
- [ ] Color palette component with theme colors
|
||||||
|
- [ ] Tag edit modal interface
|
||||||
|
|
||||||
|
### Phase 2: Merging & Aliasing
|
||||||
|
- [ ] Tag merge functionality with automatic alias creation
|
||||||
|
- [ ] Alias resolution in import/scraping logic
|
||||||
|
- [ ] Tag input component enhancements
|
||||||
|
- [ ] Search integration with alias expansion
|
||||||
|
|
||||||
|
### Phase 3: UI Polish & Advanced Features
|
||||||
|
- [ ] Hover tooltips for alias display
|
||||||
|
- [ ] Bulk operations (merge multiple, bulk delete)
|
||||||
|
- [ ] Advanced filtering and sorting options
|
||||||
|
- [ ] Tag maintenance page integration with Settings
|
||||||
|
|
||||||
|
### Phase 4: Smart Features (Optional)
|
||||||
|
- [ ] Auto-merge suggestions for similar tag names
|
||||||
|
- [ ] Color auto-assignment based on usage patterns
|
||||||
|
- [ ] Import intelligence and learning from user decisions
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Index alias names for fast lookup during imports
|
||||||
|
- Optimize tag queries with proper database indexing
|
||||||
|
- Consider caching for frequently accessed tag/alias mappings
|
||||||
|
|
||||||
|
### Data Integrity
|
||||||
|
- Prevent circular alias references
|
||||||
|
- Atomic transactions for merge operations
|
||||||
|
- Cascade deletion handling for tag relationships
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- Clear visual feedback for all operations
|
||||||
|
- Comprehensive preview before destructive actions
|
||||||
|
- Consistent color and styling across the application
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
- Sufficient color contrast for all tag colors
|
||||||
|
- Keyboard navigation support
|
||||||
|
- Screen reader compatibility
|
||||||
|
- Don't rely solely on color for information
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### New Endpoints Needed
|
||||||
|
- `GET /api/tags/{id}/aliases` - Get aliases for a tag
|
||||||
|
- `POST /api/tags/merge` - Merge multiple tags
|
||||||
|
- `POST /api/tags/{id}/aliases` - Add alias to tag
|
||||||
|
- `DELETE /api/tags/{id}/aliases/{aliasId}` - Remove alias
|
||||||
|
- `PUT /api/tags/{id}/color` - Update tag color
|
||||||
|
- `GET /api/tags/resolve/{name}` - Resolve tag name (check aliases)
|
||||||
|
|
||||||
|
### Enhanced Endpoints
|
||||||
|
- `GET /api/tags` - Include color and alias count in response
|
||||||
|
- `PUT /api/tags/{id}` - Support color and description updates
|
||||||
|
- `DELETE /api/tags/{id}` - Enhanced with story impact information
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Theme Color Palette
|
||||||
|
Define a curated set of colors that work well with both light and dark themes:
|
||||||
|
- Primary blues: #3B82F6, #1D4ED8, #60A5FA
|
||||||
|
- Greens: #10B981, #059669, #34D399
|
||||||
|
- Purples: #8B5CF6, #7C3AED, #A78BFA
|
||||||
|
- Warm tones: #F59E0B, #D97706, #F97316
|
||||||
|
- Neutrals: #6B7280, #4B5563, #9CA3AF
|
||||||
|
|
||||||
|
### Settings Integration
|
||||||
|
- Add "Tag Maintenance" button to Settings page
|
||||||
|
- Consider adding tag-related preferences (default colors, etc.)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. **Color Tags**: Tags can be assigned colors that display consistently throughout the application
|
||||||
|
2. **Tag Deletion**: Users can safely delete tags with appropriate warnings and reassignment options
|
||||||
|
3. **Tag Merging**: Similar tags can be merged with automatic alias creation
|
||||||
|
4. **Alias Resolution**: Imports automatically resolve aliases to canonical tags
|
||||||
|
5. **User Experience**: All operations are intuitive with clear feedback and preview options
|
||||||
|
6. **Performance**: Tag operations remain fast even with large numbers of tags and aliases
|
||||||
|
7. **Data Integrity**: No orphaned references or circular alias chains
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Tag Statistics**: Usage analytics and trends
|
||||||
|
- **Tag Recommendations**: AI-powered tag suggestions during story import
|
||||||
|
- **Tag Templates**: Predefined tag sets for common story types
|
||||||
|
- **Export/Import**: Backup and restore tag configurations
|
||||||
|
- **Tag Validation**: Rules for tag naming conventions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This specification serves as the definitive guide for implementing the tag enhancement features in StoryCove. All implementation should refer back to this document to ensure consistency and completeness.*
|
||||||
@@ -2,15 +2,15 @@ FROM openjdk:17-jdk-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pom.xml .
|
# Install Maven
|
||||||
COPY src ./src
|
RUN apt-get update && apt-get install -y maven && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y maven && \
|
# Copy source code
|
||||||
mvn clean package -DskipTests && \
|
COPY . .
|
||||||
apt-get remove -y maven && \
|
|
||||||
apt-get autoremove -y && \
|
# Build the application
|
||||||
rm -rf /var/lib/apt/lists/*
|
RUN mvn clean package -DskipTests
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["java", "-jar", "target/storycove-backend-0.0.1-SNAPSHOT.jar"]
|
ENTRYPOINT ["java", "-jar", "target/storycove-backend-0.0.1-SNAPSHOT.jar"]
|
||||||
4
backend/cookies_new.txt
Normal file
4
backend/cookies_new.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.2.0</version>
|
<version>3.5.5</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<testcontainers.version>1.19.3</testcontainers.version>
|
<testcontainers.version>1.21.3</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -56,18 +56,18 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-api</artifactId>
|
<artifactId>jjwt-api</artifactId>
|
||||||
<version>0.12.3</version>
|
<version>0.13.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-impl</artifactId>
|
<artifactId>jjwt-impl</artifactId>
|
||||||
<version>0.12.3</version>
|
<version>0.13.0</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-jackson</artifactId>
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
<version>0.12.3</version>
|
<version>0.13.0</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.storycove.config;
|
||||||
|
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.DependsOn;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database configuration that sets up library-aware datasource routing.
|
||||||
|
*
|
||||||
|
* This configuration replaces the default Spring Boot datasource with a routing
|
||||||
|
* datasource that automatically directs all database operations to the appropriate
|
||||||
|
* library-specific database based on the current active library.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class DatabaseConfig {
|
||||||
|
|
||||||
|
@Value("${spring.datasource.url}")
|
||||||
|
private String baseDbUrl;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.username}")
|
||||||
|
private String dbUsername;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.password}")
|
||||||
|
private String dbPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fallback datasource for when no library is active.
|
||||||
|
* This connects to the main database specified in application.yml.
|
||||||
|
*/
|
||||||
|
@Bean(name = "fallbackDataSource")
|
||||||
|
public DataSource fallbackDataSource() {
|
||||||
|
HikariConfig config = new HikariConfig();
|
||||||
|
config.setJdbcUrl(baseDbUrl);
|
||||||
|
config.setUsername(dbUsername);
|
||||||
|
config.setPassword(dbPassword);
|
||||||
|
config.setDriverClassName("org.postgresql.Driver");
|
||||||
|
config.setMaximumPoolSize(10);
|
||||||
|
config.setConnectionTimeout(30000);
|
||||||
|
|
||||||
|
return new HikariDataSource(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary datasource bean - uses smart routing that excludes authentication operations
|
||||||
|
*/
|
||||||
|
@Bean(name = "dataSource")
|
||||||
|
@Primary
|
||||||
|
@DependsOn("libraryService")
|
||||||
|
public DataSource primaryDataSource(LibraryService libraryService) {
|
||||||
|
SmartRoutingDataSource routingDataSource = new SmartRoutingDataSource(
|
||||||
|
libraryService, baseDbUrl, dbUsername, dbPassword);
|
||||||
|
routingDataSource.setDefaultTargetDataSource(fallbackDataSource());
|
||||||
|
routingDataSource.setTargetDataSources(new java.util.HashMap<>());
|
||||||
|
return routingDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.storycove.config;
|
||||||
|
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom DataSource router that dynamically routes database calls to the appropriate
|
||||||
|
* library-specific datasource based on the current active library.
|
||||||
|
*
|
||||||
|
* This makes ALL Spring Data JPA repositories automatically library-aware without
|
||||||
|
* requiring changes to existing repository or service code.
|
||||||
|
*/
|
||||||
|
public class LibraryAwareDataSource extends AbstractRoutingDataSource {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LibraryAwareDataSource.class);
|
||||||
|
|
||||||
|
private final LibraryService libraryService;
|
||||||
|
|
||||||
|
public LibraryAwareDataSource(LibraryService libraryService) {
|
||||||
|
this.libraryService = libraryService;
|
||||||
|
// Set empty target datasources to satisfy AbstractRoutingDataSource requirements
|
||||||
|
// We override determineTargetDataSource() so this won't be used
|
||||||
|
setTargetDataSources(new java.util.HashMap<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Object determineCurrentLookupKey() {
|
||||||
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
|
logger.debug("Routing database call to library: {}", currentLibraryId);
|
||||||
|
return currentLibraryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected javax.sql.DataSource determineTargetDataSource() {
|
||||||
|
try {
|
||||||
|
// Check if LibraryService is properly initialized
|
||||||
|
if (libraryService == null) {
|
||||||
|
logger.debug("LibraryService not available, using default datasource");
|
||||||
|
return getResolvedDefaultDataSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any library is currently active
|
||||||
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
|
if (currentLibraryId == null) {
|
||||||
|
logger.debug("No active library, using default datasource");
|
||||||
|
return getResolvedDefaultDataSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the current library datasource
|
||||||
|
javax.sql.DataSource libraryDataSource = libraryService.getCurrentDataSource();
|
||||||
|
logger.debug("Successfully routing database call to library: {}", currentLibraryId);
|
||||||
|
return libraryDataSource;
|
||||||
|
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
// This is expected during authentication, startup, or when no library is active
|
||||||
|
logger.debug("No active library (IllegalStateException) - using default datasource: {}", e.getMessage());
|
||||||
|
return getResolvedDefaultDataSource();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Unexpected error determining target datasource, falling back to default: {}", e.getMessage(), e);
|
||||||
|
return getResolvedDefaultDataSource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.storycove.config;
|
||||||
|
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart routing datasource that:
|
||||||
|
* 1. Routes to library-specific databases when a library is active
|
||||||
|
* 2. Excludes authentication operations (keeps them on default database)
|
||||||
|
* 3. Uses request context to determine when routing is appropriate
|
||||||
|
*/
|
||||||
|
public class SmartRoutingDataSource extends AbstractRoutingDataSource {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SmartRoutingDataSource.class);
|
||||||
|
|
||||||
|
private final LibraryService libraryService;
|
||||||
|
private final Map<String, DataSource> libraryDataSources = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Database connection details - will be injected via constructor
|
||||||
|
private final String baseDbUrl;
|
||||||
|
private final String dbUsername;
|
||||||
|
private final String dbPassword;
|
||||||
|
|
||||||
|
public SmartRoutingDataSource(LibraryService libraryService, String baseDbUrl, String dbUsername, String dbPassword) {
|
||||||
|
this.libraryService = libraryService;
|
||||||
|
this.baseDbUrl = baseDbUrl;
|
||||||
|
this.dbUsername = dbUsername;
|
||||||
|
this.dbPassword = dbPassword;
|
||||||
|
|
||||||
|
logger.info("SmartRoutingDataSource initialized with database: {}", baseDbUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Object determineCurrentLookupKey() {
|
||||||
|
try {
|
||||||
|
// Check if this is an authentication request - if so, use default database
|
||||||
|
if (isAuthenticationRequest()) {
|
||||||
|
logger.debug("Authentication request detected, using default database");
|
||||||
|
return null; // null means use default datasource
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have an active library
|
||||||
|
if (libraryService != null) {
|
||||||
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
|
if (currentLibraryId != null && !currentLibraryId.trim().isEmpty()) {
|
||||||
|
logger.info("ROUTING: Directing to library-specific database: {}", currentLibraryId);
|
||||||
|
return currentLibraryId;
|
||||||
|
} else {
|
||||||
|
logger.info("ROUTING: No active library, using default database");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("ROUTING: LibraryService is null, using default database");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Error determining lookup key, falling back to default database", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Use default datasource
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current request is an authentication request that should use the default database
|
||||||
|
*/
|
||||||
|
private boolean isAuthenticationRequest() {
|
||||||
|
try {
|
||||||
|
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
if (attributes != null) {
|
||||||
|
String requestURI = attributes.getRequest().getRequestURI();
|
||||||
|
String method = attributes.getRequest().getMethod();
|
||||||
|
|
||||||
|
// Authentication endpoints that should use default database
|
||||||
|
if (requestURI.contains("/auth/") ||
|
||||||
|
requestURI.contains("/login") ||
|
||||||
|
requestURI.contains("/api/libraries/switch") ||
|
||||||
|
(requestURI.contains("/api/libraries") && "POST".equals(method))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Could not determine request context", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected DataSource determineTargetDataSource() {
|
||||||
|
Object lookupKey = determineCurrentLookupKey();
|
||||||
|
|
||||||
|
if (lookupKey != null) {
|
||||||
|
String libraryId = (String) lookupKey;
|
||||||
|
return getLibraryDataSource(libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDefaultDataSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a datasource for the specified library
|
||||||
|
*/
|
||||||
|
private DataSource getLibraryDataSource(String libraryId) {
|
||||||
|
return libraryDataSources.computeIfAbsent(libraryId, id -> {
|
||||||
|
try {
|
||||||
|
HikariConfig config = new HikariConfig();
|
||||||
|
|
||||||
|
// Replace database name in URL with library-specific name
|
||||||
|
String libraryUrl = baseDbUrl.replaceAll("/[^/]*$", "/" + "storycove_" + id);
|
||||||
|
|
||||||
|
config.setJdbcUrl(libraryUrl);
|
||||||
|
config.setUsername(dbUsername);
|
||||||
|
config.setPassword(dbPassword);
|
||||||
|
config.setDriverClassName("org.postgresql.Driver");
|
||||||
|
config.setMaximumPoolSize(5); // Smaller pool for library-specific databases
|
||||||
|
config.setConnectionTimeout(10000);
|
||||||
|
config.setMaxLifetime(600000); // 10 minutes
|
||||||
|
|
||||||
|
logger.info("Created new datasource for library: {} -> {}", id, libraryUrl);
|
||||||
|
return new HikariDataSource(config);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to create datasource for library: {}", id, e);
|
||||||
|
return getDefaultDataSource();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private DataSource getDefaultDataSource() {
|
||||||
|
// Use the default target datasource that was set in the configuration
|
||||||
|
try {
|
||||||
|
return (DataSource) super.determineTargetDataSource();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Could not get default datasource via super method", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: create a basic datasource
|
||||||
|
logger.warn("No default datasource available, creating fallback");
|
||||||
|
HikariConfig config = new HikariConfig();
|
||||||
|
config.setJdbcUrl(baseDbUrl);
|
||||||
|
config.setUsername(dbUsername);
|
||||||
|
config.setPassword(dbPassword);
|
||||||
|
config.setDriverClassName("org.postgresql.Driver");
|
||||||
|
config.setMaximumPoolSize(10);
|
||||||
|
config.setConnectionTimeout(30000);
|
||||||
|
return new HikariDataSource(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
import com.storycove.service.PasswordAuthenticationService;
|
import com.storycove.service.PasswordAuthenticationService;
|
||||||
import com.storycove.util.JwtUtil;
|
import com.storycove.util.JwtUtil;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@@ -18,18 +19,21 @@ import java.time.Duration;
|
|||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final PasswordAuthenticationService passwordService;
|
private final PasswordAuthenticationService passwordService;
|
||||||
|
private final LibraryService libraryService;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
public AuthController(PasswordAuthenticationService passwordService, JwtUtil jwtUtil) {
|
public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil) {
|
||||||
this.passwordService = passwordService;
|
this.passwordService = passwordService;
|
||||||
|
this.libraryService = libraryService;
|
||||||
this.jwtUtil = jwtUtil;
|
this.jwtUtil = jwtUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
|
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
|
||||||
if (passwordService.authenticate(request.getPassword())) {
|
// Use new library-aware authentication
|
||||||
String token = jwtUtil.generateToken();
|
String token = passwordService.authenticateAndSwitchLibrary(request.getPassword());
|
||||||
|
|
||||||
|
if (token != null) {
|
||||||
// Set httpOnly cookie
|
// Set httpOnly cookie
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", token)
|
ResponseCookie cookie = ResponseCookie.from("token", token)
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
@@ -40,7 +44,8 @@ public class AuthController {
|
|||||||
|
|
||||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||||
|
|
||||||
return ResponseEntity.ok(new LoginResponse("Authentication successful", token));
|
String libraryInfo = passwordService.getCurrentLibraryInfo();
|
||||||
|
return ResponseEntity.ok(new LoginResponse("Authentication successful - " + libraryInfo, token));
|
||||||
} else {
|
} else {
|
||||||
return ResponseEntity.status(401).body(new ErrorResponse("Invalid password"));
|
return ResponseEntity.status(401).body(new ErrorResponse("Invalid password"));
|
||||||
}
|
}
|
||||||
@@ -48,6 +53,9 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||||
|
// Clear authentication state
|
||||||
|
libraryService.clearAuthentication();
|
||||||
|
|
||||||
// Clear the cookie
|
// Clear the cookie
|
||||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
ResponseCookie cookie = ResponseCookie.from("token", "")
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
|
|||||||
@@ -335,6 +335,44 @@ public class AuthorController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/clean-author-names")
|
||||||
|
public ResponseEntity<Map<String, Object>> cleanAuthorNames() {
|
||||||
|
try {
|
||||||
|
List<Author> allAuthors = authorService.findAllWithStories();
|
||||||
|
int cleanedCount = 0;
|
||||||
|
|
||||||
|
for (Author author : allAuthors) {
|
||||||
|
String originalName = author.getName();
|
||||||
|
String cleanedName = originalName != null ? originalName.trim() : "";
|
||||||
|
|
||||||
|
if (!cleanedName.equals(originalName)) {
|
||||||
|
logger.info("Cleaning author name: '{}' -> '{}'", originalName, cleanedName);
|
||||||
|
author.setName(cleanedName);
|
||||||
|
authorService.update(author.getId(), author);
|
||||||
|
cleanedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex all authors after cleaning
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
typesenseService.reindexAllAuthors(allAuthors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Cleaned " + cleanedCount + " author names and reindexed",
|
||||||
|
"cleanedCount", cleanedCount,
|
||||||
|
"totalAuthors", allAuthors.size()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to clean author names", e);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/top-rated")
|
@GetMapping("/top-rated")
|
||||||
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||||
Pageable pageable = PageRequest.of(0, limit);
|
Pageable pageable = PageRequest.of(0, limit);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.service.ImageService;
|
import com.storycove.service.ImageService;
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -10,6 +11,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -21,9 +23,17 @@ import java.util.Map;
|
|||||||
public class FileController {
|
public class FileController {
|
||||||
|
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
|
private final LibraryService libraryService;
|
||||||
|
|
||||||
public FileController(ImageService imageService) {
|
public FileController(ImageService imageService, LibraryService libraryService) {
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
|
this.libraryService = libraryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getCurrentLibraryId() {
|
||||||
|
String libraryId = libraryService.getCurrentLibraryId();
|
||||||
|
System.out.println("FileController - Current Library ID: " + libraryId);
|
||||||
|
return libraryId != null ? libraryId : "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/upload/cover")
|
@PostMapping("/upload/cover")
|
||||||
@@ -34,7 +44,11 @@ public class FileController {
|
|||||||
Map<String, String> response = new HashMap<>();
|
Map<String, String> response = new HashMap<>();
|
||||||
response.put("message", "Cover uploaded successfully");
|
response.put("message", "Cover uploaded successfully");
|
||||||
response.put("path", imagePath);
|
response.put("path", imagePath);
|
||||||
response.put("url", "/api/files/images/" + imagePath);
|
String currentLibraryId = getCurrentLibraryId();
|
||||||
|
String imageUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
|
||||||
|
response.put("url", imageUrl);
|
||||||
|
|
||||||
|
System.out.println("Upload response - path: " + imagePath + ", url: " + imageUrl);
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -53,7 +67,8 @@ public class FileController {
|
|||||||
Map<String, String> response = new HashMap<>();
|
Map<String, String> response = new HashMap<>();
|
||||||
response.put("message", "Avatar uploaded successfully");
|
response.put("message", "Avatar uploaded successfully");
|
||||||
response.put("path", imagePath);
|
response.put("path", imagePath);
|
||||||
response.put("url", "/api/files/images/" + imagePath);
|
String currentLibraryId = getCurrentLibraryId();
|
||||||
|
response.put("url", "/api/files/images/" + currentLibraryId + "/" + imagePath);
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -64,17 +79,18 @@ public class FileController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/images/**")
|
@GetMapping("/images/{libraryId}/**")
|
||||||
public ResponseEntity<Resource> serveImage(@RequestParam String path) {
|
public ResponseEntity<Resource> serveImage(@PathVariable String libraryId, HttpServletRequest request) {
|
||||||
try {
|
try {
|
||||||
// Extract path from the URL
|
// Extract the full request path after /api/files/images/{libraryId}/
|
||||||
String imagePath = path.replace("/api/files/images/", "");
|
String requestURI = request.getRequestURI();
|
||||||
|
String imagePath = requestURI.replaceFirst(".*/api/files/images/" + libraryId + "/", "");
|
||||||
|
|
||||||
if (!imageService.imageExists(imagePath)) {
|
if (!imageService.imageExistsInLibrary(imagePath, libraryId)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
Path fullPath = imageService.getImagePath(imagePath);
|
Path fullPath = imageService.getImagePathInLibrary(imagePath, libraryId);
|
||||||
Resource resource = new FileSystemResource(fullPath);
|
Resource resource = new FileSystemResource(fullPath);
|
||||||
|
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package com.storycove.controller;
|
||||||
|
|
||||||
|
import com.storycove.dto.LibraryDto;
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/libraries")
|
||||||
|
public class LibraryController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LibraryController.class);
|
||||||
|
|
||||||
|
private final LibraryService libraryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public LibraryController(LibraryService libraryService) {
|
||||||
|
this.libraryService = libraryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available libraries (for settings UI)
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<LibraryDto>> getAllLibraries() {
|
||||||
|
try {
|
||||||
|
List<LibraryDto> libraries = libraryService.getAllLibraries();
|
||||||
|
return ResponseEntity.ok(libraries);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get libraries", e);
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current active library info
|
||||||
|
*/
|
||||||
|
@GetMapping("/current")
|
||||||
|
public ResponseEntity<LibraryDto> getCurrentLibrary() {
|
||||||
|
try {
|
||||||
|
var library = libraryService.getCurrentLibrary();
|
||||||
|
if (library == null) {
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
LibraryDto dto = new LibraryDto(
|
||||||
|
library.getId(),
|
||||||
|
library.getName(),
|
||||||
|
library.getDescription(),
|
||||||
|
true, // always active since it's current
|
||||||
|
library.isInitialized()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(dto);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get current library", e);
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different library (requires re-authentication)
|
||||||
|
* This endpoint returns a switching status that the frontend can poll
|
||||||
|
*/
|
||||||
|
@PostMapping("/switch")
|
||||||
|
public ResponseEntity<Map<String, Object>> initiateLibrarySwitch(@RequestBody Map<String, String> request) {
|
||||||
|
try {
|
||||||
|
String password = request.get("password");
|
||||||
|
if (password == null || password.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Password required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String libraryId = libraryService.authenticateAndGetLibrary(password);
|
||||||
|
if (libraryId == null) {
|
||||||
|
return ResponseEntity.status(401).body(Map.of("error", "Invalid password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already on this library
|
||||||
|
if (libraryId.equals(libraryService.getCurrentLibraryId())) {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", "already_active",
|
||||||
|
"message", "Already using this library"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initiate switch in background thread
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
libraryService.switchToLibrary(libraryId);
|
||||||
|
logger.info("Library switch completed: {}", libraryId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Library switch failed: {}", libraryId, e);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"status", "switching",
|
||||||
|
"targetLibrary", libraryId,
|
||||||
|
"message", "Switching to library, please wait..."
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to initiate library switch", e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", "Server error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check library switch status
|
||||||
|
*/
|
||||||
|
@GetMapping("/switch/status")
|
||||||
|
public ResponseEntity<Map<String, Object>> getLibrarySwitchStatus() {
|
||||||
|
try {
|
||||||
|
var currentLibrary = libraryService.getCurrentLibrary();
|
||||||
|
boolean isReady = currentLibrary != null;
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("ready", isReady);
|
||||||
|
if (isReady) {
|
||||||
|
response.put("currentLibrary", currentLibrary.getId());
|
||||||
|
response.put("currentLibraryName", currentLibrary.getName());
|
||||||
|
} else {
|
||||||
|
response.put("currentLibrary", null);
|
||||||
|
response.put("currentLibraryName", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to get switch status", e);
|
||||||
|
return ResponseEntity.ok(Map.of("ready", false, "error", "Status check failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password for current library
|
||||||
|
*/
|
||||||
|
@PostMapping("/password")
|
||||||
|
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> request) {
|
||||||
|
try {
|
||||||
|
String currentPassword = request.get("currentPassword");
|
||||||
|
String newPassword = request.get("newPassword");
|
||||||
|
|
||||||
|
if (currentPassword == null || newPassword == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Current and new passwords required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
|
if (currentLibraryId == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "No active library"));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean success = libraryService.changeLibraryPassword(currentLibraryId, currentPassword, newPassword);
|
||||||
|
if (success) {
|
||||||
|
return ResponseEntity.ok(Map.of("success", true, "message", "Password changed successfully"));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Current password is incorrect"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to change password", e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", "Server error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new library
|
||||||
|
*/
|
||||||
|
@PostMapping("/create")
|
||||||
|
public ResponseEntity<Map<String, Object>> createLibrary(@RequestBody Map<String, String> request) {
|
||||||
|
try {
|
||||||
|
String name = request.get("name");
|
||||||
|
String description = request.get("description");
|
||||||
|
String password = request.get("password");
|
||||||
|
|
||||||
|
if (name == null || name.trim().isEmpty() || password == null || password.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Name and password are required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var newLibrary = libraryService.createNewLibrary(name.trim(), description, password);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"library", Map.of(
|
||||||
|
"id", newLibrary.getId(),
|
||||||
|
"name", newLibrary.getName(),
|
||||||
|
"description", newLibrary.getDescription()
|
||||||
|
),
|
||||||
|
"message", "Library created successfully. You can now log in with the new password to access it."
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to create library", e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", "Server error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update library metadata (name and description)
|
||||||
|
*/
|
||||||
|
@PutMapping("/{libraryId}/metadata")
|
||||||
|
public ResponseEntity<Map<String, Object>> updateLibraryMetadata(
|
||||||
|
@PathVariable String libraryId,
|
||||||
|
@RequestBody Map<String, String> updates) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
String newName = updates.get("name");
|
||||||
|
String newDescription = updates.get("description");
|
||||||
|
|
||||||
|
if (newName == null || newName.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "Library name is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the library
|
||||||
|
libraryService.updateLibraryMetadata(libraryId, newName, newDescription);
|
||||||
|
|
||||||
|
// Return updated library info
|
||||||
|
LibraryDto updatedLibrary = libraryService.getLibraryById(libraryId);
|
||||||
|
if (updatedLibrary != null) {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "Library metadata updated successfully");
|
||||||
|
response.put("library", updatedLibrary);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to update library metadata for {}: {}", libraryId, e.getMessage(), e);
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of("error", "Failed to update library metadata"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
@@ -25,6 +26,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -84,6 +86,46 @@ public class StoryController {
|
|||||||
return ResponseEntity.ok(storyDtos);
|
return ResponseEntity.ok(storyDtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/random")
|
||||||
|
public ResponseEntity<StorySummaryDto> getRandomStory(
|
||||||
|
@RequestParam(required = false) String searchQuery,
|
||||||
|
@RequestParam(required = false) List<String> tags,
|
||||||
|
@RequestParam(required = false) Long seed,
|
||||||
|
// Advanced filters
|
||||||
|
@RequestParam(required = false) Integer minWordCount,
|
||||||
|
@RequestParam(required = false) Integer maxWordCount,
|
||||||
|
@RequestParam(required = false) String createdAfter,
|
||||||
|
@RequestParam(required = false) String createdBefore,
|
||||||
|
@RequestParam(required = false) String lastReadAfter,
|
||||||
|
@RequestParam(required = false) String lastReadBefore,
|
||||||
|
@RequestParam(required = false) Integer minRating,
|
||||||
|
@RequestParam(required = false) Integer maxRating,
|
||||||
|
@RequestParam(required = false) Boolean unratedOnly,
|
||||||
|
@RequestParam(required = false) String readingStatus,
|
||||||
|
@RequestParam(required = false) Boolean hasReadingProgress,
|
||||||
|
@RequestParam(required = false) Boolean hasCoverImage,
|
||||||
|
@RequestParam(required = false) String sourceDomain,
|
||||||
|
@RequestParam(required = false) String seriesFilter,
|
||||||
|
@RequestParam(required = false) Integer minTagCount,
|
||||||
|
@RequestParam(required = false) Boolean popularOnly,
|
||||||
|
@RequestParam(required = false) Boolean hiddenGemsOnly) {
|
||||||
|
|
||||||
|
logger.info("Getting random story with filters - searchQuery: {}, tags: {}, seed: {}",
|
||||||
|
searchQuery, tags, seed);
|
||||||
|
|
||||||
|
Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags, seed,
|
||||||
|
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
|
||||||
|
minRating, maxRating, unratedOnly, readingStatus, hasReadingProgress, hasCoverImage,
|
||||||
|
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
|
|
||||||
|
if (randomStory.isPresent()) {
|
||||||
|
StorySummaryDto storyDto = convertToSummaryDto(randomStory.get());
|
||||||
|
return ResponseEntity.ok(storyDto);
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.noContent().build(); // 204 No Content when no stories match filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) {
|
public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) {
|
||||||
Story story = storyService.findById(id);
|
Story story = storyService.findById(id);
|
||||||
@@ -186,6 +228,38 @@ public class StoryController {
|
|||||||
Story story = storyService.updateReadingStatus(id, request.getIsRead());
|
Story story = storyService.updateReadingStatus(id, request.getIsRead());
|
||||||
return ResponseEntity.ok(convertToDto(story));
|
return ResponseEntity.ok(convertToDto(story));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/process-content-images")
|
||||||
|
public ResponseEntity<Map<String, Object>> processContentImages(@PathVariable UUID id, @RequestBody ProcessContentImagesRequest request) {
|
||||||
|
logger.info("Processing content images for story {}", id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process the HTML content to download and replace image URLs
|
||||||
|
ImageService.ContentImageProcessingResult result = imageService.processContentImages(request.getHtmlContent(), id);
|
||||||
|
|
||||||
|
// If there are warnings, let the client decide whether to proceed
|
||||||
|
if (result.hasWarnings()) {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"processedContent", result.getProcessedContent(),
|
||||||
|
"warnings", result.getWarnings(),
|
||||||
|
"downloadedImages", result.getDownloadedImages(),
|
||||||
|
"hasWarnings", true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - no warnings
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"processedContent", result.getProcessedContent(),
|
||||||
|
"downloadedImages", result.getDownloadedImages(),
|
||||||
|
"hasWarnings", false
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to process content images for story {}", id, e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(Map.of("error", "Failed to process content images: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/reindex")
|
@PostMapping("/reindex")
|
||||||
public ResponseEntity<String> manualReindex() {
|
public ResponseEntity<String> manualReindex() {
|
||||||
@@ -251,12 +325,32 @@ public class StoryController {
|
|||||||
@RequestParam(required = false) Integer minRating,
|
@RequestParam(required = false) Integer minRating,
|
||||||
@RequestParam(required = false) Integer maxRating,
|
@RequestParam(required = false) Integer maxRating,
|
||||||
@RequestParam(required = false) String sortBy,
|
@RequestParam(required = false) String sortBy,
|
||||||
@RequestParam(required = false) String sortDir) {
|
@RequestParam(required = false) String sortDir,
|
||||||
|
@RequestParam(required = false) String facetBy,
|
||||||
|
// Advanced filters
|
||||||
|
@RequestParam(required = false) Integer minWordCount,
|
||||||
|
@RequestParam(required = false) Integer maxWordCount,
|
||||||
|
@RequestParam(required = false) String createdAfter,
|
||||||
|
@RequestParam(required = false) String createdBefore,
|
||||||
|
@RequestParam(required = false) String lastReadAfter,
|
||||||
|
@RequestParam(required = false) String lastReadBefore,
|
||||||
|
@RequestParam(required = false) Boolean unratedOnly,
|
||||||
|
@RequestParam(required = false) String readingStatus,
|
||||||
|
@RequestParam(required = false) Boolean hasReadingProgress,
|
||||||
|
@RequestParam(required = false) Boolean hasCoverImage,
|
||||||
|
@RequestParam(required = false) String sourceDomain,
|
||||||
|
@RequestParam(required = false) String seriesFilter,
|
||||||
|
@RequestParam(required = false) Integer minTagCount,
|
||||||
|
@RequestParam(required = false) Boolean popularOnly,
|
||||||
|
@RequestParam(required = false) Boolean hiddenGemsOnly) {
|
||||||
|
|
||||||
|
|
||||||
if (typesenseService != null) {
|
if (typesenseService != null) {
|
||||||
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
|
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
|
||||||
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir);
|
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir, facetBy,
|
||||||
|
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
|
||||||
|
unratedOnly, readingStatus, hasReadingProgress, hasCoverImage, sourceDomain, seriesFilter,
|
||||||
|
minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
return ResponseEntity.ok(results);
|
return ResponseEntity.ok(results);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to basic search if Typesense is not available
|
// Fallback to basic search if Typesense is not available
|
||||||
@@ -396,14 +490,19 @@ public class StoryController {
|
|||||||
story.setDescription(updateReq.getDescription());
|
story.setDescription(updateReq.getDescription());
|
||||||
}
|
}
|
||||||
if (updateReq.getContentHtml() != null) {
|
if (updateReq.getContentHtml() != null) {
|
||||||
story.setContentHtml(sanitizationService.sanitize(updateReq.getContentHtml()));
|
logger.info("Content before sanitization (length: {}): {}",
|
||||||
|
updateReq.getContentHtml().length(),
|
||||||
|
updateReq.getContentHtml().substring(0, Math.min(500, updateReq.getContentHtml().length())));
|
||||||
|
String sanitizedContent = sanitizationService.sanitize(updateReq.getContentHtml());
|
||||||
|
logger.info("Content after sanitization (length: {}): {}",
|
||||||
|
sanitizedContent.length(),
|
||||||
|
sanitizedContent.substring(0, Math.min(500, sanitizedContent.length())));
|
||||||
|
story.setContentHtml(sanitizedContent);
|
||||||
}
|
}
|
||||||
if (updateReq.getSourceUrl() != null) {
|
if (updateReq.getSourceUrl() != null) {
|
||||||
story.setSourceUrl(updateReq.getSourceUrl());
|
story.setSourceUrl(updateReq.getSourceUrl());
|
||||||
}
|
}
|
||||||
if (updateReq.getVolume() != null) {
|
// Volume will be handled in series logic below
|
||||||
story.setVolume(updateReq.getVolume());
|
|
||||||
}
|
|
||||||
// Handle author - either by ID or by name
|
// Handle author - either by ID or by name
|
||||||
if (updateReq.getAuthorId() != null) {
|
if (updateReq.getAuthorId() != null) {
|
||||||
Author author = authorService.findById(updateReq.getAuthorId());
|
Author author = authorService.findById(updateReq.getAuthorId());
|
||||||
@@ -412,13 +511,34 @@ public class StoryController {
|
|||||||
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
||||||
story.setAuthor(author);
|
story.setAuthor(author);
|
||||||
}
|
}
|
||||||
// Handle series - either by ID or by name
|
// Handle series - either by ID, by name, or remove from series
|
||||||
if (updateReq.getSeriesId() != null) {
|
if (updateReq.getSeriesId() != null) {
|
||||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||||
story.setSeries(series);
|
story.setSeries(series);
|
||||||
} else if (updateReq.getSeriesName() != null && !updateReq.getSeriesName().trim().isEmpty()) {
|
} else if (updateReq.getSeriesName() != null) {
|
||||||
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
logger.info("Processing series update: seriesName='{}', isEmpty={}", updateReq.getSeriesName(), updateReq.getSeriesName().trim().isEmpty());
|
||||||
story.setSeries(series);
|
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||||
|
// Empty series name means remove from series
|
||||||
|
logger.info("Removing story from series");
|
||||||
|
if (story.getSeries() != null) {
|
||||||
|
story.getSeries().removeStory(story);
|
||||||
|
story.setSeries(null);
|
||||||
|
story.setVolume(null);
|
||||||
|
logger.info("Story removed from series");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-empty series name means add to series
|
||||||
|
logger.info("Adding story to series: '{}', volume: {}", updateReq.getSeriesName().trim(), updateReq.getVolume());
|
||||||
|
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
||||||
|
story.setSeries(series);
|
||||||
|
// Set volume only if series is being set
|
||||||
|
if (updateReq.getVolume() != null) {
|
||||||
|
story.setVolume(updateReq.getVolume());
|
||||||
|
logger.info("Story added to series: {} with volume: {}", series.getName(), updateReq.getVolume());
|
||||||
|
} else {
|
||||||
|
logger.info("Story added to series: {} with no volume", series.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
||||||
@@ -540,8 +660,11 @@ public class StoryController {
|
|||||||
TagDto tagDto = new TagDto();
|
TagDto tagDto = new TagDto();
|
||||||
tagDto.setId(tag.getId());
|
tagDto.setId(tag.getId());
|
||||||
tagDto.setName(tag.getName());
|
tagDto.setName(tag.getName());
|
||||||
|
tagDto.setColor(tag.getColor());
|
||||||
|
tagDto.setDescription(tag.getDescription());
|
||||||
tagDto.setCreatedAt(tag.getCreatedAt());
|
tagDto.setCreatedAt(tag.getCreatedAt());
|
||||||
// storyCount can be set if needed, but it might be expensive to calculate for each tag
|
tagDto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||||
|
tagDto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||||
return tagDto;
|
return tagDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.dto.TagDto;
|
import com.storycove.dto.TagDto;
|
||||||
|
import com.storycove.dto.TagAliasDto;
|
||||||
import com.storycove.entity.Tag;
|
import com.storycove.entity.Tag;
|
||||||
|
import com.storycove.entity.TagAlias;
|
||||||
import com.storycove.service.TagService;
|
import com.storycove.service.TagService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -21,6 +25,7 @@ import java.util.stream.Collectors;
|
|||||||
@RequestMapping("/api/tags")
|
@RequestMapping("/api/tags")
|
||||||
public class TagController {
|
public class TagController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TagController.class);
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
|
|
||||||
public TagController(TagService tagService) {
|
public TagController(TagService tagService) {
|
||||||
@@ -54,6 +59,8 @@ public class TagController {
|
|||||||
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
|
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
|
||||||
Tag tag = new Tag();
|
Tag tag = new Tag();
|
||||||
tag.setName(request.getName());
|
tag.setName(request.getName());
|
||||||
|
tag.setColor(request.getColor());
|
||||||
|
tag.setDescription(request.getDescription());
|
||||||
|
|
||||||
Tag savedTag = tagService.create(tag);
|
Tag savedTag = tagService.create(tag);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
|
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
|
||||||
@@ -66,6 +73,12 @@ public class TagController {
|
|||||||
if (request.getName() != null) {
|
if (request.getName() != null) {
|
||||||
existingTag.setName(request.getName());
|
existingTag.setName(request.getName());
|
||||||
}
|
}
|
||||||
|
if (request.getColor() != null) {
|
||||||
|
existingTag.setColor(request.getColor());
|
||||||
|
}
|
||||||
|
if (request.getDescription() != null) {
|
||||||
|
existingTag.setDescription(request.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
Tag updatedTag = tagService.update(id, existingTag);
|
Tag updatedTag = tagService.update(id, existingTag);
|
||||||
return ResponseEntity.ok(convertToDto(updatedTag));
|
return ResponseEntity.ok(convertToDto(updatedTag));
|
||||||
@@ -95,7 +108,7 @@ public class TagController {
|
|||||||
@RequestParam String query,
|
@RequestParam String query,
|
||||||
@RequestParam(defaultValue = "10") int limit) {
|
@RequestParam(defaultValue = "10") int limit) {
|
||||||
|
|
||||||
List<Tag> tags = tagService.findByNameStartingWith(query, limit);
|
List<Tag> tags = tagService.findByNameOrAliasStartingWith(query, limit);
|
||||||
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
|
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
|
||||||
|
|
||||||
return ResponseEntity.ok(tagDtos);
|
return ResponseEntity.ok(tagDtos);
|
||||||
@@ -142,15 +155,124 @@ public class TagController {
|
|||||||
return ResponseEntity.ok(tagDtos);
|
return ResponseEntity.ok(tagDtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag alias endpoints
|
||||||
|
@PostMapping("/{tagId}/aliases")
|
||||||
|
public ResponseEntity<TagAliasDto> addAlias(@PathVariable UUID tagId,
|
||||||
|
@RequestBody Map<String, String> request) {
|
||||||
|
String aliasName = request.get("aliasName");
|
||||||
|
if (aliasName == null || aliasName.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
TagAlias alias = tagService.addAlias(tagId, aliasName.trim());
|
||||||
|
TagAliasDto dto = new TagAliasDto();
|
||||||
|
dto.setId(alias.getId());
|
||||||
|
dto.setAliasName(alias.getAliasName());
|
||||||
|
dto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||||
|
dto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||||
|
dto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||||
|
dto.setCreatedAt(alias.getCreatedAt());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{tagId}/aliases/{aliasId}")
|
||||||
|
public ResponseEntity<?> removeAlias(@PathVariable UUID tagId, @PathVariable UUID aliasId) {
|
||||||
|
try {
|
||||||
|
tagService.removeAlias(tagId, aliasId);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Alias removed successfully"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/resolve/{name}")
|
||||||
|
public ResponseEntity<TagDto> resolveTag(@PathVariable String name) {
|
||||||
|
try {
|
||||||
|
Tag resolvedTag = tagService.resolveTagByName(name);
|
||||||
|
if (resolvedTag != null) {
|
||||||
|
return ResponseEntity.ok(convertToDto(resolvedTag));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/merge")
|
||||||
|
public ResponseEntity<?> mergeTags(@Valid @RequestBody MergeTagsRequest request) {
|
||||||
|
try {
|
||||||
|
Tag resultTag = tagService.mergeTags(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||||
|
return ResponseEntity.ok(convertToDto(resultTag));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to merge tags", e);
|
||||||
|
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/merge/preview")
|
||||||
|
public ResponseEntity<?> previewMerge(@Valid @RequestBody MergeTagsRequest request) {
|
||||||
|
try {
|
||||||
|
MergePreviewResponse preview = tagService.previewMerge(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||||
|
return ResponseEntity.ok(preview);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to preview merge", e);
|
||||||
|
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/suggest")
|
||||||
|
public ResponseEntity<List<TagSuggestion>> suggestTags(@RequestBody TagSuggestionRequest request) {
|
||||||
|
try {
|
||||||
|
List<TagSuggestion> suggestions = tagService.suggestTags(
|
||||||
|
request.getTitle(),
|
||||||
|
request.getContent(),
|
||||||
|
request.getSummary(),
|
||||||
|
request.getLimit() != null ? request.getLimit() : 10
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(suggestions);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to suggest tags", e);
|
||||||
|
return ResponseEntity.ok(List.of()); // Return empty list on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private TagDto convertToDto(Tag tag) {
|
private TagDto convertToDto(Tag tag) {
|
||||||
TagDto dto = new TagDto();
|
TagDto dto = new TagDto();
|
||||||
dto.setId(tag.getId());
|
dto.setId(tag.getId());
|
||||||
dto.setName(tag.getName());
|
dto.setName(tag.getName());
|
||||||
|
dto.setColor(tag.getColor());
|
||||||
|
dto.setDescription(tag.getDescription());
|
||||||
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||||
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
||||||
|
dto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||||
dto.setCreatedAt(tag.getCreatedAt());
|
dto.setCreatedAt(tag.getCreatedAt());
|
||||||
// updatedAt field not present in Tag entity per spec
|
// updatedAt field not present in Tag entity per spec
|
||||||
|
|
||||||
|
// Convert aliases to DTOs for full context
|
||||||
|
if (tag.getAliases() != null && !tag.getAliases().isEmpty()) {
|
||||||
|
List<TagAliasDto> aliaseDtos = tag.getAliases().stream()
|
||||||
|
.map(alias -> {
|
||||||
|
TagAliasDto aliasDto = new TagAliasDto();
|
||||||
|
aliasDto.setId(alias.getId());
|
||||||
|
aliasDto.setAliasName(alias.getAliasName());
|
||||||
|
aliasDto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||||
|
aliasDto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||||
|
aliasDto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||||
|
aliasDto.setCreatedAt(alias.getCreatedAt());
|
||||||
|
return aliasDto;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
dto.setAliases(aliaseDtos);
|
||||||
|
}
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,15 +290,112 @@ public class TagController {
|
|||||||
// Request DTOs
|
// Request DTOs
|
||||||
public static class CreateTagRequest {
|
public static class CreateTagRequest {
|
||||||
private String name;
|
private String name;
|
||||||
|
private String color;
|
||||||
|
private String description;
|
||||||
|
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public void setName(String name) { this.name = name; }
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getColor() { return color; }
|
||||||
|
public void setColor(String color) { this.color = color; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class UpdateTagRequest {
|
public static class UpdateTagRequest {
|
||||||
private String name;
|
private String name;
|
||||||
|
private String color;
|
||||||
|
private String description;
|
||||||
|
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public void setName(String name) { this.name = name; }
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getColor() { return color; }
|
||||||
|
public void setColor(String color) { this.color = color; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MergeTagsRequest {
|
||||||
|
private List<String> sourceTagIds;
|
||||||
|
private String targetTagId;
|
||||||
|
|
||||||
|
public List<String> getSourceTagIds() { return sourceTagIds; }
|
||||||
|
public void setSourceTagIds(List<String> sourceTagIds) { this.sourceTagIds = sourceTagIds; }
|
||||||
|
|
||||||
|
public String getTargetTagId() { return targetTagId; }
|
||||||
|
public void setTargetTagId(String targetTagId) { this.targetTagId = targetTagId; }
|
||||||
|
|
||||||
|
// Helper methods to convert to UUID
|
||||||
|
public List<UUID> getSourceTagUUIDs() {
|
||||||
|
return sourceTagIds != null ? sourceTagIds.stream().map(UUID::fromString).toList() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTargetTagUUID() {
|
||||||
|
return targetTagId != null ? UUID.fromString(targetTagId) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MergePreviewResponse {
|
||||||
|
private String targetTagName;
|
||||||
|
private int targetStoryCount;
|
||||||
|
private int totalResultStoryCount;
|
||||||
|
private List<String> aliasesToCreate;
|
||||||
|
|
||||||
|
public String getTargetTagName() { return targetTagName; }
|
||||||
|
public void setTargetTagName(String targetTagName) { this.targetTagName = targetTagName; }
|
||||||
|
|
||||||
|
public int getTargetStoryCount() { return targetStoryCount; }
|
||||||
|
public void setTargetStoryCount(int targetStoryCount) { this.targetStoryCount = targetStoryCount; }
|
||||||
|
|
||||||
|
public int getTotalResultStoryCount() { return totalResultStoryCount; }
|
||||||
|
public void setTotalResultStoryCount(int totalResultStoryCount) { this.totalResultStoryCount = totalResultStoryCount; }
|
||||||
|
|
||||||
|
public List<String> getAliasesToCreate() { return aliasesToCreate; }
|
||||||
|
public void setAliasesToCreate(List<String> aliasesToCreate) { this.aliasesToCreate = aliasesToCreate; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TagSuggestionRequest {
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private String summary;
|
||||||
|
private Integer limit;
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
|
||||||
|
public String getSummary() { return summary; }
|
||||||
|
public void setSummary(String summary) { this.summary = summary; }
|
||||||
|
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TagSuggestion {
|
||||||
|
private String tagName;
|
||||||
|
private double confidence;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public TagSuggestion() {}
|
||||||
|
|
||||||
|
public TagSuggestion(String tagName, double confidence, String reason) {
|
||||||
|
this.tagName = tagName;
|
||||||
|
this.confidence = confidence;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTagName() { return tagName; }
|
||||||
|
public void setTagName(String tagName) { this.tagName = tagName; }
|
||||||
|
|
||||||
|
public double getConfidence() { return confidence; }
|
||||||
|
public void setConfidence(double confidence) { this.confidence = confidence; }
|
||||||
|
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
public void setReason(String reason) { this.reason = reason; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
61
backend/src/main/java/com/storycove/dto/LibraryDto.java
Normal file
61
backend/src/main/java/com/storycove/dto/LibraryDto.java
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
public class LibraryDto {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private boolean isActive;
|
||||||
|
private boolean isInitialized;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public LibraryDto() {}
|
||||||
|
|
||||||
|
public LibraryDto(String id, String name, String description, boolean isActive, boolean isInitialized) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
this.isActive = isActive;
|
||||||
|
this.isInitialized = isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActive(boolean active) {
|
||||||
|
isActive = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInitialized() {
|
||||||
|
return isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInitialized(boolean initialized) {
|
||||||
|
isInitialized = initialized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public class ProcessContentImagesRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "HTML content is required")
|
||||||
|
private String htmlContent;
|
||||||
|
|
||||||
|
public ProcessContentImagesRequest() {}
|
||||||
|
|
||||||
|
public ProcessContentImagesRequest(String htmlContent) {
|
||||||
|
this.htmlContent = htmlContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getHtmlContent() {
|
||||||
|
return htmlContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHtmlContent(String htmlContent) {
|
||||||
|
this.htmlContent = htmlContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ public class StorySearchDto {
|
|||||||
private UUID id;
|
private UUID id;
|
||||||
private String title;
|
private String title;
|
||||||
private String description;
|
private String description;
|
||||||
private String contentPlain;
|
|
||||||
private String sourceUrl;
|
private String sourceUrl;
|
||||||
private String coverPath;
|
private String coverPath;
|
||||||
private Integer wordCount;
|
private Integer wordCount;
|
||||||
@@ -65,13 +64,6 @@ public class StorySearchDto {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getContentPlain() {
|
|
||||||
return contentPlain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setContentPlain(String contentPlain) {
|
|
||||||
this.contentPlain = contentPlain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getSourceUrl() {
|
public String getSourceUrl() {
|
||||||
return sourceUrl;
|
return sourceUrl;
|
||||||
|
|||||||
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class TagAliasDto {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@NotBlank(message = "Alias name is required")
|
||||||
|
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||||
|
private String aliasName;
|
||||||
|
|
||||||
|
private UUID canonicalTagId;
|
||||||
|
private String canonicalTagName; // For convenience in frontend
|
||||||
|
private Boolean createdFromMerge;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public TagAliasDto() {}
|
||||||
|
|
||||||
|
public TagAliasDto(String aliasName, UUID canonicalTagId) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
this.canonicalTagId = canonicalTagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAliasName() {
|
||||||
|
return aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliasName(String aliasName) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getCanonicalTagId() {
|
||||||
|
return canonicalTagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanonicalTagId(UUID canonicalTagId) {
|
||||||
|
this.canonicalTagId = canonicalTagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCanonicalTagName() {
|
||||||
|
return canonicalTagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanonicalTagName(String canonicalTagName) {
|
||||||
|
this.canonicalTagName = canonicalTagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getCreatedFromMerge() {
|
||||||
|
return createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||||
|
this.createdFromMerge = createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public class TagDto {
|
public class TagDto {
|
||||||
@@ -14,8 +15,16 @@ public class TagDto {
|
|||||||
@Size(max = 100, message = "Tag name must not exceed 100 characters")
|
@Size(max = 100, message = "Tag name must not exceed 100 characters")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||||
|
private String color;
|
||||||
|
|
||||||
|
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||||
|
private String description;
|
||||||
|
|
||||||
private Integer storyCount;
|
private Integer storyCount;
|
||||||
private Integer collectionCount;
|
private Integer collectionCount;
|
||||||
|
private Integer aliasCount;
|
||||||
|
private List<TagAliasDto> aliases;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
@@ -42,6 +51,22 @@ public class TagDto {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColor() {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColor(String color) {
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getStoryCount() {
|
public Integer getStoryCount() {
|
||||||
return storyCount;
|
return storyCount;
|
||||||
}
|
}
|
||||||
@@ -58,6 +83,22 @@ public class TagDto {
|
|||||||
this.collectionCount = collectionCount;
|
this.collectionCount = collectionCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Integer getAliasCount() {
|
||||||
|
return aliasCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliasCount(Integer aliasCount) {
|
||||||
|
this.aliasCount = aliasCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TagAliasDto> getAliases() {
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliases(List<TagAliasDto> aliases) {
|
||||||
|
this.aliases = aliases;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
93
backend/src/main/java/com/storycove/entity/Library.java
Normal file
93
backend/src/main/java/com/storycove/entity/Library.java
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package com.storycove.entity;
|
||||||
|
|
||||||
|
public class Library {
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private String passwordHash;
|
||||||
|
private String dbName;
|
||||||
|
private String typesenseCollection;
|
||||||
|
private String imagePath;
|
||||||
|
private boolean initialized;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public Library() {}
|
||||||
|
|
||||||
|
public Library(String id, String name, String description, String passwordHash, String dbName) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
this.dbName = dbName;
|
||||||
|
this.typesenseCollection = "stories_" + id;
|
||||||
|
this.imagePath = "/images/" + id;
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
this.typesenseCollection = "stories_" + id;
|
||||||
|
this.imagePath = "/images/" + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDbName() {
|
||||||
|
return dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDbName(String dbName) {
|
||||||
|
this.dbName = dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTypesenseCollection() {
|
||||||
|
return typesenseCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTypesenseCollection(String typesenseCollection) {
|
||||||
|
this.typesenseCollection = typesenseCollection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getImagePath() {
|
||||||
|
return imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setImagePath(String imagePath) {
|
||||||
|
this.imagePath = imagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInitialized() {
|
||||||
|
return initialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInitialized(boolean initialized) {
|
||||||
|
this.initialized = initialized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -24,6 +25,14 @@ public class Tag {
|
|||||||
@Column(nullable = false, unique = true)
|
@Column(nullable = false, unique = true)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||||
|
@Column(length = 7)
|
||||||
|
private String color; // hex color like #3B82F6
|
||||||
|
|
||||||
|
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||||
|
@Column(length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
|
||||||
@ManyToMany(mappedBy = "tags")
|
@ManyToMany(mappedBy = "tags")
|
||||||
@JsonBackReference("story-tags")
|
@JsonBackReference("story-tags")
|
||||||
@@ -33,6 +42,10 @@ public class Tag {
|
|||||||
@JsonBackReference("collection-tags")
|
@JsonBackReference("collection-tags")
|
||||||
private Set<Collection> collections = new HashSet<>();
|
private Set<Collection> collections = new HashSet<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "canonicalTag", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@JsonManagedReference("tag-aliases")
|
||||||
|
private Set<TagAlias> aliases = new HashSet<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@@ -43,6 +56,12 @@ public class Tag {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Tag(String name, String color, String description) {
|
||||||
|
this.name = name;
|
||||||
|
this.color = color;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Getters and Setters
|
// Getters and Setters
|
||||||
@@ -62,6 +81,22 @@ public class Tag {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColor() {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColor(String color) {
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public Set<Story> getStories() {
|
public Set<Story> getStories() {
|
||||||
return stories;
|
return stories;
|
||||||
@@ -79,6 +114,14 @@ public class Tag {
|
|||||||
this.collections = collections;
|
this.collections = collections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Set<TagAlias> getAliases() {
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliases(Set<TagAlias> aliases) {
|
||||||
|
this.aliases = aliases;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package com.storycove.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tag_aliases")
|
||||||
|
public class TagAlias {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@NotBlank(message = "Alias name is required")
|
||||||
|
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||||
|
@Column(name = "alias_name", nullable = false, unique = true)
|
||||||
|
private String aliasName;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "canonical_tag_id", nullable = false)
|
||||||
|
@JsonManagedReference("tag-aliases")
|
||||||
|
private Tag canonicalTag;
|
||||||
|
|
||||||
|
@Column(name = "created_from_merge", nullable = false)
|
||||||
|
private Boolean createdFromMerge = false;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public TagAlias() {}
|
||||||
|
|
||||||
|
public TagAlias(String aliasName, Tag canonicalTag) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
this.canonicalTag = canonicalTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TagAlias(String aliasName, Tag canonicalTag, Boolean createdFromMerge) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
this.canonicalTag = canonicalTag;
|
||||||
|
this.createdFromMerge = createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAliasName() {
|
||||||
|
return aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliasName(String aliasName) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Tag getCanonicalTag() {
|
||||||
|
return canonicalTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanonicalTag(Tag canonicalTag) {
|
||||||
|
this.canonicalTag = canonicalTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getCreatedFromMerge() {
|
||||||
|
return createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||||
|
this.createdFromMerge = createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof TagAlias)) return false;
|
||||||
|
TagAlias tagAlias = (TagAlias) o;
|
||||||
|
return id != null && id.equals(tagAlias.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return getClass().hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "TagAlias{" +
|
||||||
|
"id=" + id +
|
||||||
|
", aliasName='" + aliasName + '\'' +
|
||||||
|
", canonicalTag=" + (canonicalTag != null ? canonicalTag.getName() : null) +
|
||||||
|
", createdFromMerge=" + createdFromMerge +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import com.storycove.entity.Author;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.storycove.repository;
|
|||||||
|
|
||||||
import com.storycove.entity.Collection;
|
import com.storycove.entity.Collection;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.storycove.entity.Tag;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -119,4 +118,126 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
|
|||||||
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
|
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
|
||||||
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
|
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count all stories for random selection (no filters)
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT COUNT(*) FROM stories", nativeQuery = true)
|
||||||
|
long countAllStories();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count stories matching tag name filter for random selection
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " +
|
||||||
|
"JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
"JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
"WHERE UPPER(t.name) = UPPER(?1)",
|
||||||
|
nativeQuery = true)
|
||||||
|
long countStoriesByTagName(String tagName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a random story using offset (no filters)
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT s.* FROM stories s ORDER BY s.id OFFSET ?1 LIMIT 1", nativeQuery = true)
|
||||||
|
Optional<Story> findRandomStory(long offset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a random story matching tag name filter using offset
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT s.* FROM stories s " +
|
||||||
|
"JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
"JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
"WHERE UPPER(t.name) = UPPER(?1) " +
|
||||||
|
"ORDER BY s.id OFFSET ?2 LIMIT 1",
|
||||||
|
nativeQuery = true)
|
||||||
|
Optional<Story> findRandomStoryByTagName(String tagName, long offset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count stories matching multiple tags (ALL tags must be present)
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT COUNT(*) FROM (" +
|
||||||
|
" SELECT DISTINCT s.id FROM stories s " +
|
||||||
|
" JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
" JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
" WHERE UPPER(t.name) IN (?1) " +
|
||||||
|
" GROUP BY s.id " +
|
||||||
|
" HAVING COUNT(DISTINCT t.name) = ?2" +
|
||||||
|
") as matched_stories",
|
||||||
|
nativeQuery = true)
|
||||||
|
long countStoriesByMultipleTags(List<String> upperCaseTagNames, int tagCount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find random story matching multiple tags (ALL tags must be present)
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT s.* FROM stories s " +
|
||||||
|
"JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
"JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
"WHERE UPPER(t.name) IN (?1) " +
|
||||||
|
"GROUP BY s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, s.created_at, s.updated_at " +
|
||||||
|
"HAVING COUNT(DISTINCT t.name) = ?2 " +
|
||||||
|
"ORDER BY s.id OFFSET ?3 LIMIT 1",
|
||||||
|
nativeQuery = true)
|
||||||
|
Optional<Story> findRandomStoryByMultipleTags(List<String> upperCaseTagNames, int tagCount, long offset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count stories matching text search (title, author, tags)
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " +
|
||||||
|
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||||
|
"LEFT JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
"LEFT JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1))",
|
||||||
|
nativeQuery = true)
|
||||||
|
long countStoriesByTextSearch(String searchPattern);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find random story matching text search (title, author, tags)
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT DISTINCT s.* FROM stories s " +
|
||||||
|
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||||
|
"LEFT JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
"LEFT JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " +
|
||||||
|
"ORDER BY s.id OFFSET ?2 LIMIT 1",
|
||||||
|
nativeQuery = true)
|
||||||
|
Optional<Story> findRandomStoryByTextSearch(String searchPattern, long offset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count stories matching both text search AND tags
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " +
|
||||||
|
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||||
|
"LEFT JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
"LEFT JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " +
|
||||||
|
"AND s.id IN (" +
|
||||||
|
" SELECT s2.id FROM stories s2 " +
|
||||||
|
" JOIN story_tags st2 ON s2.id = st2.story_id " +
|
||||||
|
" JOIN tags t2 ON st2.tag_id = t2.id " +
|
||||||
|
" WHERE UPPER(t2.name) IN (?2) " +
|
||||||
|
" GROUP BY s2.id " +
|
||||||
|
" HAVING COUNT(DISTINCT t2.name) = ?3" +
|
||||||
|
")",
|
||||||
|
nativeQuery = true)
|
||||||
|
long countStoriesByTextSearchAndTags(String searchPattern, List<String> upperCaseTagNames, int tagCount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find random story matching both text search AND tags
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT DISTINCT s.* FROM stories s " +
|
||||||
|
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||||
|
"LEFT JOIN story_tags st ON s.id = st.story_id " +
|
||||||
|
"LEFT JOIN tags t ON st.tag_id = t.id " +
|
||||||
|
"WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " +
|
||||||
|
"AND s.id IN (" +
|
||||||
|
" SELECT s2.id FROM stories s2 " +
|
||||||
|
" JOIN story_tags st2 ON s2.id = st2.story_id " +
|
||||||
|
" JOIN tags t2 ON st2.tag_id = t2.id " +
|
||||||
|
" WHERE UPPER(t2.name) IN (?2) " +
|
||||||
|
" GROUP BY s2.id " +
|
||||||
|
" HAVING COUNT(DISTINCT t2.name) = ?3" +
|
||||||
|
") " +
|
||||||
|
"ORDER BY s.id OFFSET ?4 LIMIT 1",
|
||||||
|
nativeQuery = true)
|
||||||
|
Optional<Story> findRandomStoryByTextSearchAndTags(String searchPattern, List<String> upperCaseTagNames, int tagCount, long offset);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.storycove.repository;
|
||||||
|
|
||||||
|
import com.storycove.entity.TagAlias;
|
||||||
|
import com.storycove.entity.Tag;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TagAliasRepository extends JpaRepository<TagAlias, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find alias by exact alias name (case-insensitive)
|
||||||
|
*/
|
||||||
|
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) = LOWER(:aliasName)")
|
||||||
|
Optional<TagAlias> findByAliasNameIgnoreCase(@Param("aliasName") String aliasName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all aliases for a specific canonical tag
|
||||||
|
*/
|
||||||
|
List<TagAlias> findByCanonicalTag(Tag canonicalTag);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all aliases for a specific canonical tag ID
|
||||||
|
*/
|
||||||
|
@Query("SELECT ta FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||||
|
List<TagAlias> findByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find aliases created from merge operations
|
||||||
|
*/
|
||||||
|
List<TagAlias> findByCreatedFromMergeTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an alias name already exists
|
||||||
|
*/
|
||||||
|
boolean existsByAliasNameIgnoreCase(String aliasName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all aliases for a specific tag
|
||||||
|
*/
|
||||||
|
void deleteByCanonicalTag(Tag canonicalTag);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count aliases for a specific tag
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(ta) FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||||
|
long countByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find aliases that start with the given prefix (case-insensitive)
|
||||||
|
*/
|
||||||
|
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) LIKE LOWER(CONCAT(:prefix, '%'))")
|
||||||
|
List<TagAlias> findByAliasNameStartingWithIgnoreCase(@Param("prefix") String prefix);
|
||||||
|
}
|
||||||
@@ -17,8 +17,12 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
|||||||
|
|
||||||
Optional<Tag> findByName(String name);
|
Optional<Tag> findByName(String name);
|
||||||
|
|
||||||
|
Optional<Tag> findByNameIgnoreCase(String name);
|
||||||
|
|
||||||
boolean existsByName(String name);
|
boolean existsByName(String name);
|
||||||
|
|
||||||
|
boolean existsByNameIgnoreCase(String name);
|
||||||
|
|
||||||
List<Tag> findByNameContainingIgnoreCase(String name);
|
List<Tag> findByNameContainingIgnoreCase(String name);
|
||||||
|
|
||||||
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.storycove.security;
|
|||||||
import com.storycove.util.JwtUtil;
|
import com.storycove.util.JwtUtil;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
@@ -28,13 +29,27 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
FilterChain filterChain) throws ServletException, IOException {
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
String authHeader = request.getHeader("Authorization");
|
|
||||||
String token = null;
|
String token = null;
|
||||||
|
|
||||||
|
// First try to get token from Authorization header
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
token = authHeader.substring(7);
|
token = authHeader.substring(7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no token in header, try to get from cookies
|
||||||
|
if (token == null) {
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
if (cookies != null) {
|
||||||
|
for (Cookie cookie : cookies) {
|
||||||
|
if ("token".equals(cookie.getName())) {
|
||||||
|
token = cookie.getValue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (token != null && jwtUtil.validateToken(token) && !jwtUtil.isTokenExpired(token)) {
|
if (token != null && jwtUtil.validateToken(token) && !jwtUtil.isTokenExpired(token)) {
|
||||||
String subject = jwtUtil.getSubjectFromToken(token);
|
String subject = jwtUtil.getSubjectFromToken(token);
|
||||||
|
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ public class AuthorService {
|
|||||||
rating, author.getName(), author.getAuthorRating());
|
rating, author.getName(), author.getAuthorRating());
|
||||||
|
|
||||||
author.setAuthorRating(rating);
|
author.setAuthorRating(rating);
|
||||||
Author savedAuthor = authorRepository.save(author);
|
authorRepository.save(author);
|
||||||
|
|
||||||
// Flush and refresh to ensure the entity is up-to-date
|
// Flush and refresh to ensure the entity is up-to-date
|
||||||
authorRepository.flush();
|
authorRepository.flush();
|
||||||
|
|||||||
@@ -11,14 +11,10 @@ import com.storycove.repository.CollectionRepository;
|
|||||||
import com.storycove.repository.CollectionStoryRepository;
|
import com.storycove.repository.CollectionStoryRepository;
|
||||||
import com.storycove.repository.StoryRepository;
|
import com.storycove.repository.StoryRepository;
|
||||||
import com.storycove.repository.TagRepository;
|
import com.storycove.repository.TagRepository;
|
||||||
import com.storycove.service.exception.DuplicateResourceException;
|
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -266,7 +262,7 @@ public class CollectionService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void reorderStories(UUID collectionId, List<Map<String, Object>> storyOrders) {
|
public void reorderStories(UUID collectionId, List<Map<String, Object>> storyOrders) {
|
||||||
Collection collection = findByIdBasic(collectionId);
|
findByIdBasic(collectionId); // Validate collection exists
|
||||||
|
|
||||||
// Two-phase update to avoid unique constraint violations:
|
// Two-phase update to avoid unique constraint violations:
|
||||||
// Phase 1: Set all positions to negative values (temporary)
|
// Phase 1: Set all positions to negative values (temporary)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.storycove.service;
|
package com.storycove.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.storycove.entity.*;
|
|
||||||
import com.storycove.repository.*;
|
import com.storycove.repository.*;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.ApplicationContextAware;
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -23,10 +25,16 @@ import java.util.zip.ZipInputStream;
|
|||||||
import java.util.zip.ZipOutputStream;
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DatabaseManagementService {
|
public class DatabaseManagementService implements ApplicationContextAware {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
@Qualifier("dataSource") // Use the primary routing datasource
|
||||||
private DataSource dataSource;
|
private DataSource dataSource;
|
||||||
|
|
||||||
|
// Use the routing datasource which automatically handles library switching
|
||||||
|
private DataSource getDataSource() {
|
||||||
|
return dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private StoryRepository storyRepository;
|
private StoryRepository storyRepository;
|
||||||
@@ -45,12 +53,22 @@ public class DatabaseManagementService {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private TypesenseService typesenseService;
|
private TypesenseService typesenseService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryService libraryService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ReadingPositionRepository readingPositionRepository;
|
private ReadingPositionRepository readingPositionRepository;
|
||||||
|
|
||||||
@Value("${storycove.images.upload-dir:/app/images}")
|
@Value("${storycove.images.upload-dir:/app/images}")
|
||||||
private String uploadDir;
|
private String uploadDir;
|
||||||
|
|
||||||
|
private ApplicationContext applicationContext;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||||
|
this.applicationContext = applicationContext;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a comprehensive backup including database and files in ZIP format
|
* Create a comprehensive backup including database and files in ZIP format
|
||||||
@@ -80,7 +98,12 @@ public class DatabaseManagementService {
|
|||||||
* Restore from complete backup (ZIP format)
|
* Restore from complete backup (ZIP format)
|
||||||
*/
|
*/
|
||||||
public void restoreFromCompleteBackup(InputStream backupStream) throws IOException, SQLException {
|
public void restoreFromCompleteBackup(InputStream backupStream) throws IOException, SQLException {
|
||||||
System.err.println("Starting complete backup restore...");
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
|
System.err.println("Starting complete backup restore for library: " + currentLibraryId);
|
||||||
|
if (currentLibraryId == null) {
|
||||||
|
throw new IllegalStateException("No current library active - please authenticate and select a library first");
|
||||||
|
}
|
||||||
|
|
||||||
Path tempDir = Files.createTempDirectory("storycove-restore");
|
Path tempDir = Files.createTempDirectory("storycove-restore");
|
||||||
System.err.println("Created temp directory: " + tempDir);
|
System.err.println("Created temp directory: " + tempDir);
|
||||||
|
|
||||||
@@ -122,6 +145,17 @@ public class DatabaseManagementService {
|
|||||||
System.err.println("No files directory found in backup - skipping file restore.");
|
System.err.println("No files directory found in backup - skipping file restore.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6. Trigger complete Typesense reindex after data restoration
|
||||||
|
try {
|
||||||
|
System.err.println("Starting Typesense reindex after restore...");
|
||||||
|
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
|
||||||
|
typesenseService.performCompleteReindex();
|
||||||
|
System.err.println("Typesense reindex completed successfully.");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Warning: Failed to reindex Typesense after restore: " + e.getMessage());
|
||||||
|
// Don't fail the entire restore for Typesense issues
|
||||||
|
}
|
||||||
|
|
||||||
System.err.println("Complete backup restore finished successfully.");
|
System.err.println("Complete backup restore finished successfully.");
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -139,7 +173,7 @@ public class DatabaseManagementService {
|
|||||||
public Resource createBackup() throws SQLException, IOException {
|
public Resource createBackup() throws SQLException, IOException {
|
||||||
StringBuilder sqlDump = new StringBuilder();
|
StringBuilder sqlDump = new StringBuilder();
|
||||||
|
|
||||||
try (Connection connection = dataSource.getConnection()) {
|
try (Connection connection = getDataSource().getConnection()) {
|
||||||
// Add header
|
// Add header
|
||||||
sqlDump.append("-- StoryCove Database Backup\n");
|
sqlDump.append("-- StoryCove Database Backup\n");
|
||||||
sqlDump.append("-- Generated at: ").append(new java.util.Date()).append("\n\n");
|
sqlDump.append("-- Generated at: ").append(new java.util.Date()).append("\n\n");
|
||||||
@@ -225,10 +259,13 @@ public class DatabaseManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute the SQL statements
|
// Execute the SQL statements
|
||||||
try (Connection connection = dataSource.getConnection()) {
|
try (Connection connection = getDataSource().getConnection()) {
|
||||||
connection.setAutoCommit(false);
|
connection.setAutoCommit(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Ensure database schema exists before restoring data
|
||||||
|
ensureDatabaseSchemaExists(connection);
|
||||||
|
|
||||||
// Parse SQL statements properly (handle semicolons inside string literals)
|
// Parse SQL statements properly (handle semicolons inside string literals)
|
||||||
List<String> statements = parseStatements(sqlContent.toString());
|
List<String> statements = parseStatements(sqlContent.toString());
|
||||||
|
|
||||||
@@ -261,11 +298,19 @@ public class DatabaseManagementService {
|
|||||||
|
|
||||||
// Reindex search after successful restore
|
// Reindex search after successful restore
|
||||||
try {
|
try {
|
||||||
System.err.println("Starting Typesense reindex after successful restore...");
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
typesenseService.recreateStoriesCollection();
|
System.err.println("Starting Typesense reindex after successful restore for library: " + currentLibraryId);
|
||||||
typesenseService.recreateAuthorsCollection();
|
if (currentLibraryId == null) {
|
||||||
|
System.err.println("ERROR: No current library set during restore - cannot reindex Typesense!");
|
||||||
|
throw new IllegalStateException("No current library active during restore");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually trigger reindexing using the correct database connection
|
||||||
|
System.err.println("Triggering manual reindex from library-specific database for library: " + currentLibraryId);
|
||||||
|
reindexStoriesAndAuthorsFromCurrentDatabase();
|
||||||
|
|
||||||
// Note: Collections collection will be recreated when needed by the service
|
// Note: Collections collection will be recreated when needed by the service
|
||||||
System.err.println("Typesense reindex completed successfully.");
|
System.err.println("Typesense reindex completed successfully for library: " + currentLibraryId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Log the error but don't fail the restore
|
// Log the error but don't fail the restore
|
||||||
System.err.println("Warning: Failed to reindex Typesense after restore: " + e.getMessage());
|
System.err.println("Warning: Failed to reindex Typesense after restore: " + e.getMessage());
|
||||||
@@ -419,10 +464,14 @@ public class DatabaseManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all uploaded files
|
* Clear all uploaded files for the current library
|
||||||
*/
|
*/
|
||||||
private void clearAllFiles() {
|
private void clearAllFiles() {
|
||||||
Path imagesPath = Paths.get(uploadDir);
|
// Use library-specific image path
|
||||||
|
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||||
|
Path imagesPath = Paths.get(uploadDir + libraryImagePath);
|
||||||
|
|
||||||
|
System.err.println("Clearing files for library: " + libraryService.getCurrentLibraryId() + " at path: " + imagesPath);
|
||||||
|
|
||||||
if (Files.exists(imagesPath)) {
|
if (Files.exists(imagesPath)) {
|
||||||
try {
|
try {
|
||||||
@@ -431,6 +480,7 @@ public class DatabaseManagementService {
|
|||||||
.forEach(filePath -> {
|
.forEach(filePath -> {
|
||||||
try {
|
try {
|
||||||
Files.deleteIfExists(filePath);
|
Files.deleteIfExists(filePath);
|
||||||
|
System.err.println("Deleted file: " + filePath);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("Warning: Failed to delete file: " + filePath + " - " + e.getMessage());
|
System.err.println("Warning: Failed to delete file: " + filePath + " - " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -438,19 +488,28 @@ public class DatabaseManagementService {
|
|||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("Warning: Failed to clear files directory: " + e.getMessage());
|
System.err.println("Warning: Failed to clear files directory: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
System.err.println("Library image directory does not exist: " + imagesPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear search indexes
|
* Clear search indexes (recreate empty collections)
|
||||||
*/
|
*/
|
||||||
private void clearSearchIndexes() {
|
private void clearSearchIndexes() {
|
||||||
try {
|
try {
|
||||||
System.err.println("Clearing search indexes after complete clear...");
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
|
System.err.println("Clearing search indexes after complete clear for library: " + currentLibraryId);
|
||||||
|
if (currentLibraryId == null) {
|
||||||
|
System.err.println("WARNING: No current library set during clear - skipping search index clear");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For clearing, we only want to recreate empty collections (no data to index)
|
||||||
typesenseService.recreateStoriesCollection();
|
typesenseService.recreateStoriesCollection();
|
||||||
typesenseService.recreateAuthorsCollection();
|
typesenseService.recreateAuthorsCollection();
|
||||||
// Note: Collections collection will be recreated when needed by the service
|
// Note: Collections collection will be recreated when needed by the service
|
||||||
System.err.println("Search indexes cleared successfully.");
|
System.err.println("Search indexes cleared successfully for library: " + currentLibraryId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Log the error but don't fail the clear operation
|
// Log the error but don't fail the clear operation
|
||||||
System.err.println("Warning: Failed to clear search indexes: " + e.getMessage());
|
System.err.println("Warning: Failed to clear search indexes: " + e.getMessage());
|
||||||
@@ -458,6 +517,219 @@ public class DatabaseManagementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure database schema exists before restoring backup data.
|
||||||
|
* This creates all necessary tables, indexes, and constraints if they don't exist.
|
||||||
|
*/
|
||||||
|
private void ensureDatabaseSchemaExists(Connection connection) throws SQLException {
|
||||||
|
try {
|
||||||
|
// Check if a key table exists to determine if schema is already created
|
||||||
|
String checkTableQuery = "SELECT 1 FROM information_schema.tables WHERE table_name = 'stories' LIMIT 1";
|
||||||
|
try (PreparedStatement stmt = connection.prepareStatement(checkTableQuery);
|
||||||
|
var resultSet = stmt.executeQuery()) {
|
||||||
|
if (resultSet.next()) {
|
||||||
|
System.err.println("Database schema already exists, skipping schema creation.");
|
||||||
|
return; // Schema exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.err.println("Creating database schema for restore in library: " + libraryService.getCurrentLibraryId());
|
||||||
|
|
||||||
|
// Create the schema using the same DDL as LibraryService
|
||||||
|
String[] createTableStatements = {
|
||||||
|
// Authors table
|
||||||
|
"""
|
||||||
|
CREATE TABLE authors (
|
||||||
|
author_rating integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
avatar_image_path varchar(255),
|
||||||
|
name varchar(255) not null,
|
||||||
|
notes TEXT,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Author URLs table
|
||||||
|
"""
|
||||||
|
CREATE TABLE author_urls (
|
||||||
|
author_id uuid not null,
|
||||||
|
url varchar(255)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Series table
|
||||||
|
"""
|
||||||
|
CREATE TABLE series (
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
description varchar(1000),
|
||||||
|
name varchar(255) not null,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Tags table
|
||||||
|
"""
|
||||||
|
CREATE TABLE tags (
|
||||||
|
color varchar(7),
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
description varchar(500),
|
||||||
|
name varchar(255) not null unique,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Tag aliases table
|
||||||
|
"""
|
||||||
|
CREATE TABLE tag_aliases (
|
||||||
|
created_from_merge boolean not null,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
canonical_tag_id uuid not null,
|
||||||
|
id uuid not null,
|
||||||
|
alias_name varchar(255) not null unique,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Collections table
|
||||||
|
"""
|
||||||
|
CREATE TABLE collections (
|
||||||
|
is_archived boolean not null,
|
||||||
|
rating integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
cover_image_path varchar(500),
|
||||||
|
name varchar(500) not null,
|
||||||
|
description TEXT,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Stories table
|
||||||
|
"""
|
||||||
|
CREATE TABLE stories (
|
||||||
|
is_read boolean,
|
||||||
|
rating integer,
|
||||||
|
reading_position integer,
|
||||||
|
volume integer,
|
||||||
|
word_count integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
last_read_at timestamp(6),
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
author_id uuid,
|
||||||
|
id uuid not null,
|
||||||
|
series_id uuid,
|
||||||
|
description varchar(1000),
|
||||||
|
content_html TEXT,
|
||||||
|
content_plain TEXT,
|
||||||
|
cover_path varchar(255),
|
||||||
|
source_url varchar(255),
|
||||||
|
summary TEXT,
|
||||||
|
title varchar(255) not null,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Reading positions table
|
||||||
|
"""
|
||||||
|
CREATE TABLE reading_positions (
|
||||||
|
chapter_index integer,
|
||||||
|
character_position integer,
|
||||||
|
percentage_complete float(53),
|
||||||
|
word_position integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
story_id uuid not null,
|
||||||
|
context_after varchar(500),
|
||||||
|
context_before varchar(500),
|
||||||
|
chapter_title varchar(255),
|
||||||
|
epub_cfi TEXT,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Junction tables
|
||||||
|
"""
|
||||||
|
CREATE TABLE story_tags (
|
||||||
|
story_id uuid not null,
|
||||||
|
tag_id uuid not null,
|
||||||
|
primary key (story_id, tag_id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
"""
|
||||||
|
CREATE TABLE collection_stories (
|
||||||
|
position integer not null,
|
||||||
|
added_at timestamp(6) not null,
|
||||||
|
collection_id uuid not null,
|
||||||
|
story_id uuid not null,
|
||||||
|
primary key (collection_id, story_id),
|
||||||
|
unique (collection_id, position)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
"""
|
||||||
|
CREATE TABLE collection_tags (
|
||||||
|
collection_id uuid not null,
|
||||||
|
tag_id uuid not null,
|
||||||
|
primary key (collection_id, tag_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
};
|
||||||
|
|
||||||
|
String[] createIndexStatements = {
|
||||||
|
"CREATE INDEX idx_reading_position_story ON reading_positions (story_id)"
|
||||||
|
};
|
||||||
|
|
||||||
|
String[] createConstraintStatements = {
|
||||||
|
// Foreign key constraints
|
||||||
|
"ALTER TABLE author_urls ADD CONSTRAINT FKdqhp51m0uveybsts098gd79uo FOREIGN KEY (author_id) REFERENCES authors",
|
||||||
|
"ALTER TABLE stories ADD CONSTRAINT FKhwecpqeaxy40ftrctef1u7gw7 FOREIGN KEY (author_id) REFERENCES authors",
|
||||||
|
"ALTER TABLE stories ADD CONSTRAINT FK1kulyvy7wwcolp2gkndt57cp7 FOREIGN KEY (series_id) REFERENCES series",
|
||||||
|
"ALTER TABLE reading_positions ADD CONSTRAINT FKglfhdhflan3pgyr2u0gxi21i5 FOREIGN KEY (story_id) REFERENCES stories",
|
||||||
|
"ALTER TABLE story_tags ADD CONSTRAINT FKmans33ijt0nf65t0sng2r848j FOREIGN KEY (tag_id) REFERENCES tags",
|
||||||
|
"ALTER TABLE story_tags ADD CONSTRAINT FKq9guid7swnjxwdpgxj3jo1rsi FOREIGN KEY (story_id) REFERENCES stories",
|
||||||
|
"ALTER TABLE tag_aliases ADD CONSTRAINT FKqfsawmcj3ey4yycb6958y24ch FOREIGN KEY (canonical_tag_id) REFERENCES tags",
|
||||||
|
"ALTER TABLE collection_stories ADD CONSTRAINT FKr55ho4vhj0wp03x13iskr1jds FOREIGN KEY (collection_id) REFERENCES collections",
|
||||||
|
"ALTER TABLE collection_stories ADD CONSTRAINT FK7n41tbbrt7r2e81hpu3612r1o FOREIGN KEY (story_id) REFERENCES stories",
|
||||||
|
"ALTER TABLE collection_tags ADD CONSTRAINT FKceq7ggev8n8ibjui1x5yo4x67 FOREIGN KEY (tag_id) REFERENCES tags",
|
||||||
|
"ALTER TABLE collection_tags ADD CONSTRAINT FKq9sa5s8csdpbphrvb48tts8jt FOREIGN KEY (collection_id) REFERENCES collections"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
for (String sql : createTableStatements) {
|
||||||
|
try (var statement = connection.createStatement()) {
|
||||||
|
statement.executeUpdate(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
for (String sql : createIndexStatements) {
|
||||||
|
try (var statement = connection.createStatement()) {
|
||||||
|
statement.executeUpdate(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create constraints
|
||||||
|
for (String sql : createConstraintStatements) {
|
||||||
|
try (var statement = connection.createStatement()) {
|
||||||
|
statement.executeUpdate(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.err.println("Database schema created successfully for restore.");
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
System.err.println("Error creating database schema: " + e.getMessage());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add database dump to ZIP archive
|
* Add database dump to ZIP archive
|
||||||
*/
|
*/
|
||||||
@@ -479,12 +751,17 @@ public class DatabaseManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add all files to ZIP archive
|
* Add all files to ZIP archive for the current library
|
||||||
*/
|
*/
|
||||||
private void addFilesToZip(ZipOutputStream zipOut) throws IOException {
|
private void addFilesToZip(ZipOutputStream zipOut) throws IOException {
|
||||||
Path imagesPath = Paths.get(uploadDir);
|
// Use library-specific image path
|
||||||
|
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||||
|
Path imagesPath = Paths.get(uploadDir + libraryImagePath);
|
||||||
|
|
||||||
|
System.err.println("Adding files to backup for library: " + libraryService.getCurrentLibraryId() + " from path: " + imagesPath);
|
||||||
|
|
||||||
if (!Files.exists(imagesPath)) {
|
if (!Files.exists(imagesPath)) {
|
||||||
|
System.err.println("Library image directory does not exist, skipping file backup: " + imagesPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,6 +776,7 @@ public class DatabaseManagementService {
|
|||||||
zipOut.putNextEntry(entry);
|
zipOut.putNextEntry(entry);
|
||||||
Files.copy(filePath, zipOut);
|
Files.copy(filePath, zipOut);
|
||||||
zipOut.closeEntry();
|
zipOut.closeEntry();
|
||||||
|
System.err.println("Added file to backup: " + zipEntryName);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Failed to add file to backup: " + filePath, e);
|
throw new RuntimeException("Failed to add file to backup: " + filePath, e);
|
||||||
}
|
}
|
||||||
@@ -515,9 +793,19 @@ public class DatabaseManagementService {
|
|||||||
metadata.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
metadata.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||||
metadata.put("generator", "StoryCove Database Management Service");
|
metadata.put("generator", "StoryCove Database Management Service");
|
||||||
|
|
||||||
|
// Add library information
|
||||||
|
var currentLibrary = libraryService.getCurrentLibrary();
|
||||||
|
if (currentLibrary != null) {
|
||||||
|
Map<String, Object> libraryInfo = new HashMap<>();
|
||||||
|
libraryInfo.put("id", currentLibrary.getId());
|
||||||
|
libraryInfo.put("name", currentLibrary.getName());
|
||||||
|
libraryInfo.put("description", currentLibrary.getDescription());
|
||||||
|
metadata.put("library", libraryInfo);
|
||||||
|
}
|
||||||
|
|
||||||
// Add statistics
|
// Add statistics
|
||||||
Map<String, Object> stats = new HashMap<>();
|
Map<String, Object> stats = new HashMap<>();
|
||||||
try (Connection connection = dataSource.getConnection()) {
|
try (Connection connection = getDataSource().getConnection()) {
|
||||||
stats.put("stories", getTableCount(connection, "stories"));
|
stats.put("stories", getTableCount(connection, "stories"));
|
||||||
stats.put("authors", getTableCount(connection, "authors"));
|
stats.put("authors", getTableCount(connection, "authors"));
|
||||||
stats.put("collections", getTableCount(connection, "collections"));
|
stats.put("collections", getTableCount(connection, "collections"));
|
||||||
@@ -526,8 +814,9 @@ public class DatabaseManagementService {
|
|||||||
}
|
}
|
||||||
metadata.put("statistics", stats);
|
metadata.put("statistics", stats);
|
||||||
|
|
||||||
// Count files
|
// Count files for current library
|
||||||
Path imagesPath = Paths.get(uploadDir);
|
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||||
|
Path imagesPath = Paths.get(uploadDir + libraryImagePath);
|
||||||
int fileCount = 0;
|
int fileCount = 0;
|
||||||
if (Files.exists(imagesPath)) {
|
if (Files.exists(imagesPath)) {
|
||||||
fileCount = (int) Files.walk(imagesPath).filter(Files::isRegularFile).count();
|
fileCount = (int) Files.walk(imagesPath).filter(Files::isRegularFile).count();
|
||||||
@@ -587,6 +876,7 @@ public class DatabaseManagementService {
|
|||||||
// Validate metadata
|
// Validate metadata
|
||||||
try {
|
try {
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> metadata = mapper.readValue(Files.newInputStream(metadataFile), Map.class);
|
Map<String, Object> metadata = mapper.readValue(Files.newInputStream(metadataFile), Map.class);
|
||||||
|
|
||||||
String format = (String) metadata.get("format");
|
String format = (String) metadata.get("format");
|
||||||
@@ -605,10 +895,14 @@ public class DatabaseManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore files from backup
|
* Restore files from backup to the current library's directory
|
||||||
*/
|
*/
|
||||||
private void restoreFiles(Path filesDir) throws IOException {
|
private void restoreFiles(Path filesDir) throws IOException {
|
||||||
Path targetDir = Paths.get(uploadDir);
|
// Use library-specific image path
|
||||||
|
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||||
|
Path targetDir = Paths.get(uploadDir + libraryImagePath);
|
||||||
|
|
||||||
|
System.err.println("Restoring files for library: " + libraryService.getCurrentLibraryId() + " to path: " + targetDir);
|
||||||
Files.createDirectories(targetDir);
|
Files.createDirectories(targetDir);
|
||||||
|
|
||||||
Files.walk(filesDir)
|
Files.walk(filesDir)
|
||||||
@@ -620,6 +914,7 @@ public class DatabaseManagementService {
|
|||||||
|
|
||||||
Files.createDirectories(targetFile.getParent());
|
Files.createDirectories(targetFile.getParent());
|
||||||
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
System.err.println("Restored file: " + relativePath + " to " + targetFile);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Failed to restore file: " + sourceFile, e);
|
throw new RuntimeException("Failed to restore file: " + sourceFile, e);
|
||||||
}
|
}
|
||||||
@@ -655,4 +950,169 @@ public class DatabaseManagementService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually reindex stories and authors from the current library's database
|
||||||
|
* This bypasses the repository layer and uses direct database access
|
||||||
|
*/
|
||||||
|
private void reindexStoriesAndAuthorsFromCurrentDatabase() throws SQLException {
|
||||||
|
try (Connection connection = getDataSource().getConnection()) {
|
||||||
|
// First, recreate empty collections
|
||||||
|
try {
|
||||||
|
typesenseService.recreateStoriesCollection();
|
||||||
|
typesenseService.recreateAuthorsCollection();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new SQLException("Failed to recreate Typesense collections", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count and reindex stories with full author and series information
|
||||||
|
int storyCount = 0;
|
||||||
|
String storyQuery = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " +
|
||||||
|
"s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " +
|
||||||
|
"s.created_at, s.updated_at, " +
|
||||||
|
"a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " +
|
||||||
|
"a.created_at as author_created_at, a.updated_at as author_updated_at, " +
|
||||||
|
"ser.name as series_name, ser.description as series_description, " +
|
||||||
|
"ser.created_at as series_created_at " +
|
||||||
|
"FROM stories s " +
|
||||||
|
"LEFT JOIN authors a ON s.author_id = a.id " +
|
||||||
|
"LEFT JOIN series ser ON s.series_id = ser.id";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = connection.prepareStatement(storyQuery);
|
||||||
|
ResultSet rs = stmt.executeQuery()) {
|
||||||
|
|
||||||
|
while (rs.next()) {
|
||||||
|
// Create a complete Story object for indexing
|
||||||
|
var story = createStoryFromResultSet(rs);
|
||||||
|
typesenseService.indexStory(story);
|
||||||
|
storyCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count and reindex authors
|
||||||
|
int authorCount = 0;
|
||||||
|
String authorQuery = "SELECT id, name, notes, avatar_image_path, author_rating, created_at, updated_at FROM authors";
|
||||||
|
|
||||||
|
try (PreparedStatement stmt = connection.prepareStatement(authorQuery);
|
||||||
|
ResultSet rs = stmt.executeQuery()) {
|
||||||
|
|
||||||
|
while (rs.next()) {
|
||||||
|
// Create a minimal Author object for indexing
|
||||||
|
var author = createAuthorFromResultSet(rs);
|
||||||
|
typesenseService.indexAuthor(author);
|
||||||
|
authorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.err.println("Reindexed " + storyCount + " stories and " + authorCount + " authors from library database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Story entity from ResultSet for indexing purposes (includes joined author/series data)
|
||||||
|
*/
|
||||||
|
private com.storycove.entity.Story createStoryFromResultSet(ResultSet rs) throws SQLException {
|
||||||
|
var story = new com.storycove.entity.Story();
|
||||||
|
story.setId(UUID.fromString(rs.getString("id")));
|
||||||
|
story.setTitle(rs.getString("title"));
|
||||||
|
story.setSummary(rs.getString("summary"));
|
||||||
|
story.setDescription(rs.getString("description"));
|
||||||
|
story.setContentHtml(rs.getString("content_html"));
|
||||||
|
// Note: contentPlain will be auto-generated from contentHtml by the entity
|
||||||
|
story.setSourceUrl(rs.getString("source_url"));
|
||||||
|
story.setCoverPath(rs.getString("cover_path"));
|
||||||
|
story.setWordCount(rs.getInt("word_count"));
|
||||||
|
story.setRating(rs.getInt("rating"));
|
||||||
|
story.setVolume(rs.getInt("volume"));
|
||||||
|
story.setIsRead(rs.getBoolean("is_read"));
|
||||||
|
story.setReadingPosition(rs.getInt("reading_position"));
|
||||||
|
|
||||||
|
var lastReadAtTimestamp = rs.getTimestamp("last_read_at");
|
||||||
|
if (lastReadAtTimestamp != null) {
|
||||||
|
story.setLastReadAt(lastReadAtTimestamp.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||||
|
if (createdAtTimestamp != null) {
|
||||||
|
story.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||||
|
if (updatedAtTimestamp != null) {
|
||||||
|
story.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set complete author information
|
||||||
|
String authorIdStr = rs.getString("author_id");
|
||||||
|
if (authorIdStr != null) {
|
||||||
|
var author = new com.storycove.entity.Author();
|
||||||
|
author.setId(UUID.fromString(authorIdStr));
|
||||||
|
author.setName(rs.getString("author_name"));
|
||||||
|
author.setNotes(rs.getString("author_notes"));
|
||||||
|
author.setAvatarImagePath(rs.getString("author_avatar"));
|
||||||
|
|
||||||
|
Integer authorRating = rs.getInt("author_rating");
|
||||||
|
if (!rs.wasNull()) {
|
||||||
|
author.setAuthorRating(authorRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorCreatedAt = rs.getTimestamp("author_created_at");
|
||||||
|
if (authorCreatedAt != null) {
|
||||||
|
author.setCreatedAt(authorCreatedAt.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorUpdatedAt = rs.getTimestamp("author_updated_at");
|
||||||
|
if (authorUpdatedAt != null) {
|
||||||
|
author.setUpdatedAt(authorUpdatedAt.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
story.setAuthor(author);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set complete series information
|
||||||
|
String seriesIdStr = rs.getString("series_id");
|
||||||
|
if (seriesIdStr != null) {
|
||||||
|
var series = new com.storycove.entity.Series();
|
||||||
|
series.setId(UUID.fromString(seriesIdStr));
|
||||||
|
series.setName(rs.getString("series_name"));
|
||||||
|
series.setDescription(rs.getString("series_description"));
|
||||||
|
|
||||||
|
var seriesCreatedAt = rs.getTimestamp("series_created_at");
|
||||||
|
if (seriesCreatedAt != null) {
|
||||||
|
series.setCreatedAt(seriesCreatedAt.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
story.setSeries(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
return story;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Author entity from ResultSet for indexing purposes
|
||||||
|
*/
|
||||||
|
private com.storycove.entity.Author createAuthorFromResultSet(ResultSet rs) throws SQLException {
|
||||||
|
var author = new com.storycove.entity.Author();
|
||||||
|
author.setId(UUID.fromString(rs.getString("id")));
|
||||||
|
author.setName(rs.getString("name"));
|
||||||
|
author.setNotes(rs.getString("notes"));
|
||||||
|
author.setAvatarImagePath(rs.getString("avatar_image_path"));
|
||||||
|
|
||||||
|
Integer rating = rs.getInt("author_rating");
|
||||||
|
if (!rs.wasNull()) {
|
||||||
|
author.setAuthorRating(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdAtTimestamp = rs.getTimestamp("created_at");
|
||||||
|
if (createdAtTimestamp != null) {
|
||||||
|
author.setCreatedAt(createdAtTimestamp.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedAtTimestamp = rs.getTimestamp("updated_at");
|
||||||
|
if (updatedAtTimestamp != null) {
|
||||||
|
author.setUpdatedAt(updatedAtTimestamp.toLocalDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return author;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,6 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ import java.io.InputStream;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class HtmlSanitizationService {
|
|||||||
"p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
"p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
||||||
"sup", "sub", "small", "big", "mark", "pre", "code",
|
"sup", "sub", "small", "big", "mark", "pre", "code",
|
||||||
"ul", "ol", "li", "dl", "dt", "dd", "a",
|
"ul", "ol", "li", "dl", "dt", "dd", "a", "img",
|
||||||
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
|
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
|
||||||
"blockquote", "cite", "q", "hr"
|
"blockquote", "cite", "q", "hr"
|
||||||
));
|
));
|
||||||
@@ -65,13 +65,13 @@ public class HtmlSanitizationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void createSafelist() {
|
private void createSafelist() {
|
||||||
this.allowlist = new Safelist();
|
this.allowlist = Safelist.relaxed();
|
||||||
|
|
||||||
// Add allowed tags
|
// Add allowed tags
|
||||||
if (config.getAllowedTags() != null) {
|
if (config.getAllowedTags() != null) {
|
||||||
config.getAllowedTags().forEach(allowlist::addTags);
|
config.getAllowedTags().forEach(allowlist::addTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add allowed attributes
|
// Add allowed attributes
|
||||||
if (config.getAllowedAttributes() != null) {
|
if (config.getAllowedAttributes() != null) {
|
||||||
for (Map.Entry<String, List<String>> entry : config.getAllowedAttributes().entrySet()) {
|
for (Map.Entry<String, List<String>> entry : config.getAllowedAttributes().entrySet()) {
|
||||||
@@ -82,25 +82,33 @@ public class HtmlSanitizationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure allowed protocols for specific attributes (e.g., href)
|
// Special handling for img tags - allow all src attributes and validate later
|
||||||
|
allowlist.removeProtocols("img", "src", "http", "https");
|
||||||
|
// This is the key: preserve relative URLs by not restricting them
|
||||||
|
allowlist.preserveRelativeLinks(true);
|
||||||
|
|
||||||
|
// Configure allowed protocols for other attributes
|
||||||
if (config.getAllowedProtocols() != null) {
|
if (config.getAllowedProtocols() != null) {
|
||||||
for (Map.Entry<String, Map<String, List<String>>> tagEntry : config.getAllowedProtocols().entrySet()) {
|
for (Map.Entry<String, Map<String, List<String>>> tagEntry : config.getAllowedProtocols().entrySet()) {
|
||||||
String tag = tagEntry.getKey();
|
String tag = tagEntry.getKey();
|
||||||
Map<String, List<String>> attributeProtocols = tagEntry.getValue();
|
Map<String, List<String>> attributeProtocols = tagEntry.getValue();
|
||||||
|
|
||||||
if (attributeProtocols != null) {
|
if (attributeProtocols != null) {
|
||||||
for (Map.Entry<String, List<String>> attrEntry : attributeProtocols.entrySet()) {
|
for (Map.Entry<String, List<String>> attrEntry : attributeProtocols.entrySet()) {
|
||||||
String attribute = attrEntry.getKey();
|
String attribute = attrEntry.getKey();
|
||||||
List<String> protocols = attrEntry.getValue();
|
List<String> protocols = attrEntry.getValue();
|
||||||
|
|
||||||
if (protocols != null) {
|
if (protocols != null && !("img".equals(tag) && "src".equals(attribute))) {
|
||||||
|
// Skip img src since we handled it above
|
||||||
allowlist.addProtocols(tag, attribute, protocols.toArray(new String[0]));
|
allowlist.addProtocols(tag, attribute, protocols.toArray(new String[0]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("Configured Jsoup Safelist with preserveRelativeLinks=true for local image URLs");
|
||||||
|
|
||||||
// Remove specific attributes if needed (deprecated in favor of protocol control)
|
// Remove specific attributes if needed (deprecated in favor of protocol control)
|
||||||
if (config.getRemovedAttributes() != null) {
|
if (config.getRemovedAttributes() != null) {
|
||||||
@@ -133,8 +141,10 @@ public class HtmlSanitizationService {
|
|||||||
if (html == null || html.trim().isEmpty()) {
|
if (html == null || html.trim().isEmpty()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
logger.info("Content before sanitization: "+html);
|
||||||
return Jsoup.clean(html, allowlist);
|
String saniztedHtml = Jsoup.clean(html, allowlist.preserveRelativeLinks(true));
|
||||||
|
logger.info("Content after sanitization: "+saniztedHtml);
|
||||||
|
return saniztedHtml;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String extractPlainText(String html) {
|
public String extractPlainText(String html) {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.storycove.service;
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -7,18 +10,22 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import java.awt.*;
|
import java.awt.*;
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.*;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.net.HttpURLConnection;
|
||||||
import java.io.IOException;
|
import java.net.URL;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.Set;
|
import java.util.*;
|
||||||
import java.util.UUID;
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ImageService {
|
public class ImageService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
|
||||||
|
|
||||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||||
"image/jpeg", "image/jpg", "image/png"
|
"image/jpeg", "image/jpg", "image/png"
|
||||||
);
|
);
|
||||||
@@ -28,7 +35,15 @@ public class ImageService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@Value("${storycove.images.upload-dir:/app/images}")
|
@Value("${storycove.images.upload-dir:/app/images}")
|
||||||
private String uploadDir;
|
private String baseUploadDir;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryService libraryService;
|
||||||
|
|
||||||
|
private String getUploadDir() {
|
||||||
|
String libraryPath = libraryService.getCurrentImagePath();
|
||||||
|
return baseUploadDir + libraryPath;
|
||||||
|
}
|
||||||
|
|
||||||
@Value("${storycove.images.cover.max-width:800}")
|
@Value("${storycove.images.cover.max-width:800}")
|
||||||
private int coverMaxWidth;
|
private int coverMaxWidth;
|
||||||
@@ -44,14 +59,15 @@ public class ImageService {
|
|||||||
|
|
||||||
public enum ImageType {
|
public enum ImageType {
|
||||||
COVER("covers"),
|
COVER("covers"),
|
||||||
AVATAR("avatars");
|
AVATAR("avatars"),
|
||||||
|
CONTENT("content");
|
||||||
|
|
||||||
private final String directory;
|
private final String directory;
|
||||||
|
|
||||||
ImageType(String directory) {
|
ImageType(String directory) {
|
||||||
this.directory = directory;
|
this.directory = directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDirectory() {
|
public String getDirectory() {
|
||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
@@ -61,7 +77,7 @@ public class ImageService {
|
|||||||
validateFile(file);
|
validateFile(file);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
Path typeDir = Paths.get(uploadDir, imageType.getDirectory());
|
Path typeDir = Paths.get(getUploadDir(), imageType.getDirectory());
|
||||||
Files.createDirectories(typeDir);
|
Files.createDirectories(typeDir);
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
@@ -88,7 +104,7 @@ public class ImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Path fullPath = Paths.get(uploadDir, imagePath);
|
Path fullPath = Paths.get(getUploadDir(), imagePath);
|
||||||
return Files.deleteIfExists(fullPath);
|
return Files.deleteIfExists(fullPath);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -96,7 +112,7 @@ public class ImageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Path getImagePath(String imagePath) {
|
public Path getImagePath(String imagePath) {
|
||||||
return Paths.get(uploadDir, imagePath);
|
return Paths.get(getUploadDir(), imagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean imageExists(String imagePath) {
|
public boolean imageExists(String imagePath) {
|
||||||
@@ -107,6 +123,19 @@ public class ImageService {
|
|||||||
return Files.exists(getImagePath(imagePath));
|
return Files.exists(getImagePath(imagePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean imageExistsInLibrary(String imagePath, String libraryId) {
|
||||||
|
if (imagePath == null || imagePath.trim().isEmpty() || libraryId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Files.exists(getImagePathInLibrary(imagePath, libraryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getImagePathInLibrary(String imagePath, String libraryId) {
|
||||||
|
String libraryPath = libraryService.getImagePathForLibrary(libraryId);
|
||||||
|
return Paths.get(baseUploadDir + libraryPath, imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
private void validateFile(MultipartFile file) throws IOException {
|
private void validateFile(MultipartFile file) throws IOException {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new IllegalArgumentException("File is empty");
|
throw new IllegalArgumentException("File is empty");
|
||||||
@@ -160,6 +189,9 @@ public class ImageService {
|
|||||||
maxWidth = avatarMaxSize;
|
maxWidth = avatarMaxSize;
|
||||||
maxHeight = avatarMaxSize;
|
maxHeight = avatarMaxSize;
|
||||||
break;
|
break;
|
||||||
|
case CONTENT:
|
||||||
|
// Content images are not resized
|
||||||
|
return new Dimension(originalWidth, originalHeight);
|
||||||
default:
|
default:
|
||||||
return new Dimension(originalWidth, originalHeight);
|
return new Dimension(originalWidth, originalHeight);
|
||||||
}
|
}
|
||||||
@@ -206,4 +238,224 @@ public class ImageService {
|
|||||||
String extension = getFileExtension(filename);
|
String extension = getFileExtension(filename);
|
||||||
return ALLOWED_EXTENSIONS.contains(extension);
|
return ALLOWED_EXTENSIONS.contains(extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content image processing methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process HTML content and download all referenced images, replacing URLs with local paths
|
||||||
|
*/
|
||||||
|
public ContentImageProcessingResult processContentImages(String htmlContent, UUID storyId) {
|
||||||
|
logger.info("Processing content images for story: {}, content length: {}", storyId,
|
||||||
|
htmlContent != null ? htmlContent.length() : 0);
|
||||||
|
|
||||||
|
List<String> warnings = new ArrayList<>();
|
||||||
|
List<String> downloadedImages = new ArrayList<>();
|
||||||
|
|
||||||
|
if (htmlContent == null || htmlContent.trim().isEmpty()) {
|
||||||
|
logger.info("No content to process for story: {}", storyId);
|
||||||
|
return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all img tags with src attributes
|
||||||
|
Pattern imgPattern = Pattern.compile("<img[^>]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
|
||||||
|
Matcher matcher = imgPattern.matcher(htmlContent);
|
||||||
|
|
||||||
|
int imageCount = 0;
|
||||||
|
int externalImageCount = 0;
|
||||||
|
|
||||||
|
StringBuffer processedContent = new StringBuffer();
|
||||||
|
|
||||||
|
while (matcher.find()) {
|
||||||
|
String fullImgTag = matcher.group(0);
|
||||||
|
String imageUrl = matcher.group(1);
|
||||||
|
imageCount++;
|
||||||
|
|
||||||
|
logger.info("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Skip if it's already a local path or data URL
|
||||||
|
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
|
||||||
|
logger.info("Skipping local/data URL: {}", imageUrl);
|
||||||
|
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
externalImageCount++;
|
||||||
|
logger.info("Processing external image #{}: {}", externalImageCount, imageUrl);
|
||||||
|
|
||||||
|
// Download and store the image
|
||||||
|
String localPath = downloadImageFromUrl(imageUrl, storyId);
|
||||||
|
downloadedImages.add(localPath);
|
||||||
|
|
||||||
|
// Generate local URL
|
||||||
|
String localUrl = getLocalImageUrl(storyId, localPath);
|
||||||
|
logger.info("Downloaded image: {} -> {}", imageUrl, localUrl);
|
||||||
|
|
||||||
|
// Replace the src attribute with the local path - handle both single and double quotes
|
||||||
|
String newImgTag = fullImgTag
|
||||||
|
.replaceFirst("src=\"" + Pattern.quote(imageUrl) + "\"", "src=\"" + localUrl + "\"")
|
||||||
|
.replaceFirst("src='" + Pattern.quote(imageUrl) + "'", "src=\"" + localUrl + "\"");
|
||||||
|
|
||||||
|
// If replacement didn't work, try a more generic approach
|
||||||
|
if (newImgTag.equals(fullImgTag)) {
|
||||||
|
logger.warn("Standard replacement failed for image URL: {}, trying generic replacement", imageUrl);
|
||||||
|
newImgTag = fullImgTag.replaceAll("src\\s*=\\s*[\"']?" + Pattern.quote(imageUrl) + "[\"']?", "src=\"" + localUrl + "\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Replaced img tag: {} -> {}", fullImgTag, newImgTag);
|
||||||
|
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to download image: {} - {}", imageUrl, e.getMessage(), e);
|
||||||
|
warnings.add("Failed to download image: " + imageUrl + " - " + e.getMessage());
|
||||||
|
// Keep original URL in case of failure
|
||||||
|
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher.appendTail(processedContent);
|
||||||
|
|
||||||
|
logger.info("Finished processing images for story: {}. Found {} total images, {} external. Downloaded {} images, {} warnings",
|
||||||
|
storyId, imageCount, externalImageCount, downloadedImages.size(), warnings.size());
|
||||||
|
|
||||||
|
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download an image from a URL and store it locally
|
||||||
|
*/
|
||||||
|
private String downloadImageFromUrl(String imageUrl, UUID storyId) throws IOException {
|
||||||
|
URL url = new URL(imageUrl);
|
||||||
|
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||||
|
|
||||||
|
// Set a reasonable user agent to avoid blocks
|
||||||
|
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (StoryCove Image Processor)");
|
||||||
|
connection.setConnectTimeout(30000); // 30 seconds
|
||||||
|
connection.setReadTimeout(30000);
|
||||||
|
|
||||||
|
try (InputStream inputStream = connection.getInputStream()) {
|
||||||
|
// Get content type to determine file extension
|
||||||
|
String contentType = connection.getContentType();
|
||||||
|
String extension = getExtensionFromContentType(contentType);
|
||||||
|
|
||||||
|
if (extension == null) {
|
||||||
|
// Try to extract from URL
|
||||||
|
extension = getExtensionFromUrl(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
|
||||||
|
throw new IllegalArgumentException("Unsupported image format: " + contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directories for content images
|
||||||
|
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
|
||||||
|
Files.createDirectories(contentDir);
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
String filename = UUID.randomUUID().toString() + "." + extension.toLowerCase();
|
||||||
|
Path filePath = contentDir.resolve(filename);
|
||||||
|
|
||||||
|
// Read and validate the image
|
||||||
|
byte[] imageData = inputStream.readAllBytes();
|
||||||
|
ByteArrayInputStream bais = new ByteArrayInputStream(imageData);
|
||||||
|
BufferedImage image = ImageIO.read(bais);
|
||||||
|
|
||||||
|
if (image == null) {
|
||||||
|
throw new IOException("Invalid image format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the image
|
||||||
|
Files.write(filePath, imageData);
|
||||||
|
|
||||||
|
// Return relative path
|
||||||
|
return ImageType.CONTENT.getDirectory() + "/" + storyId.toString() + "/" + filename;
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate local image URL for serving
|
||||||
|
*/
|
||||||
|
private String getLocalImageUrl(UUID storyId, String imagePath) {
|
||||||
|
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||||
|
if (currentLibraryId == null || currentLibraryId.trim().isEmpty()) {
|
||||||
|
logger.warn("Current library ID is null or empty when generating local image URL for story: {}", storyId);
|
||||||
|
return "/api/files/images/default/" + imagePath;
|
||||||
|
}
|
||||||
|
String localUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
|
||||||
|
logger.info("Generated local image URL: {} for story: {}", localUrl, storyId);
|
||||||
|
return localUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file extension from content type
|
||||||
|
*/
|
||||||
|
private String getExtensionFromContentType(String contentType) {
|
||||||
|
if (contentType == null) return null;
|
||||||
|
|
||||||
|
switch (contentType.toLowerCase()) {
|
||||||
|
case "image/jpeg":
|
||||||
|
case "image/jpg":
|
||||||
|
return "jpg";
|
||||||
|
case "image/png":
|
||||||
|
return "png";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file extension from URL
|
||||||
|
*/
|
||||||
|
private String getExtensionFromUrl(String url) {
|
||||||
|
try {
|
||||||
|
String path = new URL(url).getPath();
|
||||||
|
int lastDot = path.lastIndexOf('.');
|
||||||
|
if (lastDot > 0 && lastDot < path.length() - 1) {
|
||||||
|
return path.substring(lastDot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up content images for a story
|
||||||
|
*/
|
||||||
|
public void deleteContentImages(UUID storyId) {
|
||||||
|
try {
|
||||||
|
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
|
||||||
|
if (Files.exists(contentDir)) {
|
||||||
|
Files.walk(contentDir)
|
||||||
|
.sorted(Comparator.reverseOrder())
|
||||||
|
.map(Path::toFile)
|
||||||
|
.forEach(java.io.File::delete);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
// Log but don't throw - this is cleanup
|
||||||
|
System.err.println("Failed to clean up content images for story " + storyId + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result class for content image processing
|
||||||
|
*/
|
||||||
|
public static class ContentImageProcessingResult {
|
||||||
|
private final String processedContent;
|
||||||
|
private final List<String> warnings;
|
||||||
|
private final List<String> downloadedImages;
|
||||||
|
|
||||||
|
public ContentImageProcessingResult(String processedContent, List<String> warnings, List<String> downloadedImages) {
|
||||||
|
this.processedContent = processedContent;
|
||||||
|
this.warnings = warnings;
|
||||||
|
this.downloadedImages = downloadedImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProcessedContent() { return processedContent; }
|
||||||
|
public List<String> getWarnings() { return warnings; }
|
||||||
|
public List<String> getDownloadedImages() { return downloadedImages; }
|
||||||
|
public boolean hasWarnings() { return !warnings.isEmpty(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base service class that provides library-aware database access.
|
||||||
|
*
|
||||||
|
* This approach is safer than routing at the datasource level because:
|
||||||
|
* 1. It doesn't interfere with Spring's initialization process
|
||||||
|
* 2. It allows fine-grained control over which operations are library-aware
|
||||||
|
* 3. It provides clear separation between authentication (uses default DB) and library operations
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LibraryAwareService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryService libraryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
@Qualifier("dataSource")
|
||||||
|
private DataSource defaultDataSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a database connection for the current active library.
|
||||||
|
* Falls back to default datasource if no library is active.
|
||||||
|
*/
|
||||||
|
public Connection getCurrentLibraryConnection() throws SQLException {
|
||||||
|
try {
|
||||||
|
// Try to get library-specific connection
|
||||||
|
DataSource libraryDataSource = libraryService.getCurrentDataSource();
|
||||||
|
return libraryDataSource.getConnection();
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
// No active library - use default datasource
|
||||||
|
return defaultDataSource.getConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a database connection for the default/fallback database.
|
||||||
|
* Use this for authentication and system-level operations.
|
||||||
|
*/
|
||||||
|
public Connection getDefaultConnection() throws SQLException {
|
||||||
|
return defaultDataSource.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a library is currently active
|
||||||
|
*/
|
||||||
|
public boolean hasActiveLibrary() {
|
||||||
|
try {
|
||||||
|
return libraryService.getCurrentLibraryId() != null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current active library ID, or null if none
|
||||||
|
*/
|
||||||
|
public String getCurrentLibraryId() {
|
||||||
|
try {
|
||||||
|
return libraryService.getCurrentLibraryId();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
862
backend/src/main/java/com/storycove/service/LibraryService.java
Normal file
862
backend/src/main/java/com/storycove/service/LibraryService.java
Normal file
@@ -0,0 +1,862 @@
|
|||||||
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.entity.Library;
|
||||||
|
import com.storycove.dto.LibraryDto;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.ApplicationContextAware;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.typesense.api.Client;
|
||||||
|
import org.typesense.resources.Node;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LibraryService implements ApplicationContextAware {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LibraryService.class);
|
||||||
|
|
||||||
|
@Value("${spring.datasource.url}")
|
||||||
|
private String baseDbUrl;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.username}")
|
||||||
|
private String dbUsername;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.password}")
|
||||||
|
private String dbPassword;
|
||||||
|
|
||||||
|
@Value("${typesense.host}")
|
||||||
|
private String typesenseHost;
|
||||||
|
|
||||||
|
@Value("${typesense.port}")
|
||||||
|
private String typesensePort;
|
||||||
|
|
||||||
|
@Value("${typesense.api-key}")
|
||||||
|
private String typesenseApiKey;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
private final Map<String, Library> libraries = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Spring ApplicationContext for accessing other services without circular dependencies
|
||||||
|
private ApplicationContext applicationContext;
|
||||||
|
|
||||||
|
// Current active resources
|
||||||
|
private volatile String currentLibraryId;
|
||||||
|
private volatile Client currentTypesenseClient;
|
||||||
|
|
||||||
|
// Security: Track if user has explicitly authenticated in this session
|
||||||
|
private volatile boolean explicitlyAuthenticated = false;
|
||||||
|
|
||||||
|
private static final String LIBRARIES_CONFIG_PATH = "/app/config/libraries.json";
|
||||||
|
private static final Path libraryConfigDir = Paths.get("/app/config");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||||
|
this.applicationContext = applicationContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void initialize() {
|
||||||
|
loadLibrariesFromFile();
|
||||||
|
|
||||||
|
// If no libraries exist, create a default one
|
||||||
|
if (libraries.isEmpty()) {
|
||||||
|
createDefaultLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Do NOT automatically switch to any library on startup
|
||||||
|
// Users must authenticate before accessing any library
|
||||||
|
explicitlyAuthenticated = false;
|
||||||
|
currentLibraryId = null;
|
||||||
|
|
||||||
|
if (!libraries.isEmpty()) {
|
||||||
|
logger.info("Loaded {} libraries. Authentication required to access any library.", libraries.size());
|
||||||
|
} else {
|
||||||
|
logger.info("No libraries found. A default library will be created on first authentication.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Security: Application startup completed. All users must re-authenticate.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void cleanup() {
|
||||||
|
currentLibraryId = null;
|
||||||
|
currentTypesenseClient = null;
|
||||||
|
explicitlyAuthenticated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear authentication state (for logout)
|
||||||
|
*/
|
||||||
|
public void clearAuthentication() {
|
||||||
|
explicitlyAuthenticated = false;
|
||||||
|
currentLibraryId = null;
|
||||||
|
currentTypesenseClient = null;
|
||||||
|
logger.info("Authentication cleared - user must re-authenticate to access libraries");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public String authenticateAndGetLibrary(String password) {
|
||||||
|
for (Library library : libraries.values()) {
|
||||||
|
if (passwordEncoder.matches(password, library.getPasswordHash())) {
|
||||||
|
// Mark as explicitly authenticated for this session
|
||||||
|
explicitlyAuthenticated = true;
|
||||||
|
logger.info("User explicitly authenticated for library: {}", library.getId());
|
||||||
|
return library.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // Authentication failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to library after authentication with forced reindexing
|
||||||
|
* This ensures Typesense is always up-to-date after login
|
||||||
|
*/
|
||||||
|
public synchronized void switchToLibraryAfterAuthentication(String libraryId) throws Exception {
|
||||||
|
logger.info("Switching to library after authentication: {} (forcing reindex)", libraryId);
|
||||||
|
switchToLibrary(libraryId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void switchToLibrary(String libraryId) throws Exception {
|
||||||
|
switchToLibrary(libraryId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void switchToLibrary(String libraryId, boolean forceReindex) throws Exception {
|
||||||
|
// Security: Only allow library switching after explicit authentication
|
||||||
|
if (!explicitlyAuthenticated) {
|
||||||
|
throw new IllegalStateException("Library switching requires explicit authentication. Please log in first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryId.equals(currentLibraryId) && !forceReindex) {
|
||||||
|
return; // Already active and no forced reindex requested
|
||||||
|
}
|
||||||
|
|
||||||
|
Library library = libraries.get(libraryId);
|
||||||
|
if (library == null) {
|
||||||
|
throw new IllegalArgumentException("Library not found: " + libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String previousLibraryId = currentLibraryId;
|
||||||
|
|
||||||
|
if (libraryId.equals(currentLibraryId) && forceReindex) {
|
||||||
|
logger.info("Forcing reindex for current library: {} ({})", library.getName(), libraryId);
|
||||||
|
} else {
|
||||||
|
logger.info("Switching to library: {} ({})", library.getName(), libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close current resources
|
||||||
|
closeCurrentResources();
|
||||||
|
|
||||||
|
// Set new active library (datasource routing handled by SmartRoutingDataSource)
|
||||||
|
currentLibraryId = libraryId;
|
||||||
|
currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection());
|
||||||
|
|
||||||
|
// Initialize Typesense collections for this library
|
||||||
|
try {
|
||||||
|
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
|
||||||
|
// First ensure collections exist
|
||||||
|
typesenseService.initializeCollectionsForCurrentLibrary();
|
||||||
|
logger.info("Completed Typesense initialization for library: {}", libraryId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to initialize Typesense for library {}: {}", libraryId, e.getMessage());
|
||||||
|
// Don't fail the switch - collections can be created later
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Successfully switched to library: {}", library.getName());
|
||||||
|
|
||||||
|
// Perform complete reindex AFTER library switch is fully complete
|
||||||
|
// This ensures database routing is properly established
|
||||||
|
if (forceReindex || !libraryId.equals(previousLibraryId)) {
|
||||||
|
logger.info("Starting post-switch Typesense reindex for library: {}", libraryId);
|
||||||
|
|
||||||
|
// Run reindex asynchronously to avoid blocking authentication response
|
||||||
|
// and allow time for database routing to fully stabilize
|
||||||
|
String finalLibraryId = libraryId;
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
// Give routing time to stabilize
|
||||||
|
Thread.sleep(500);
|
||||||
|
logger.info("Starting async Typesense reindex for library: {}", finalLibraryId);
|
||||||
|
|
||||||
|
TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class);
|
||||||
|
typesenseService.performCompleteReindex();
|
||||||
|
logger.info("Completed async Typesense reindexing for library: {}", finalLibraryId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to async reindex Typesense for library {}: {}", finalLibraryId, e.getMessage());
|
||||||
|
}
|
||||||
|
}, "TypesenseReindex-" + libraryId).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataSource getCurrentDataSource() {
|
||||||
|
if (currentLibraryId == null) {
|
||||||
|
throw new IllegalStateException("No active library - please authenticate first");
|
||||||
|
}
|
||||||
|
// Return the Spring-managed primary datasource which handles routing automatically
|
||||||
|
try {
|
||||||
|
return applicationContext.getBean("dataSource", DataSource.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to get routing datasource", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Client getCurrentTypesenseClient() {
|
||||||
|
if (currentTypesenseClient == null) {
|
||||||
|
throw new IllegalStateException("No active library - please authenticate first");
|
||||||
|
}
|
||||||
|
return currentTypesenseClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrentLibraryId() {
|
||||||
|
return currentLibraryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Library getCurrentLibrary() {
|
||||||
|
if (currentLibraryId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return libraries.get(currentLibraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<LibraryDto> getAllLibraries() {
|
||||||
|
List<LibraryDto> result = new ArrayList<>();
|
||||||
|
for (Library library : libraries.values()) {
|
||||||
|
boolean isActive = library.getId().equals(currentLibraryId);
|
||||||
|
result.add(new LibraryDto(
|
||||||
|
library.getId(),
|
||||||
|
library.getName(),
|
||||||
|
library.getDescription(),
|
||||||
|
isActive,
|
||||||
|
library.isInitialized()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LibraryDto getLibraryById(String libraryId) {
|
||||||
|
Library library = libraries.get(libraryId);
|
||||||
|
if (library != null) {
|
||||||
|
boolean isActive = library.getId().equals(currentLibraryId);
|
||||||
|
return new LibraryDto(
|
||||||
|
library.getId(),
|
||||||
|
library.getName(),
|
||||||
|
library.getDescription(),
|
||||||
|
isActive,
|
||||||
|
library.isInitialized()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrentImagePath() {
|
||||||
|
Library current = getCurrentLibrary();
|
||||||
|
return current != null ? current.getImagePath() : "/images/default";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getImagePathForLibrary(String libraryId) {
|
||||||
|
if (libraryId == null) {
|
||||||
|
return "/images/default";
|
||||||
|
}
|
||||||
|
|
||||||
|
Library library = libraries.get(libraryId);
|
||||||
|
return library != null ? library.getImagePath() : "/images/default";
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean changeLibraryPassword(String libraryId, String currentPassword, String newPassword) {
|
||||||
|
Library library = libraries.get(libraryId);
|
||||||
|
if (library == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
if (!passwordEncoder.matches(currentPassword, library.getPasswordHash())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
library.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
|
saveLibrariesToFile();
|
||||||
|
|
||||||
|
logger.info("Password changed for library: {}", library.getName());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Library createNewLibrary(String name, String description, String password) {
|
||||||
|
// Generate unique ID
|
||||||
|
String id = name.toLowerCase().replaceAll("[^a-z0-9]", "");
|
||||||
|
int counter = 1;
|
||||||
|
String originalId = id;
|
||||||
|
while (libraries.containsKey(id)) {
|
||||||
|
id = originalId + counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Library newLibrary = new Library(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
passwordEncoder.encode(password),
|
||||||
|
"storycove_" + id
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test database creation by creating a connection
|
||||||
|
DataSource testDs = createDataSource(newLibrary.getDbName());
|
||||||
|
testDs.getConnection().close(); // This will create the database and schema if it doesn't exist
|
||||||
|
|
||||||
|
// Initialize library resources (image directories)
|
||||||
|
initializeNewLibraryResources(id);
|
||||||
|
|
||||||
|
newLibrary.setInitialized(true);
|
||||||
|
logger.info("Database and resources created for library: {}", newLibrary.getDbName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Database/resource creation failed for library {}: {}", id, e.getMessage());
|
||||||
|
// Continue anyway - resources will be created when needed
|
||||||
|
}
|
||||||
|
|
||||||
|
libraries.put(id, newLibrary);
|
||||||
|
saveLibrariesToFile();
|
||||||
|
|
||||||
|
logger.info("Created new library: {} ({})", name, id);
|
||||||
|
return newLibrary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadLibrariesFromFile() {
|
||||||
|
try {
|
||||||
|
File configFile = new File(LIBRARIES_CONFIG_PATH);
|
||||||
|
if (configFile.exists()) {
|
||||||
|
String content = Files.readString(Paths.get(LIBRARIES_CONFIG_PATH));
|
||||||
|
Map<String, Object> config = objectMapper.readValue(content, new TypeReference<Map<String, Object>>() {});
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Map<String, Object>> librariesData = (Map<String, Map<String, Object>>) config.get("libraries");
|
||||||
|
|
||||||
|
for (Map.Entry<String, Map<String, Object>> entry : librariesData.entrySet()) {
|
||||||
|
String id = entry.getKey();
|
||||||
|
Map<String, Object> data = entry.getValue();
|
||||||
|
|
||||||
|
Library library = new Library();
|
||||||
|
library.setId(id);
|
||||||
|
library.setName((String) data.get("name"));
|
||||||
|
library.setDescription((String) data.get("description"));
|
||||||
|
library.setPasswordHash((String) data.get("passwordHash"));
|
||||||
|
library.setDbName((String) data.get("dbName"));
|
||||||
|
library.setInitialized((Boolean) data.getOrDefault("initialized", false));
|
||||||
|
|
||||||
|
libraries.put(id, library);
|
||||||
|
logger.info("Loaded library: {} ({})", library.getName(), id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("No libraries configuration file found, will create default");
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to load libraries configuration", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createDefaultLibrary() {
|
||||||
|
// Check if we're migrating from the old single-library system
|
||||||
|
String existingDbName = extractDatabaseName(baseDbUrl);
|
||||||
|
|
||||||
|
Library defaultLibrary = new Library(
|
||||||
|
"main",
|
||||||
|
"Main Library",
|
||||||
|
"Your existing story collection (migrated)",
|
||||||
|
passwordEncoder.encode("temp-password-change-me"), // Temporary password
|
||||||
|
existingDbName // Use existing database name
|
||||||
|
);
|
||||||
|
defaultLibrary.setInitialized(true); // Mark as initialized since it has existing data
|
||||||
|
|
||||||
|
libraries.put("main", defaultLibrary);
|
||||||
|
saveLibrariesToFile();
|
||||||
|
|
||||||
|
logger.warn("=".repeat(80));
|
||||||
|
logger.warn("MIGRATION: Created 'Main Library' for your existing data");
|
||||||
|
logger.warn("Temporary password: 'temp-password-change-me'");
|
||||||
|
logger.warn("IMPORTANT: Please set a proper password in Settings > Library Settings");
|
||||||
|
logger.warn("=".repeat(80));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractDatabaseName(String jdbcUrl) {
|
||||||
|
// Extract database name from JDBC URL like "jdbc:postgresql://db:5432/storycove"
|
||||||
|
int lastSlash = jdbcUrl.lastIndexOf('/');
|
||||||
|
if (lastSlash != -1 && lastSlash < jdbcUrl.length() - 1) {
|
||||||
|
String dbPart = jdbcUrl.substring(lastSlash + 1);
|
||||||
|
// Remove any query parameters
|
||||||
|
int queryStart = dbPart.indexOf('?');
|
||||||
|
return queryStart != -1 ? dbPart.substring(0, queryStart) : dbPart;
|
||||||
|
}
|
||||||
|
return "storycove"; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveLibrariesToFile() {
|
||||||
|
try {
|
||||||
|
Map<String, Object> config = new HashMap<>();
|
||||||
|
Map<String, Map<String, Object>> librariesData = new HashMap<>();
|
||||||
|
|
||||||
|
for (Library library : libraries.values()) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("name", library.getName());
|
||||||
|
data.put("description", library.getDescription());
|
||||||
|
data.put("passwordHash", library.getPasswordHash());
|
||||||
|
data.put("dbName", library.getDbName());
|
||||||
|
data.put("initialized", library.isInitialized());
|
||||||
|
|
||||||
|
librariesData.put(library.getId(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.put("libraries", librariesData);
|
||||||
|
|
||||||
|
// Ensure config directory exists
|
||||||
|
new File("/app/config").mkdirs();
|
||||||
|
|
||||||
|
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(config);
|
||||||
|
Files.writeString(Paths.get(LIBRARIES_CONFIG_PATH), json);
|
||||||
|
|
||||||
|
logger.info("Saved libraries configuration");
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to save libraries configuration", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DataSource createDataSource(String dbName) {
|
||||||
|
String url = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
||||||
|
logger.info("Creating DataSource for: {}", url);
|
||||||
|
|
||||||
|
// First, ensure the database exists
|
||||||
|
ensureDatabaseExists(dbName);
|
||||||
|
|
||||||
|
HikariConfig config = new HikariConfig();
|
||||||
|
config.setJdbcUrl(url);
|
||||||
|
config.setUsername(dbUsername);
|
||||||
|
config.setPassword(dbPassword);
|
||||||
|
config.setDriverClassName("org.postgresql.Driver");
|
||||||
|
config.setMaximumPoolSize(10);
|
||||||
|
config.setConnectionTimeout(30000);
|
||||||
|
|
||||||
|
return new HikariDataSource(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureDatabaseExists(String dbName) {
|
||||||
|
// Connect to the 'postgres' database to create the new database
|
||||||
|
String adminUrl = baseDbUrl.replaceAll("/[^/]*$", "/postgres");
|
||||||
|
|
||||||
|
HikariConfig adminConfig = new HikariConfig();
|
||||||
|
adminConfig.setJdbcUrl(adminUrl);
|
||||||
|
adminConfig.setUsername(dbUsername);
|
||||||
|
adminConfig.setPassword(dbPassword);
|
||||||
|
adminConfig.setDriverClassName("org.postgresql.Driver");
|
||||||
|
adminConfig.setMaximumPoolSize(1);
|
||||||
|
adminConfig.setConnectionTimeout(30000);
|
||||||
|
|
||||||
|
boolean databaseCreated = false;
|
||||||
|
|
||||||
|
try (HikariDataSource adminDataSource = new HikariDataSource(adminConfig);
|
||||||
|
var connection = adminDataSource.getConnection();
|
||||||
|
var statement = connection.createStatement()) {
|
||||||
|
|
||||||
|
// Check if database exists
|
||||||
|
String checkQuery = "SELECT 1 FROM pg_database WHERE datname = ?";
|
||||||
|
try (var preparedStatement = connection.prepareStatement(checkQuery)) {
|
||||||
|
preparedStatement.setString(1, dbName);
|
||||||
|
try (var resultSet = preparedStatement.executeQuery()) {
|
||||||
|
if (resultSet.next()) {
|
||||||
|
logger.info("Database {} already exists", dbName);
|
||||||
|
return; // Database exists, nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create database if it doesn't exist
|
||||||
|
// Note: Database names cannot be parameterized, but we validate the name is safe
|
||||||
|
if (!dbName.matches("^[a-zA-Z][a-zA-Z0-9_]*$")) {
|
||||||
|
throw new IllegalArgumentException("Invalid database name: " + dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
String createQuery = "CREATE DATABASE " + dbName;
|
||||||
|
statement.executeUpdate(createQuery);
|
||||||
|
logger.info("Created database: {}", dbName);
|
||||||
|
databaseCreated = true;
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.error("Failed to ensure database {} exists: {}", dbName, e.getMessage());
|
||||||
|
throw new RuntimeException("Database creation failed", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we just created the database, initialize its schema
|
||||||
|
if (databaseCreated) {
|
||||||
|
initializeNewDatabaseSchema(dbName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeNewDatabaseSchema(String dbName) {
|
||||||
|
logger.info("Initializing schema for new database: {}", dbName);
|
||||||
|
|
||||||
|
// Create a temporary DataSource for the new database to initialize schema
|
||||||
|
String newDbUrl = baseDbUrl.replaceAll("/[^/]*$", "/" + dbName);
|
||||||
|
|
||||||
|
HikariConfig config = new HikariConfig();
|
||||||
|
config.setJdbcUrl(newDbUrl);
|
||||||
|
config.setUsername(dbUsername);
|
||||||
|
config.setPassword(dbPassword);
|
||||||
|
config.setDriverClassName("org.postgresql.Driver");
|
||||||
|
config.setMaximumPoolSize(1);
|
||||||
|
config.setConnectionTimeout(30000);
|
||||||
|
|
||||||
|
try (HikariDataSource tempDataSource = new HikariDataSource(config)) {
|
||||||
|
// Use Hibernate to create the schema
|
||||||
|
// This mimics what Spring Boot does during startup
|
||||||
|
createSchemaUsingHibernate(tempDataSource);
|
||||||
|
logger.info("Schema initialized for database: {}", dbName);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to initialize schema for database {}: {}", dbName, e.getMessage());
|
||||||
|
throw new RuntimeException("Schema initialization failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void initializeNewLibraryResources(String libraryId) {
|
||||||
|
Library library = libraries.get(libraryId);
|
||||||
|
if (library == null) {
|
||||||
|
throw new IllegalArgumentException("Library not found: " + libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("Initializing resources for new library: {}", library.getName());
|
||||||
|
|
||||||
|
// 1. Create image directory structure
|
||||||
|
initializeImageDirectories(library);
|
||||||
|
|
||||||
|
// 2. Initialize Typesense collections (this will be done when switching to the library)
|
||||||
|
// The TypesenseService.initializeCollections() will be called automatically
|
||||||
|
|
||||||
|
logger.info("Successfully initialized resources for library: {}", library.getName());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to initialize resources for library {}: {}", libraryId, e.getMessage());
|
||||||
|
throw new RuntimeException("Library resource initialization failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeImageDirectories(Library library) {
|
||||||
|
try {
|
||||||
|
// Create the library-specific image directory
|
||||||
|
String imagePath = "/app/images/" + library.getId();
|
||||||
|
java.nio.file.Path libraryImagePath = java.nio.file.Paths.get(imagePath);
|
||||||
|
|
||||||
|
if (!java.nio.file.Files.exists(libraryImagePath)) {
|
||||||
|
java.nio.file.Files.createDirectories(libraryImagePath);
|
||||||
|
logger.info("Created image directory: {}", imagePath);
|
||||||
|
|
||||||
|
// Create subdirectories for different image types
|
||||||
|
java.nio.file.Files.createDirectories(libraryImagePath.resolve("stories"));
|
||||||
|
java.nio.file.Files.createDirectories(libraryImagePath.resolve("authors"));
|
||||||
|
java.nio.file.Files.createDirectories(libraryImagePath.resolve("collections"));
|
||||||
|
|
||||||
|
logger.info("Created image subdirectories for library: {}", library.getId());
|
||||||
|
} else {
|
||||||
|
logger.info("Image directory already exists: {}", imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to create image directories for library {}: {}", library.getId(), e.getMessage());
|
||||||
|
throw new RuntimeException("Image directory creation failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createSchemaUsingHibernate(DataSource dataSource) {
|
||||||
|
// Create the essential tables manually using the same DDL that Hibernate would generate
|
||||||
|
// This is simpler than setting up a full Hibernate configuration for schema creation
|
||||||
|
|
||||||
|
String[] createTableStatements = {
|
||||||
|
// Authors table
|
||||||
|
"""
|
||||||
|
CREATE TABLE authors (
|
||||||
|
author_rating integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
avatar_image_path varchar(255),
|
||||||
|
name varchar(255) not null,
|
||||||
|
notes TEXT,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Author URLs table
|
||||||
|
"""
|
||||||
|
CREATE TABLE author_urls (
|
||||||
|
author_id uuid not null,
|
||||||
|
url varchar(255)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Series table
|
||||||
|
"""
|
||||||
|
CREATE TABLE series (
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
description varchar(1000),
|
||||||
|
name varchar(255) not null,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Tags table
|
||||||
|
"""
|
||||||
|
CREATE TABLE tags (
|
||||||
|
color varchar(7),
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
description varchar(500),
|
||||||
|
name varchar(255) not null unique,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Tag aliases table
|
||||||
|
"""
|
||||||
|
CREATE TABLE tag_aliases (
|
||||||
|
created_from_merge boolean not null,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
canonical_tag_id uuid not null,
|
||||||
|
id uuid not null,
|
||||||
|
alias_name varchar(255) not null unique,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Collections table
|
||||||
|
"""
|
||||||
|
CREATE TABLE collections (
|
||||||
|
is_archived boolean not null,
|
||||||
|
rating integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
cover_image_path varchar(500),
|
||||||
|
name varchar(500) not null,
|
||||||
|
description TEXT,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Stories table
|
||||||
|
"""
|
||||||
|
CREATE TABLE stories (
|
||||||
|
is_read boolean,
|
||||||
|
rating integer,
|
||||||
|
reading_position integer,
|
||||||
|
volume integer,
|
||||||
|
word_count integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
last_read_at timestamp(6),
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
author_id uuid,
|
||||||
|
id uuid not null,
|
||||||
|
series_id uuid,
|
||||||
|
description varchar(1000),
|
||||||
|
content_html TEXT,
|
||||||
|
content_plain TEXT,
|
||||||
|
cover_path varchar(255),
|
||||||
|
source_url varchar(255),
|
||||||
|
summary TEXT,
|
||||||
|
title varchar(255) not null,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Reading positions table
|
||||||
|
"""
|
||||||
|
CREATE TABLE reading_positions (
|
||||||
|
chapter_index integer,
|
||||||
|
character_position integer,
|
||||||
|
percentage_complete float(53),
|
||||||
|
word_position integer,
|
||||||
|
created_at timestamp(6) not null,
|
||||||
|
updated_at timestamp(6) not null,
|
||||||
|
id uuid not null,
|
||||||
|
story_id uuid not null,
|
||||||
|
context_after varchar(500),
|
||||||
|
context_before varchar(500),
|
||||||
|
chapter_title varchar(255),
|
||||||
|
epub_cfi TEXT,
|
||||||
|
primary key (id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// Junction tables
|
||||||
|
"""
|
||||||
|
CREATE TABLE story_tags (
|
||||||
|
story_id uuid not null,
|
||||||
|
tag_id uuid not null,
|
||||||
|
primary key (story_id, tag_id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
"""
|
||||||
|
CREATE TABLE collection_stories (
|
||||||
|
position integer not null,
|
||||||
|
added_at timestamp(6) not null,
|
||||||
|
collection_id uuid not null,
|
||||||
|
story_id uuid not null,
|
||||||
|
primary key (collection_id, story_id),
|
||||||
|
unique (collection_id, position)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
"""
|
||||||
|
CREATE TABLE collection_tags (
|
||||||
|
collection_id uuid not null,
|
||||||
|
tag_id uuid not null,
|
||||||
|
primary key (collection_id, tag_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
};
|
||||||
|
|
||||||
|
String[] createIndexStatements = {
|
||||||
|
"CREATE INDEX idx_reading_position_story ON reading_positions (story_id)"
|
||||||
|
};
|
||||||
|
|
||||||
|
String[] createConstraintStatements = {
|
||||||
|
// Foreign key constraints
|
||||||
|
"ALTER TABLE author_urls ADD CONSTRAINT FKdqhp51m0uveybsts098gd79uo FOREIGN KEY (author_id) REFERENCES authors",
|
||||||
|
"ALTER TABLE stories ADD CONSTRAINT FKhwecpqeaxy40ftrctef1u7gw7 FOREIGN KEY (author_id) REFERENCES authors",
|
||||||
|
"ALTER TABLE stories ADD CONSTRAINT FK1kulyvy7wwcolp2gkndt57cp7 FOREIGN KEY (series_id) REFERENCES series",
|
||||||
|
"ALTER TABLE reading_positions ADD CONSTRAINT FKglfhdhflan3pgyr2u0gxi21i5 FOREIGN KEY (story_id) REFERENCES stories",
|
||||||
|
"ALTER TABLE story_tags ADD CONSTRAINT FKmans33ijt0nf65t0sng2r848j FOREIGN KEY (tag_id) REFERENCES tags",
|
||||||
|
"ALTER TABLE story_tags ADD CONSTRAINT FKq9guid7swnjxwdpgxj3jo1rsi FOREIGN KEY (story_id) REFERENCES stories",
|
||||||
|
"ALTER TABLE tag_aliases ADD CONSTRAINT FKqfsawmcj3ey4yycb6958y24ch FOREIGN KEY (canonical_tag_id) REFERENCES tags",
|
||||||
|
"ALTER TABLE collection_stories ADD CONSTRAINT FKr55ho4vhj0wp03x13iskr1jds FOREIGN KEY (collection_id) REFERENCES collections",
|
||||||
|
"ALTER TABLE collection_stories ADD CONSTRAINT FK7n41tbbrt7r2e81hpu3612r1o FOREIGN KEY (story_id) REFERENCES stories",
|
||||||
|
"ALTER TABLE collection_tags ADD CONSTRAINT FKceq7ggev8n8ibjui1x5yo4x67 FOREIGN KEY (tag_id) REFERENCES tags",
|
||||||
|
"ALTER TABLE collection_tags ADD CONSTRAINT FKq9sa5s8csdpbphrvb48tts8jt FOREIGN KEY (collection_id) REFERENCES collections"
|
||||||
|
};
|
||||||
|
|
||||||
|
try (var connection = dataSource.getConnection();
|
||||||
|
var statement = connection.createStatement()) {
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
for (String sql : createTableStatements) {
|
||||||
|
statement.executeUpdate(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
for (String sql : createIndexStatements) {
|
||||||
|
statement.executeUpdate(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create constraints
|
||||||
|
for (String sql : createConstraintStatements) {
|
||||||
|
statement.executeUpdate(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Successfully created all database tables and constraints");
|
||||||
|
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.error("Failed to create database schema", e);
|
||||||
|
throw new RuntimeException("Schema creation failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Client createTypesenseClient(String collection) {
|
||||||
|
logger.info("Creating Typesense client for collection: {}", collection);
|
||||||
|
|
||||||
|
List<Node> nodes = Arrays.asList(
|
||||||
|
new Node("http", typesenseHost, typesensePort)
|
||||||
|
);
|
||||||
|
|
||||||
|
org.typesense.api.Configuration configuration = new org.typesense.api.Configuration(nodes, Duration.ofSeconds(10), typesenseApiKey);
|
||||||
|
return new Client(configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeCurrentResources() {
|
||||||
|
// No need to close datasource - SmartRoutingDataSource handles this
|
||||||
|
// Typesense client doesn't need explicit cleanup
|
||||||
|
currentTypesenseClient = null;
|
||||||
|
// Don't clear currentLibraryId here - only when explicitly switching
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update library metadata (name and description)
|
||||||
|
*/
|
||||||
|
public synchronized void updateLibraryMetadata(String libraryId, String newName, String newDescription) throws Exception {
|
||||||
|
if (libraryId == null || libraryId.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Library ID cannot be null or empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
Library library = libraries.get(libraryId);
|
||||||
|
if (library == null) {
|
||||||
|
throw new IllegalArgumentException("Library not found: " + libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new name
|
||||||
|
if (newName == null || newName.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Library name cannot be null or empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
String oldName = library.getName();
|
||||||
|
String oldDescription = library.getDescription();
|
||||||
|
|
||||||
|
// Update the library object
|
||||||
|
library.setName(newName.trim());
|
||||||
|
library.setDescription(newDescription != null ? newDescription.trim() : "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save to configuration file
|
||||||
|
saveLibraryConfiguration(library);
|
||||||
|
|
||||||
|
logger.info("Updated library metadata - ID: {}, Name: '{}' -> '{}', Description: '{}' -> '{}'",
|
||||||
|
libraryId, oldName, newName, oldDescription, library.getDescription());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Rollback changes on failure
|
||||||
|
library.setName(oldName);
|
||||||
|
library.setDescription(oldDescription);
|
||||||
|
throw new RuntimeException("Failed to update library metadata: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save library configuration to file
|
||||||
|
*/
|
||||||
|
private void saveLibraryConfiguration(Library library) throws Exception {
|
||||||
|
Path libraryConfigPath = libraryConfigDir.resolve(library.getId() + ".json");
|
||||||
|
|
||||||
|
// Create library configuration object
|
||||||
|
Map<String, Object> config = new HashMap<>();
|
||||||
|
config.put("id", library.getId());
|
||||||
|
config.put("name", library.getName());
|
||||||
|
config.put("description", library.getDescription());
|
||||||
|
config.put("passwordHash", library.getPasswordHash());
|
||||||
|
config.put("dbName", library.getDbName());
|
||||||
|
config.put("typesenseCollection", library.getTypesenseCollection());
|
||||||
|
config.put("imagePath", library.getImagePath());
|
||||||
|
config.put("initialized", library.isInitialized());
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
String configJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(config);
|
||||||
|
Files.writeString(libraryConfigPath, configJson, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
logger.debug("Saved library configuration to: {}", libraryConfigPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,83 @@
|
|||||||
package com.storycove.service;
|
package com.storycove.service;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import com.storycove.util.JwtUtil;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PasswordAuthenticationService {
|
public class PasswordAuthenticationService {
|
||||||
|
|
||||||
@Value("${storycove.auth.password}")
|
private static final Logger logger = LoggerFactory.getLogger(PasswordAuthenticationService.class);
|
||||||
private String applicationPassword;
|
|
||||||
|
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final LibraryService libraryService;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
public PasswordAuthenticationService(PasswordEncoder passwordEncoder) {
|
@Autowired
|
||||||
|
public PasswordAuthenticationService(
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
LibraryService libraryService,
|
||||||
|
JwtUtil jwtUtil) {
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.libraryService = libraryService;
|
||||||
|
this.jwtUtil = jwtUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean authenticate(String providedPassword) {
|
/**
|
||||||
|
* Authenticate user and switch to the appropriate library
|
||||||
|
* Returns JWT token if authentication successful, null otherwise
|
||||||
|
*/
|
||||||
|
public String authenticateAndSwitchLibrary(String providedPassword) {
|
||||||
if (providedPassword == null || providedPassword.trim().isEmpty()) {
|
if (providedPassword == null || providedPassword.trim().isEmpty()) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If application password starts with {bcrypt}, it's already encoded
|
// Find which library this password belongs to
|
||||||
if (applicationPassword.startsWith("{bcrypt}") || applicationPassword.startsWith("$2")) {
|
String libraryId = libraryService.authenticateAndGetLibrary(providedPassword);
|
||||||
return passwordEncoder.matches(providedPassword, applicationPassword);
|
if (libraryId == null) {
|
||||||
|
logger.warn("Authentication failed - invalid password");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, compare directly (for development/testing)
|
try {
|
||||||
return applicationPassword.equals(providedPassword);
|
// Switch to the authenticated library with forced reindexing (may take 2-3 seconds)
|
||||||
|
libraryService.switchToLibraryAfterAuthentication(libraryId);
|
||||||
|
|
||||||
|
// Generate JWT token with library context
|
||||||
|
String token = jwtUtil.generateToken("user", libraryId);
|
||||||
|
|
||||||
|
logger.info("Successfully authenticated and switched to library: {}", libraryId);
|
||||||
|
return token;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to switch to library: {}", libraryId, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy method - kept for backward compatibility
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public boolean authenticate(String providedPassword) {
|
||||||
|
return authenticateAndSwitchLibrary(providedPassword) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String encodePassword(String rawPassword) {
|
public String encodePassword(String rawPassword) {
|
||||||
return passwordEncoder.encode(rawPassword);
|
return passwordEncoder.encode(rawPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current library info for authenticated user
|
||||||
|
*/
|
||||||
|
public String getCurrentLibraryInfo() {
|
||||||
|
var library = libraryService.getCurrentLibrary();
|
||||||
|
if (library != null) {
|
||||||
|
return String.format("Library: %s (%s)", library.getName(), library.getId());
|
||||||
|
}
|
||||||
|
return "No library active";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ import com.storycove.repository.SeriesRepository;
|
|||||||
import com.storycove.service.exception.DuplicateResourceException;
|
import com.storycove.service.exception.DuplicateResourceException;
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -20,6 +22,8 @@ import java.util.UUID;
|
|||||||
@Validated
|
@Validated
|
||||||
@Transactional
|
@Transactional
|
||||||
public class SeriesService {
|
public class SeriesService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SeriesService.class);
|
||||||
|
|
||||||
private final SeriesRepository seriesRepository;
|
private final SeriesRepository seriesRepository;
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import com.storycove.repository.TagRepository;
|
|||||||
import com.storycove.service.exception.DuplicateResourceException;
|
import com.storycove.service.exception.DuplicateResourceException;
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -25,11 +26,14 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Validated
|
@Validated
|
||||||
@Transactional
|
@Transactional
|
||||||
public class StoryService {
|
public class StoryService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(StoryService.class);
|
||||||
|
|
||||||
private final StoryRepository storyRepository;
|
private final StoryRepository storyRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
@@ -79,11 +83,13 @@ public class StoryService {
|
|||||||
return storyRepository.findById(id)
|
return storyRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Optional<Story> findByIdOptional(UUID id) {
|
public Optional<Story> findByIdOptional(UUID id) {
|
||||||
return storyRepository.findById(id);
|
return storyRepository.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Optional<Story> findByTitle(String title) {
|
public Optional<Story> findByTitle(String title) {
|
||||||
@@ -119,7 +125,7 @@ public class StoryService {
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<Story> findBySeries(UUID seriesId) {
|
public List<Story> findBySeries(UUID seriesId) {
|
||||||
Series series = seriesService.findById(seriesId);
|
seriesService.findById(seriesId); // Validate series exists
|
||||||
return storyRepository.findBySeriesOrderByVolume(seriesId);
|
return storyRepository.findBySeriesOrderByVolume(seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,9 +621,24 @@ public class StoryService {
|
|||||||
Author author = authorService.findById(updateReq.getAuthorId());
|
Author author = authorService.findById(updateReq.getAuthorId());
|
||||||
story.setAuthor(author);
|
story.setAuthor(author);
|
||||||
}
|
}
|
||||||
|
// Handle series - either by ID or by name
|
||||||
if (updateReq.getSeriesId() != null) {
|
if (updateReq.getSeriesId() != null) {
|
||||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||||
story.setSeries(series);
|
story.setSeries(series);
|
||||||
|
} else if (updateReq.getSeriesName() != null) {
|
||||||
|
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||||
|
// Empty series name means remove from series
|
||||||
|
story.setSeries(null);
|
||||||
|
} else {
|
||||||
|
// Find or create series by name
|
||||||
|
Series series = seriesService.findByNameOptional(updateReq.getSeriesName().trim())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Series newSeries = new Series();
|
||||||
|
newSeries.setName(updateReq.getSeriesName().trim());
|
||||||
|
return seriesService.create(newSeries);
|
||||||
|
});
|
||||||
|
story.setSeries(series);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,4 +671,137 @@ public class StoryService {
|
|||||||
}
|
}
|
||||||
return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim());
|
return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a random story based on optional filters.
|
||||||
|
* Uses Typesense for consistency with Library search functionality.
|
||||||
|
* Supports text search and multiple tags using the same logic as the Library view.
|
||||||
|
* @param searchQuery Optional search query
|
||||||
|
* @param tags Optional list of tags to filter by
|
||||||
|
* @return Optional containing the random story if found
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
|
||||||
|
return findRandomStory(searchQuery, tags, null, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed) {
|
||||||
|
return findRandomStory(searchQuery, tags, seed, null, null, null, null, null, null,
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a random story based on optional filters with seed support.
|
||||||
|
* Uses Typesense for consistency with Library search functionality.
|
||||||
|
* Supports text search and multiple tags using the same logic as the Library view.
|
||||||
|
* @param searchQuery Optional search query
|
||||||
|
* @param tags Optional list of tags to filter by
|
||||||
|
* @param seed Optional seed for consistent randomization (null for truly random)
|
||||||
|
* @return Optional containing the random story if found
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed,
|
||||||
|
Integer minWordCount, Integer maxWordCount,
|
||||||
|
String createdAfter, String createdBefore,
|
||||||
|
String lastReadAfter, String lastReadBefore,
|
||||||
|
Integer minRating, Integer maxRating, Boolean unratedOnly,
|
||||||
|
String readingStatus, Boolean hasReadingProgress,
|
||||||
|
Boolean hasCoverImage, String sourceDomain,
|
||||||
|
String seriesFilter, Integer minTagCount,
|
||||||
|
Boolean popularOnly, Boolean hiddenGemsOnly) {
|
||||||
|
|
||||||
|
// Use Typesense if available for consistency with Library search
|
||||||
|
if (typesenseService != null) {
|
||||||
|
try {
|
||||||
|
Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags, seed,
|
||||||
|
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
|
||||||
|
minRating, maxRating, unratedOnly, readingStatus, hasReadingProgress, hasCoverImage,
|
||||||
|
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
|
||||||
|
if (randomStoryId.isPresent()) {
|
||||||
|
return storyRepository.findById(randomStoryId.get());
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Fallback to database queries if Typesense fails
|
||||||
|
logger.warn("Typesense random story lookup failed, falling back to database queries", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to repository-based implementation (global routing handles library selection)
|
||||||
|
return findRandomStoryFromRepository(searchQuery, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find random story using repository methods (for default database or when library-aware fails)
|
||||||
|
*/
|
||||||
|
private Optional<Story> findRandomStoryFromRepository(String searchQuery, List<String> tags) {
|
||||||
|
// Clean up inputs
|
||||||
|
String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null;
|
||||||
|
List<String> cleanTags = (tags != null) ? tags.stream()
|
||||||
|
.filter(tag -> tag != null && !tag.trim().isEmpty())
|
||||||
|
.map(String::trim)
|
||||||
|
.collect(Collectors.toList()) : List.of();
|
||||||
|
|
||||||
|
long totalCount = 0;
|
||||||
|
Optional<Story> randomStory = Optional.empty();
|
||||||
|
|
||||||
|
if (cleanSearchQuery != null && !cleanTags.isEmpty()) {
|
||||||
|
// Both search query and tags
|
||||||
|
String searchPattern = "%" + cleanSearchQuery + "%";
|
||||||
|
List<String> upperCaseTags = cleanTags.stream()
|
||||||
|
.map(String::toUpperCase)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
totalCount = storyRepository.countStoriesByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size());
|
||||||
|
if (totalCount > 0) {
|
||||||
|
long randomOffset = (long) (Math.random() * totalCount);
|
||||||
|
randomStory = storyRepository.findRandomStoryByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size(), randomOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (cleanSearchQuery != null) {
|
||||||
|
// Only search query
|
||||||
|
String searchPattern = "%" + cleanSearchQuery + "%";
|
||||||
|
totalCount = storyRepository.countStoriesByTextSearch(searchPattern);
|
||||||
|
if (totalCount > 0) {
|
||||||
|
long randomOffset = (long) (Math.random() * totalCount);
|
||||||
|
randomStory = storyRepository.findRandomStoryByTextSearch(searchPattern, randomOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (!cleanTags.isEmpty()) {
|
||||||
|
// Only tags
|
||||||
|
if (cleanTags.size() == 1) {
|
||||||
|
// Single tag - use optimized single tag query
|
||||||
|
totalCount = storyRepository.countStoriesByTagName(cleanTags.get(0));
|
||||||
|
if (totalCount > 0) {
|
||||||
|
long randomOffset = (long) (Math.random() * totalCount);
|
||||||
|
randomStory = storyRepository.findRandomStoryByTagName(cleanTags.get(0), randomOffset);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple tags
|
||||||
|
List<String> upperCaseTags = cleanTags.stream()
|
||||||
|
.map(String::toUpperCase)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
totalCount = storyRepository.countStoriesByMultipleTags(upperCaseTags, cleanTags.size());
|
||||||
|
if (totalCount > 0) {
|
||||||
|
long randomOffset = (long) (Math.random() * totalCount);
|
||||||
|
randomStory = storyRepository.findRandomStoryByMultipleTags(upperCaseTags, cleanTags.size(), randomOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No filters - get random from all stories
|
||||||
|
totalCount = storyRepository.countAllStories();
|
||||||
|
if (totalCount > 0) {
|
||||||
|
long randomOffset = (long) (Math.random() * totalCount);
|
||||||
|
randomStory = storyRepository.findRandomStory(randomOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomStory;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
1192
backend/src/main/java/com/storycove/service/StoryService.java.backup
Normal file
1192
backend/src/main/java/com/storycove/service/StoryService.java.backup
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
|||||||
package com.storycove.service;
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.entity.Story;
|
||||||
import com.storycove.entity.Tag;
|
import com.storycove.entity.Tag;
|
||||||
|
import com.storycove.entity.TagAlias;
|
||||||
import com.storycove.repository.TagRepository;
|
import com.storycove.repository.TagRepository;
|
||||||
|
import com.storycove.repository.TagAliasRepository;
|
||||||
import com.storycove.service.exception.DuplicateResourceException;
|
import com.storycove.service.exception.DuplicateResourceException;
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -12,20 +17,27 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Validated
|
@Validated
|
||||||
@Transactional
|
@Transactional
|
||||||
public class TagService {
|
public class TagService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TagService.class);
|
||||||
|
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
|
private final TagAliasRepository tagAliasRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public TagService(TagRepository tagRepository) {
|
public TagService(TagRepository tagRepository, TagAliasRepository tagAliasRepository) {
|
||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
|
this.tagAliasRepository = tagAliasRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -207,5 +219,273 @@ public class TagService {
|
|||||||
if (updates.getName() != null) {
|
if (updates.getName() != null) {
|
||||||
existing.setName(updates.getName());
|
existing.setName(updates.getName());
|
||||||
}
|
}
|
||||||
|
if (updates.getColor() != null) {
|
||||||
|
existing.setColor(updates.getColor());
|
||||||
|
}
|
||||||
|
if (updates.getDescription() != null) {
|
||||||
|
existing.setDescription(updates.getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag alias management methods
|
||||||
|
|
||||||
|
public TagAlias addAlias(UUID tagId, String aliasName) {
|
||||||
|
Tag canonicalTag = findById(tagId);
|
||||||
|
|
||||||
|
// Check if alias already exists (case-insensitive)
|
||||||
|
if (tagAliasRepository.existsByAliasNameIgnoreCase(aliasName)) {
|
||||||
|
throw new DuplicateResourceException("Tag alias", aliasName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if alias name conflicts with existing tag names
|
||||||
|
if (tagRepository.existsByNameIgnoreCase(aliasName)) {
|
||||||
|
throw new DuplicateResourceException("Tag alias conflicts with existing tag name", aliasName);
|
||||||
|
}
|
||||||
|
|
||||||
|
TagAlias alias = new TagAlias();
|
||||||
|
alias.setAliasName(aliasName);
|
||||||
|
alias.setCanonicalTag(canonicalTag);
|
||||||
|
alias.setCreatedFromMerge(false);
|
||||||
|
|
||||||
|
return tagAliasRepository.save(alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeAlias(UUID tagId, UUID aliasId) {
|
||||||
|
findById(tagId); // Validate tag exists
|
||||||
|
TagAlias alias = tagAliasRepository.findById(aliasId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Tag alias", aliasId.toString()));
|
||||||
|
|
||||||
|
// Verify the alias belongs to the specified tag
|
||||||
|
if (!alias.getCanonicalTag().getId().equals(tagId)) {
|
||||||
|
throw new IllegalArgumentException("Alias does not belong to the specified tag");
|
||||||
|
}
|
||||||
|
|
||||||
|
tagAliasRepository.delete(alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Tag resolveTagByName(String name) {
|
||||||
|
// First try to find exact tag match
|
||||||
|
Optional<Tag> directMatch = tagRepository.findByNameIgnoreCase(name);
|
||||||
|
if (directMatch.isPresent()) {
|
||||||
|
return directMatch.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try to find by alias
|
||||||
|
Optional<TagAlias> aliasMatch = tagAliasRepository.findByAliasNameIgnoreCase(name);
|
||||||
|
if (aliasMatch.isPresent()) {
|
||||||
|
return aliasMatch.get().getCanonicalTag();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Tag mergeTags(List<UUID> sourceTagIds, UUID targetTagId) {
|
||||||
|
// Validate target tag exists
|
||||||
|
Tag targetTag = findById(targetTagId);
|
||||||
|
|
||||||
|
// Validate source tags exist and are different from target
|
||||||
|
List<Tag> sourceTags = sourceTagIds.stream()
|
||||||
|
.filter(id -> !id.equals(targetTagId)) // Don't merge tag with itself
|
||||||
|
.map(this::findById)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (sourceTags.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("No valid source tags to merge");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the merge atomically
|
||||||
|
for (Tag sourceTag : sourceTags) {
|
||||||
|
// Move all stories from source tag to target tag
|
||||||
|
// Create a copy to avoid ConcurrentModificationException
|
||||||
|
List<Story> storiesToMove = new ArrayList<>(sourceTag.getStories());
|
||||||
|
storiesToMove.forEach(story -> {
|
||||||
|
story.removeTag(sourceTag);
|
||||||
|
story.addTag(targetTag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create alias for the source tag name
|
||||||
|
TagAlias alias = new TagAlias();
|
||||||
|
alias.setAliasName(sourceTag.getName());
|
||||||
|
alias.setCanonicalTag(targetTag);
|
||||||
|
alias.setCreatedFromMerge(true);
|
||||||
|
tagAliasRepository.save(alias);
|
||||||
|
|
||||||
|
// Delete the source tag
|
||||||
|
tagRepository.delete(sourceTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagRepository.save(targetTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Tag> findByNameOrAliasStartingWith(String query, int limit) {
|
||||||
|
// Find tags that start with the query
|
||||||
|
List<Tag> directMatches = tagRepository.findByNameStartingWithIgnoreCase(query.toLowerCase());
|
||||||
|
|
||||||
|
// Find tags via aliases that start with the query
|
||||||
|
List<TagAlias> aliasMatches = tagAliasRepository.findByAliasNameStartingWithIgnoreCase(query.toLowerCase());
|
||||||
|
List<Tag> aliasTagMatches = aliasMatches.stream()
|
||||||
|
.map(TagAlias::getCanonicalTag)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Combine and deduplicate
|
||||||
|
Set<Tag> allMatches = new HashSet<>(directMatches);
|
||||||
|
allMatches.addAll(aliasTagMatches);
|
||||||
|
|
||||||
|
// Convert to list and limit results
|
||||||
|
return allMatches.stream()
|
||||||
|
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
|
||||||
|
.limit(limit)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public com.storycove.controller.TagController.MergePreviewResponse previewMerge(List<UUID> sourceTagIds, UUID targetTagId) {
|
||||||
|
// Validate target tag exists
|
||||||
|
Tag targetTag = findById(targetTagId);
|
||||||
|
|
||||||
|
// Validate source tags exist and are different from target
|
||||||
|
List<Tag> sourceTags = sourceTagIds.stream()
|
||||||
|
.filter(id -> !id.equals(targetTagId))
|
||||||
|
.map(this::findById)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (sourceTags.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("No valid source tags to merge");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate preview data
|
||||||
|
int targetStoryCount = targetTag.getStories().size();
|
||||||
|
|
||||||
|
// Collect all unique stories from all tags (including target) to handle overlaps correctly
|
||||||
|
Set<Story> allUniqueStories = new HashSet<>(targetTag.getStories());
|
||||||
|
for (Tag sourceTag : sourceTags) {
|
||||||
|
allUniqueStories.addAll(sourceTag.getStories());
|
||||||
|
}
|
||||||
|
int totalStories = allUniqueStories.size();
|
||||||
|
|
||||||
|
List<String> aliasesToCreate = sourceTags.stream()
|
||||||
|
.map(Tag::getName)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Create response object using the controller's inner class
|
||||||
|
var preview = new com.storycove.controller.TagController.MergePreviewResponse();
|
||||||
|
preview.setTargetTagName(targetTag.getName());
|
||||||
|
preview.setTargetStoryCount(targetStoryCount);
|
||||||
|
preview.setTotalResultStoryCount(totalStories);
|
||||||
|
preview.setAliasesToCreate(aliasesToCreate);
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<com.storycove.controller.TagController.TagSuggestion> suggestTags(String title, String content, String summary, int limit) {
|
||||||
|
List<com.storycove.controller.TagController.TagSuggestion> suggestions = new ArrayList<>();
|
||||||
|
|
||||||
|
// Get all existing tags for matching
|
||||||
|
List<Tag> existingTags = findAll();
|
||||||
|
|
||||||
|
// Combine all text for analysis
|
||||||
|
String combinedText = (title != null ? title : "") + " " +
|
||||||
|
(summary != null ? summary : "") + " " +
|
||||||
|
(content != null ? stripHtml(content) : "");
|
||||||
|
|
||||||
|
if (combinedText.trim().isEmpty()) {
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
String lowerText = combinedText.toLowerCase();
|
||||||
|
|
||||||
|
// Score each existing tag based on how well it matches the content
|
||||||
|
for (Tag tag : existingTags) {
|
||||||
|
double score = calculateTagRelevanceScore(tag, lowerText, title, summary);
|
||||||
|
|
||||||
|
if (score > 0.1) { // Only suggest tags with reasonable confidence
|
||||||
|
String reason = generateReason(tag, lowerText, title, summary);
|
||||||
|
suggestions.add(new com.storycove.controller.TagController.TagSuggestion(
|
||||||
|
tag.getName(), score, reason
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by confidence score (descending) and limit results
|
||||||
|
return suggestions.stream()
|
||||||
|
.sorted((a, b) -> Double.compare(b.getConfidence(), a.getConfidence()))
|
||||||
|
.limit(limit)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private double calculateTagRelevanceScore(Tag tag, String lowerText, String title, String summary) {
|
||||||
|
String tagName = tag.getName().toLowerCase();
|
||||||
|
double score = 0.0;
|
||||||
|
|
||||||
|
// Exact matches get highest score
|
||||||
|
if (lowerText.contains(" " + tagName + " ") || lowerText.startsWith(tagName + " ") || lowerText.endsWith(" " + tagName)) {
|
||||||
|
score += 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial matches in title get high score
|
||||||
|
if (title != null && title.toLowerCase().contains(tagName)) {
|
||||||
|
score += 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial matches in summary get medium score
|
||||||
|
if (summary != null && summary.toLowerCase().contains(tagName)) {
|
||||||
|
score += 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word-based matching (split tag name and look for individual words)
|
||||||
|
String[] tagWords = tagName.split("[\\s-_]+");
|
||||||
|
int matchedWords = 0;
|
||||||
|
for (String word : tagWords) {
|
||||||
|
if (word.length() > 2 && lowerText.contains(word)) {
|
||||||
|
matchedWords++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tagWords.length > 0) {
|
||||||
|
score += 0.3 * ((double) matchedWords / tagWords.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost score based on tag popularity (more used tags are more likely to be relevant)
|
||||||
|
int storyCount = tag.getStories() != null ? tag.getStories().size() : 0;
|
||||||
|
if (storyCount > 0) {
|
||||||
|
score += Math.min(0.2, storyCount * 0.01); // Small boost, capped at 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(1.0, score); // Cap at 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateReason(Tag tag, String lowerText, String title, String summary) {
|
||||||
|
String tagName = tag.getName().toLowerCase();
|
||||||
|
|
||||||
|
if (title != null && title.toLowerCase().contains(tagName)) {
|
||||||
|
return "Found in title";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary != null && summary.toLowerCase().contains(tagName)) {
|
||||||
|
return "Found in summary";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerText.contains(" " + tagName + " ") || lowerText.startsWith(tagName + " ") || lowerText.endsWith(" " + tagName)) {
|
||||||
|
return "Exact match in content";
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] tagWords = tagName.split("[\\s-_]+");
|
||||||
|
for (String word : tagWords) {
|
||||||
|
if (word.length() > 2 && lowerText.contains(word)) {
|
||||||
|
return "Related keywords found";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Similar content";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripHtml(String html) {
|
||||||
|
if (html == null) return "";
|
||||||
|
// Simple HTML tag removal - replace with a proper HTML parser if needed
|
||||||
|
return html.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,35 +3,64 @@ package com.storycove.util;
|
|||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import io.jsonwebtoken.security.Keys;
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class JwtUtil {
|
public class JwtUtil {
|
||||||
|
|
||||||
@Value("${storycove.jwt.secret}")
|
private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class);
|
||||||
|
|
||||||
|
// Security: Generate new secret on each startup to invalidate all existing tokens
|
||||||
private String secret;
|
private String secret;
|
||||||
|
|
||||||
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default
|
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default
|
||||||
private Long expiration;
|
private Long expiration;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void initialize() {
|
||||||
|
// Generate a new random secret on startup to invalidate all existing JWT tokens
|
||||||
|
// This ensures users must re-authenticate after application restart
|
||||||
|
SecureRandom random = new SecureRandom();
|
||||||
|
byte[] secretBytes = new byte[64]; // 512 bits
|
||||||
|
random.nextBytes(secretBytes);
|
||||||
|
this.secret = Base64.getEncoder().encodeToString(secretBytes);
|
||||||
|
|
||||||
|
logger.info("JWT secret rotated on startup - all existing tokens invalidated");
|
||||||
|
logger.info("Users will need to re-authenticate after application restart for security");
|
||||||
|
}
|
||||||
|
|
||||||
private SecretKey getSigningKey() {
|
private SecretKey getSigningKey() {
|
||||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
public String generateToken() {
|
public String generateToken() {
|
||||||
|
return generateToken("user", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(String subject, String libraryId) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + expiration);
|
Date expiryDate = new Date(now.getTime() + expiration);
|
||||||
|
|
||||||
return Jwts.builder()
|
var builder = Jwts.builder()
|
||||||
.subject("user")
|
.subject(subject)
|
||||||
.issuedAt(now)
|
.issuedAt(now)
|
||||||
.expiration(expiryDate)
|
.expiration(expiryDate);
|
||||||
.signWith(getSigningKey())
|
|
||||||
.compact();
|
// Add library context if provided
|
||||||
|
if (libraryId != null) {
|
||||||
|
builder.claim("libraryId", libraryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.signWith(getSigningKey()).compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean validateToken(String token) {
|
public boolean validateToken(String token) {
|
||||||
@@ -62,4 +91,13 @@ public class JwtUtil {
|
|||||||
public String getSubjectFromToken(String token) {
|
public String getSubjectFromToken(String token) {
|
||||||
return getClaimsFromToken(token).getSubject();
|
return getClaimsFromToken(token).getSubject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getLibraryIdFromToken(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = getClaimsFromToken(token);
|
||||||
|
return claims.get("libraryId", String.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,8 +16,8 @@ spring:
|
|||||||
|
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 10MB # Reduced for security (was 250MB)
|
max-file-size: 256MB # Increased for backup restore
|
||||||
max-request-size: 15MB # Slightly higher to account for form data
|
max-request-size: 260MB # Slightly higher to account for form data
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
||||||
"sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var",
|
"sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var",
|
||||||
"ul", "ol", "li", "dl", "dt", "dd",
|
"ul", "ol", "li", "dl", "dt", "dd",
|
||||||
"a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
"a", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
||||||
"blockquote", "cite", "q", "hr", "details", "summary"
|
"blockquote", "cite", "q", "hr", "details", "summary"
|
||||||
],
|
],
|
||||||
"allowedAttributes": {
|
"allowedAttributes": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"h5": ["class", "style"],
|
"h5": ["class", "style"],
|
||||||
"h6": ["class", "style"],
|
"h6": ["class", "style"],
|
||||||
"a": ["class", "href", "title"],
|
"a": ["class", "href", "title"],
|
||||||
|
"img": ["src", "alt", "width", "height", "class", "style"],
|
||||||
"table": ["class", "style"],
|
"table": ["class", "style"],
|
||||||
"th": ["class", "style", "colspan", "rowspan"],
|
"th": ["class", "style", "colspan", "rowspan"],
|
||||||
"td": ["class", "style", "colspan", "rowspan"],
|
"td": ["class", "style", "colspan", "rowspan"],
|
||||||
@@ -41,6 +42,9 @@
|
|||||||
"allowedProtocols": {
|
"allowedProtocols": {
|
||||||
"a": {
|
"a": {
|
||||||
"href": ["http", "https", "#", "/"]
|
"href": ["http", "https", "#", "/"]
|
||||||
|
},
|
||||||
|
"img": {
|
||||||
|
"src": ["http", "https", "data", "/", "cid"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency."
|
"description": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency."
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ public abstract class BaseRepositoryTest {
|
|||||||
private static final PostgreSQLContainer<?> postgres;
|
private static final PostgreSQLContainer<?> postgres;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
postgres = new PostgreSQLContainer<>("postgres:15-alpine")
|
@SuppressWarnings("resource") // Container is managed by shutdown hook
|
||||||
|
PostgreSQLContainer<?> container = new PostgreSQLContainer<>("postgres:15-alpine")
|
||||||
.withDatabaseName("storycove_test")
|
.withDatabaseName("storycove_test")
|
||||||
.withUsername("test")
|
.withUsername("test")
|
||||||
.withPassword("test");
|
.withPassword("test");
|
||||||
|
postgres = container;
|
||||||
postgres.start();
|
postgres.start();
|
||||||
|
|
||||||
// Add shutdown hook to properly close the container
|
// Add shutdown hook to properly close the container
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -23,7 +22,6 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
|
|
||||||
@@ -46,7 +44,7 @@ class AuthorServiceTest {
|
|||||||
testAuthor.setId(testId);
|
testAuthor.setId(testId);
|
||||||
testAuthor.setNotes("Test notes");
|
testAuthor.setNotes("Test notes");
|
||||||
|
|
||||||
// Initialize service with null TypesenseService (which is allowed)
|
// Initialize service with null TypesenseService (which is allowed for tests)
|
||||||
authorService = new AuthorService(authorRepository, null);
|
authorService = new AuthorService(authorRepository, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +174,7 @@ class AuthorServiceTest {
|
|||||||
when(authorRepository.existsByName("Updated Author")).thenReturn(false);
|
when(authorRepository.existsByName("Updated Author")).thenReturn(false);
|
||||||
when(authorRepository.save(any(Author.class))).thenReturn(testAuthor);
|
when(authorRepository.save(any(Author.class))).thenReturn(testAuthor);
|
||||||
|
|
||||||
Author result = authorService.update(testId, updates);
|
authorService.update(testId, updates);
|
||||||
|
|
||||||
assertEquals("Updated Author", testAuthor.getName());
|
assertEquals("Updated Author", testAuthor.getName());
|
||||||
assertEquals("Updated notes", testAuthor.getNotes());
|
assertEquals("Updated notes", testAuthor.getNotes());
|
||||||
@@ -318,7 +316,7 @@ class AuthorServiceTest {
|
|||||||
when(authorRepository.findById(testId)).thenReturn(Optional.of(testAuthor));
|
when(authorRepository.findById(testId)).thenReturn(Optional.of(testAuthor));
|
||||||
when(authorRepository.save(any(Author.class))).thenReturn(testAuthor);
|
when(authorRepository.save(any(Author.class))).thenReturn(testAuthor);
|
||||||
|
|
||||||
Author result = authorService.setRating(testId, 4);
|
authorService.setRating(testId, 4);
|
||||||
|
|
||||||
assertEquals(4, testAuthor.getAuthorRating());
|
assertEquals(4, testAuthor.getAuthorRating());
|
||||||
verify(authorRepository, times(2)).findById(testId); // Called twice: once initially, once after flush
|
verify(authorRepository, times(2)).findById(testId); // Called twice: once initially, once after flush
|
||||||
@@ -342,7 +340,7 @@ class AuthorServiceTest {
|
|||||||
when(authorRepository.findById(testId)).thenReturn(Optional.of(testAuthor));
|
when(authorRepository.findById(testId)).thenReturn(Optional.of(testAuthor));
|
||||||
when(authorRepository.save(any(Author.class))).thenReturn(testAuthor);
|
when(authorRepository.save(any(Author.class))).thenReturn(testAuthor);
|
||||||
|
|
||||||
Author result = authorService.setRating(testId, null);
|
authorService.setRating(testId, null);
|
||||||
|
|
||||||
assertNull(testAuthor.getAuthorRating());
|
assertNull(testAuthor.getAuthorRating());
|
||||||
verify(authorRepository, times(2)).findById(testId); // Called twice: once initially, once after flush
|
verify(authorRepository, times(2)).findById(testId); // Called twice: once initially, once after flush
|
||||||
|
|||||||
4
cookies.txt
Normal file
4
cookies.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ services:
|
|||||||
- STORYCOVE_CORS_ALLOWED_ORIGINS=${STORYCOVE_CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:6925}
|
- STORYCOVE_CORS_ALLOWED_ORIGINS=${STORYCOVE_CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:6925}
|
||||||
volumes:
|
volumes:
|
||||||
- images_data:/app/images
|
- images_data:/app/images
|
||||||
|
- library_config:/app/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
- typesense
|
- typesense
|
||||||
@@ -51,6 +52,8 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
# No port mapping - only accessible within the Docker network
|
# No port mapping - only accessible within the Docker network
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=storycove
|
- POSTGRES_DB=storycove
|
||||||
- POSTGRES_USER=storycove
|
- POSTGRES_USER=storycove
|
||||||
@@ -61,7 +64,7 @@ services:
|
|||||||
- storycove-network
|
- storycove-network
|
||||||
|
|
||||||
typesense:
|
typesense:
|
||||||
image: typesense/typesense:0.25.0
|
image: typesense/typesense:29.0
|
||||||
# No port mapping - only accessible within the Docker network
|
# No port mapping - only accessible within the Docker network
|
||||||
environment:
|
environment:
|
||||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||||
@@ -75,6 +78,7 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
typesense_data:
|
typesense_data:
|
||||||
images_data:
|
images_data:
|
||||||
|
library_config:
|
||||||
|
|
||||||
configs:
|
configs:
|
||||||
nginx_config:
|
nginx_config:
|
||||||
@@ -91,7 +95,7 @@ configs:
|
|||||||
}
|
}
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
client_max_body_size 10M;
|
client_max_body_size 256M;
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend;
|
proxy_pass http://frontend;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -1,40 +1,58 @@
|
|||||||
# Use node 18 alpine for smaller image size
|
# Multi-stage build for better layer caching and smaller final image
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling
|
# Install dumb-init early
|
||||||
RUN apk add --no-cache dumb-init
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files first to leverage Docker layer caching
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install all dependencies (including devDependencies needed for build)
|
# Install dependencies with optimized settings
|
||||||
# Set npm config for better CI performance
|
RUN npm ci --prefer-offline --no-audit --frozen-lockfile
|
||||||
RUN npm ci --prefer-offline --no-audit
|
|
||||||
|
|
||||||
# Copy source code
|
# Build stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependencies from deps stage
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Set Node.js memory limit for build (helpful in constrained environments)
|
# Set Node.js memory limit for build
|
||||||
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Remove devDependencies after build to reduce image size
|
# Production stage
|
||||||
RUN npm prune --omit=dev
|
FROM node:18-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Install dumb-init for proper signal handling
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN addgroup -g 1001 -S nodejs
|
RUN addgroup -g 1001 -S nodejs
|
||||||
RUN adduser -S nextjs -u 1001
|
RUN adduser -S nextjs -u 1001
|
||||||
|
|
||||||
# Change ownership of the app directory
|
# Copy necessary files from builder stage
|
||||||
RUN chown -R nextjs:nodejs /app
|
COPY --from=builder /app/next.config.js* ./
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Use dumb-init to handle signals properly
|
# Use dumb-init to handle signals properly
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["npm", "start"]
|
CMD ["node", "server.js"]
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
// Enable standalone output for optimized Docker builds
|
||||||
|
output: 'standalone',
|
||||||
// Removed Next.js rewrites since nginx handles all API routing
|
// Removed Next.js rewrites since nginx handles all API routing
|
||||||
webpack: (config, { isServer }) => {
|
webpack: (config, { isServer }) => {
|
||||||
// Exclude cheerio and its dependencies from client-side bundling
|
// Exclude cheerio and its dependencies from client-side bundling
|
||||||
|
|||||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -10,9 +10,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.11.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"dompurify": "^3.0.5",
|
"dompurify": "^3.2.6",
|
||||||
"next": "14.0.0",
|
"next": "14.0.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
@@ -1372,13 +1372,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.4",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.11.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"dompurify": "^3.0.5",
|
"dompurify": "^3.2.6",
|
||||||
"next": "14.0.0",
|
"next": "14.0.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
|||||||
@@ -1,39 +1,554 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import ImportLayout from '../../components/layout/ImportLayout';
|
||||||
|
import { Input, Textarea } from '../../components/ui/Input';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import TagInput from '../../components/stories/TagInput';
|
||||||
|
import RichTextEditor from '../../components/stories/RichTextEditor';
|
||||||
|
import ImageUpload from '../../components/ui/ImageUpload';
|
||||||
|
import AuthorSelector from '../../components/stories/AuthorSelector';
|
||||||
|
import SeriesSelector from '../../components/stories/SeriesSelector';
|
||||||
|
import { storyApi, authorApi } from '../../lib/api';
|
||||||
|
|
||||||
export default function AddStoryRedirectPage() {
|
export default function AddStoryPage() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
authorName: '',
|
||||||
|
authorId: undefined as string | undefined,
|
||||||
|
contentHtml: '',
|
||||||
|
sourceUrl: '',
|
||||||
|
tags: [] as string[],
|
||||||
|
seriesName: '',
|
||||||
|
seriesId: undefined as string | undefined,
|
||||||
|
volume: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [processingImages, setProcessingImages] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [duplicateWarning, setDuplicateWarning] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
count: number;
|
||||||
|
duplicates: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
authorName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
}>({ show: false, count: 0, duplicates: [] });
|
||||||
|
const [checkingDuplicates, setCheckingDuplicates] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// Handle URL parameters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect to the new /import route while preserving query parameters
|
|
||||||
const mode = searchParams.get('mode');
|
|
||||||
const authorId = searchParams.get('authorId');
|
const authorId = searchParams.get('authorId');
|
||||||
const from = searchParams.get('from');
|
const from = searchParams.get('from');
|
||||||
|
|
||||||
let redirectUrl = '/import';
|
// Pre-fill author if authorId is provided in URL
|
||||||
const queryParams = new URLSearchParams();
|
if (authorId) {
|
||||||
|
const loadAuthor = async () => {
|
||||||
if (mode) queryParams.set('mode', mode);
|
try {
|
||||||
if (authorId) queryParams.set('authorId', authorId);
|
const author = await authorApi.getAuthor(authorId);
|
||||||
if (from) queryParams.set('from', from);
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
const queryString = queryParams.toString();
|
authorName: author.name,
|
||||||
if (queryString) {
|
authorId: author.id
|
||||||
redirectUrl += '?' + queryString;
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load author:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAuthor();
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(redirectUrl);
|
// Handle URL import data
|
||||||
}, [router, searchParams]);
|
if (from === 'url-import') {
|
||||||
|
const title = searchParams.get('title') || '';
|
||||||
|
const summary = searchParams.get('summary') || '';
|
||||||
|
const author = searchParams.get('author') || '';
|
||||||
|
const sourceUrl = searchParams.get('sourceUrl') || '';
|
||||||
|
const tagsParam = searchParams.get('tags');
|
||||||
|
const content = searchParams.get('content') || '';
|
||||||
|
|
||||||
|
let tags: string[] = [];
|
||||||
|
try {
|
||||||
|
tags = tagsParam ? JSON.parse(tagsParam) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse tags:', error);
|
||||||
|
tags = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
authorName: author,
|
||||||
|
authorId: undefined, // Reset author ID when importing from URL
|
||||||
|
contentHtml: content,
|
||||||
|
sourceUrl,
|
||||||
|
tags
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' });
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Load pending story data from bulk combine operation
|
||||||
|
useEffect(() => {
|
||||||
|
const fromBulkCombine = searchParams.get('from') === 'bulk-combine';
|
||||||
|
if (fromBulkCombine) {
|
||||||
|
const pendingStoryData = localStorage.getItem('pendingStory');
|
||||||
|
if (pendingStoryData) {
|
||||||
|
try {
|
||||||
|
const storyData = JSON.parse(pendingStoryData);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
title: storyData.title || '',
|
||||||
|
authorName: storyData.author || '',
|
||||||
|
authorId: undefined, // Reset author ID for bulk combined stories
|
||||||
|
contentHtml: storyData.content || '',
|
||||||
|
sourceUrl: storyData.sourceUrl || '',
|
||||||
|
summary: storyData.summary || '',
|
||||||
|
tags: storyData.tags || []
|
||||||
|
}));
|
||||||
|
// Clear the pending data
|
||||||
|
localStorage.removeItem('pendingStory');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load pending story data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Check for duplicates when title and author are both present
|
||||||
|
useEffect(() => {
|
||||||
|
const checkDuplicates = async () => {
|
||||||
|
const title = formData.title.trim();
|
||||||
|
const authorName = formData.authorName.trim();
|
||||||
|
|
||||||
|
// Don't check if user isn't authenticated or if title/author are empty
|
||||||
|
if (!isAuthenticated || !title || !authorName) {
|
||||||
|
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the check to avoid too many API calls
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
setCheckingDuplicates(true);
|
||||||
|
const result = await storyApi.checkDuplicate(title, authorName);
|
||||||
|
|
||||||
|
if (result.hasDuplicates) {
|
||||||
|
setDuplicateWarning({
|
||||||
|
show: true,
|
||||||
|
count: result.count,
|
||||||
|
duplicates: result.duplicates
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check for duplicates:', error);
|
||||||
|
// Clear any existing duplicate warnings on error
|
||||||
|
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
||||||
|
// Don't show error to user as this is just a helpful warning
|
||||||
|
// Authentication errors will be handled by the API interceptor
|
||||||
|
} finally {
|
||||||
|
setCheckingDuplicates(false);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkDuplicates();
|
||||||
|
}, [formData.title, formData.authorName, isAuthenticated]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string) => (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: e.target.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (html: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, contentHtml: html }));
|
||||||
|
if (errors.contentHtml) {
|
||||||
|
setErrors(prev => ({ ...prev, contentHtml: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagsChange = (tags: string[]) => {
|
||||||
|
setFormData(prev => ({ ...prev, tags }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
authorName,
|
||||||
|
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user changes author
|
||||||
|
if (errors.authorName) {
|
||||||
|
setErrors(prev => ({ ...prev, authorName: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeriesChange = (seriesName: string, seriesId?: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
seriesName,
|
||||||
|
seriesId: seriesId // This will be undefined if creating new series, which clears the existing ID
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user changes series
|
||||||
|
if (errors.seriesName) {
|
||||||
|
setErrors(prev => ({ ...prev, seriesName: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
newErrors.title = 'Title is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.authorName.trim()) {
|
||||||
|
newErrors.authorName = 'Author name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.contentHtml.trim()) {
|
||||||
|
newErrors.contentHtml = 'Story content is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.seriesName && !formData.volume) {
|
||||||
|
newErrors.volume = 'Volume number is required when series is specified';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.volume && !formData.seriesName.trim()) {
|
||||||
|
newErrors.seriesName = 'Series name is required when volume is specified';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to detect external images in HTML content
|
||||||
|
const hasExternalImages = (htmlContent: string): boolean => {
|
||||||
|
if (!htmlContent) return false;
|
||||||
|
|
||||||
|
// Create a temporary DOM element to parse HTML
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = htmlContent;
|
||||||
|
|
||||||
|
const images = tempDiv.querySelectorAll('img');
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const img = images[i];
|
||||||
|
const src = img.getAttribute('src');
|
||||||
|
if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, create the story with JSON data
|
||||||
|
const storyData = {
|
||||||
|
title: formData.title,
|
||||||
|
summary: formData.summary || undefined,
|
||||||
|
contentHtml: formData.contentHtml,
|
||||||
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
|
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||||
|
// Send seriesId if we have it (existing series), otherwise send seriesName (new series)
|
||||||
|
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
|
||||||
|
// Send authorId if we have it (existing author), otherwise send authorName (new author)
|
||||||
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||||
|
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const story = await storyApi.createStory(storyData);
|
||||||
|
|
||||||
|
// Process images if there are external images in the content
|
||||||
|
if (hasExternalImages(formData.contentHtml)) {
|
||||||
|
try {
|
||||||
|
setProcessingImages(true);
|
||||||
|
const imageResult = await storyApi.processContentImages(story.id, formData.contentHtml);
|
||||||
|
|
||||||
|
// If images were processed and content was updated, save the updated content
|
||||||
|
if (imageResult.processedContent !== formData.contentHtml) {
|
||||||
|
await storyApi.updateStory(story.id, {
|
||||||
|
title: formData.title,
|
||||||
|
summary: formData.summary || undefined,
|
||||||
|
contentHtml: imageResult.processedContent,
|
||||||
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
|
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||||
|
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
|
||||||
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||||
|
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message with image processing info
|
||||||
|
if (imageResult.downloadedImages.length > 0) {
|
||||||
|
console.log(`Successfully processed ${imageResult.downloadedImages.length} images`);
|
||||||
|
}
|
||||||
|
if (imageResult.warnings && imageResult.warnings.length > 0) {
|
||||||
|
console.warn('Image processing warnings:', imageResult.warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (imageError) {
|
||||||
|
console.error('Failed to process images:', imageError);
|
||||||
|
// Don't fail the entire operation if image processing fails
|
||||||
|
// The story was created successfully, just without processed images
|
||||||
|
} finally {
|
||||||
|
setProcessingImages(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's a cover image, upload it separately
|
||||||
|
if (coverImage) {
|
||||||
|
await storyApi.uploadCover(story.id, coverImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/stories/${story.id}/detail`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to create story:', error);
|
||||||
|
const errorMessage = error.response?.data?.message || 'Failed to create story';
|
||||||
|
setErrors({ submit: errorMessage });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<ImportLayout
|
||||||
<div className="text-center">
|
title="Add New Story"
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
description="Add a story to your personal collection"
|
||||||
<p className="text-gray-600">Redirecting...</p>
|
>
|
||||||
</div>
|
{/* Success Message */}
|
||||||
</div>
|
{errors.success && (
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-6">
|
||||||
|
<p className="text-green-800 dark:text-green-200">{errors.success}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
label="Title *"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleInputChange('title')}
|
||||||
|
placeholder="Enter the story title"
|
||||||
|
error={errors.title}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Author Selector */}
|
||||||
|
<AuthorSelector
|
||||||
|
label="Author *"
|
||||||
|
value={formData.authorName}
|
||||||
|
onChange={handleAuthorChange}
|
||||||
|
placeholder="Select or enter author name"
|
||||||
|
error={errors.authorName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Duplicate Warning */}
|
||||||
|
{duplicateWarning.show && (
|
||||||
|
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
|
||||||
|
⚠️
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
|
||||||
|
Potential Duplicate Detected
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||||
|
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{duplicateWarning.duplicates.map((duplicate, index) => (
|
||||||
|
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
• <span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
|
||||||
|
<span className="text-xs ml-2">
|
||||||
|
(added {new Date(duplicate.createdAt).toLocaleDateString()})
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
|
||||||
|
You can still create this story if it's different from the existing ones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checking indicator */}
|
||||||
|
{checkingDuplicates && (
|
||||||
|
<div className="flex items-center gap-2 text-sm theme-text">
|
||||||
|
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
|
||||||
|
Checking for duplicates...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Summary
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.summary}
|
||||||
|
onChange={handleInputChange('summary')}
|
||||||
|
placeholder="Brief summary or description of the story..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="text-sm theme-text mt-1">
|
||||||
|
Optional summary that will be displayed on the story detail page
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Cover Image
|
||||||
|
</label>
|
||||||
|
<ImageUpload
|
||||||
|
onImageSelect={setCoverImage}
|
||||||
|
accept="image/jpeg,image/png"
|
||||||
|
maxSizeMB={5}
|
||||||
|
aspectRatio="3:4"
|
||||||
|
placeholder="Drop a cover image here or click to select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Story Content *
|
||||||
|
</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={formData.contentHtml}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
placeholder="Write or paste your story content here..."
|
||||||
|
error={errors.contentHtml}
|
||||||
|
enableImageProcessing={false}
|
||||||
|
/>
|
||||||
|
<p className="text-sm theme-text mt-2">
|
||||||
|
💡 <strong>Tip:</strong> If you paste content with images, they'll be automatically downloaded and stored locally when you save the story.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<TagInput
|
||||||
|
tags={formData.tags}
|
||||||
|
onChange={handleTagsChange}
|
||||||
|
placeholder="Add tags to categorize your story..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Series and Volume */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<SeriesSelector
|
||||||
|
label="Series (optional)"
|
||||||
|
value={formData.seriesName}
|
||||||
|
onChange={handleSeriesChange}
|
||||||
|
placeholder="Select or enter series name if part of a series"
|
||||||
|
error={errors.seriesName}
|
||||||
|
authorId={formData.authorId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Volume/Part (optional)"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.volume}
|
||||||
|
onChange={handleInputChange('volume')}
|
||||||
|
placeholder="Enter volume/part number"
|
||||||
|
error={errors.volume}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source URL */}
|
||||||
|
<Input
|
||||||
|
label="Source URL (optional)"
|
||||||
|
type="url"
|
||||||
|
value={formData.sourceUrl}
|
||||||
|
onChange={handleInputChange('sourceUrl')}
|
||||||
|
placeholder="https://example.com/original-story-url"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Image Processing Indicator */}
|
||||||
|
{processingImages && (
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||||
|
<p className="text-blue-800 dark:text-blue-200">
|
||||||
|
Processing and downloading images...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Error */}
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-4 pt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
||||||
|
>
|
||||||
|
{processingImages ? 'Processing Images...' : 'Add Story'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ImportLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,7 +211,7 @@ export default function AuthorDetailPage() {
|
|||||||
<p className="theme-text">
|
<p className="theme-text">
|
||||||
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
||||||
</p>
|
</p>
|
||||||
<Button href={`/import?authorId=${authorId}`}>
|
<Button href={`/add-story?authorId=${authorId}`}>
|
||||||
Add Story
|
Add Story
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +220,7 @@ export default function AuthorDetailPage() {
|
|||||||
{stories.length === 0 ? (
|
{stories.length === 0 ? (
|
||||||
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
||||||
<p className="theme-text text-lg mb-4">No stories by this author yet.</p>
|
<p className="theme-text text-lg mb-4">No stories by this author yet.</p>
|
||||||
<Button href="/import">Add a Story</Button>
|
<Button href="/add-story">Add a Story</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function AuthorsPage() {
|
|||||||
const [authors, setAuthors] = useState<Author[]>([]);
|
const [authors, setAuthors] = useState<Author[]>([]);
|
||||||
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||||
const [sortBy, setSortBy] = useState('name');
|
const [sortBy, setSortBy] = useState('name');
|
||||||
@@ -24,50 +25,61 @@ export default function AuthorsPage() {
|
|||||||
const ITEMS_PER_PAGE = 50; // Safe limit under Typesense's 250 limit
|
const ITEMS_PER_PAGE = 50; // Safe limit under Typesense's 250 limit
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAuthors = async () => {
|
const debounceTimer = setTimeout(() => {
|
||||||
try {
|
const loadAuthors = async () => {
|
||||||
setLoading(true);
|
try {
|
||||||
const searchResults = await authorApi.searchAuthorsTypesense({
|
// Use searchLoading for background search, loading only for initial load
|
||||||
q: searchQuery || '*',
|
const isInitialLoad = authors.length === 0 && !searchQuery && currentPage === 0;
|
||||||
page: currentPage,
|
if (isInitialLoad) {
|
||||||
size: ITEMS_PER_PAGE,
|
setLoading(true);
|
||||||
sortBy: sortBy,
|
} else {
|
||||||
sortOrder: sortOrder
|
setSearchLoading(true);
|
||||||
});
|
|
||||||
|
|
||||||
if (currentPage === 0) {
|
|
||||||
// First page - replace all results
|
|
||||||
setAuthors(searchResults.results || []);
|
|
||||||
setFilteredAuthors(searchResults.results || []);
|
|
||||||
} else {
|
|
||||||
// Subsequent pages - append results
|
|
||||||
setAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
|
||||||
setFilteredAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTotalHits(searchResults.totalHits);
|
|
||||||
setHasMore(searchResults.results.length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < searchResults.totalHits);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load authors:', error);
|
|
||||||
// Fallback to regular API if Typesense fails (only for first page)
|
|
||||||
if (currentPage === 0) {
|
|
||||||
try {
|
|
||||||
const authorsResult = await authorApi.getAuthors({ page: 0, size: ITEMS_PER_PAGE });
|
|
||||||
setAuthors(authorsResult.content || []);
|
|
||||||
setFilteredAuthors(authorsResult.content || []);
|
|
||||||
setTotalHits(authorsResult.totalElements || 0);
|
|
||||||
setHasMore(authorsResult.content.length === ITEMS_PER_PAGE);
|
|
||||||
} catch (fallbackError) {
|
|
||||||
console.error('Fallback also failed:', fallbackError);
|
|
||||||
}
|
}
|
||||||
|
const searchResults = await authorApi.searchAuthorsTypesense({
|
||||||
|
q: searchQuery || '*',
|
||||||
|
page: currentPage,
|
||||||
|
size: ITEMS_PER_PAGE,
|
||||||
|
sortBy: sortBy,
|
||||||
|
sortOrder: sortOrder
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentPage === 0) {
|
||||||
|
// First page - replace all results
|
||||||
|
setAuthors(searchResults.results || []);
|
||||||
|
setFilteredAuthors(searchResults.results || []);
|
||||||
|
} else {
|
||||||
|
// Subsequent pages - append results
|
||||||
|
setAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
||||||
|
setFilteredAuthors(prev => [...prev, ...(searchResults.results || [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotalHits(searchResults.totalHits);
|
||||||
|
setHasMore(searchResults.results.length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < searchResults.totalHits);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load authors:', error);
|
||||||
|
// Fallback to regular API if Typesense fails (only for first page)
|
||||||
|
if (currentPage === 0) {
|
||||||
|
try {
|
||||||
|
const authorsResult = await authorApi.getAuthors({ page: 0, size: ITEMS_PER_PAGE });
|
||||||
|
setAuthors(authorsResult.content || []);
|
||||||
|
setFilteredAuthors(authorsResult.content || []);
|
||||||
|
setTotalHits(authorsResult.totalElements || 0);
|
||||||
|
setHasMore(authorsResult.content.length === ITEMS_PER_PAGE);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Fallback also failed:', fallbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setSearchLoading(false);
|
||||||
}
|
}
|
||||||
} finally {
|
};
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadAuthors();
|
loadAuthors();
|
||||||
|
}, searchQuery ? 500 : 0); // 500ms debounce for search, immediate for other changes
|
||||||
|
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
}, [searchQuery, sortBy, sortOrder, currentPage]);
|
}, [searchQuery, sortBy, sortOrder, currentPage]);
|
||||||
|
|
||||||
// Reset pagination when search or sort changes
|
// Reset pagination when search or sort changes
|
||||||
@@ -133,13 +145,18 @@ export default function AuthorsPage() {
|
|||||||
|
|
||||||
{/* Search and Sort Controls */}
|
{/* Search and Sort Controls */}
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<div className="flex-1 max-w-md">
|
<div className="flex-1 max-w-md relative">
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search authors..."
|
placeholder="Search authors..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{searchLoading && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -85,13 +85,28 @@
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reading-content h1,
|
.reading-content h1 {
|
||||||
.reading-content h2,
|
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||||
.reading-content h3,
|
}
|
||||||
.reading-content h4,
|
|
||||||
.reading-content h5,
|
.reading-content h2 {
|
||||||
|
@apply text-xl font-bold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content h3 {
|
||||||
|
@apply text-lg font-semibold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content h4 {
|
||||||
|
@apply text-base font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content h5 {
|
||||||
|
@apply text-sm font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
.reading-content h6 {
|
.reading-content h6 {
|
||||||
@apply font-bold mt-8 mb-4 theme-header;
|
@apply text-xs font-semibold mt-4 mb-2 theme-header uppercase tracking-wide;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reading-content p {
|
.reading-content p {
|
||||||
@@ -118,4 +133,107 @@
|
|||||||
.reading-content em {
|
.reading-content em {
|
||||||
@apply italic;
|
@apply italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image styling for story content */
|
||||||
|
.reading-content img {
|
||||||
|
@apply max-w-full h-auto mx-auto my-6 rounded-lg shadow-sm;
|
||||||
|
max-height: 80vh; /* Prevent images from being too tall */
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content img[align="left"] {
|
||||||
|
@apply float-left mr-4 mb-4 ml-0;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content img[align="right"] {
|
||||||
|
@apply float-right ml-4 mb-4 mr-0;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reading-content img[align="center"] {
|
||||||
|
@apply block mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor content styling - same as reading content but for the rich text editor */
|
||||||
|
.editor-content h1 {
|
||||||
|
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h2 {
|
||||||
|
@apply text-xl font-bold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h3 {
|
||||||
|
@apply text-lg font-semibold mt-6 mb-3 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h4 {
|
||||||
|
@apply text-base font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h5 {
|
||||||
|
@apply text-sm font-semibold mt-4 mb-2 theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content h6 {
|
||||||
|
@apply text-xs font-semibold mt-4 mb-2 theme-header uppercase tracking-wide;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content p {
|
||||||
|
@apply mb-4 theme-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content blockquote {
|
||||||
|
@apply border-l-4 pl-4 italic my-6 theme-border theme-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content ul,
|
||||||
|
.editor-content ol {
|
||||||
|
@apply mb-4 pl-6 theme-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content li {
|
||||||
|
@apply mb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content strong {
|
||||||
|
@apply font-semibold theme-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content em {
|
||||||
|
@apply italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image styling for editor content */
|
||||||
|
.editor-content img {
|
||||||
|
@apply max-w-full h-auto mx-auto my-4 rounded border;
|
||||||
|
max-height: 60vh; /* Slightly smaller for editor */
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content img[align="left"] {
|
||||||
|
@apply float-left mr-4 mb-4 ml-0;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content img[align="right"] {
|
||||||
|
@apply float-right ml-4 mb-4 mr-0;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content img[align="center"] {
|
||||||
|
@apply block mx-auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading placeholder for images being processed */
|
||||||
|
.image-processing-placeholder {
|
||||||
|
@apply bg-gray-100 dark:bg-gray-800 animate-pulse rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-processing-placeholder::before {
|
||||||
|
content: "🖼️ Processing image...";
|
||||||
|
@apply text-gray-500 dark:text-gray-400 text-sm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ export default function BulkImportPage() {
|
|||||||
if (data.combinedStory && combineIntoOne) {
|
if (data.combinedStory && combineIntoOne) {
|
||||||
// For combine mode, redirect to import page with the combined content
|
// For combine mode, redirect to import page with the combined content
|
||||||
localStorage.setItem('pendingStory', JSON.stringify(data.combinedStory));
|
localStorage.setItem('pendingStory', JSON.stringify(data.combinedStory));
|
||||||
router.push('/import?from=bulk-combine');
|
router.push('/add-story?from=bulk-combine');
|
||||||
return;
|
return;
|
||||||
} else if (data.results && data.summary) {
|
} else if (data.results && data.summary) {
|
||||||
// For individual mode, show the results
|
// For individual mode, show the results
|
||||||
|
|||||||
@@ -1,188 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
|
||||||
import ImportLayout from '../../components/layout/ImportLayout';
|
import ImportLayout from '../../components/layout/ImportLayout';
|
||||||
import { Input, Textarea } from '../../components/ui/Input';
|
import { Input } from '../../components/ui/Input';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import TagInput from '../../components/stories/TagInput';
|
|
||||||
import RichTextEditor from '../../components/stories/RichTextEditor';
|
|
||||||
import ImageUpload from '../../components/ui/ImageUpload';
|
|
||||||
import AuthorSelector from '../../components/stories/AuthorSelector';
|
|
||||||
import { storyApi, authorApi } from '../../lib/api';
|
|
||||||
|
|
||||||
export default function AddStoryPage() {
|
export default function ImportFromUrlPage() {
|
||||||
const [importMode, setImportMode] = useState<'manual' | 'url'>('manual');
|
|
||||||
const [importUrl, setImportUrl] = useState('');
|
const [importUrl, setImportUrl] = useState('');
|
||||||
const [scraping, setScraping] = useState(false);
|
const [scraping, setScraping] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
summary: '',
|
|
||||||
authorName: '',
|
|
||||||
authorId: undefined as string | undefined,
|
|
||||||
contentHtml: '',
|
|
||||||
sourceUrl: '',
|
|
||||||
tags: [] as string[],
|
|
||||||
seriesName: '',
|
|
||||||
volume: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [coverImage, setCoverImage] = useState<File | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
const [duplicateWarning, setDuplicateWarning] = useState<{
|
|
||||||
show: boolean;
|
|
||||||
count: number;
|
|
||||||
duplicates: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
authorName: string;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
}>({ show: false, count: 0, duplicates: [] });
|
|
||||||
const [checkingDuplicates, setCheckingDuplicates] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
// Handle URL parameters
|
|
||||||
useEffect(() => {
|
|
||||||
const authorId = searchParams.get('authorId');
|
|
||||||
const mode = searchParams.get('mode');
|
|
||||||
|
|
||||||
// Set import mode if specified in URL
|
|
||||||
if (mode === 'url') {
|
|
||||||
setImportMode('url');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fill author if authorId is provided in URL
|
|
||||||
if (authorId) {
|
|
||||||
const loadAuthor = async () => {
|
|
||||||
try {
|
|
||||||
const author = await authorApi.getAuthor(authorId);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
authorName: author.name,
|
|
||||||
authorId: author.id
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load author:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadAuthor();
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Load pending story data from bulk combine operation
|
|
||||||
useEffect(() => {
|
|
||||||
const fromBulkCombine = searchParams.get('from') === 'bulk-combine';
|
|
||||||
if (fromBulkCombine) {
|
|
||||||
const pendingStoryData = localStorage.getItem('pendingStory');
|
|
||||||
if (pendingStoryData) {
|
|
||||||
try {
|
|
||||||
const storyData = JSON.parse(pendingStoryData);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
title: storyData.title || '',
|
|
||||||
authorName: storyData.author || '',
|
|
||||||
authorId: undefined, // Reset author ID for bulk combined stories
|
|
||||||
contentHtml: storyData.content || '',
|
|
||||||
sourceUrl: storyData.sourceUrl || '',
|
|
||||||
summary: storyData.summary || '',
|
|
||||||
tags: storyData.tags || []
|
|
||||||
}));
|
|
||||||
// Clear the pending data
|
|
||||||
localStorage.removeItem('pendingStory');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load pending story data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Check for duplicates when title and author are both present
|
|
||||||
useEffect(() => {
|
|
||||||
const checkDuplicates = async () => {
|
|
||||||
const title = formData.title.trim();
|
|
||||||
const authorName = formData.authorName.trim();
|
|
||||||
|
|
||||||
// Don't check if user isn't authenticated or if title/author are empty
|
|
||||||
if (!isAuthenticated || !title || !authorName) {
|
|
||||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce the check to avoid too many API calls
|
|
||||||
const timeoutId = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
setCheckingDuplicates(true);
|
|
||||||
const result = await storyApi.checkDuplicate(title, authorName);
|
|
||||||
|
|
||||||
if (result.hasDuplicates) {
|
|
||||||
setDuplicateWarning({
|
|
||||||
show: true,
|
|
||||||
count: result.count,
|
|
||||||
duplicates: result.duplicates
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check for duplicates:', error);
|
|
||||||
// Clear any existing duplicate warnings on error
|
|
||||||
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
|
|
||||||
// Don't show error to user as this is just a helpful warning
|
|
||||||
// Authentication errors will be handled by the API interceptor
|
|
||||||
} finally {
|
|
||||||
setCheckingDuplicates(false);
|
|
||||||
}
|
|
||||||
}, 500); // 500ms debounce
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkDuplicates();
|
|
||||||
}, [formData.title, formData.authorName, isAuthenticated]);
|
|
||||||
|
|
||||||
const handleInputChange = (field: string) => (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[field]: e.target.value
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (errors[field]) {
|
|
||||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContentChange = (html: string) => {
|
|
||||||
setFormData(prev => ({ ...prev, contentHtml: html }));
|
|
||||||
if (errors.contentHtml) {
|
|
||||||
setErrors(prev => ({ ...prev, contentHtml: '' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagsChange = (tags: string[]) => {
|
|
||||||
setFormData(prev => ({ ...prev, tags }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
authorName,
|
|
||||||
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clear error when user changes author
|
|
||||||
if (errors.authorName) {
|
|
||||||
setErrors(prev => ({ ...prev, authorName: '' }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportFromUrl = async () => {
|
const handleImportFromUrl = async () => {
|
||||||
if (!importUrl.trim()) {
|
if (!importUrl.trim()) {
|
||||||
@@ -209,25 +38,18 @@ export default function AddStoryPage() {
|
|||||||
|
|
||||||
const scrapedStory = await response.json();
|
const scrapedStory = await response.json();
|
||||||
|
|
||||||
// Pre-fill the form with scraped data
|
// Redirect to add-story page with pre-filled data
|
||||||
setFormData({
|
const queryParams = new URLSearchParams({
|
||||||
|
from: 'url-import',
|
||||||
title: scrapedStory.title || '',
|
title: scrapedStory.title || '',
|
||||||
summary: scrapedStory.summary || '',
|
summary: scrapedStory.summary || '',
|
||||||
authorName: scrapedStory.author || '',
|
author: scrapedStory.author || '',
|
||||||
authorId: undefined, // Reset author ID when importing from URL (likely new author)
|
|
||||||
contentHtml: scrapedStory.content || '',
|
|
||||||
sourceUrl: scrapedStory.sourceUrl || importUrl,
|
sourceUrl: scrapedStory.sourceUrl || importUrl,
|
||||||
tags: scrapedStory.tags || [],
|
tags: JSON.stringify(scrapedStory.tags || []),
|
||||||
seriesName: '',
|
content: scrapedStory.content || ''
|
||||||
volume: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch to manual mode so user can edit the pre-filled data
|
router.push(`/add-story?${queryParams.toString()}`);
|
||||||
setImportMode('manual');
|
|
||||||
setImportUrl('');
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' });
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to import story:', error);
|
console.error('Failed to import story:', error);
|
||||||
setErrors({ importUrl: error.message });
|
setErrors({ importUrl: error.message });
|
||||||
@@ -236,310 +58,56 @@ export default function AddStoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = () => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.title.trim()) {
|
|
||||||
newErrors.title = 'Title is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.authorName.trim()) {
|
|
||||||
newErrors.authorName = 'Author name is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.contentHtml.trim()) {
|
|
||||||
newErrors.contentHtml = 'Story content is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.seriesName && !formData.volume) {
|
|
||||||
newErrors.volume = 'Volume number is required when series is specified';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.volume && !formData.seriesName.trim()) {
|
|
||||||
newErrors.seriesName = 'Series name is required when volume is specified';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First, create the story with JSON data
|
|
||||||
const storyData = {
|
|
||||||
title: formData.title,
|
|
||||||
summary: formData.summary || undefined,
|
|
||||||
contentHtml: formData.contentHtml,
|
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
|
||||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
|
||||||
seriesName: formData.seriesName || undefined,
|
|
||||||
// Send authorId if we have it (existing author), otherwise send authorName (new author)
|
|
||||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
|
||||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const story = await storyApi.createStory(storyData);
|
|
||||||
|
|
||||||
// If there's a cover image, upload it separately
|
|
||||||
if (coverImage) {
|
|
||||||
await storyApi.uploadCover(story.id, coverImage);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`/stories/${story.id}`);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to create story:', error);
|
|
||||||
const errorMessage = error.response?.data?.message || 'Failed to create story';
|
|
||||||
setErrors({ submit: errorMessage });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImportLayout
|
<ImportLayout
|
||||||
title="Add New Story"
|
title="Import Story from URL"
|
||||||
description="Add a story to your personal collection"
|
description="Import a single story from a website"
|
||||||
>
|
>
|
||||||
{/* URL Import Section */}
|
<div className="space-y-6">
|
||||||
{importMode === 'url' && (
|
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-6">
|
||||||
<div className="space-y-6">
|
<h3 className="text-lg font-medium theme-header mb-4">Import Story from URL</h3>
|
||||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-6">
|
<p className="theme-text text-sm mb-4">
|
||||||
<h3 className="text-lg font-medium theme-header mb-4">Import Story from URL</h3>
|
Enter a URL from a supported story site to automatically extract the story content, title, author, and other metadata. After importing, you'll be able to review and edit the data before saving.
|
||||||
<p className="theme-text text-sm mb-4">
|
</p>
|
||||||
Enter a URL from a supported story site to automatically extract the story content, title, author, and other metadata.
|
|
||||||
</p>
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Story URL"
|
||||||
|
type="url"
|
||||||
|
value={importUrl}
|
||||||
|
onChange={(e) => setImportUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/story-url"
|
||||||
|
error={errors.importUrl}
|
||||||
|
disabled={scraping}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="flex gap-3">
|
||||||
<Input
|
<Button
|
||||||
label="Story URL"
|
type="button"
|
||||||
type="url"
|
onClick={handleImportFromUrl}
|
||||||
value={importUrl}
|
loading={scraping}
|
||||||
onChange={(e) => setImportUrl(e.target.value)}
|
disabled={!importUrl.trim() || scraping}
|
||||||
placeholder="https://example.com/story-url"
|
>
|
||||||
error={errors.importUrl}
|
{scraping ? 'Importing...' : 'Import Story'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
href="/add-story"
|
||||||
disabled={scraping}
|
disabled={scraping}
|
||||||
/>
|
>
|
||||||
|
Enter Manually Instead
|
||||||
<div className="flex gap-3">
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
|
||||||
onClick={handleImportFromUrl}
|
<div className="text-xs theme-text">
|
||||||
loading={scraping}
|
<p className="font-medium mb-1">Supported Sites:</p>
|
||||||
disabled={!importUrl.trim() || scraping}
|
<p>Archive of Our Own, DeviantArt, FanFiction.Net, Literotica, Royal Road, Wattpad, and more</p>
|
||||||
>
|
|
||||||
{scraping ? 'Importing...' : 'Import Story'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setImportMode('manual')}
|
|
||||||
disabled={scraping}
|
|
||||||
>
|
|
||||||
Enter Manually Instead
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs theme-text">
|
|
||||||
<p className="font-medium mb-1">Supported Sites:</p>
|
|
||||||
<p>Archive of Our Own, DeviantArt, FanFiction.Net, Literotica, Royal Road, Wattpad, and more</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Success Message */}
|
|
||||||
{errors.success && (
|
|
||||||
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-6">
|
|
||||||
<p className="text-green-800 dark:text-green-200">{errors.success}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Manual Entry Form */}
|
|
||||||
{importMode === 'manual' && (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Title */}
|
|
||||||
<Input
|
|
||||||
label="Title *"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={handleInputChange('title')}
|
|
||||||
placeholder="Enter the story title"
|
|
||||||
error={errors.title}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Author Selector */}
|
|
||||||
<AuthorSelector
|
|
||||||
label="Author *"
|
|
||||||
value={formData.authorName}
|
|
||||||
onChange={handleAuthorChange}
|
|
||||||
placeholder="Select or enter author name"
|
|
||||||
error={errors.authorName}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Duplicate Warning */}
|
|
||||||
{duplicateWarning.show && (
|
|
||||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
|
|
||||||
⚠️
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
|
|
||||||
Potential Duplicate Detected
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
|
||||||
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{duplicateWarning.duplicates.map((duplicate, index) => (
|
|
||||||
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
|
|
||||||
• <span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
|
|
||||||
<span className="text-xs ml-2">
|
|
||||||
(added {new Date(duplicate.createdAt).toLocaleDateString()})
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
|
|
||||||
You can still create this story if it's different from the existing ones.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Checking indicator */}
|
|
||||||
{checkingDuplicates && (
|
|
||||||
<div className="flex items-center gap-2 text-sm theme-text">
|
|
||||||
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
|
|
||||||
Checking for duplicates...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Summary
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={formData.summary}
|
|
||||||
onChange={handleInputChange('summary')}
|
|
||||||
placeholder="Brief summary or description of the story..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<p className="text-sm theme-text mt-1">
|
|
||||||
Optional summary that will be displayed on the story detail page
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cover Image Upload */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Cover Image
|
|
||||||
</label>
|
|
||||||
<ImageUpload
|
|
||||||
onImageSelect={setCoverImage}
|
|
||||||
accept="image/jpeg,image/png"
|
|
||||||
maxSizeMB={5}
|
|
||||||
aspectRatio="3:4"
|
|
||||||
placeholder="Drop a cover image here or click to select"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Story Content *
|
|
||||||
</label>
|
|
||||||
<RichTextEditor
|
|
||||||
value={formData.contentHtml}
|
|
||||||
onChange={handleContentChange}
|
|
||||||
placeholder="Write or paste your story content here..."
|
|
||||||
error={errors.contentHtml}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium theme-header mb-2">
|
|
||||||
Tags
|
|
||||||
</label>
|
|
||||||
<TagInput
|
|
||||||
tags={formData.tags}
|
|
||||||
onChange={handleTagsChange}
|
|
||||||
placeholder="Add tags to categorize your story..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Series and Volume */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label="Series (optional)"
|
|
||||||
value={formData.seriesName}
|
|
||||||
onChange={handleInputChange('seriesName')}
|
|
||||||
placeholder="Enter series name if part of a series"
|
|
||||||
error={errors.seriesName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Volume/Part (optional)"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={formData.volume}
|
|
||||||
onChange={handleInputChange('volume')}
|
|
||||||
placeholder="Enter volume/part number"
|
|
||||||
error={errors.volume}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Source URL */}
|
|
||||||
<Input
|
|
||||||
label="Source URL (optional)"
|
|
||||||
type="url"
|
|
||||||
value={formData.sourceUrl}
|
|
||||||
onChange={handleInputChange('sourceUrl')}
|
|
||||||
placeholder="https://example.com/original-story-url"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Submit Error */}
|
|
||||||
{errors.submit && (
|
|
||||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
||||||
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-4 pt-6">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={loading}
|
|
||||||
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
|
||||||
>
|
|
||||||
Add Story
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</ImportLayout>
|
</ImportLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { searchApi } from '../../lib/api';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Story, Tag, FacetCount } from '../../types/api';
|
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
||||||
|
import { Story, Tag, FacetCount, AdvancedFilters } from '../../types/api';
|
||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { Input } from '../../components/ui/Input';
|
import { Input } from '../../components/ui/Input';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
|
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
|
||||||
import TagFilter from '../../components/stories/TagFilter';
|
import TagFilter from '../../components/stories/TagFilter';
|
||||||
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../components/ui/LoadingSpinner';
|
||||||
|
import SidebarLayout from '../../components/library/SidebarLayout';
|
||||||
|
import ToolbarLayout from '../../components/library/ToolbarLayout';
|
||||||
|
import MinimalLayout from '../../components/library/MinimalLayout';
|
||||||
|
import { useLibraryLayout } from '../../hooks/useLibraryLayout';
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'list';
|
type ViewMode = 'grid' | 'list';
|
||||||
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
|
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { layout } = useLibraryLayout();
|
||||||
const [stories, setStories] = useState<Story[]>([]);
|
const [stories, setStories] = useState<Story[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [randomLoading, setRandomLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
@@ -27,29 +36,101 @@ export default function LibraryPage() {
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalElements, setTotalElements] = useState(0);
|
const [totalElements, setTotalElements] = useState(0);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
|
||||||
|
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>({});
|
||||||
|
|
||||||
|
// Initialize filters from URL parameters
|
||||||
|
useEffect(() => {
|
||||||
|
const tagsParam = searchParams.get('tags');
|
||||||
|
if (tagsParam) {
|
||||||
|
console.log('URL tag filter detected:', tagsParam);
|
||||||
|
// Use functional updates to ensure all state changes happen together
|
||||||
|
setSelectedTags([tagsParam]);
|
||||||
|
setPage(0); // Reset to first page when applying URL filter
|
||||||
|
}
|
||||||
|
setUrlParamsProcessed(true);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Convert facet counts to Tag objects for the UI, enriched with full tag data
|
||||||
// Convert facet counts to Tag objects for the UI
|
const [fullTags, setFullTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
|
// Fetch full tag data for enrichment
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFullTags = async () => {
|
||||||
|
try {
|
||||||
|
const result = await tagApi.getTags({ size: 1000 }); // Get all tags
|
||||||
|
setFullTags(result.content || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch full tag data:', error);
|
||||||
|
setFullTags([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFullTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
|
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
|
||||||
if (!facets || !facets.tagNames) {
|
if (!facets || !facets.tagNames) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return facets.tagNames.map(facet => ({
|
return facets.tagNames.map(facet => {
|
||||||
id: facet.value, // Use tag name as ID since we don't have actual IDs from search results
|
// Find the full tag data by name
|
||||||
name: facet.value,
|
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
|
||||||
storyCount: facet.count
|
|
||||||
}));
|
return {
|
||||||
|
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
|
||||||
|
name: facet.value,
|
||||||
|
storyCount: facet.count,
|
||||||
|
// Include color and other metadata from the full tag data
|
||||||
|
color: fullTag?.color,
|
||||||
|
description: fullTag?.description,
|
||||||
|
aliasCount: fullTag?.aliasCount,
|
||||||
|
createdAt: fullTag?.createdAt,
|
||||||
|
aliases: fullTag?.aliases
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Enrich existing tags when fullTags are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (fullTags.length > 0) {
|
||||||
|
// Use functional update to get the current tags state
|
||||||
|
setTags(currentTags => {
|
||||||
|
if (currentTags.length > 0) {
|
||||||
|
// Check if tags already have color data to avoid infinite loops
|
||||||
|
const hasColors = currentTags.some(tag => tag.color);
|
||||||
|
if (!hasColors) {
|
||||||
|
// Re-enrich existing tags with color data
|
||||||
|
return currentTags.map(tag => {
|
||||||
|
const fullTag = fullTags.find(ft => ft.name.toLowerCase() === tag.name.toLowerCase());
|
||||||
|
return {
|
||||||
|
...tag,
|
||||||
|
color: fullTag?.color,
|
||||||
|
description: fullTag?.description,
|
||||||
|
aliasCount: fullTag?.aliasCount,
|
||||||
|
createdAt: fullTag?.createdAt,
|
||||||
|
aliases: fullTag?.aliases,
|
||||||
|
id: fullTag?.id || tag.id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentTags; // Return unchanged if no enrichment needed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [fullTags]); // Only run when fullTags change
|
||||||
|
|
||||||
// Debounce search to avoid too many API calls
|
// Debounce search to avoid too many API calls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't run search until URL parameters have been processed
|
||||||
|
if (!urlParamsProcessed) return;
|
||||||
|
|
||||||
const debounceTimer = setTimeout(() => {
|
const debounceTimer = setTimeout(() => {
|
||||||
const performSearch = async () => {
|
const performSearch = async () => {
|
||||||
try {
|
try {
|
||||||
// Use searchLoading for background search, loading only for initial load
|
// Use searchLoading for background search, loading only for initial load
|
||||||
const isInitialLoad = stories.length === 0 && !searchQuery && selectedTags.length === 0;
|
const isInitialLoad = stories.length === 0 && !searchQuery;
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -57,7 +138,7 @@ export default function LibraryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always use search API for consistency - use '*' for match-all when no query
|
// Always use search API for consistency - use '*' for match-all when no query
|
||||||
const result = await searchApi.search({
|
const apiParams = {
|
||||||
query: searchQuery.trim() || '*',
|
query: searchQuery.trim() || '*',
|
||||||
page: page, // Use 0-based pagination consistently
|
page: page, // Use 0-based pagination consistently
|
||||||
size: 20,
|
size: 20,
|
||||||
@@ -65,7 +146,12 @@ export default function LibraryPage() {
|
|||||||
sortBy: sortOption,
|
sortBy: sortOption,
|
||||||
sortDir: sortDirection,
|
sortDir: sortDirection,
|
||||||
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
||||||
});
|
// Advanced filters
|
||||||
|
...advancedFilters
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Performing search with params:', apiParams);
|
||||||
|
const result = await searchApi.search(apiParams);
|
||||||
|
|
||||||
const currentStories = result?.results || [];
|
const currentStories = result?.results || [];
|
||||||
setStories(currentStories);
|
setStories(currentStories);
|
||||||
@@ -75,67 +161,80 @@ export default function LibraryPage() {
|
|||||||
// Update tags from facets - these represent all matching stories, not just current page
|
// Update tags from facets - these represent all matching stories, not just current page
|
||||||
const resultTags = convertFacetsToTags(result?.facets);
|
const resultTags = convertFacetsToTags(result?.facets);
|
||||||
setTags(resultTags);
|
setTags(resultTags);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load stories:', error);
|
console.error('Failed to load stories:', error);
|
||||||
setStories([]);
|
setStories([]);
|
||||||
|
setTags([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
performSearch();
|
performSearch();
|
||||||
}, searchQuery ? 500 : 0); // 500ms debounce for search, immediate for other changes
|
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
||||||
|
|
||||||
return () => clearTimeout(debounceTimer);
|
return () => clearTimeout(debounceTimer);
|
||||||
}, [searchQuery, selectedTags, page, sortOption, sortDirection, refreshTrigger]);
|
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
|
||||||
|
|
||||||
// Reset page when search or filters change
|
|
||||||
const resetPage = () => {
|
|
||||||
if (page !== 0) {
|
|
||||||
setPage(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTagToggle = (tagName: string) => {
|
|
||||||
setSelectedTags(prev => {
|
|
||||||
const newTags = prev.includes(tagName)
|
|
||||||
? prev.filter(t => t !== tagName)
|
|
||||||
: [...prev, tagName];
|
|
||||||
resetPage();
|
|
||||||
return newTags;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
resetPage();
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSortChange = (newSortOption: SortOption) => {
|
const handleStoryUpdate = () => {
|
||||||
setSortOption(newSortOption);
|
setRefreshTrigger(prev => prev + 1);
|
||||||
// Set appropriate default direction for the sort option
|
};
|
||||||
if (newSortOption === 'title' || newSortOption === 'authorName') {
|
|
||||||
setSortDirection('asc'); // Alphabetical fields default to ascending
|
const handleRandomStory = async () => {
|
||||||
} else {
|
if (totalElements === 0) return;
|
||||||
setSortDirection('desc'); // Numeric/date fields default to descending
|
|
||||||
|
try {
|
||||||
|
setRandomLoading(true);
|
||||||
|
const randomStory = await storyApi.getRandomStory({
|
||||||
|
searchQuery: searchQuery || undefined,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
...advancedFilters
|
||||||
|
});
|
||||||
|
if (randomStory) {
|
||||||
|
router.push(`/stories/${randomStory.id}`);
|
||||||
|
} else {
|
||||||
|
alert('No stories available. Please add some stories first.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get random story:', error);
|
||||||
|
alert('Failed to get a random story. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setRandomLoading(false);
|
||||||
}
|
}
|
||||||
resetPage();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSortDirection = () => {
|
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
||||||
resetPage();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedTags([]);
|
setSelectedTags([]);
|
||||||
resetPage();
|
setAdvancedFilters({});
|
||||||
|
setPage(0);
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStoryUpdate = () => {
|
const handleTagToggle = (tagName: string) => {
|
||||||
// Trigger reload by incrementing refresh trigger
|
setSelectedTags(prev =>
|
||||||
|
prev.includes(tagName)
|
||||||
|
? prev.filter(t => t !== tagName)
|
||||||
|
: [...prev, tagName]
|
||||||
|
);
|
||||||
|
setPage(0);
|
||||||
|
setRefreshTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortDirectionToggle = () => {
|
||||||
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdvancedFiltersChange = (filters: AdvancedFilters) => {
|
||||||
|
setAdvancedFilters(filters);
|
||||||
|
setPage(0);
|
||||||
setRefreshTrigger(prev => prev + 1);
|
setRefreshTrigger(prev => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,148 +248,62 @@ export default function LibraryPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handleSortChange = (option: string) => {
|
||||||
<AppLayout>
|
setSortOption(option as SortOption);
|
||||||
<div className="space-y-6">
|
};
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
const layoutProps = {
|
||||||
<div>
|
stories,
|
||||||
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
|
tags,
|
||||||
<p className="theme-text mt-1">
|
totalElements,
|
||||||
{totalElements} {totalElements === 1 ? 'story' : 'stories'}
|
searchQuery,
|
||||||
{searchQuery || selectedTags.length > 0 ? ` found` : ` total`}
|
selectedTags,
|
||||||
</p>
|
viewMode,
|
||||||
</div>
|
sortOption,
|
||||||
|
sortDirection,
|
||||||
<div className="flex gap-2">
|
advancedFilters,
|
||||||
<Button href="/import">
|
onSearchChange: handleSearchChange,
|
||||||
Add New Story
|
onTagToggle: handleTagToggle,
|
||||||
|
onViewModeChange: setViewMode,
|
||||||
|
onSortChange: handleSortChange,
|
||||||
|
onSortDirectionToggle: handleSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange: handleAdvancedFiltersChange,
|
||||||
|
onRandomStory: handleRandomStory,
|
||||||
|
onClearFilters: clearFilters,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (stories.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
||||||
|
<p className="theme-text text-lg mb-4">
|
||||||
|
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false)
|
||||||
|
? 'No stories match your search criteria.'
|
||||||
|
: 'Your library is empty.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false) ? (
|
||||||
|
<Button variant="ghost" onClick={clearFilters}>
|
||||||
|
Clear Filters
|
||||||
</Button>
|
</Button>
|
||||||
<Button href="/import/epub" variant="secondary">
|
) : (
|
||||||
📖 Import EPUB
|
<Button href="/add-story">
|
||||||
|
Add Your First Story
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Search and Filters */}
|
return (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
{/* Search Bar */}
|
<StoryMultiSelect
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
stories={stories}
|
||||||
<div className="flex-1 relative">
|
viewMode={viewMode}
|
||||||
<Input
|
onUpdate={handleStoryUpdate}
|
||||||
type="search"
|
allowMultiSelect={true}
|
||||||
placeholder="Search by title, author, or tags..."
|
/>
|
||||||
value={searchQuery}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
{searchLoading && (
|
|
||||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Mode Toggle */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
|
||||||
viewMode === 'grid'
|
|
||||||
? 'theme-accent-bg text-white'
|
|
||||||
: 'theme-card theme-text hover:bg-opacity-80'
|
|
||||||
}`}
|
|
||||||
aria-label="Grid view"
|
|
||||||
>
|
|
||||||
⊞
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'theme-accent-bg text-white'
|
|
||||||
: 'theme-card theme-text hover:bg-opacity-80'
|
|
||||||
}`}
|
|
||||||
aria-label="List view"
|
|
||||||
>
|
|
||||||
☰
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort and Tag Filters */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
|
||||||
{/* Sort Options */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="theme-text font-medium text-sm">Sort by:</label>
|
|
||||||
<select
|
|
||||||
value={sortOption}
|
|
||||||
onChange={(e) => handleSortChange(e.target.value as SortOption)}
|
|
||||||
className="px-3 py-1 rounded-lg theme-card theme-text theme-border border focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
|
||||||
>
|
|
||||||
<option value="createdAt">Date Added</option>
|
|
||||||
<option value="title">Title</option>
|
|
||||||
<option value="authorName">Author</option>
|
|
||||||
<option value="rating">Rating</option>
|
|
||||||
<option value="wordCount">Word Count</option>
|
|
||||||
<option value="lastRead">Last Read</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Sort Direction Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={toggleSortDirection}
|
|
||||||
className="p-2 rounded-lg theme-card theme-text hover:bg-opacity-80 transition-colors border theme-border"
|
|
||||||
title={`Sort ${sortDirection === 'asc' ? 'Ascending' : 'Descending'}`}
|
|
||||||
aria-label={`Toggle sort direction - currently ${sortDirection === 'asc' ? 'ascending' : 'descending'}`}
|
|
||||||
>
|
|
||||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clear Filters */}
|
|
||||||
{(searchQuery || selectedTags.length > 0) && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag Filter */}
|
|
||||||
<TagFilter
|
|
||||||
tags={tags}
|
|
||||||
selectedTags={selectedTags}
|
|
||||||
onTagToggle={handleTagToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stories Display */}
|
|
||||||
{stories.length === 0 && !loading ? (
|
|
||||||
<div className="text-center py-20">
|
|
||||||
<div className="theme-text text-lg mb-4">
|
|
||||||
{searchQuery || selectedTags.length > 0
|
|
||||||
? 'No stories match your filters'
|
|
||||||
: 'No stories in your library yet'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{searchQuery || selectedTags.length > 0 ? (
|
|
||||||
<Button variant="ghost" onClick={clearFilters}>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button href="/import">
|
|
||||||
Add Your First Story
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<StoryMultiSelect
|
|
||||||
stories={stories}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onUpdate={handleStoryUpdate}
|
|
||||||
allowMultiSelect={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex justify-center gap-2 mt-8">
|
<div className="flex justify-center gap-2 mt-8">
|
||||||
@@ -315,7 +328,19 @@ export default function LibraryPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LayoutComponent = layout === 'sidebar' ? SidebarLayout :
|
||||||
|
layout === 'toolbar' ? ToolbarLayout :
|
||||||
|
MinimalLayout;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<LayoutComponent {...layoutProps}>
|
||||||
|
{renderContent()}
|
||||||
|
</LayoutComponent>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ import AppLayout from '../../components/layout/AppLayout';
|
|||||||
import { useTheme } from '../../lib/theme';
|
import { useTheme } from '../../lib/theme';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import { storyApi, authorApi, databaseApi } from '../../lib/api';
|
import { storyApi, authorApi, databaseApi } from '../../lib/api';
|
||||||
|
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
|
||||||
|
import LibrarySettings from '../../components/library/LibrarySettings';
|
||||||
|
|
||||||
type FontFamily = 'serif' | 'sans' | 'mono';
|
type FontFamily = 'serif' | 'sans' | 'mono';
|
||||||
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||||
@@ -28,6 +30,7 @@ const defaultSettings: Settings = {
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { layout, setLayout } = useLibraryLayout();
|
||||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [typesenseStatus, setTypesenseStatus] = useState<{
|
const [typesenseStatus, setTypesenseStatus] = useState<{
|
||||||
@@ -350,6 +353,60 @@ export default function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Library Layout */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
Library Layout
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setLayout('sidebar')}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-colors ${
|
||||||
|
layout === 'sidebar'
|
||||||
|
? 'theme-accent-bg text-white border-transparent'
|
||||||
|
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📋 Sidebar Layout
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLayout('toolbar')}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-colors ${
|
||||||
|
layout === 'toolbar'
|
||||||
|
? 'theme-accent-bg text-white border-transparent'
|
||||||
|
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🛠️ Toolbar Layout
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setLayout('minimal')}
|
||||||
|
className={`px-4 py-2 rounded-lg border transition-colors ${
|
||||||
|
layout === 'minimal'
|
||||||
|
? 'theme-accent-bg text-white border-transparent'
|
||||||
|
: 'theme-card theme-text theme-border hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
✨ Minimal Layout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm theme-text">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mt-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<strong>Sidebar:</strong> Filters and controls in a side panel, maximum space for stories
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
<strong>Toolbar:</strong> Everything visible at once with integrated search and tag filters
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
<strong>Minimal:</strong> Clean, content-focused design with floating controls
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -718,6 +775,24 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Library Settings */}
|
||||||
|
<LibrarySettings />
|
||||||
|
|
||||||
|
{/* Tag Management */}
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold theme-header mb-4">Tag Management</h2>
|
||||||
|
<p className="theme-text mb-6">
|
||||||
|
Manage your story tags with colors, descriptions, and aliases. Use the Tag Maintenance page to organize and customize your tags.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href="/settings/tag-maintenance"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
🏷️ Open Tag Maintenance
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
799
frontend/src/app/settings/tag-maintenance/page.tsx
Normal file
799
frontend/src/app/settings/tag-maintenance/page.tsx
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import AppLayout from '../../../components/layout/AppLayout';
|
||||||
|
import { tagApi } from '../../../lib/api';
|
||||||
|
import { Tag } from '../../../types/api';
|
||||||
|
import Button from '../../../components/ui/Button';
|
||||||
|
import { Input } from '../../../components/ui/Input';
|
||||||
|
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
||||||
|
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||||
|
import TagEditModal from '../../../components/tags/TagEditModal';
|
||||||
|
|
||||||
|
export default function TagMaintenancePage() {
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortBy, setSortBy] = useState<'name' | 'storyCount' | 'createdAt'>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
|
||||||
|
const [isMergeModalOpen, setIsMergeModalOpen] = useState(false);
|
||||||
|
const [mergeTargetTagId, setMergeTargetTagId] = useState<string>('');
|
||||||
|
const [mergePreview, setMergePreview] = useState<any>(null);
|
||||||
|
const [merging, setMerging] = useState(false);
|
||||||
|
const [isMergeSuggestionsModalOpen, setIsMergeSuggestionsModalOpen] = useState(false);
|
||||||
|
const [mergeSuggestions, setMergeSuggestions] = useState<Array<{
|
||||||
|
group: Tag[];
|
||||||
|
similarity: number;
|
||||||
|
reason: string;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTags = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await tagApi.getTags({
|
||||||
|
page: 0,
|
||||||
|
size: 1000, // Load all tags for maintenance
|
||||||
|
sortBy,
|
||||||
|
sortDir: sortDirection
|
||||||
|
});
|
||||||
|
setTags(result.content || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tags:', error);
|
||||||
|
setTags([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagSave = (updatedTag: Tag) => {
|
||||||
|
if (selectedTag) {
|
||||||
|
// Update existing tag
|
||||||
|
setTags(prev => prev.map(tag =>
|
||||||
|
tag.id === updatedTag.id ? updatedTag : tag
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Add new tag
|
||||||
|
setTags(prev => [...prev, updatedTag]);
|
||||||
|
}
|
||||||
|
setSelectedTag(null);
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagDelete = (deletedTag: Tag) => {
|
||||||
|
setTags(prev => prev.filter(tag => tag.id !== deletedTag.id));
|
||||||
|
setSelectedTag(null);
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTag = (tag: Tag) => {
|
||||||
|
setSelectedTag(tag);
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = () => {
|
||||||
|
setSelectedTag(null);
|
||||||
|
setIsCreateModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (newSortBy: typeof sortBy) => {
|
||||||
|
if (newSortBy === sortBy) {
|
||||||
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagSelection = (tagId: string, selected: boolean) => {
|
||||||
|
setSelectedTagIds(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (selected) {
|
||||||
|
newSet.add(tagId);
|
||||||
|
} else {
|
||||||
|
newSet.delete(tagId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (selected: boolean) => {
|
||||||
|
if (selected) {
|
||||||
|
setSelectedTagIds(new Set(filteredTags.map(tag => tag.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedTagIds(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectUnused = () => {
|
||||||
|
const unusedTags = filteredTags.filter(tag => !tag.storyCount || tag.storyCount === 0);
|
||||||
|
setSelectedTagIds(new Set(unusedTags.map(tag => tag.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelected = async () => {
|
||||||
|
if (selectedTagIds.size === 0) return;
|
||||||
|
|
||||||
|
const confirmation = confirm(
|
||||||
|
`Are you sure you want to delete ${selectedTagIds.size} selected tag(s)? This action cannot be undone.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmation) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletePromises = Array.from(selectedTagIds).map(tagId =>
|
||||||
|
tagApi.deleteTag(tagId)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
|
||||||
|
// Reload tags and reset selection
|
||||||
|
await loadTags();
|
||||||
|
setSelectedTagIds(new Set());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete tags:', error);
|
||||||
|
alert('Failed to delete some tags. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMergeSuggestions = () => {
|
||||||
|
const suggestions: Array<{
|
||||||
|
group: Tag[];
|
||||||
|
similarity: number;
|
||||||
|
reason: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Helper function to calculate similarity between two strings
|
||||||
|
const calculateSimilarity = (str1: string, str2: string): number => {
|
||||||
|
const s1 = str1.toLowerCase();
|
||||||
|
const s2 = str2.toLowerCase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (s1 === s2) return 1.0;
|
||||||
|
|
||||||
|
// Check for common patterns
|
||||||
|
const patterns = [
|
||||||
|
// Plural vs singular
|
||||||
|
{ regex: /(.+)s$/, match: (a: string, b: string) => a === b + 's' || b === a + 's' },
|
||||||
|
// Hyphen vs underscore vs space
|
||||||
|
{ regex: /[-_\s]/, match: (a: string, b: string) =>
|
||||||
|
a.replace(/[-_\s]/g, '') === b.replace(/[-_\s]/g, '') },
|
||||||
|
// Common abbreviations
|
||||||
|
{ regex: /\b(and|&)\b/, match: (a: string, b: string) =>
|
||||||
|
a.replace(/\band\b/g, '&') === b || a === b.replace(/\band\b/g, '&') },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.match(s1, s2)) return 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Levenshtein distance for similar words
|
||||||
|
const distance = levenshteinDistance(s1, s2);
|
||||||
|
const maxLength = Math.max(s1.length, s2.length);
|
||||||
|
const similarity = 1 - (distance / maxLength);
|
||||||
|
|
||||||
|
return similarity > 0.8 ? similarity : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple Levenshtein distance implementation
|
||||||
|
const levenshteinDistance = (str1: string, str2: string): number => {
|
||||||
|
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||||
|
|
||||||
|
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||||
|
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||||
|
|
||||||
|
for (let j = 1; j <= str2.length; j++) {
|
||||||
|
for (let i = 1; i <= str1.length; i++) {
|
||||||
|
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||||
|
matrix[j][i] = Math.min(
|
||||||
|
matrix[j][i - 1] + 1,
|
||||||
|
matrix[j - 1][i] + 1,
|
||||||
|
matrix[j - 1][i - 1] + indicator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[str2.length][str1.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find similar tags
|
||||||
|
const processedTags = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < tags.length; i++) {
|
||||||
|
if (processedTags.has(tags[i].id)) continue;
|
||||||
|
|
||||||
|
const similarTags = [tags[i]];
|
||||||
|
processedTags.add(tags[i].id);
|
||||||
|
|
||||||
|
for (let j = i + 1; j < tags.length; j++) {
|
||||||
|
if (processedTags.has(tags[j].id)) continue;
|
||||||
|
|
||||||
|
const similarity = calculateSimilarity(tags[i].name, tags[j].name);
|
||||||
|
if (similarity > 0.8) {
|
||||||
|
similarTags.push(tags[j]);
|
||||||
|
processedTags.add(tags[j].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (similarTags.length > 1) {
|
||||||
|
const maxSimilarity = Math.max(...similarTags.slice(1).map(tag =>
|
||||||
|
calculateSimilarity(similarTags[0].name, tag.name)
|
||||||
|
));
|
||||||
|
|
||||||
|
let reason = 'Similar names detected';
|
||||||
|
if (maxSimilarity === 0.9) {
|
||||||
|
reason = 'Likely plural/singular or formatting variations';
|
||||||
|
} else if (maxSimilarity > 0.95) {
|
||||||
|
reason = 'Very similar names, possible duplicates';
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.push({
|
||||||
|
group: similarTags,
|
||||||
|
similarity: maxSimilarity,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by similarity descending
|
||||||
|
suggestions.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
|
||||||
|
setMergeSuggestions(suggestions);
|
||||||
|
setIsMergeSuggestionsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMergeSelected = () => {
|
||||||
|
if (selectedTagIds.size < 2) {
|
||||||
|
alert('Please select at least 2 tags to merge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsMergeModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMergePreview = async () => {
|
||||||
|
if (!mergeTargetTagId || selectedTagIds.size < 2) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceTagIds = Array.from(selectedTagIds).filter(id => id !== mergeTargetTagId);
|
||||||
|
const preview = await tagApi.previewMerge(sourceTagIds, mergeTargetTagId);
|
||||||
|
setMergePreview(preview);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to preview merge:', error);
|
||||||
|
alert('Failed to preview merge');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmMerge = async () => {
|
||||||
|
if (!mergeTargetTagId || selectedTagIds.size < 2) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setMerging(true);
|
||||||
|
const sourceTagIds = Array.from(selectedTagIds).filter(id => id !== mergeTargetTagId);
|
||||||
|
await tagApi.mergeTags(sourceTagIds, mergeTargetTagId);
|
||||||
|
|
||||||
|
// Reload tags and reset state
|
||||||
|
await loadTags();
|
||||||
|
setSelectedTagIds(new Set());
|
||||||
|
setMergeTargetTagId('');
|
||||||
|
setMergePreview(null);
|
||||||
|
setIsMergeModalOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to merge tags:', error);
|
||||||
|
alert('Failed to merge tags');
|
||||||
|
} finally {
|
||||||
|
setMerging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort tags
|
||||||
|
const filteredTags = tags
|
||||||
|
.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(tag.description && tag.description.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
let aValue, bValue;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
aValue = a.name.toLowerCase();
|
||||||
|
bValue = b.name.toLowerCase();
|
||||||
|
break;
|
||||||
|
case 'storyCount':
|
||||||
|
aValue = a.storyCount || 0;
|
||||||
|
bValue = b.storyCount || 0;
|
||||||
|
break;
|
||||||
|
case 'createdAt':
|
||||||
|
aValue = new Date(a.createdAt || 0).getTime();
|
||||||
|
bValue = new Date(b.createdAt || 0).getTime();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||||
|
} else {
|
||||||
|
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSortIcon = (column: typeof sortBy) => {
|
||||||
|
if (sortBy !== column) return '↕️';
|
||||||
|
return sortDirection === 'asc' ? '↑' : '↓';
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagStats = {
|
||||||
|
total: tags.length,
|
||||||
|
withColors: tags.filter(tag => tag.color).length,
|
||||||
|
withDescriptions: tags.filter(tag => tag.description).length,
|
||||||
|
withAliases: tags.filter(tag => tag.aliasCount && tag.aliasCount > 0).length,
|
||||||
|
unused: tags.filter(tag => !tag.storyCount || tag.storyCount === 0).length
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold theme-header">Tag Maintenance</h1>
|
||||||
|
<p className="theme-text mt-2">
|
||||||
|
Manage tag colors, descriptions, and aliases for better organization
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button href="/settings" variant="ghost">
|
||||||
|
← Back to Settings
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateTag} variant="primary">
|
||||||
|
+ Create Tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-lg font-semibold theme-header mb-4">Tag Statistics</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold theme-accent">{tagStats.total}</div>
|
||||||
|
<div className="text-sm theme-text">Total Tags</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{tagStats.withColors}</div>
|
||||||
|
<div className="text-sm theme-text">With Colors</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{tagStats.withDescriptions}</div>
|
||||||
|
<div className="text-sm theme-text">With Descriptions</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-purple-600">{tagStats.withAliases}</div>
|
||||||
|
<div className="text-sm theme-text">With Aliases</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gray-500">{tagStats.unused}</div>
|
||||||
|
<div className="text-sm theme-text">Unused</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search tags by name or description..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSortChange('name')}
|
||||||
|
className="px-3 py-2 text-sm border theme-border rounded-lg theme-card theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
Name {getSortIcon('name')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSortChange('storyCount')}
|
||||||
|
className="px-3 py-2 text-sm border theme-border rounded-lg theme-card theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
Usage {getSortIcon('storyCount')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSortChange('createdAt')}
|
||||||
|
className="px-3 py-2 text-sm border theme-border rounded-lg theme-card theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
Date {getSortIcon('createdAt')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags List */}
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-semibold theme-header">
|
||||||
|
Tags ({filteredTags.length})
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={generateMergeSuggestions}
|
||||||
|
>
|
||||||
|
🔍 Merge Suggestions
|
||||||
|
</Button>
|
||||||
|
{selectedTagIds.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTagIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear Selection ({selectedTagIds.size})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
>
|
||||||
|
🗑️ Delete Selected
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMergeSelected}
|
||||||
|
disabled={selectedTagIds.size < 2}
|
||||||
|
>
|
||||||
|
Merge Selected
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredTags.length > 0 && (
|
||||||
|
<div className="mb-4 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label className="text-sm theme-text">Select All</label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSelectUnused}
|
||||||
|
disabled={tagStats.unused === 0}
|
||||||
|
>
|
||||||
|
Select Unused ({tagStats.unused})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredTags.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="theme-text text-lg mb-4">
|
||||||
|
{searchQuery ? 'No tags match your search.' : 'No tags found.'}
|
||||||
|
</p>
|
||||||
|
{!searchQuery && (
|
||||||
|
<Button onClick={handleCreateTag} variant="primary">
|
||||||
|
Create Your First Tag
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredTags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className="flex items-center justify-between p-4 border theme-border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTagIds.has(tag.id)}
|
||||||
|
onChange={(e) => handleTagSelection(tag.id, e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<TagDisplay
|
||||||
|
tag={tag}
|
||||||
|
size="md"
|
||||||
|
showAliasesTooltip={true}
|
||||||
|
clickable={false}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{tag.description && (
|
||||||
|
<p className="text-sm theme-text-muted mt-1 truncate">
|
||||||
|
{tag.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-4 text-xs theme-text-muted mt-1">
|
||||||
|
<a
|
||||||
|
href={`/library?tags=${encodeURIComponent(tag.name)}`}
|
||||||
|
className="hover:theme-accent hover:underline cursor-pointer"
|
||||||
|
title={`View ${tag.storyCount || 0} stories with tag "${tag.name}"`}
|
||||||
|
>
|
||||||
|
{tag.storyCount || 0} stories
|
||||||
|
</a>
|
||||||
|
{tag.aliasCount && tag.aliasCount > 0 && (
|
||||||
|
<span>{tag.aliasCount} aliases</span>
|
||||||
|
)}
|
||||||
|
{tag.createdAt && (
|
||||||
|
<span>Created {new Date(tag.createdAt).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditTag(tag)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<TagEditModal
|
||||||
|
tag={selectedTag || undefined}
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
setSelectedTag(null);
|
||||||
|
}}
|
||||||
|
onSave={handleTagSave}
|
||||||
|
onDelete={handleTagDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<TagEditModal
|
||||||
|
tag={undefined}
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onSave={handleTagSave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Merge Modal */}
|
||||||
|
{isMergeModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
<h2 className="text-2xl font-bold theme-header mb-4">Merge Tags</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="theme-text mb-2">
|
||||||
|
You have selected {selectedTagIds.size} tags to merge.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm theme-text-muted mb-4">
|
||||||
|
Choose which tag should become the canonical name. All other tags will become aliases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Tag Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium theme-text mb-2">
|
||||||
|
Canonical Tag (keep this name):
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={mergeTargetTagId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMergeTargetTagId(e.target.value);
|
||||||
|
setMergePreview(null);
|
||||||
|
}}
|
||||||
|
className="w-full p-2 border theme-border rounded-lg theme-card theme-text"
|
||||||
|
>
|
||||||
|
<option value="">Select canonical tag...</option>
|
||||||
|
{Array.from(selectedTagIds).map(tagId => {
|
||||||
|
const tag = tags.find(t => t.id === tagId);
|
||||||
|
return tag ? (
|
||||||
|
<option key={tagId} value={tagId}>
|
||||||
|
{tag.name} ({tag.storyCount || 0} stories)
|
||||||
|
</option>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Button */}
|
||||||
|
{mergeTargetTagId && (
|
||||||
|
<Button
|
||||||
|
onClick={handleMergePreview}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Preview Merge
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Merge Preview */}
|
||||||
|
{mergePreview && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium theme-header mb-2">Merge Preview</h3>
|
||||||
|
<div className="space-y-2 text-sm theme-text">
|
||||||
|
<p>
|
||||||
|
<strong>Result:</strong> "{mergePreview.targetTagName}" with {mergePreview.totalResultStoryCount} stories
|
||||||
|
</p>
|
||||||
|
{mergePreview.aliasesToCreate && mergePreview.aliasesToCreate.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong>Aliases to create:</strong>
|
||||||
|
<ul className="ml-4 mt-1 list-disc">
|
||||||
|
{mergePreview.aliasesToCreate.map((alias: string) => (
|
||||||
|
<li key={alias}>{alias}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsMergeModalOpen(false);
|
||||||
|
setMergeTargetTagId('');
|
||||||
|
setMergePreview(null);
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmMerge}
|
||||||
|
variant="primary"
|
||||||
|
disabled={!mergeTargetTagId || merging}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{merging ? 'Merging...' : 'Confirm Merge'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Merge Suggestions Modal */}
|
||||||
|
{isMergeSuggestionsModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
<h2 className="text-2xl font-bold theme-header mb-4">Merge Suggestions</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="theme-text">
|
||||||
|
Found {mergeSuggestions.length} potential merge opportunities based on similar tag names.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{mergeSuggestions.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="theme-text text-lg">No similar tags found.</p>
|
||||||
|
<p className="theme-text-muted text-sm mt-2">
|
||||||
|
All your tags appear to have unique names.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mergeSuggestions.map((suggestion, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border theme-border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-900/20"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium theme-header">
|
||||||
|
Suggestion {index + 1}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm theme-text-muted">
|
||||||
|
{suggestion.reason} (Similarity: {(suggestion.similarity * 100).toFixed(1)}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
// Pre-select these tags for merging and go directly to merge modal
|
||||||
|
const suggestedTagIds = new Set(suggestion.group.map(tag => tag.id));
|
||||||
|
setSelectedTagIds(suggestedTagIds);
|
||||||
|
setIsMergeSuggestionsModalOpen(false);
|
||||||
|
|
||||||
|
// Open merge modal directly
|
||||||
|
setIsMergeModalOpen(true);
|
||||||
|
setMergeTargetTagId('');
|
||||||
|
setMergePreview(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Merge These
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{suggestion.group.map((tag, tagIndex) => (
|
||||||
|
<div key={tag.id} className="flex items-center gap-2">
|
||||||
|
<TagDisplay
|
||||||
|
tag={tag}
|
||||||
|
size="sm"
|
||||||
|
showAliasesTooltip={true}
|
||||||
|
clickable={false}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text-muted">
|
||||||
|
({tag.storyCount || 0} stories)
|
||||||
|
</span>
|
||||||
|
{tagIndex < suggestion.group.length - 1 && (
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t theme-border">
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsMergeSuggestionsModalOpen(false)}
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{mergeSuggestions.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
// Select all suggested tags for batch processing
|
||||||
|
const allSuggestedTagIds = new Set<string>();
|
||||||
|
mergeSuggestions.forEach(suggestion => {
|
||||||
|
suggestion.group.forEach(tag => allSuggestedTagIds.add(tag.id));
|
||||||
|
});
|
||||||
|
setSelectedTagIds(allSuggestedTagIds);
|
||||||
|
setIsMergeSuggestionsModalOpen(false);
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Select All Suggested ({mergeSuggestions.reduce((acc, s) => acc + s.group.length, 0)} tags)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { Story, Collection } from '../../../../types/api';
|
|||||||
import AppLayout from '../../../../components/layout/AppLayout';
|
import AppLayout from '../../../../components/layout/AppLayout';
|
||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||||
|
import TagDisplay from '../../../../components/tags/TagDisplay';
|
||||||
|
import TableOfContents from '../../../../components/stories/TableOfContents';
|
||||||
import { calculateReadingTime } from '../../../../lib/settings';
|
import { calculateReadingTime } from '../../../../lib/settings';
|
||||||
|
|
||||||
export default function StoryDetailPage() {
|
export default function StoryDetailPage() {
|
||||||
@@ -365,18 +367,27 @@ export default function StoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Table of Contents */}
|
||||||
|
<TableOfContents
|
||||||
|
htmlContent={story.contentHtml || ''}
|
||||||
|
onItemClick={(item) => {
|
||||||
|
// Scroll to the story reading view with the specific heading
|
||||||
|
window.location.href = `/stories/${story.id}#${item.id}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{story.tags && story.tags.length > 0 && (
|
{story.tags && story.tags.length > 0 && (
|
||||||
<div className="theme-card theme-shadow rounded-lg p-4">
|
<div className="theme-card theme-shadow rounded-lg p-4">
|
||||||
<h3 className="font-semibold theme-header mb-3">Tags</h3>
|
<h3 className="font-semibold theme-header mb-3">Tags</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{story.tags.map((tag) => (
|
{story.tags.map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
|
tag={tag}
|
||||||
>
|
size="md"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import AppLayout from '../../../../components/layout/AppLayout';
|
|||||||
import { Input, Textarea } from '../../../../components/ui/Input';
|
import { Input, Textarea } from '../../../../components/ui/Input';
|
||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import TagInput from '../../../../components/stories/TagInput';
|
import TagInput from '../../../../components/stories/TagInput';
|
||||||
|
import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
||||||
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
||||||
import ImageUpload from '../../../../components/ui/ImageUpload';
|
import ImageUpload from '../../../../components/ui/ImageUpload';
|
||||||
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
||||||
|
import SeriesSelector from '../../../../components/stories/SeriesSelector';
|
||||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||||
import { storyApi } from '../../../../lib/api';
|
import { storyApi } from '../../../../lib/api';
|
||||||
import { Story } from '../../../../types/api';
|
import { Story } from '../../../../types/api';
|
||||||
@@ -21,6 +23,7 @@ export default function EditStoryPage() {
|
|||||||
const [story, setStory] = useState<Story | null>(null);
|
const [story, setStory] = useState<Story | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [resetingPosition, setResetingPosition] = useState(false);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -32,6 +35,7 @@ export default function EditStoryPage() {
|
|||||||
sourceUrl: '',
|
sourceUrl: '',
|
||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
seriesName: '',
|
seriesName: '',
|
||||||
|
seriesId: undefined as string | undefined,
|
||||||
volume: '',
|
volume: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,6 +58,7 @@ export default function EditStoryPage() {
|
|||||||
sourceUrl: storyData.sourceUrl || '',
|
sourceUrl: storyData.sourceUrl || '',
|
||||||
tags: storyData.tags?.map(tag => tag.name) || [],
|
tags: storyData.tags?.map(tag => tag.name) || [],
|
||||||
seriesName: storyData.seriesName || '',
|
seriesName: storyData.seriesName || '',
|
||||||
|
seriesId: storyData.seriesId,
|
||||||
volume: storyData.volume?.toString() || '',
|
volume: storyData.volume?.toString() || '',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -94,6 +99,15 @@ export default function EditStoryPage() {
|
|||||||
setFormData(prev => ({ ...prev, tags }));
|
setFormData(prev => ({ ...prev, tags }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddSuggestedTag = (tagName: string) => {
|
||||||
|
if (!formData.tags.includes(tagName.toLowerCase())) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
tags: [...prev.tags, tagName.toLowerCase()]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -107,6 +121,19 @@ export default function EditStoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSeriesChange = (seriesName: string, seriesId?: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
seriesName,
|
||||||
|
seriesId: seriesId // This will be undefined if creating new series, which clears the existing ID
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user changes series
|
||||||
|
if (errors.seriesName) {
|
||||||
|
setErrors(prev => ({ ...prev, seriesName: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -150,8 +177,9 @@ export default function EditStoryPage() {
|
|||||||
summary: formData.summary || undefined,
|
summary: formData.summary || undefined,
|
||||||
contentHtml: formData.contentHtml,
|
contentHtml: formData.contentHtml,
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
volume: formData.seriesName && formData.volume ? parseInt(formData.volume) : undefined,
|
||||||
seriesName: formData.seriesName || undefined,
|
// Send seriesId if we have it (existing series), otherwise send seriesName (new/changed series)
|
||||||
|
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName }),
|
||||||
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
||||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||||
tagNames: formData.tags,
|
tagNames: formData.tags,
|
||||||
@@ -174,6 +202,32 @@ export default function EditStoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetReadingPosition = async () => {
|
||||||
|
if (!story || !confirm('Are you sure you want to reset the reading position to the beginning? This will remove your current place in the story.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setResetingPosition(true);
|
||||||
|
await storyApi.updateReadingProgress(storyId, 0);
|
||||||
|
setStory(prev => prev ? { ...prev, readingPosition: 0 } : null);
|
||||||
|
// Show success feedback
|
||||||
|
setErrors({ resetSuccess: 'Reading position reset! The story will start from the beginning next time you read it.' });
|
||||||
|
// Clear success message after 4 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setErrors(prev => {
|
||||||
|
const { resetSuccess, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}, 4000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset reading position:', error);
|
||||||
|
setErrors({ submit: 'Failed to reset reading position' });
|
||||||
|
} finally {
|
||||||
|
setResetingPosition(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!story || !confirm('Are you sure you want to delete this story? This action cannot be undone.')) {
|
if (!story || !confirm('Are you sure you want to delete this story? This action cannot be undone.')) {
|
||||||
return;
|
return;
|
||||||
@@ -288,6 +342,8 @@ export default function EditStoryPage() {
|
|||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
placeholder="Edit your story content here..."
|
placeholder="Edit your story content here..."
|
||||||
error={errors.contentHtml}
|
error={errors.contentHtml}
|
||||||
|
storyId={storyId}
|
||||||
|
enableImageProcessing={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -301,17 +357,28 @@ export default function EditStoryPage() {
|
|||||||
onChange={handleTagsChange}
|
onChange={handleTagsChange}
|
||||||
placeholder="Edit tags to categorize your story..."
|
placeholder="Edit tags to categorize your story..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Tag Suggestions */}
|
||||||
|
<TagSuggestions
|
||||||
|
title={formData.title}
|
||||||
|
content={formData.contentHtml}
|
||||||
|
summary={formData.summary}
|
||||||
|
currentTags={formData.tags}
|
||||||
|
onAddTag={handleAddSuggestedTag}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Series and Volume */}
|
{/* Series and Volume */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<SeriesSelector
|
||||||
label="Series (optional)"
|
label="Series (optional)"
|
||||||
value={formData.seriesName}
|
value={formData.seriesName}
|
||||||
onChange={handleInputChange('seriesName')}
|
onChange={handleSeriesChange}
|
||||||
placeholder="Enter series name if part of a series"
|
placeholder="Select or enter series name if part of a series"
|
||||||
error={errors.seriesName}
|
error={errors.seriesName}
|
||||||
|
authorId={formData.authorId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -336,6 +403,38 @@ export default function EditStoryPage() {
|
|||||||
placeholder="https://example.com/original-story-url"
|
placeholder="https://example.com/original-story-url"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Reading Position Reset Section */}
|
||||||
|
<div className="theme-card p-4 rounded-lg border theme-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium theme-header">Reading Position</h3>
|
||||||
|
<p className="text-sm theme-text mt-1">
|
||||||
|
{story?.readingPosition && story.readingPosition > 0
|
||||||
|
? `Currently saved at position ${story.readingPosition.toLocaleString()}`
|
||||||
|
: 'No reading position saved (story will start from the beginning)'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleResetReadingPosition}
|
||||||
|
loading={resetingPosition}
|
||||||
|
disabled={saving || !story?.readingPosition || story.readingPosition === 0}
|
||||||
|
className="text-orange-600 hover:text-orange-700 dark:text-orange-400 dark:hover:text-orange-300"
|
||||||
|
>
|
||||||
|
Reset to Beginning
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{errors.resetSuccess && (
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
|
<p className="text-green-800 dark:text-green-200">{errors.resetSuccess}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submit Error */}
|
{/* Submit Error */}
|
||||||
{errors.submit && (
|
{errors.submit && (
|
||||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Story } from '../../../types/api';
|
|||||||
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
||||||
import Button from '../../../components/ui/Button';
|
import Button from '../../../components/ui/Button';
|
||||||
import StoryRating from '../../../components/stories/StoryRating';
|
import StoryRating from '../../../components/stories/StoryRating';
|
||||||
|
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||||
|
import TableOfContents from '../../../components/stories/TableOfContents';
|
||||||
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
||||||
|
|
||||||
export default function StoryReadingPage() {
|
export default function StoryReadingPage() {
|
||||||
@@ -20,6 +22,11 @@ export default function StoryReadingPage() {
|
|||||||
const [readingProgress, setReadingProgress] = useState(0);
|
const [readingProgress, setReadingProgress] = useState(0);
|
||||||
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
||||||
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
const [hasScrolledToPosition, setHasScrolledToPosition] = useState(false);
|
||||||
|
const [showToc, setShowToc] = useState(false);
|
||||||
|
const [hasHeadings, setHasHeadings] = useState(false);
|
||||||
|
const [showEndOfStoryPopup, setShowEndOfStoryPopup] = useState(false);
|
||||||
|
const [hasReachedEnd, setHasReachedEnd] = useState(false);
|
||||||
|
const [resettingPosition, setResettingPosition] = useState(false);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
@@ -111,9 +118,32 @@ export default function StoryReadingPage() {
|
|||||||
|
|
||||||
setStory(storyData);
|
setStory(storyData);
|
||||||
|
|
||||||
// Sanitize story content
|
// Sanitize story content and add IDs to headings
|
||||||
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
|
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
|
||||||
setSanitizedContent(sanitized);
|
|
||||||
|
// Add IDs to headings for TOC functionality using regex instead of DOMParser
|
||||||
|
// This avoids potential browser-specific sanitization that might strip src attributes
|
||||||
|
let processedContent = sanitized;
|
||||||
|
const headingMatches = processedContent.match(/<h[1-6][^>]*>/gi);
|
||||||
|
let headingCount = 0;
|
||||||
|
|
||||||
|
if (headingMatches) {
|
||||||
|
processedContent = processedContent.replace(/<h([1-6])([^>]*)>/gi, (match, level, attrs) => {
|
||||||
|
const headingId = `heading-${headingCount++}`;
|
||||||
|
|
||||||
|
// Check if id attribute already exists
|
||||||
|
if (attrs.includes('id=')) {
|
||||||
|
// Replace existing id
|
||||||
|
return match.replace(/id=['"][^'"]*['"]/, `id="${headingId}"`);
|
||||||
|
} else {
|
||||||
|
// Add id attribute
|
||||||
|
return `<h${level}${attrs} id="${headingId}">`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSanitizedContent(processedContent);
|
||||||
|
setHasHeadings(headingCount > 0);
|
||||||
|
|
||||||
// Load series stories if part of a series
|
// Load series stories if part of a series
|
||||||
if (storyData.seriesId) {
|
if (storyData.seriesId) {
|
||||||
@@ -133,12 +163,29 @@ export default function StoryReadingPage() {
|
|||||||
}
|
}
|
||||||
}, [storyId]);
|
}, [storyId]);
|
||||||
|
|
||||||
// Auto-scroll to saved reading position when story content is loaded
|
// Auto-scroll to saved reading position or URL hash when story content is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (story && sanitizedContent && !hasScrolledToPosition) {
|
if (story && sanitizedContent && !hasScrolledToPosition) {
|
||||||
// Use a small delay to ensure content is rendered
|
// Use a small delay to ensure content is rendered
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
console.log('Initializing reading position tracking, saved position:', story.readingPosition);
|
console.log('Initializing reading position tracking, saved position:', story.readingPosition);
|
||||||
|
|
||||||
|
// Check if there's a hash in the URL (for TOC navigation)
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
if (hash && hash.startsWith('heading-')) {
|
||||||
|
console.log('Auto-scrolling to heading from URL hash:', hash);
|
||||||
|
const element = document.getElementById(hash);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
setHasScrolledToPosition(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use saved reading position
|
||||||
if (story.readingPosition && story.readingPosition > 0) {
|
if (story.readingPosition && story.readingPosition > 0) {
|
||||||
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
||||||
scrollToCharacterPosition(story.readingPosition);
|
scrollToCharacterPosition(story.readingPosition);
|
||||||
@@ -162,13 +209,41 @@ export default function StoryReadingPage() {
|
|||||||
const articleTop = article.offsetTop;
|
const articleTop = article.offsetTop;
|
||||||
const articleHeight = article.scrollHeight;
|
const articleHeight = article.scrollHeight;
|
||||||
const windowHeight = window.innerHeight;
|
const windowHeight = window.innerHeight;
|
||||||
|
|
||||||
const progress = Math.min(100, Math.max(0,
|
const progress = Math.min(100, Math.max(0,
|
||||||
((scrolled - articleTop + windowHeight) / articleHeight) * 100
|
((scrolled - articleTop + windowHeight) / articleHeight) * 100
|
||||||
));
|
));
|
||||||
|
|
||||||
setReadingProgress(progress);
|
setReadingProgress(progress);
|
||||||
|
|
||||||
|
// Multi-method end-of-story detection
|
||||||
|
const documentHeight = document.documentElement.scrollHeight;
|
||||||
|
const windowBottom = scrolled + windowHeight;
|
||||||
|
const distanceFromBottom = documentHeight - windowBottom;
|
||||||
|
|
||||||
|
// Method 1: Distance from bottom (most reliable)
|
||||||
|
const nearBottom = distanceFromBottom <= 200;
|
||||||
|
|
||||||
|
// Method 2: High progress but only as secondary check
|
||||||
|
const highProgress = progress >= 98;
|
||||||
|
|
||||||
|
// Method 3: Check if story content itself is fully visible
|
||||||
|
const storyContentElement = contentRef.current;
|
||||||
|
let storyContentFullyVisible = false;
|
||||||
|
if (storyContentElement) {
|
||||||
|
const contentRect = storyContentElement.getBoundingClientRect();
|
||||||
|
const contentBottom = scrolled + contentRect.bottom;
|
||||||
|
const documentContentHeight = Math.max(documentHeight - 300, contentBottom); // Account for footer padding
|
||||||
|
storyContentFullyVisible = windowBottom >= documentContentHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger end detection if user is near bottom AND (has high progress OR story content is fully visible)
|
||||||
|
if (nearBottom && (highProgress || storyContentFullyVisible) && !hasReachedEnd && hasScrolledToPosition) {
|
||||||
|
console.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress });
|
||||||
|
setHasReachedEnd(true);
|
||||||
|
setShowEndOfStoryPopup(true);
|
||||||
|
}
|
||||||
|
|
||||||
// Save reading position (debounced)
|
// Save reading position (debounced)
|
||||||
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
||||||
const characterPosition = getCharacterPositionFromScroll();
|
const characterPosition = getCharacterPositionFromScroll();
|
||||||
@@ -188,11 +263,11 @@ export default function StoryReadingPage() {
|
|||||||
clearTimeout(saveTimeoutRef.current);
|
clearTimeout(saveTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
|
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition, hasReachedEnd]);
|
||||||
|
|
||||||
const handleRatingUpdate = async (newRating: number) => {
|
const handleRatingUpdate = async (newRating: number) => {
|
||||||
if (!story) return;
|
if (!story) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storyApi.updateRating(story.id, newRating);
|
await storyApi.updateRating(story.id, newRating);
|
||||||
setStory(prev => prev ? { ...prev, rating: newRating } : null);
|
setStory(prev => prev ? { ...prev, rating: newRating } : null);
|
||||||
@@ -201,6 +276,25 @@ export default function StoryReadingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetReadingPosition = async () => {
|
||||||
|
if (!story) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setResettingPosition(true);
|
||||||
|
await storyApi.updateReadingProgress(story.id, 0);
|
||||||
|
setStory(prev => prev ? { ...prev, readingPosition: 0 } : null);
|
||||||
|
setShowEndOfStoryPopup(false);
|
||||||
|
setHasReachedEnd(false);
|
||||||
|
|
||||||
|
// DON'T scroll immediately - let user stay at current position
|
||||||
|
// The reset will take effect when they next open the story
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset reading position:', error);
|
||||||
|
} finally {
|
||||||
|
setResettingPosition(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const findNextStory = (): Story | null => {
|
const findNextStory = (): Story | null => {
|
||||||
if (!story?.seriesId || seriesStories.length <= 1) return null;
|
if (!story?.seriesId || seriesStories.length <= 1) return null;
|
||||||
@@ -265,6 +359,16 @@ export default function StoryReadingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{hasHeadings && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowToc(!showToc)}
|
||||||
|
className="text-sm theme-text hover:theme-accent transition-colors"
|
||||||
|
title="Table of Contents"
|
||||||
|
>
|
||||||
|
📋 TOC
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<StoryRating
|
<StoryRating
|
||||||
rating={story.rating || 0}
|
rating={story.rating || 0}
|
||||||
onRatingChange={handleRatingUpdate}
|
onRatingChange={handleRatingUpdate}
|
||||||
@@ -279,6 +383,76 @@ export default function StoryReadingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Table of Contents Modal */}
|
||||||
|
{showToc && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-50"
|
||||||
|
onClick={() => setShowToc(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TOC Modal */}
|
||||||
|
<div className="fixed top-20 right-4 left-4 md:left-auto md:w-80 max-h-96 z-50">
|
||||||
|
<TableOfContents
|
||||||
|
htmlContent={sanitizedContent}
|
||||||
|
collapsible={false}
|
||||||
|
onItemClick={(item) => {
|
||||||
|
const element = document.getElementById(item.id);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
setShowToc(false); // Close TOC after navigation
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of Story Popup */}
|
||||||
|
{showEndOfStoryPopup && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-50"
|
||||||
|
onClick={() => setShowEndOfStoryPopup(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Popup Modal */}
|
||||||
|
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 max-w-md w-full mx-4">
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold theme-header mb-3">
|
||||||
|
🎉 Story Complete!
|
||||||
|
</h3>
|
||||||
|
<p className="theme-text mb-6">
|
||||||
|
You've reached the end of "{story?.title}". Would you like to reset your reading position so the story starts from the beginning next time you open it?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowEndOfStoryPopup(false)}
|
||||||
|
>
|
||||||
|
Keep Current Position
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleResetReadingPosition}
|
||||||
|
loading={resettingPosition}
|
||||||
|
>
|
||||||
|
Reset for Next Time
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Story Content */}
|
{/* Story Content */}
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||||
<article data-reading-content>
|
<article data-reading-content>
|
||||||
@@ -314,12 +488,12 @@ export default function StoryReadingPage() {
|
|||||||
{story.tags && story.tags.length > 0 && (
|
{story.tags && story.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||||
{story.tags.map((tag) => (
|
{story.tags.map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="px-3 py-1 text-sm theme-accent-bg text-white rounded-full"
|
tag={tag}
|
||||||
>
|
size="md"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { searchApi, tagApi } from '../../lib/api';
|
import { searchApi, tagApi, getImageUrl } from '../../lib/api';
|
||||||
import { Story, Tag } from '../../types/api';
|
import { Story, Tag } from '../../types/api';
|
||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
@@ -239,7 +239,7 @@ export default function CollectionForm({
|
|||||||
{(coverImagePreview || initialData?.coverImagePath) && (
|
{(coverImagePreview || initialData?.coverImagePath) && (
|
||||||
<div className="w-20 h-24 rounded overflow-hidden bg-gray-100">
|
<div className="w-20 h-24 rounded overflow-hidden bg-gray-100">
|
||||||
<img
|
<img
|
||||||
src={coverImagePreview || (initialData?.coverImagePath ? `/images/${initialData.coverImagePath}` : '')}
|
src={coverImagePreview || (initialData?.coverImagePath ? getImageUrl(initialData.coverImagePath) : '')}
|
||||||
alt="Cover preview"
|
alt="Cover preview"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { StoryWithCollectionContext } from '../../types/api';
|
import { StoryWithCollectionContext } from '../../types/api';
|
||||||
import { storyApi } from '../../lib/api';
|
import { storyApi, getImageUrl } from '../../lib/api';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface CollectionReadingViewProps {
|
interface CollectionReadingViewProps {
|
||||||
@@ -211,7 +212,7 @@ export default function CollectionReadingView({
|
|||||||
{story.coverPath && (
|
{story.coverPath && (
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={`/images/${story.coverPath}`}
|
src={getImageUrl(story.coverPath)}
|
||||||
alt={`${story.title} cover`}
|
alt={`${story.title} cover`}
|
||||||
className="w-32 h-40 object-cover rounded-lg mx-auto md:mx-0"
|
className="w-32 h-40 object-cover rounded-lg mx-auto md:mx-0"
|
||||||
/>
|
/>
|
||||||
@@ -255,12 +256,12 @@ export default function CollectionReadingView({
|
|||||||
{story.tags && story.tags.length > 0 && (
|
{story.tags && story.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{story.tags.map((tag) => (
|
{story.tags.map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
tag={tag}
|
||||||
>
|
size="sm"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ export default function Header() {
|
|||||||
|
|
||||||
const addStoryItems = [
|
const addStoryItems = [
|
||||||
{
|
{
|
||||||
href: '/import',
|
href: '/add-story',
|
||||||
label: 'Manual Entry',
|
label: 'Manual Entry',
|
||||||
description: 'Add a story by manually entering details'
|
description: 'Add a story by manually entering details'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/import?mode=url',
|
href: '/import',
|
||||||
label: 'Import from URL',
|
label: 'Import from URL',
|
||||||
description: 'Import a single story from a website'
|
description: 'Import a single story from a website'
|
||||||
},
|
},
|
||||||
@@ -156,34 +156,16 @@ export default function Header() {
|
|||||||
<div className="px-2 py-1">
|
<div className="px-2 py-1">
|
||||||
<div className="font-medium theme-text mb-1">Add Story</div>
|
<div className="font-medium theme-text mb-1">Add Story</div>
|
||||||
<div className="pl-4 space-y-1">
|
<div className="pl-4 space-y-1">
|
||||||
<Link
|
{addStoryItems.map((item) => (
|
||||||
href="/import"
|
<Link
|
||||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
key={item.href}
|
||||||
onClick={() => setIsMenuOpen(false)}
|
href={item.href}
|
||||||
>
|
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||||
Manual Entry
|
onClick={() => setIsMenuOpen(false)}
|
||||||
</Link>
|
>
|
||||||
<Link
|
{item.label}
|
||||||
href="/import?mode=url"
|
</Link>
|
||||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
))}
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Import from URL
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/import/epub"
|
|
||||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Import EPUB
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/import/bulk"
|
|
||||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Bulk Import
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ const importTabs: ImportTab[] = [
|
|||||||
{
|
{
|
||||||
id: 'manual',
|
id: 'manual',
|
||||||
label: 'Manual Entry',
|
label: 'Manual Entry',
|
||||||
href: '/import',
|
href: '/add-story',
|
||||||
description: 'Add a story by manually entering details'
|
description: 'Add a story by manually entering details'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'url',
|
id: 'url',
|
||||||
label: 'Import from URL',
|
label: 'Import from URL',
|
||||||
href: '/import?mode=url',
|
href: '/import',
|
||||||
description: 'Import a single story from a website'
|
description: 'Import a single story from a website'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,8 +52,10 @@ export default function ImportLayout({ children, title, description }: ImportLay
|
|||||||
|
|
||||||
// Determine which tab is active
|
// Determine which tab is active
|
||||||
const getActiveTab = () => {
|
const getActiveTab = () => {
|
||||||
if (pathname === '/import') {
|
if (pathname === '/add-story') {
|
||||||
return mode === 'url' ? 'url' : 'manual';
|
return 'manual';
|
||||||
|
} else if (pathname === '/import') {
|
||||||
|
return 'url';
|
||||||
} else if (pathname === '/import/epub') {
|
} else if (pathname === '/import/epub') {
|
||||||
return 'epub';
|
return 'epub';
|
||||||
} else if (pathname === '/import/bulk') {
|
} else if (pathname === '/import/bulk') {
|
||||||
|
|||||||
554
frontend/src/components/library/AdvancedFilters.tsx
Normal file
554
frontend/src/components/library/AdvancedFilters.tsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { AdvancedFilters, FilterPreset } from '../../types/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
|
||||||
|
interface AdvancedFiltersProps {
|
||||||
|
filters: AdvancedFilters;
|
||||||
|
onChange: (filters: AdvancedFilters) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined filter presets with both detailed controls and quick buttons
|
||||||
|
const FILTER_PRESETS: FilterPreset[] = [
|
||||||
|
// Length presets
|
||||||
|
{
|
||||||
|
id: 'short-stories',
|
||||||
|
label: '< 5k words',
|
||||||
|
description: 'Short stories under 5,000 words',
|
||||||
|
filters: { maxWordCount: 5000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'medium-stories',
|
||||||
|
label: '5k - 20k',
|
||||||
|
description: 'Medium length stories (5k-20k words)',
|
||||||
|
filters: { minWordCount: 5000, maxWordCount: 20000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'long-stories',
|
||||||
|
label: '> 20k words',
|
||||||
|
description: 'Long stories over 20,000 words',
|
||||||
|
filters: { minWordCount: 20000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'very-long',
|
||||||
|
label: '> 50k words',
|
||||||
|
description: 'Very long stories over 50,000 words',
|
||||||
|
filters: { minWordCount: 50000 },
|
||||||
|
category: 'length'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Date presets
|
||||||
|
{
|
||||||
|
id: 'last-week',
|
||||||
|
label: 'Last 7 days',
|
||||||
|
description: 'Stories added in the last week',
|
||||||
|
filters: { createdAfter: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] },
|
||||||
|
category: 'date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last-month',
|
||||||
|
label: 'Last 30 days',
|
||||||
|
description: 'Stories added in the last month',
|
||||||
|
filters: { createdAfter: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] },
|
||||||
|
category: 'date'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'this-year',
|
||||||
|
label: 'This year',
|
||||||
|
description: 'Stories added this year',
|
||||||
|
filters: { createdAfter: `${new Date().getFullYear()}-01-01` },
|
||||||
|
category: 'date'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reading status presets
|
||||||
|
{
|
||||||
|
id: 'unread',
|
||||||
|
label: 'Unread',
|
||||||
|
description: 'Stories you haven\'t read yet',
|
||||||
|
filters: { readingStatus: 'unread' },
|
||||||
|
category: 'reading'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'in-progress',
|
||||||
|
label: 'Started',
|
||||||
|
description: 'Stories you\'ve started reading',
|
||||||
|
filters: { readingStatus: 'started' },
|
||||||
|
category: 'reading'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'completed',
|
||||||
|
label: 'Finished',
|
||||||
|
description: 'Stories you\'ve completed',
|
||||||
|
filters: { readingStatus: 'completed' },
|
||||||
|
category: 'reading'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rating presets
|
||||||
|
{
|
||||||
|
id: 'highly-rated',
|
||||||
|
label: '4+ stars',
|
||||||
|
description: 'Highly rated stories (4 stars or more)',
|
||||||
|
filters: { minRating: 4 },
|
||||||
|
category: 'rating'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unrated',
|
||||||
|
label: 'Unrated',
|
||||||
|
description: 'Stories without ratings',
|
||||||
|
filters: { unratedOnly: true },
|
||||||
|
category: 'rating'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content presets
|
||||||
|
{
|
||||||
|
id: 'with-covers',
|
||||||
|
label: 'Has Cover',
|
||||||
|
description: 'Stories with cover images',
|
||||||
|
filters: { hasCoverImage: true },
|
||||||
|
category: 'content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'standalone',
|
||||||
|
label: 'Standalone',
|
||||||
|
description: 'Stories not part of a series',
|
||||||
|
filters: { seriesFilter: 'standalone' },
|
||||||
|
category: 'content'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'series-only',
|
||||||
|
label: 'Series',
|
||||||
|
description: 'Stories that are part of a series',
|
||||||
|
filters: { seriesFilter: 'series' },
|
||||||
|
category: 'content'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Organization presets
|
||||||
|
{
|
||||||
|
id: 'well-tagged',
|
||||||
|
label: '3+ tags',
|
||||||
|
description: 'Well-tagged stories with 3 or more tags',
|
||||||
|
filters: { minTagCount: 3 },
|
||||||
|
category: 'organization'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'popular',
|
||||||
|
label: 'Popular',
|
||||||
|
description: 'Stories with above-average ratings',
|
||||||
|
filters: { popularOnly: true },
|
||||||
|
category: 'organization'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hidden-gems',
|
||||||
|
label: 'Hidden Gems',
|
||||||
|
description: 'Underrated or unrated stories to discover',
|
||||||
|
filters: { hiddenGemsOnly: true },
|
||||||
|
category: 'organization'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdvancedFilters({
|
||||||
|
filters,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
className = ''
|
||||||
|
}: AdvancedFiltersProps) {
|
||||||
|
|
||||||
|
// Prevent event bubbling when interacting with the component
|
||||||
|
const handleContainerClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
// Prevent escape key from bubbling up (let parent handle it)
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
|
length: false,
|
||||||
|
date: false,
|
||||||
|
rating: false,
|
||||||
|
reading: false,
|
||||||
|
content: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const updateFilter = <K extends keyof AdvancedFilters>(
|
||||||
|
key: K,
|
||||||
|
value: AdvancedFilters[K]
|
||||||
|
) => {
|
||||||
|
onChange({ ...filters, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (preset: FilterPreset) => {
|
||||||
|
onChange({ ...filters, ...preset.filters });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPresetActive = (preset: FilterPreset) => {
|
||||||
|
return Object.entries(preset.filters).every(([key, value]) =>
|
||||||
|
filters[key as keyof AdvancedFilters] === value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = Object.values(filters).some(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group presets by category
|
||||||
|
const presetsByCategory = FILTER_PRESETS.reduce((acc, preset) => {
|
||||||
|
if (!acc[preset.category]) acc[preset.category] = [];
|
||||||
|
acc[preset.category].push(preset);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, FilterPreset[]>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`space-y-4 ${className}`}
|
||||||
|
onClick={handleContainerClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{/* Quick Filter Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium theme-header text-sm">Quick Filters</h4>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(presetsByCategory).map(([category, presets]) => (
|
||||||
|
<div key={category} className="space-y-1">
|
||||||
|
<div className="text-xs font-medium theme-text opacity-75 uppercase tracking-wide">
|
||||||
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{presets.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => applyPreset(preset)}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium transition-all hover:scale-105 ${
|
||||||
|
isPresetActive(preset)
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||||
|
}`}
|
||||||
|
title={preset.description}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t theme-border pt-4">
|
||||||
|
<h4 className="font-medium theme-header text-sm mb-3">Detailed Controls</h4>
|
||||||
|
|
||||||
|
{/* Word Count Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('length')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.length ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
📏 Story Length
|
||||||
|
{(filters.minWordCount || filters.maxWordCount) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.length && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Min Words</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filters.minWordCount || ''}
|
||||||
|
onChange={(e) => updateFilter('minWordCount', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="0"
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Max Words</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filters.maxWordCount || ''}
|
||||||
|
onChange={(e) => updateFilter('maxWordCount', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="∞"
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Word count range display */}
|
||||||
|
{(filters.minWordCount || filters.maxWordCount) && (
|
||||||
|
<div className="text-xs theme-text bg-white dark:bg-gray-700 p-2 rounded">
|
||||||
|
Range: {filters.minWordCount || 0} - {filters.maxWordCount || '∞'} words
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('date')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.date ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
📅 Date Added
|
||||||
|
{(filters.createdAfter || filters.createdBefore) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.date && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">After Date</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.createdAfter || ''}
|
||||||
|
onChange={(e) => updateFilter('createdAfter', e.target.value || undefined)}
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Before Date</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.createdBefore || ''}
|
||||||
|
onChange={(e) => updateFilter('createdBefore', e.target.value || undefined)}
|
||||||
|
className="text-xs w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('rating')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.rating ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
⭐ Rating
|
||||||
|
{(filters.minRating || filters.maxRating || filters.unratedOnly) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.rating && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.unratedOnly || false}
|
||||||
|
onChange={(e) => updateFilter('unratedOnly', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Unrated stories only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filters.unratedOnly && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Min Rating</label>
|
||||||
|
<select
|
||||||
|
value={filters.minRating || ''}
|
||||||
|
onChange={(e) => updateFilter('minRating', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">No minimum</option>
|
||||||
|
<option value="1">1 star</option>
|
||||||
|
<option value="2">2 stars</option>
|
||||||
|
<option value="3">3 stars</option>
|
||||||
|
<option value="4">4 stars</option>
|
||||||
|
<option value="5">5 stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Max Rating</label>
|
||||||
|
<select
|
||||||
|
value={filters.maxRating || ''}
|
||||||
|
onChange={(e) => updateFilter('maxRating', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">No maximum</option>
|
||||||
|
<option value="1">1 star</option>
|
||||||
|
<option value="2">2 stars</option>
|
||||||
|
<option value="3">3 stars</option>
|
||||||
|
<option value="4">4 stars</option>
|
||||||
|
<option value="5">5 stars</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reading Status Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('reading')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.reading ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
👁️ Reading Status
|
||||||
|
{(filters.readingStatus && filters.readingStatus !== 'all') && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.reading && (
|
||||||
|
<div className="pl-6 space-y-2 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
{ value: 'all', label: 'All stories' },
|
||||||
|
{ value: 'unread', label: 'Unread' },
|
||||||
|
{ value: 'started', label: 'Started reading' },
|
||||||
|
{ value: 'completed', label: 'Completed' }
|
||||||
|
].map(option => (
|
||||||
|
<label key={option.value} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="readingStatus"
|
||||||
|
value={option.value}
|
||||||
|
checked={(filters.readingStatus || 'all') === option.value}
|
||||||
|
onChange={(e) => updateFilter('readingStatus', e.target.value as any)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSection('content')}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transform transition-transform ${expandedSections.content ? 'rotate-90' : ''}`}>
|
||||||
|
▶
|
||||||
|
</span>
|
||||||
|
📚 Content
|
||||||
|
{(filters.hasCoverImage || filters.seriesFilter !== 'all' || filters.sourceDomain) && (
|
||||||
|
<span className="text-xs bg-blue-500 text-white px-1 rounded">●</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.content && (
|
||||||
|
<div className="pl-6 space-y-3 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.hasCoverImage || false}
|
||||||
|
onChange={(e) => updateFilter('hasCoverImage', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Has cover image</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Series Filter</label>
|
||||||
|
<select
|
||||||
|
value={filters.seriesFilter || 'all'}
|
||||||
|
onChange={(e) => updateFilter('seriesFilter', e.target.value as any)}
|
||||||
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="all">All stories</option>
|
||||||
|
<option value="standalone">Standalone only</option>
|
||||||
|
<option value="series">Series only</option>
|
||||||
|
<option value="firstInSeries">First in series</option>
|
||||||
|
<option value="lastInSeries">Last in series</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Source Domain</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={filters.sourceDomain || ''}
|
||||||
|
onChange={(e) => updateFilter('sourceDomain', e.target.value || undefined)}
|
||||||
|
placeholder="e.g., archiveofourown.org"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Options */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-medium theme-text opacity-75 uppercase tracking-wide">
|
||||||
|
Advanced
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs theme-text mb-1">Minimum Tag Count</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filters.minTagCount || ''}
|
||||||
|
onChange={(e) => updateFilter('minTagCount', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="0"
|
||||||
|
className="text-xs"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.popularOnly || false}
|
||||||
|
onChange={(e) => updateFilter('popularOnly', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Popular stories only (above average rating)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filters.hiddenGemsOnly || false}
|
||||||
|
onChange={(e) => updateFilter('hiddenGemsOnly', e.target.checked || undefined)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs theme-text">Hidden gems (underrated/unrated)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
607
frontend/src/components/library/LibrarySettings.tsx
Normal file
607
frontend/src/components/library/LibrarySettings.tsx
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import LibrarySwitchLoader from '../ui/LibrarySwitchLoader';
|
||||||
|
import { useLibrarySwitch } from '../../hooks/useLibrarySwitch';
|
||||||
|
import { setCurrentLibraryId, clearLibraryCache } from '../../lib/api';
|
||||||
|
|
||||||
|
interface Library {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isInitialized: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibrarySettings() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { state: switchState, switchLibrary, clearError, reset } = useLibrarySwitch();
|
||||||
|
|
||||||
|
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||||
|
const [currentLibrary, setCurrentLibrary] = useState<Library | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [switchPassword, setSwitchPassword] = useState('');
|
||||||
|
const [showSwitchForm, setShowSwitchForm] = useState(false);
|
||||||
|
const [passwordChangeForm, setPasswordChangeForm] = useState({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
const [showPasswordChangeForm, setShowPasswordChangeForm] = useState(false);
|
||||||
|
const [passwordChangeLoading, setPasswordChangeLoading] = useState(false);
|
||||||
|
const [passwordChangeMessage, setPasswordChangeMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
|
||||||
|
const [createLibraryForm, setCreateLibraryForm] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
const [showCreateLibraryForm, setShowCreateLibraryForm] = useState(false);
|
||||||
|
const [createLibraryLoading, setCreateLibraryLoading] = useState(false);
|
||||||
|
const [createLibraryMessage, setCreateLibraryMessage] = useState<{type: 'success' | 'error', text: string} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLibraries();
|
||||||
|
loadCurrentLibrary();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLibraries = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setLibraries(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load libraries:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCurrentLibrary = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/current');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCurrentLibrary(data);
|
||||||
|
// Set the library ID for image URL generation
|
||||||
|
setCurrentLibraryId(data.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load current library:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchLibrary = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!switchPassword.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await switchLibrary(switchPassword);
|
||||||
|
if (success) {
|
||||||
|
// The LibrarySwitchLoader will handle the rest
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchComplete = () => {
|
||||||
|
// Clear the library cache so images use the new library
|
||||||
|
clearLibraryCache();
|
||||||
|
// Refresh the page to reload with new library context
|
||||||
|
router.refresh();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchError = (error: string) => {
|
||||||
|
console.error('Library switch error:', error);
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (passwordChangeForm.newPassword !== passwordChangeForm.confirmPassword) {
|
||||||
|
setPasswordChangeMessage({type: 'error', text: 'New passwords do not match'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordChangeForm.newPassword.length < 8) {
|
||||||
|
setPasswordChangeMessage({type: 'error', text: 'Password must be at least 8 characters long'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordChangeLoading(true);
|
||||||
|
setPasswordChangeMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
currentPassword: passwordChangeForm.currentPassword,
|
||||||
|
newPassword: passwordChangeForm.newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
setPasswordChangeMessage({type: 'success', text: 'Password changed successfully'});
|
||||||
|
setPasswordChangeForm({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
setShowPasswordChangeForm(false);
|
||||||
|
} else {
|
||||||
|
setPasswordChangeMessage({type: 'error', text: data.error || 'Failed to change password'});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setPasswordChangeMessage({type: 'error', text: 'Network error occurred'});
|
||||||
|
} finally {
|
||||||
|
setPasswordChangeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLibrary = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (createLibraryForm.password !== createLibraryForm.confirmPassword) {
|
||||||
|
setCreateLibraryMessage({type: 'error', text: 'Passwords do not match'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createLibraryForm.password.length < 8) {
|
||||||
|
setCreateLibraryMessage({type: 'error', text: 'Password must be at least 8 characters long'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createLibraryForm.name.trim().length < 2) {
|
||||||
|
setCreateLibraryMessage({type: 'error', text: 'Library name must be at least 2 characters long'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreateLibraryLoading(true);
|
||||||
|
setCreateLibraryMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: createLibraryForm.name.trim(),
|
||||||
|
description: createLibraryForm.description.trim(),
|
||||||
|
password: createLibraryForm.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
setCreateLibraryMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `Library "${data.library.name}" created successfully! You can now log out and log in with the new password to access it.`
|
||||||
|
});
|
||||||
|
setCreateLibraryForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
setShowCreateLibraryForm(false);
|
||||||
|
loadLibraries(); // Refresh the library list
|
||||||
|
} else {
|
||||||
|
setCreateLibraryMessage({type: 'error', text: data.error || 'Failed to create library'});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCreateLibraryMessage({type: 'error', text: 'Network error occurred'});
|
||||||
|
} finally {
|
||||||
|
setCreateLibraryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Library Settings
|
||||||
|
</h2>
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/4 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
Library Settings
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Current Library Info */}
|
||||||
|
{currentLibrary && (
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<h3 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||||
|
Active Library
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-700 dark:text-blue-300 text-sm">
|
||||||
|
<strong>{currentLibrary.name}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-600 dark:text-blue-400 text-xs mt-1">
|
||||||
|
{currentLibrary.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Change Password Section */}
|
||||||
|
<div className="mb-6 border-t pt-4">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Change Library Password
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{passwordChangeMessage && (
|
||||||
|
<div className={`p-3 rounded-lg mb-4 ${
|
||||||
|
passwordChangeMessage.type === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||||
|
}`}>
|
||||||
|
<p className={`text-sm ${
|
||||||
|
passwordChangeMessage.type === 'success'
|
||||||
|
? 'text-green-700 dark:text-green-300'
|
||||||
|
: 'text-red-700 dark:text-red-300'
|
||||||
|
}`}>
|
||||||
|
{passwordChangeMessage.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showPasswordChangeForm ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||||
|
Change the password for the current library ({currentLibrary?.name}).
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowPasswordChangeForm(true)}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={passwordChangeForm.currentPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPasswordChangeForm(prev => ({ ...prev, currentPassword: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter current password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={passwordChangeForm.newPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPasswordChangeForm(prev => ({ ...prev, newPassword: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter new password (min 8 characters)"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={passwordChangeForm.confirmPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setPasswordChangeForm(prev => ({ ...prev, confirmPassword: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={passwordChangeLoading}
|
||||||
|
loading={passwordChangeLoading}
|
||||||
|
>
|
||||||
|
{passwordChangeLoading ? 'Changing...' : 'Change Password'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPasswordChangeForm(false);
|
||||||
|
setPasswordChangeForm({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
setPasswordChangeMessage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Libraries */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Available Libraries
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{libraries.map((library) => (
|
||||||
|
<div
|
||||||
|
key={library.id}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
library.isActive
|
||||||
|
? 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{library.name}
|
||||||
|
{library.isActive && (
|
||||||
|
<span className="ml-2 text-xs px-2 py-1 bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200 rounded-full">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{library.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!library.isActive && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
ID: {library.id}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Switch Library Section */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Switch Library
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!showSwitchForm ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||||
|
Enter the password for a different library to switch to it.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowSwitchForm(true)}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Switch to Different Library
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSwitchLibrary} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Library Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={switchPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSwitchPassword(e.target.value)}
|
||||||
|
placeholder="Enter password for the library you want to access"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{switchState.error && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">
|
||||||
|
{switchState.error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<Button type="submit" disabled={switchState.isLoading}>
|
||||||
|
{switchState.isLoading ? 'Switching...' : 'Switch Library'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSwitchForm(false);
|
||||||
|
setSwitchPassword('');
|
||||||
|
clearError();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create New Library Section */}
|
||||||
|
<div className="border-t pt-4 mb-6">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
Create New Library
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{createLibraryMessage && (
|
||||||
|
<div className={`p-3 rounded-lg mb-4 ${
|
||||||
|
createLibraryMessage.type === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
|
||||||
|
}`}>
|
||||||
|
<p className={`text-sm ${
|
||||||
|
createLibraryMessage.type === 'success'
|
||||||
|
? 'text-green-700 dark:text-green-300'
|
||||||
|
: 'text-red-700 dark:text-red-300'
|
||||||
|
}`}>
|
||||||
|
{createLibraryMessage.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showCreateLibraryForm ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||||
|
Create a completely separate library with its own stories, authors, and password.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowCreateLibraryForm(true)}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Create New Library
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleCreateLibrary} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Library Name *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={createLibraryForm.name}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setCreateLibraryForm(prev => ({ ...prev, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g., Private Stories, Work Collection"
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={createLibraryForm.description}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setCreateLibraryForm(prev => ({ ...prev, description: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Optional description for this library"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Password *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={createLibraryForm.password}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setCreateLibraryForm(prev => ({ ...prev, password: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter password (min 8 characters)"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Confirm Password *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={createLibraryForm.confirmPassword}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setCreateLibraryForm(prev => ({ ...prev, confirmPassword: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Confirm password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createLibraryLoading}
|
||||||
|
loading={createLibraryLoading}
|
||||||
|
>
|
||||||
|
{createLibraryLoading ? 'Creating...' : 'Create Library'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateLibraryForm(false);
|
||||||
|
setCreateLibraryForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
});
|
||||||
|
setCreateLibraryMessage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<strong>Note:</strong> Libraries are completely separate datasets. Switching libraries
|
||||||
|
will reload the application with a different set of stories, authors, and settings.
|
||||||
|
Each library has its own password for security.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Library Switch Loader */}
|
||||||
|
<LibrarySwitchLoader
|
||||||
|
isVisible={switchState.isLoading}
|
||||||
|
targetLibraryName={switchState.targetLibraryName || undefined}
|
||||||
|
onComplete={handleSwitchComplete}
|
||||||
|
onError={handleSwitchError}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
frontend/src/components/library/MinimalLayout.tsx
Normal file
319
frontend/src/components/library/MinimalLayout.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
|
import AdvancedFilters from './AdvancedFilters';
|
||||||
|
import type { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
|
||||||
|
|
||||||
|
interface MinimalLayoutProps {
|
||||||
|
stories: Story[];
|
||||||
|
tags: Tag[];
|
||||||
|
totalElements: number;
|
||||||
|
searchQuery: string;
|
||||||
|
selectedTags: string[];
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
sortOption: string;
|
||||||
|
sortDirection: 'asc' | 'desc';
|
||||||
|
advancedFilters?: AdvancedFiltersType;
|
||||||
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onTagToggle: (tagName: string) => void;
|
||||||
|
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||||
|
onSortChange: (option: string) => void;
|
||||||
|
onSortDirectionToggle: () => void;
|
||||||
|
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
|
||||||
|
onRandomStory: () => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MinimalLayout({
|
||||||
|
stories,
|
||||||
|
tags,
|
||||||
|
totalElements,
|
||||||
|
searchQuery,
|
||||||
|
selectedTags,
|
||||||
|
viewMode,
|
||||||
|
sortOption,
|
||||||
|
sortDirection,
|
||||||
|
advancedFilters = {},
|
||||||
|
onSearchChange,
|
||||||
|
onTagToggle,
|
||||||
|
onViewModeChange,
|
||||||
|
onSortChange,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange,
|
||||||
|
onRandomStory,
|
||||||
|
onClearFilters,
|
||||||
|
children
|
||||||
|
}: MinimalLayoutProps) {
|
||||||
|
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
|
||||||
|
const [advancedFiltersOpen, setAdvancedFiltersOpen] = useState(false);
|
||||||
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
|
||||||
|
const popularTags = tags.slice(0, 5);
|
||||||
|
|
||||||
|
// Filter tags based on search query
|
||||||
|
const filteredTags = tagSearch
|
||||||
|
? tags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||||
|
: tags;
|
||||||
|
|
||||||
|
// Count active advanced filters
|
||||||
|
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all' && value !== false
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const getSortDisplayText = () => {
|
||||||
|
const sortLabels: Record<string, string> = {
|
||||||
|
lastRead: 'Last Read',
|
||||||
|
createdAt: 'Date Added',
|
||||||
|
title: 'Title',
|
||||||
|
authorName: 'Author',
|
||||||
|
rating: 'Rating',
|
||||||
|
};
|
||||||
|
const direction = sortDirection === 'asc' ? '↑' : '↓';
|
||||||
|
return `Sort: ${sortLabels[sortOption] || sortOption} ${direction}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-10 max-md:p-5">
|
||||||
|
{/* Minimal Header */}
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<h1 className="text-4xl font-light theme-header mb-2">Story Library</h1>
|
||||||
|
<p className="theme-text text-lg mb-8">
|
||||||
|
Your personal collection of {totalElements} stories
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button variant="primary" onClick={onRandomStory}>
|
||||||
|
🎲 Random Story
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Control Bar */}
|
||||||
|
<div className="sticky top-5 z-10 mb-8">
|
||||||
|
<div className="bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm border theme-border rounded-xl p-4 shadow-lg">
|
||||||
|
<div className="grid grid-cols-3 gap-6 items-center max-md:grid-cols-1 max-md:gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search stories, authors, tags..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort & Clear */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={onSortDirectionToggle}
|
||||||
|
className="text-sm theme-text hover:theme-accent transition-colors border-none bg-transparent"
|
||||||
|
>
|
||||||
|
{getSortDisplayText()}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
|
||||||
|
{/* Advanced Filters Button */}
|
||||||
|
{onAdvancedFiltersChange && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAdvancedFiltersOpen(true)}
|
||||||
|
className={activeAdvancedFiltersCount > 0 ? 'text-blue-600 dark:text-blue-400' : ''}
|
||||||
|
>
|
||||||
|
⚙️ Advanced
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-blue-500 text-white px-1 rounded">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(searchQuery || selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClearFilters}>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="justify-self-end max-md:justify-self-auto">
|
||||||
|
<div className="flex border theme-border rounded-lg overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
className="rounded-none border-0 px-3 py-2"
|
||||||
|
>
|
||||||
|
☰ List
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('grid')}
|
||||||
|
className="rounded-none border-0 px-3 py-2"
|
||||||
|
>
|
||||||
|
⊞ Grid
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Filter */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="inline-flex flex-wrap gap-2 justify-center mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onClearFilters()}
|
||||||
|
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
|
||||||
|
selectedTags.length === 0
|
||||||
|
? 'bg-blue-500 text-white border-blue-500'
|
||||||
|
: 'bg-white dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{popularTags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => onTagToggle(tag.name)}
|
||||||
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-2' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TagDisplay
|
||||||
|
tag={tag}
|
||||||
|
size="md"
|
||||||
|
clickable={true}
|
||||||
|
className={`${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTagBrowserOpen(true)}
|
||||||
|
>
|
||||||
|
Browse All Tags ({tags.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Tag Browser Modal */}
|
||||||
|
{tagBrowserOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-5">
|
||||||
|
<h3 className="text-xl font-semibold theme-header">Browse All Tags</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTagBrowserOpen(false);
|
||||||
|
setTagSearch('');
|
||||||
|
}}
|
||||||
|
className="text-2xl theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tags..."
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||||
|
{filteredTags.length === 0 && tagSearch ? (
|
||||||
|
<div className="col-span-4 max-md:col-span-2 max-sm:col-span-1 text-center text-sm text-gray-500 py-4">
|
||||||
|
No tags match "{tagSearch}"
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredTags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => onTagToggle(tag.name)}
|
||||||
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TagDisplay
|
||||||
|
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||||
|
size="sm"
|
||||||
|
clickable={true}
|
||||||
|
className={`w-full text-left ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onClearFilters}>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => {
|
||||||
|
setTagBrowserOpen(false);
|
||||||
|
setTagSearch('');
|
||||||
|
}}>
|
||||||
|
Apply Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced Filters Modal */}
|
||||||
|
{advancedFiltersOpen && onAdvancedFiltersChange && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-5">
|
||||||
|
<h3 className="text-xl font-semibold theme-header">Advanced Filters</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setAdvancedFiltersOpen(false)}
|
||||||
|
className="text-2xl theme-text hover:theme-accent transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdvancedFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
onChange={onAdvancedFiltersChange}
|
||||||
|
onReset={() => onAdvancedFiltersChange({})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="ghost" onClick={() => setAdvancedFiltersOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setAdvancedFiltersOpen(false)}
|
||||||
|
>
|
||||||
|
Apply Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
436
frontend/src/components/library/SidebarLayout.tsx
Normal file
436
frontend/src/components/library/SidebarLayout.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
|
import AdvancedFilters from './AdvancedFilters';
|
||||||
|
import type { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
|
||||||
|
|
||||||
|
interface SidebarLayoutProps {
|
||||||
|
stories: Story[];
|
||||||
|
tags: Tag[];
|
||||||
|
totalElements: number;
|
||||||
|
searchQuery: string;
|
||||||
|
selectedTags: string[];
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
sortOption: string;
|
||||||
|
sortDirection: 'asc' | 'desc';
|
||||||
|
advancedFilters?: AdvancedFiltersType;
|
||||||
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onTagToggle: (tagName: string) => void;
|
||||||
|
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||||
|
onSortChange: (option: string) => void;
|
||||||
|
onSortDirectionToggle: () => void;
|
||||||
|
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
|
||||||
|
onRandomStory: () => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarLayout({
|
||||||
|
stories,
|
||||||
|
tags,
|
||||||
|
totalElements,
|
||||||
|
searchQuery,
|
||||||
|
selectedTags,
|
||||||
|
viewMode,
|
||||||
|
sortOption,
|
||||||
|
sortDirection,
|
||||||
|
advancedFilters = {},
|
||||||
|
onSearchChange,
|
||||||
|
onTagToggle,
|
||||||
|
onViewModeChange,
|
||||||
|
onSortChange,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange,
|
||||||
|
onRandomStory,
|
||||||
|
onClearFilters,
|
||||||
|
children
|
||||||
|
}: SidebarLayoutProps) {
|
||||||
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||||
|
|
||||||
|
// Filter tags based on search query
|
||||||
|
const filteredTags = tags.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count active advanced filters
|
||||||
|
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all' && value !== false
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen max-md:flex-col">
|
||||||
|
{/* Mobile Header - Only shown on mobile */}
|
||||||
|
<div className="hidden max-md:block bg-white dark:bg-gray-800 p-4 border-b theme-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold theme-header">Your Library</h1>
|
||||||
|
<p className="theme-text text-sm">{totalElements} stories total</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onRandomStory}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
🎲 Random
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Search */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search stories..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Controls Row */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="flex border theme-border rounded-lg overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('grid')}
|
||||||
|
className="rounded-none border-0 flex-1 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
⊞
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
className="rounded-none border-0 flex-1 px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<select
|
||||||
|
value={`${sortOption}_${sortDirection}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [option, direction] = e.target.value.split('_');
|
||||||
|
onSortChange(option);
|
||||||
|
if (sortDirection !== direction) {
|
||||||
|
onSortDirectionToggle();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 border rounded-lg theme-card border-gray-300 dark:border-gray-600 text-xs"
|
||||||
|
>
|
||||||
|
<option value="lastRead_desc">Last Read ↓</option>
|
||||||
|
<option value="lastRead_asc">Last Read ↑</option>
|
||||||
|
<option value="createdAt_desc">Date Added ↓</option>
|
||||||
|
<option value="createdAt_asc">Date Added ↑</option>
|
||||||
|
<option value="title_asc">Title ↑</option>
|
||||||
|
<option value="title_desc">Title ↓</option>
|
||||||
|
<option value="authorName_asc">Author ↑</option>
|
||||||
|
<option value="authorName_desc">Author ↓</option>
|
||||||
|
<option value="rating_desc">Rating ↓</option>
|
||||||
|
<option value="rating_asc">Rating ↑</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Filter Toggle */}
|
||||||
|
<Button
|
||||||
|
variant={showAdvancedFilters || selectedTags.length > 0 || activeAdvancedFiltersCount > 0 ? "primary" : "ghost"}
|
||||||
|
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||||
|
className="text-xs px-2 py-1"
|
||||||
|
>
|
||||||
|
Filters
|
||||||
|
{(selectedTags.length + activeAdvancedFiltersCount) > 0 && (
|
||||||
|
<span className="ml-1 bg-white text-blue-500 px-1 rounded text-xs">
|
||||||
|
{selectedTags.length + activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Tag Pills - Show selected tags */}
|
||||||
|
{selectedTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
|
{selectedTags.slice(0, 3).map((tagName) => {
|
||||||
|
const tag = tags.find(t => t.name === tagName);
|
||||||
|
return tag ? (
|
||||||
|
<div key={tag.id} onClick={() => onTagToggle(tag.name)} className="cursor-pointer">
|
||||||
|
<TagDisplay tag={tag} size="sm" clickable={true} className="bg-blue-500 text-white" />
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
{selectedTags.length > 3 && (
|
||||||
|
<span className="text-xs text-gray-500 px-2 py-1">+{selectedTags.length - 3} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left Sidebar - Hidden on mobile by default */}
|
||||||
|
<div className="w-80 min-w-80 max-w-80 bg-white dark:bg-gray-800 p-4 border-r theme-border sticky top-0 h-screen overflow-y-auto overflow-x-hidden max-md:hidden">
|
||||||
|
{/* Random Story Button */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button
|
||||||
|
onClick={onRandomStory}
|
||||||
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
🎲 Random Story
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold theme-header">Your Library</h1>
|
||||||
|
<p className="theme-text mt-1">{totalElements} stories total</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search stories..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('grid')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
⊞ Grid
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
☰ List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Controls */}
|
||||||
|
<div className="mb-6 theme-card p-4 rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium theme-header mb-3">Sort By</h3>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<select
|
||||||
|
value={sortOption}
|
||||||
|
onChange={(e) => onSortChange(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="lastRead">Last Read</option>
|
||||||
|
<option value="createdAt">Date Added</option>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="authorName">Author</option>
|
||||||
|
<option value="rating">Rating</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onSortDirectionToggle}
|
||||||
|
className="px-3 py-2"
|
||||||
|
title={`Toggle sort direction (currently ${sortDirection === 'asc' ? 'ascending' : 'descending'})`}
|
||||||
|
>
|
||||||
|
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Filters */}
|
||||||
|
<div className="theme-card p-4 rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium theme-header mb-3">Filter by Tags</h3>
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tags..."
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 overflow-y-auto border theme-border rounded p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex items-center gap-2 py-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTags.length === 0}
|
||||||
|
onChange={() => onClearFilters()}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">All Stories ({totalElements})</span>
|
||||||
|
</label>
|
||||||
|
{filteredTags.map((tag) => (
|
||||||
|
<label
|
||||||
|
key={tag.id}
|
||||||
|
className="flex items-center gap-2 py-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedTags.includes(tag.name)}
|
||||||
|
onChange={() => onTagToggle(tag.name)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<TagDisplay
|
||||||
|
tag={tag}
|
||||||
|
size="sm"
|
||||||
|
clickable={false}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">
|
||||||
|
({tag.storyCount})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{filteredTags.length === 0 && tagSearch && (
|
||||||
|
<div className="text-center text-xs text-gray-500 py-2">
|
||||||
|
No tags match "{tagSearch}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredTags.length > 10 && !tagSearch && (
|
||||||
|
<div className="text-center text-xs text-gray-500 py-2">
|
||||||
|
... and {filteredTags.length - 10} more tags
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
className="w-full text-xs py-1"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Advanced Filters Toggle */}
|
||||||
|
{onAdvancedFiltersChange && (
|
||||||
|
<Button
|
||||||
|
variant={showAdvancedFilters || activeAdvancedFiltersCount > 0 ? "primary" : "ghost"}
|
||||||
|
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||||
|
className={`w-full text-xs py-1 ${showAdvancedFilters || activeAdvancedFiltersCount > 0 ? '' : 'border-dashed border-2'}`}
|
||||||
|
>
|
||||||
|
⚙️ Advanced Filters
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1 bg-white text-blue-500 px-1 rounded text-xs">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters Section */}
|
||||||
|
{showAdvancedFilters && onAdvancedFiltersChange && (
|
||||||
|
<div className="mt-4 pt-4 border-t theme-border">
|
||||||
|
<AdvancedFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
onChange={onAdvancedFiltersChange}
|
||||||
|
onReset={() => onAdvancedFiltersChange({})}
|
||||||
|
className="space-y-3 max-w-full overflow-hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Filter Panel - Shows when filters expanded */}
|
||||||
|
{showAdvancedFilters && (
|
||||||
|
<div className="hidden max-md:block bg-white dark:bg-gray-800 border-b theme-border">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-medium theme-header">Filters</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAdvancedFilters(false)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
✕ Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Grid */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-sm font-medium theme-header mb-2">Tags</h4>
|
||||||
|
<div className="mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tags..."
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
|
className="w-full px-2 py-1 text-sm border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-32 overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onClearFilters()}
|
||||||
|
className={`px-2 py-1 text-xs border rounded text-left ${
|
||||||
|
selectedTags.length === 0 ? 'bg-blue-500 text-white border-blue-500' : 'theme-card border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All ({totalElements})
|
||||||
|
</button>
|
||||||
|
{filteredTags.slice(0, 19).map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => onTagToggle(tag.name)}
|
||||||
|
className={`px-2 py-1 text-xs border rounded text-left truncate ${
|
||||||
|
selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'theme-card border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.name} ({tag.storyCount})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters */}
|
||||||
|
{onAdvancedFiltersChange && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium theme-header mb-2">Advanced Filters</h4>
|
||||||
|
<AdvancedFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
onChange={onAdvancedFiltersChange}
|
||||||
|
onReset={() => onAdvancedFiltersChange({})}
|
||||||
|
className="space-y-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setShowAdvancedFilters(false)}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="flex-1 p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
frontend/src/components/library/ToolbarLayout.tsx
Normal file
362
frontend/src/components/library/ToolbarLayout.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Input } from '../ui/Input';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
|
import AdvancedFilters from './AdvancedFilters';
|
||||||
|
import { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
|
||||||
|
|
||||||
|
interface ToolbarLayoutProps {
|
||||||
|
stories: Story[];
|
||||||
|
tags: Tag[];
|
||||||
|
totalElements: number;
|
||||||
|
searchQuery: string;
|
||||||
|
selectedTags: string[];
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
|
sortOption: string;
|
||||||
|
sortDirection: 'asc' | 'desc';
|
||||||
|
advancedFilters?: AdvancedFiltersType;
|
||||||
|
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onTagToggle: (tagName: string) => void;
|
||||||
|
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||||
|
onSortChange: (option: string) => void;
|
||||||
|
onSortDirectionToggle: () => void;
|
||||||
|
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
|
||||||
|
onRandomStory: () => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolbarLayout({
|
||||||
|
stories,
|
||||||
|
tags,
|
||||||
|
totalElements,
|
||||||
|
searchQuery,
|
||||||
|
selectedTags,
|
||||||
|
viewMode,
|
||||||
|
sortOption,
|
||||||
|
sortDirection,
|
||||||
|
advancedFilters = {},
|
||||||
|
onSearchChange,
|
||||||
|
onTagToggle,
|
||||||
|
onViewModeChange,
|
||||||
|
onSortChange,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
onAdvancedFiltersChange,
|
||||||
|
onRandomStory,
|
||||||
|
onClearFilters,
|
||||||
|
children
|
||||||
|
}: ToolbarLayoutProps) {
|
||||||
|
const [filterExpanded, setFilterExpanded] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'tags' | 'advanced'>('tags');
|
||||||
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
|
||||||
|
const popularTags = tags.slice(0, 6);
|
||||||
|
|
||||||
|
// Filter remaining tags based on search query
|
||||||
|
const remainingTags = tags.slice(6);
|
||||||
|
const filteredRemainingTags = tagSearch
|
||||||
|
? remainingTags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||||
|
: remainingTags;
|
||||||
|
|
||||||
|
const remainingTagsCount = Math.max(0, remainingTags.length);
|
||||||
|
|
||||||
|
// Count active advanced filters
|
||||||
|
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
|
||||||
|
value !== undefined && value !== '' && value !== 'all' && value !== false
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
|
||||||
|
{/* Integrated Header */}
|
||||||
|
<div className="theme-card theme-shadow rounded-xl p-6 mb-6 relative max-md:p-4">
|
||||||
|
{/* Title and Random Story Button */}
|
||||||
|
<div className="flex justify-between items-start mb-6 max-md:flex-col max-md:gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
|
||||||
|
<p className="theme-text mt-1">{totalElements} stories in your collection</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-md:self-end">
|
||||||
|
<Button variant="secondary" onClick={onRandomStory}>
|
||||||
|
🎲 Random Story
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integrated Toolbar */}
|
||||||
|
<div className="grid grid-cols-4 gap-5 items-center mb-5 max-md:grid-cols-1 max-md:gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="col-span-2 max-md:col-span-1">
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by title, author, or tags..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="max-md:order-3">
|
||||||
|
<select
|
||||||
|
value={`${sortOption}_${sortDirection}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [option, direction] = e.target.value.split('_');
|
||||||
|
onSortChange(option);
|
||||||
|
if (sortDirection !== direction) {
|
||||||
|
onSortDirectionToggle();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600 max-md:text-sm"
|
||||||
|
>
|
||||||
|
<option value="lastRead_desc">Sort: Last Read ↓</option>
|
||||||
|
<option value="lastRead_asc">Sort: Last Read ↑</option>
|
||||||
|
<option value="createdAt_desc">Sort: Date Added ↓</option>
|
||||||
|
<option value="createdAt_asc">Sort: Date Added ↑</option>
|
||||||
|
<option value="title_asc">Sort: Title ↑</option>
|
||||||
|
<option value="title_desc">Sort: Title ↓</option>
|
||||||
|
<option value="authorName_asc">Sort: Author ↑</option>
|
||||||
|
<option value="authorName_desc">Sort: Author ↓</option>
|
||||||
|
<option value="rating_desc">Sort: Rating ↓</option>
|
||||||
|
<option value="rating_asc">Sort: Rating ↑</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Toggle, Advanced Filters & Clear */}
|
||||||
|
<div className="flex gap-2 max-md:order-2">
|
||||||
|
<div className="flex border theme-border rounded-lg overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('grid')}
|
||||||
|
className="rounded-none border-0 max-md:px-2 max-md:text-sm"
|
||||||
|
>
|
||||||
|
<span className="max-md:hidden">⊞ Grid</span>
|
||||||
|
<span className="md:hidden">⊞</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
className="rounded-none border-0 max-md:px-2 max-md:text-sm"
|
||||||
|
>
|
||||||
|
<span className="max-md:hidden">☰ List</span>
|
||||||
|
<span className="md:hidden">☰</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters Button */}
|
||||||
|
<Button
|
||||||
|
variant={filterExpanded && activeTab === 'advanced' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => {
|
||||||
|
if (!filterExpanded) {
|
||||||
|
// Panel closed → open and switch to advanced tab
|
||||||
|
setFilterExpanded(true);
|
||||||
|
setActiveTab('advanced');
|
||||||
|
} else if (activeTab !== 'advanced') {
|
||||||
|
// Panel open but wrong tab → just switch to advanced tab
|
||||||
|
setActiveTab('advanced');
|
||||||
|
} else {
|
||||||
|
// Panel open and on advanced tab → close panel
|
||||||
|
setFilterExpanded(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="max-md:text-sm max-md:px-2"
|
||||||
|
>
|
||||||
|
<span className="max-md:hidden">⚙️ Advanced</span>
|
||||||
|
<span className="md:hidden">⚙️</span>
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded font-bold max-md:ml-0.5 max-md:px-1">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(searchQuery || selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
|
||||||
|
<Button variant="ghost" onClick={onClearFilters} className="max-md:text-sm max-md:px-2">
|
||||||
|
<span className="max-md:hidden">Clear</span>
|
||||||
|
<span className="md:hidden">✕</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Section */}
|
||||||
|
<div className="border-t theme-border pt-5">
|
||||||
|
{/* Top row - Popular tags and expand button */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span className="font-medium theme-text text-sm">Popular Tags:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onClearFilters()}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
selectedTags.length === 0 && activeAdvancedFiltersCount === 0
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All Stories
|
||||||
|
</button>
|
||||||
|
{popularTags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => onTagToggle(tag.name)}
|
||||||
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TagDisplay
|
||||||
|
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||||
|
size="sm"
|
||||||
|
clickable={true}
|
||||||
|
className={selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* More Tags Button */}
|
||||||
|
{remainingTagsCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!filterExpanded) {
|
||||||
|
// Panel closed → open and switch to tags tab
|
||||||
|
setFilterExpanded(true);
|
||||||
|
setActiveTab('tags');
|
||||||
|
} else if (activeTab !== 'tags') {
|
||||||
|
// Panel open but wrong tab → just switch to tags tab
|
||||||
|
setActiveTab('tags');
|
||||||
|
} else {
|
||||||
|
// Panel open and on tags tab → close panel
|
||||||
|
setFilterExpanded(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1 rounded-full text-xs font-medium border-2 transition-colors ${
|
||||||
|
filterExpanded && activeTab === 'tags'
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-500 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'border-dashed bg-gray-50 dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
+{remainingTagsCount} more tags
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto text-sm theme-text">
|
||||||
|
Showing {stories.length} of {totalElements} stories
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable Filter Panel */}
|
||||||
|
{filterExpanded && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border theme-border">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex gap-1 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('advanced')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'advanced'
|
||||||
|
? 'bg-blue-500 text-white shadow-sm'
|
||||||
|
: 'theme-text hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
⚙️ Advanced Filters
|
||||||
|
{activeAdvancedFiltersCount > 0 && (
|
||||||
|
<span className="ml-1 text-xs bg-white text-blue-500 px-1.5 py-0.5 rounded font-bold">
|
||||||
|
{activeAdvancedFiltersCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tags')}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'tags'
|
||||||
|
? 'bg-blue-500 text-white shadow-sm'
|
||||||
|
: 'theme-text hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📋 More Tags
|
||||||
|
{remainingTagsCount > 0 && (
|
||||||
|
<span className={`ml-1 text-xs px-1.5 py-0.5 rounded font-bold ${
|
||||||
|
activeTab === 'tags'
|
||||||
|
? 'bg-white text-blue-500'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-600'
|
||||||
|
}`}>
|
||||||
|
{remainingTagsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'tags' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search from all available tags..."
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{tagSearch && (
|
||||||
|
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2 max-sm:grid-cols-1">
|
||||||
|
{filteredRemainingTags.length === 0 && tagSearch ? (
|
||||||
|
<div className="col-span-4 max-md:col-span-2 max-sm:col-span-1 text-center text-sm text-gray-500 py-4">
|
||||||
|
No tags match "{tagSearch}"
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRemainingTags.map((tag) => (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => onTagToggle(tag.name)}
|
||||||
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TagDisplay
|
||||||
|
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||||
|
size="sm"
|
||||||
|
clickable={true}
|
||||||
|
className={`w-full ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'advanced' && onAdvancedFiltersChange && (
|
||||||
|
<AdvancedFilters
|
||||||
|
filters={advancedFilters}
|
||||||
|
onChange={onAdvancedFiltersChange}
|
||||||
|
onReset={() => onAdvancedFiltersChange({})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex justify-end gap-3 mt-4 pt-3 border-t theme-border">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setFilterExpanded(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{(selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
|
||||||
|
<Button variant="ghost" onClick={onClearFilters}>
|
||||||
|
Clear All Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Textarea } from '../ui/Input';
|
import { Textarea } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||||
|
import { storyApi } from '../../lib/api';
|
||||||
|
|
||||||
interface RichTextEditorProps {
|
interface RichTextEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
storyId?: string; // Optional - for image processing (undefined for new stories)
|
||||||
|
enableImageProcessing?: boolean; // Enable background image processing
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RichTextEditor({
|
export default function RichTextEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Write your story here...',
|
placeholder = 'Write your story here...',
|
||||||
error
|
error,
|
||||||
|
storyId,
|
||||||
|
enableImageProcessing = false
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||||
const [htmlValue, setHtmlValue] = useState(value);
|
const [htmlValue, setHtmlValue] = useState(value);
|
||||||
@@ -28,6 +33,12 @@ export default function RichTextEditor({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||||
|
|
||||||
|
// Image processing state
|
||||||
|
const [imageProcessingQueue, setImageProcessingQueue] = useState<string[]>([]);
|
||||||
|
const [processedImages, setProcessedImages] = useState<Set<string>>(new Set());
|
||||||
|
const [imageWarnings, setImageWarnings] = useState<string[]>([]);
|
||||||
|
const imageProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Utility functions for cursor position preservation
|
// Utility functions for cursor position preservation
|
||||||
const saveCursorPosition = () => {
|
const saveCursorPosition = () => {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -63,6 +74,82 @@ export default function RichTextEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Image processing functionality
|
||||||
|
const findImageUrlsInHtml = (html: string): string[] => {
|
||||||
|
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
const urls: string[] = [];
|
||||||
|
let match;
|
||||||
|
while ((match = imgRegex.exec(html)) !== null) {
|
||||||
|
const url = match[1];
|
||||||
|
// Skip local URLs and data URLs
|
||||||
|
if (!url.startsWith('/') && !url.startsWith('data:')) {
|
||||||
|
urls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processContentImagesDebounced = useCallback(async (content: string) => {
|
||||||
|
if (!enableImageProcessing || !storyId) return;
|
||||||
|
|
||||||
|
const imageUrls = findImageUrlsInHtml(content);
|
||||||
|
if (imageUrls.length === 0) return;
|
||||||
|
|
||||||
|
// Find new URLs that haven't been processed yet
|
||||||
|
const newUrls = imageUrls.filter(url => !processedImages.has(url));
|
||||||
|
if (newUrls.length === 0) return;
|
||||||
|
|
||||||
|
// Add to processing queue
|
||||||
|
setImageProcessingQueue(prev => [...prev, ...newUrls]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the API to process images
|
||||||
|
const result = await storyApi.processContentImages(storyId, content);
|
||||||
|
|
||||||
|
// Mark URLs as processed
|
||||||
|
setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls]));
|
||||||
|
|
||||||
|
// Remove from processing queue
|
||||||
|
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||||
|
|
||||||
|
// Update content with processed images
|
||||||
|
if (result.processedContent !== content) {
|
||||||
|
onChange(result.processedContent);
|
||||||
|
setHtmlValue(result.processedContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle warnings
|
||||||
|
if (result.hasWarnings && result.warnings) {
|
||||||
|
setImageWarnings(prev => [...prev, ...result.warnings!]);
|
||||||
|
// Show brief warning notification - could be enhanced with a toast system
|
||||||
|
console.warn('Image processing warnings:', result.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to process content images:', error);
|
||||||
|
// Remove failed URLs from queue
|
||||||
|
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||||
|
|
||||||
|
// Show error message - could be enhanced with user notification
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
setImageWarnings(prev => [...prev, `Failed to process some images: ${errorMessage}`]);
|
||||||
|
}
|
||||||
|
}, [enableImageProcessing, storyId, processedImages, onChange]);
|
||||||
|
|
||||||
|
const triggerImageProcessing = useCallback((content: string) => {
|
||||||
|
if (!enableImageProcessing || !storyId) return;
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (imageProcessingTimeoutRef.current) {
|
||||||
|
clearTimeout(imageProcessingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout to process after user stops typing
|
||||||
|
imageProcessingTimeoutRef.current = setTimeout(() => {
|
||||||
|
processContentImagesDebounced(content);
|
||||||
|
}, 2000); // Wait 2 seconds after user stops typing
|
||||||
|
}, [enableImageProcessing, storyId, processContentImagesDebounced]);
|
||||||
|
|
||||||
// Maximize/minimize functionality
|
// Maximize/minimize functionality
|
||||||
const toggleMaximize = () => {
|
const toggleMaximize = () => {
|
||||||
if (!isMaximized) {
|
if (!isMaximized) {
|
||||||
@@ -74,6 +161,108 @@ export default function RichTextEditor({
|
|||||||
setIsMaximized(!isMaximized);
|
setIsMaximized(!isMaximized);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatText = useCallback((tag: string) => {
|
||||||
|
if (viewMode === 'visual') {
|
||||||
|
const visualDiv = visualDivRef.current;
|
||||||
|
if (!visualDiv) return;
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const selectedText = range.toString();
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
// Wrap selected text in the formatting tag
|
||||||
|
const formattedElement = document.createElement(tag);
|
||||||
|
formattedElement.textContent = selectedText;
|
||||||
|
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(formattedElement);
|
||||||
|
|
||||||
|
// Move cursor to end of inserted content
|
||||||
|
range.selectNodeContents(formattedElement);
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
} else {
|
||||||
|
// No selection - insert template
|
||||||
|
const template = tag === 'h1' ? 'Heading 1' :
|
||||||
|
tag === 'h2' ? 'Heading 2' :
|
||||||
|
tag === 'h3' ? 'Heading 3' :
|
||||||
|
tag === 'h4' ? 'Heading 4' :
|
||||||
|
tag === 'h5' ? 'Heading 5' :
|
||||||
|
tag === 'h6' ? 'Heading 6' :
|
||||||
|
'Formatted text';
|
||||||
|
|
||||||
|
const formattedElement = document.createElement(tag);
|
||||||
|
formattedElement.textContent = template;
|
||||||
|
|
||||||
|
range.insertNode(formattedElement);
|
||||||
|
|
||||||
|
// Select the inserted text for easy editing
|
||||||
|
range.selectNodeContents(formattedElement);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the state
|
||||||
|
setIsUserTyping(true);
|
||||||
|
const newContent = visualDiv.innerHTML;
|
||||||
|
onChange(newContent);
|
||||||
|
setHtmlValue(newContent);
|
||||||
|
setTimeout(() => setIsUserTyping(false), 100);
|
||||||
|
|
||||||
|
// Trigger image processing if enabled
|
||||||
|
triggerImageProcessing(newContent);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// HTML mode - existing logic with improvements
|
||||||
|
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const selectedText = htmlValue.substring(start, end);
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
const beforeText = htmlValue.substring(0, start);
|
||||||
|
const afterText = htmlValue.substring(end);
|
||||||
|
const formattedText = `<${tag}>${selectedText}</${tag}>`;
|
||||||
|
const newValue = beforeText + formattedText + afterText;
|
||||||
|
|
||||||
|
setHtmlValue(newValue);
|
||||||
|
onChange(newValue);
|
||||||
|
|
||||||
|
// Restore cursor position
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(start, start + formattedText.length);
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
// No selection - insert template at cursor
|
||||||
|
const template = tag === 'h1' ? '<h1>Heading 1</h1>' :
|
||||||
|
tag === 'h2' ? '<h2>Heading 2</h2>' :
|
||||||
|
tag === 'h3' ? '<h3>Heading 3</h3>' :
|
||||||
|
tag === 'h4' ? '<h4>Heading 4</h4>' :
|
||||||
|
tag === 'h5' ? '<h5>Heading 5</h5>' :
|
||||||
|
tag === 'h6' ? '<h6>Heading 6</h6>' :
|
||||||
|
`<${tag}>Formatted text</${tag}>`;
|
||||||
|
|
||||||
|
const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start);
|
||||||
|
setHtmlValue(newValue);
|
||||||
|
onChange(newValue);
|
||||||
|
|
||||||
|
// Position cursor inside the new tag
|
||||||
|
setTimeout(() => {
|
||||||
|
const tagLength = `<${tag}>`.length;
|
||||||
|
const newPosition = start + tagLength;
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(newPosition, newPosition + (tag === 'p' ? 0 : template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [viewMode, htmlValue, onChange]);
|
||||||
|
|
||||||
// Handle manual resize when dragging resize handle
|
// Handle manual resize when dragging resize handle
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (isMaximized) return; // Don't allow resize when maximized
|
if (isMaximized) return; // Don't allow resize when maximized
|
||||||
@@ -97,16 +286,43 @@ export default function RichTextEditor({
|
|||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Escape key handler for maximized mode
|
// Keyboard shortcuts handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Escape key to exit maximized mode
|
||||||
if (e.key === 'Escape' && isMaximized) {
|
if (e.key === 'Escape' && isMaximized) {
|
||||||
setIsMaximized(false);
|
setIsMaximized(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading shortcuts: Ctrl+Shift+1-6
|
||||||
|
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
const num = parseInt(e.key);
|
||||||
|
if (num >= 1 && num <= 6) {
|
||||||
|
e.preventDefault();
|
||||||
|
formatText(`h${num}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional common shortcuts
|
||||||
|
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 'b':
|
||||||
|
e.preventDefault();
|
||||||
|
formatText('strong');
|
||||||
|
return;
|
||||||
|
case 'i':
|
||||||
|
e.preventDefault();
|
||||||
|
formatText('em');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
if (isMaximized) {
|
if (isMaximized) {
|
||||||
document.addEventListener('keydown', handleEscapeKey);
|
|
||||||
// Prevent body from scrolling when maximized
|
// Prevent body from scrolling when maximized
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
} else {
|
} else {
|
||||||
@@ -114,10 +330,19 @@ export default function RichTextEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleEscapeKey);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
};
|
};
|
||||||
}, [isMaximized]);
|
}, [isMaximized, formatText]);
|
||||||
|
|
||||||
|
// Cleanup image processing timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (imageProcessingTimeoutRef.current) {
|
||||||
|
clearTimeout(imageProcessingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Set initial content when component mounts
|
// Set initial content when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -159,6 +384,8 @@ export default function RichTextEditor({
|
|||||||
if (newHtml !== value) {
|
if (newHtml !== value) {
|
||||||
onChange(newHtml);
|
onChange(newHtml);
|
||||||
setHtmlValue(newHtml);
|
setHtmlValue(newHtml);
|
||||||
|
// Trigger image processing if enabled
|
||||||
|
triggerImageProcessing(newHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset typing state after a short delay
|
// Reset typing state after a short delay
|
||||||
@@ -232,13 +459,38 @@ export default function RichTextEditor({
|
|||||||
if (htmlContent && htmlContent.trim().length > 0) {
|
if (htmlContent && htmlContent.trim().length > 0) {
|
||||||
console.log('Processing HTML content...');
|
console.log('Processing HTML content...');
|
||||||
console.log('Raw HTML:', htmlContent.substring(0, 500));
|
console.log('Raw HTML:', htmlContent.substring(0, 500));
|
||||||
|
|
||||||
const sanitizedHtml = sanitizeHtmlSync(htmlContent);
|
// Check if we have embedded images and image processing is enabled
|
||||||
|
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent);
|
||||||
|
let processedHtml = htmlContent;
|
||||||
|
|
||||||
|
if (hasImages && enableImageProcessing && storyId) {
|
||||||
|
console.log('Found images in pasted content, processing before sanitization...');
|
||||||
|
try {
|
||||||
|
// Process images synchronously before sanitization
|
||||||
|
const result = await storyApi.processContentImages(storyId, htmlContent);
|
||||||
|
processedHtml = result.processedContent;
|
||||||
|
console.log('Image processing completed, processed content length:', processedHtml.length);
|
||||||
|
|
||||||
|
// Update image processing state
|
||||||
|
if (result.downloadedImages && result.downloadedImages.length > 0) {
|
||||||
|
setProcessedImages(prev => new Set([...Array.from(prev), ...result.downloadedImages]));
|
||||||
|
}
|
||||||
|
if (result.warnings && result.warnings.length > 0) {
|
||||||
|
setImageWarnings(prev => [...prev, ...result.warnings!]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image processing failed during paste:', error);
|
||||||
|
// Continue with original content if image processing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedHtml = sanitizeHtmlSync(processedHtml);
|
||||||
console.log('Sanitized HTML length:', sanitizedHtml.length);
|
console.log('Sanitized HTML length:', sanitizedHtml.length);
|
||||||
console.log('Sanitized HTML preview:', sanitizedHtml.substring(0, 500));
|
console.log('Sanitized HTML preview:', sanitizedHtml.substring(0, 500));
|
||||||
|
|
||||||
// Check if sanitization removed too much content
|
// Check if sanitization removed too much content
|
||||||
const ratio = sanitizedHtml.length / htmlContent.length;
|
const ratio = sanitizedHtml.length / processedHtml.length;
|
||||||
console.log('Sanitization ratio (kept/original):', ratio.toFixed(3));
|
console.log('Sanitization ratio (kept/original):', ratio.toFixed(3));
|
||||||
if (ratio < 0.1) {
|
if (ratio < 0.1) {
|
||||||
console.warn('Sanitization removed >90% of content - this might be too aggressive');
|
console.warn('Sanitization removed >90% of content - this might be too aggressive');
|
||||||
@@ -279,9 +531,12 @@ export default function RichTextEditor({
|
|||||||
|
|
||||||
// Update the state
|
// Update the state
|
||||||
setIsUserTyping(true);
|
setIsUserTyping(true);
|
||||||
onChange(visualDiv.innerHTML);
|
const newContent = visualDiv.innerHTML;
|
||||||
setHtmlValue(visualDiv.innerHTML);
|
onChange(newContent);
|
||||||
|
setHtmlValue(newContent);
|
||||||
setTimeout(() => setIsUserTyping(false), 100);
|
setTimeout(() => setIsUserTyping(false), 100);
|
||||||
|
|
||||||
|
// Note: Image processing already completed during paste, no need to trigger again
|
||||||
} else if (textarea) {
|
} else if (textarea) {
|
||||||
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
|
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
@@ -368,6 +623,9 @@ export default function RichTextEditor({
|
|||||||
const html = e.target.value;
|
const html = e.target.value;
|
||||||
setHtmlValue(html);
|
setHtmlValue(html);
|
||||||
onChange(html);
|
onChange(html);
|
||||||
|
|
||||||
|
// Trigger image processing if enabled
|
||||||
|
triggerImageProcessing(html);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlainText = (html: string): string => {
|
const getPlainText = (html: string): string => {
|
||||||
@@ -380,97 +638,6 @@ export default function RichTextEditor({
|
|||||||
.trim();
|
.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatText = (tag: string) => {
|
|
||||||
if (viewMode === 'visual') {
|
|
||||||
const visualDiv = visualDivRef.current;
|
|
||||||
if (!visualDiv) return;
|
|
||||||
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (selection && selection.rangeCount > 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const selectedText = range.toString();
|
|
||||||
|
|
||||||
if (selectedText) {
|
|
||||||
// Wrap selected text in the formatting tag
|
|
||||||
const formattedElement = document.createElement(tag);
|
|
||||||
formattedElement.textContent = selectedText;
|
|
||||||
|
|
||||||
range.deleteContents();
|
|
||||||
range.insertNode(formattedElement);
|
|
||||||
|
|
||||||
// Move cursor to end of inserted content
|
|
||||||
range.selectNodeContents(formattedElement);
|
|
||||||
range.collapse(false);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
} else {
|
|
||||||
// No selection - insert template
|
|
||||||
const template = tag === 'h1' ? 'Heading 1' :
|
|
||||||
tag === 'h2' ? 'Heading 2' :
|
|
||||||
tag === 'h3' ? 'Heading 3' :
|
|
||||||
'Formatted text';
|
|
||||||
|
|
||||||
const formattedElement = document.createElement(tag);
|
|
||||||
formattedElement.textContent = template;
|
|
||||||
|
|
||||||
range.insertNode(formattedElement);
|
|
||||||
|
|
||||||
// Select the inserted text for easy editing
|
|
||||||
range.selectNodeContents(formattedElement);
|
|
||||||
selection.removeAllRanges();
|
|
||||||
selection.addRange(range);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the state
|
|
||||||
setIsUserTyping(true);
|
|
||||||
onChange(visualDiv.innerHTML);
|
|
||||||
setHtmlValue(visualDiv.innerHTML);
|
|
||||||
setTimeout(() => setIsUserTyping(false), 100);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// HTML mode - existing logic with improvements
|
|
||||||
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
|
||||||
if (!textarea) return;
|
|
||||||
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
const selectedText = htmlValue.substring(start, end);
|
|
||||||
|
|
||||||
if (selectedText) {
|
|
||||||
const beforeText = htmlValue.substring(0, start);
|
|
||||||
const afterText = htmlValue.substring(end);
|
|
||||||
const formattedText = `<${tag}>${selectedText}</${tag}>`;
|
|
||||||
const newValue = beforeText + formattedText + afterText;
|
|
||||||
|
|
||||||
setHtmlValue(newValue);
|
|
||||||
onChange(newValue);
|
|
||||||
|
|
||||||
// Restore cursor position
|
|
||||||
setTimeout(() => {
|
|
||||||
textarea.focus();
|
|
||||||
textarea.setSelectionRange(start, start + formattedText.length);
|
|
||||||
}, 0);
|
|
||||||
} else {
|
|
||||||
// No selection - insert template at cursor
|
|
||||||
const template = tag === 'h1' ? '<h1>Heading 1</h1>' :
|
|
||||||
tag === 'h2' ? '<h2>Heading 2</h2>' :
|
|
||||||
tag === 'h3' ? '<h3>Heading 3</h3>' :
|
|
||||||
`<${tag}>Formatted text</${tag}>`;
|
|
||||||
|
|
||||||
const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start);
|
|
||||||
setHtmlValue(newValue);
|
|
||||||
onChange(newValue);
|
|
||||||
|
|
||||||
// Position cursor inside the new tag
|
|
||||||
setTimeout(() => {
|
|
||||||
const tagLength = `<${tag}>`.length;
|
|
||||||
const newPosition = start + tagLength;
|
|
||||||
textarea.focus();
|
|
||||||
textarea.setSelectionRange(newPosition, newPosition + (tag === 'p' ? 0 : template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length));
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -498,6 +665,24 @@ export default function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Image processing status indicator */}
|
||||||
|
{enableImageProcessing && (
|
||||||
|
<>
|
||||||
|
{imageProcessingQueue.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
|
||||||
|
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||||
|
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{imageWarnings.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -514,7 +699,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('strong')}
|
onClick={() => formatText('strong')}
|
||||||
title="Bold"
|
title="Bold (Ctrl+B)"
|
||||||
className="font-bold"
|
className="font-bold"
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
@@ -524,7 +709,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('em')}
|
onClick={() => formatText('em')}
|
||||||
title="Italic"
|
title="Italic (Ctrl+I)"
|
||||||
className="italic"
|
className="italic"
|
||||||
>
|
>
|
||||||
I
|
I
|
||||||
@@ -535,7 +720,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h1')}
|
onClick={() => formatText('h1')}
|
||||||
title="Heading 1"
|
title="Heading 1 (Ctrl+Shift+1)"
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
>
|
>
|
||||||
H1
|
H1
|
||||||
@@ -545,7 +730,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h2')}
|
onClick={() => formatText('h2')}
|
||||||
title="Heading 2"
|
title="Heading 2 (Ctrl+Shift+2)"
|
||||||
className="text-base font-bold"
|
className="text-base font-bold"
|
||||||
>
|
>
|
||||||
H2
|
H2
|
||||||
@@ -555,11 +740,41 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h3')}
|
onClick={() => formatText('h3')}
|
||||||
title="Heading 3"
|
title="Heading 3 (Ctrl+Shift+3)"
|
||||||
className="text-sm font-bold"
|
className="text-sm font-bold"
|
||||||
>
|
>
|
||||||
H3
|
H3
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h4')}
|
||||||
|
title="Heading 4 (Ctrl+Shift+4)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H4
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h5')}
|
||||||
|
title="Heading 5 (Ctrl+Shift+5)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H5
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h6')}
|
||||||
|
title="Heading 6 (Ctrl+Shift+6)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H6
|
||||||
|
</Button>
|
||||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -609,6 +824,24 @@ export default function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Image processing status indicator */}
|
||||||
|
{enableImageProcessing && (
|
||||||
|
<>
|
||||||
|
{imageProcessingQueue.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
|
||||||
|
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||||
|
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{imageWarnings.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -625,7 +858,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('strong')}
|
onClick={() => formatText('strong')}
|
||||||
title="Bold"
|
title="Bold (Ctrl+B)"
|
||||||
className="font-bold"
|
className="font-bold"
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
@@ -635,7 +868,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('em')}
|
onClick={() => formatText('em')}
|
||||||
title="Italic"
|
title="Italic (Ctrl+I)"
|
||||||
className="italic"
|
className="italic"
|
||||||
>
|
>
|
||||||
I
|
I
|
||||||
@@ -646,7 +879,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h1')}
|
onClick={() => formatText('h1')}
|
||||||
title="Heading 1"
|
title="Heading 1 (Ctrl+Shift+1)"
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
>
|
>
|
||||||
H1
|
H1
|
||||||
@@ -656,7 +889,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h2')}
|
onClick={() => formatText('h2')}
|
||||||
title="Heading 2"
|
title="Heading 2 (Ctrl+Shift+2)"
|
||||||
className="text-base font-bold"
|
className="text-base font-bold"
|
||||||
>
|
>
|
||||||
H2
|
H2
|
||||||
@@ -666,11 +899,41 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h3')}
|
onClick={() => formatText('h3')}
|
||||||
title="Heading 3"
|
title="Heading 3 (Ctrl+Shift+3)"
|
||||||
className="text-sm font-bold"
|
className="text-sm font-bold"
|
||||||
>
|
>
|
||||||
H3
|
H3
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h4')}
|
||||||
|
title="Heading 4 (Ctrl+Shift+4)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H4
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h5')}
|
||||||
|
title="Heading 5 (Ctrl+Shift+5)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H5
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => formatText('h6')}
|
||||||
|
title="Heading 6 (Ctrl+Shift+6)"
|
||||||
|
className="text-xs font-bold"
|
||||||
|
>
|
||||||
|
H6
|
||||||
|
</Button>
|
||||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -694,7 +957,7 @@ export default function RichTextEditor({
|
|||||||
contentEditable
|
contentEditable
|
||||||
onInput={handleVisualContentChange}
|
onInput={handleVisualContentChange}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
className="p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none"
|
className="editor-content p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none"
|
||||||
suppressContentEditableWarning={true}
|
suppressContentEditableWarning={true}
|
||||||
/>
|
/>
|
||||||
{!value && (
|
{!value && (
|
||||||
@@ -732,7 +995,7 @@ export default function RichTextEditor({
|
|||||||
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
className="p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto"
|
className="editor-content p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto"
|
||||||
dangerouslySetInnerHTML={{ __html: value }}
|
dangerouslySetInnerHTML={{ __html: value }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -751,6 +1014,9 @@ export default function RichTextEditor({
|
|||||||
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
|
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
|
||||||
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
|
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Tips:</strong> Use the ⊞ button to maximize the editor for larger stories.
|
<strong>Tips:</strong> Use the ⊞ button to maximize the editor for larger stories.
|
||||||
Drag the resize handle at the bottom to adjust height. Press Escape to exit maximized mode.
|
Drag the resize handle at the bottom to adjust height. Press Escape to exit maximized mode.
|
||||||
|
|||||||
290
frontend/src/components/stories/SeriesSelector.tsx
Normal file
290
frontend/src/components/stories/SeriesSelector.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { seriesApi, storyApi } from '../../lib/api';
|
||||||
|
import { Series } from '../../types/api';
|
||||||
|
|
||||||
|
interface SeriesSelectorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (seriesName: string, seriesId?: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
label?: string;
|
||||||
|
authorId?: string; // Optional author ID to prioritize that author's series
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeriesSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Enter or select a series',
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
label = 'Series',
|
||||||
|
authorId
|
||||||
|
}: SeriesSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [series, setSeries] = useState<Series[]>([]);
|
||||||
|
const [filteredSeries, setFilteredSeries] = useState<Series[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
const [authorSeriesMap, setAuthorSeriesMap] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load series and author-series mappings when component mounts or when dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSeriesData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Load all series
|
||||||
|
const seriesResult = await seriesApi.getSeries({ page: 0, size: 100 }); // Get first 100 series
|
||||||
|
setSeries(seriesResult.content);
|
||||||
|
|
||||||
|
// Load some recent stories to build author-series mapping
|
||||||
|
// This gives us a sample of which authors have written in which series
|
||||||
|
try {
|
||||||
|
const storiesResult = await storyApi.getStories({ page: 0, size: 200 }); // Get recent stories
|
||||||
|
const newAuthorSeriesMap: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
storiesResult.content.forEach(story => {
|
||||||
|
if (story.authorId && story.seriesName) {
|
||||||
|
if (!newAuthorSeriesMap[story.authorId]) {
|
||||||
|
newAuthorSeriesMap[story.authorId] = [];
|
||||||
|
}
|
||||||
|
if (!newAuthorSeriesMap[story.authorId].includes(story.seriesName)) {
|
||||||
|
newAuthorSeriesMap[story.authorId].push(story.seriesName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAuthorSeriesMap(newAuthorSeriesMap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load author-series mapping:', error);
|
||||||
|
// Continue without author prioritization if this fails
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load series:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen && series.length === 0) {
|
||||||
|
loadSeriesData();
|
||||||
|
}
|
||||||
|
}, [isOpen, series.length]);
|
||||||
|
|
||||||
|
// Update internal value when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value || '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Filter and sort series based on input and author priority
|
||||||
|
useEffect(() => {
|
||||||
|
let filtered: Series[];
|
||||||
|
|
||||||
|
if (!inputValue.trim()) {
|
||||||
|
filtered = [...series];
|
||||||
|
} else {
|
||||||
|
filtered = series.filter(s =>
|
||||||
|
s.name.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort series: prioritize those from the current author if authorId is provided
|
||||||
|
if (authorId && authorSeriesMap[authorId]) {
|
||||||
|
const authorSeriesNames = authorSeriesMap[authorId];
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aIsAuthorSeries = authorSeriesNames.includes(a.name);
|
||||||
|
const bIsAuthorSeries = authorSeriesNames.includes(b.name);
|
||||||
|
|
||||||
|
if (aIsAuthorSeries && !bIsAuthorSeries) return -1; // a first
|
||||||
|
if (!aIsAuthorSeries && bIsAuthorSeries) return 1; // b first
|
||||||
|
|
||||||
|
// If both or neither are author series, sort alphabetically
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No author prioritization, just sort alphabetically
|
||||||
|
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredSeries(filtered);
|
||||||
|
}, [inputValue, series, authorId, authorSeriesMap]);
|
||||||
|
|
||||||
|
// Handle clicks outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
!inputRef.current?.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setInputValue(newValue);
|
||||||
|
setIsOpen(true);
|
||||||
|
|
||||||
|
// If user is typing and it doesn't match any existing series exactly, clear the seriesId
|
||||||
|
onChange(newValue, undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeriesSelect = (selectedSeries: Series) => {
|
||||||
|
setInputValue(selectedSeries.name);
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange(selectedSeries.name, selectedSeries.id);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
// Small delay to allow clicks on dropdown items
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!dropdownRef.current?.contains(document.activeElement)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 border rounded-lg theme-card theme-text theme-border focus:outline-none focus:ring-2 focus:ring-theme-accent focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
error ? 'border-red-500 focus:ring-red-500' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dropdown Arrow */}
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 theme-text transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border theme-border rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-3 py-2 text-sm theme-text">Loading series...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredSeries.length > 0 ? (
|
||||||
|
filteredSeries.map((s) => {
|
||||||
|
const isAuthorSeries = authorId && authorSeriesMap[authorId]?.includes(s.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors flex items-center justify-between ${
|
||||||
|
isAuthorSeries ? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-400' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSeriesSelect(s)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{s.name}</span>
|
||||||
|
{isAuthorSeries && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-100">
|
||||||
|
Author
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{s.storyCount} {s.storyCount === 1 ? 'story' : 'stories'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{inputValue.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange(inputValue.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create new series: "{inputValue.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!inputValue.trim() && (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-500">No series found</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inputValue.trim() && !filteredSeries.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange(inputValue.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use "{inputValue.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import Image from 'next/image';
|
|||||||
import { Story } from '../../types/api';
|
import { Story } from '../../types/api';
|
||||||
import { storyApi, getImageUrl } from '../../lib/api';
|
import { storyApi, getImageUrl } from '../../lib/api';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
|
|
||||||
interface StoryCardProps {
|
interface StoryCardProps {
|
||||||
story: Story;
|
story: Story;
|
||||||
@@ -27,7 +28,11 @@ export default function StoryCard({
|
|||||||
const [rating, setRating] = useState(story.rating || 0);
|
const [rating, setRating] = useState(story.rating || 0);
|
||||||
const [updating, setUpdating] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
const handleRatingClick = async (newRating: number) => {
|
const handleRatingClick = async (e: React.MouseEvent, newRating: number) => {
|
||||||
|
// Prevent default and stop propagation to avoid triggering navigation
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
if (updating) return;
|
if (updating) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -106,12 +111,12 @@ export default function StoryCard({
|
|||||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{story.tags.slice(0, 3).map((tag) => (
|
{story.tags.slice(0, 3).map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
tag={tag}
|
||||||
>
|
size="sm"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
{story.tags.length > 3 && (
|
{story.tags.length > 3 && (
|
||||||
<span className="px-2 py-1 text-xs theme-text">
|
<span className="px-2 py-1 text-xs theme-text">
|
||||||
@@ -129,7 +134,7 @@ export default function StoryCard({
|
|||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<button
|
<button
|
||||||
key={star}
|
key={star}
|
||||||
onClick={() => handleRatingClick(star)}
|
onClick={(e) => handleRatingClick(e, star)}
|
||||||
className={`text-lg ${
|
className={`text-lg ${
|
||||||
star <= rating
|
star <= rating
|
||||||
? 'text-yellow-400'
|
? 'text-yellow-400'
|
||||||
@@ -207,7 +212,7 @@ export default function StoryCard({
|
|||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<button
|
<button
|
||||||
key={star}
|
key={star}
|
||||||
onClick={() => handleRatingClick(star)}
|
onClick={(e) => handleRatingClick(e, star)}
|
||||||
className={`text-sm ${
|
className={`text-sm ${
|
||||||
star <= rating
|
star <= rating
|
||||||
? 'text-yellow-400'
|
? 'text-yellow-400'
|
||||||
@@ -237,12 +242,12 @@ export default function StoryCard({
|
|||||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{story.tags.slice(0, 2).map((tag) => (
|
{story.tags.slice(0, 2).map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
tag={tag}
|
||||||
>
|
size="sm"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
{story.tags.length > 2 && (
|
{story.tags.length > 2 && (
|
||||||
<span className="px-2 py-1 text-xs theme-text">
|
<span className="px-2 py-1 text-xs theme-text">
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ export default function StoryMultiSelect({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedStoryIds.includes(story.id)}
|
checked={selectedStoryIds.includes(story.id)}
|
||||||
onChange={() => handleStorySelect(story.id)}
|
onChange={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent checkbox clicks from interfering
|
||||||
|
handleStorySelect(story.id);
|
||||||
|
}}
|
||||||
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 bg-white shadow-lg"
|
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 bg-white shadow-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +115,6 @@ export default function StoryMultiSelect({
|
|||||||
className={`transition-all duration-200 ${
|
className={`transition-all duration-200 ${
|
||||||
selectedStoryIds.includes(story.id) ? 'ring-2 ring-blue-500 ring-opacity-50' : ''
|
selectedStoryIds.includes(story.id) ? 'ring-2 ring-blue-500 ring-opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
onDoubleClick={() => allowMultiSelect && handleStorySelect(story.id)}
|
|
||||||
>
|
>
|
||||||
<StoryCard
|
<StoryCard
|
||||||
story={story}
|
story={story}
|
||||||
|
|||||||
156
frontend/src/components/stories/TableOfContents.tsx
Normal file
156
frontend/src/components/stories/TableOfContents.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface TocItem {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
title: string;
|
||||||
|
element?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableOfContentsProps {
|
||||||
|
htmlContent: string;
|
||||||
|
className?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
onItemClick?: (item: TocItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TableOfContents({
|
||||||
|
htmlContent,
|
||||||
|
className = '',
|
||||||
|
collapsible = false,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
onItemClick
|
||||||
|
}: TableOfContentsProps) {
|
||||||
|
const [tocItems, setTocItems] = useState<TocItem[]>([]);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Parse HTML content to extract headings
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||||
|
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
|
|
||||||
|
const items: TocItem[] = [];
|
||||||
|
|
||||||
|
headings.forEach((heading, index) => {
|
||||||
|
const level = parseInt(heading.tagName.charAt(1));
|
||||||
|
let title = heading.textContent?.trim() || '';
|
||||||
|
// Use existing ID if present, otherwise fall back to index-based ID
|
||||||
|
const id = heading.id || `heading-${index}`;
|
||||||
|
|
||||||
|
// Handle empty headings with a fallback
|
||||||
|
if (!title) {
|
||||||
|
title = `Heading ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit title length for display
|
||||||
|
if (title.length > 60) {
|
||||||
|
title = title.substring(0, 57) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
level,
|
||||||
|
title,
|
||||||
|
element: heading as HTMLElement
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTocItems(items);
|
||||||
|
}, [htmlContent]);
|
||||||
|
|
||||||
|
const handleItemClick = (item: TocItem) => {
|
||||||
|
if (onItemClick) {
|
||||||
|
onItemClick(item);
|
||||||
|
} else {
|
||||||
|
// Default behavior: smooth scroll to heading
|
||||||
|
const element = document.getElementById(item.id);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIndentClass = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 1: return 'pl-0';
|
||||||
|
case 2: return 'pl-4';
|
||||||
|
case 3: return 'pl-8';
|
||||||
|
case 4: return 'pl-12';
|
||||||
|
case 5: return 'pl-16';
|
||||||
|
case 6: return 'pl-20';
|
||||||
|
default: return 'pl-0';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFontSizeClass = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 1: return 'text-base font-semibold';
|
||||||
|
case 2: return 'text-sm font-medium';
|
||||||
|
case 3: return 'text-sm';
|
||||||
|
case 4: return 'text-xs';
|
||||||
|
case 5: return 'text-xs';
|
||||||
|
case 6: return 'text-xs';
|
||||||
|
default: return 'text-sm';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tocItems.length === 0) {
|
||||||
|
// Don't render anything if no headings are found
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`theme-card theme-shadow rounded-lg overflow-hidden ${className}`}>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b theme-border flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold theme-header">Table of Contents</h3>
|
||||||
|
<span className="theme-text">
|
||||||
|
{isCollapsed ? '▼' : '▲'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!collapsible && (
|
||||||
|
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b theme-border">
|
||||||
|
<h3 className="font-semibold theme-header">Table of Contents</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!collapsible || !isCollapsed) && (
|
||||||
|
<div className="p-4 max-h-96 overflow-y-auto">
|
||||||
|
<nav>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{tocItems.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
className={`
|
||||||
|
w-full text-left py-1 px-2 rounded transition-colors
|
||||||
|
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||||
|
theme-text hover:theme-accent
|
||||||
|
${getIndentClass(item.level)}
|
||||||
|
${getFontSizeClass(item.level)}
|
||||||
|
`}
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
<span className="block truncate">{item.title}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { tagApi } from '../../lib/api';
|
import { tagApi } from '../../lib/api';
|
||||||
|
import { Tag } from '../../types/api';
|
||||||
|
|
||||||
interface TagInputProps {
|
interface TagInputProps {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -9,25 +10,178 @@ interface TagInputProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fuzzy matching utilities
|
||||||
|
const levenshteinDistance = (str1: string, str2: string): number => {
|
||||||
|
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||||
|
|
||||||
|
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||||
|
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||||
|
|
||||||
|
for (let j = 1; j <= str2.length; j++) {
|
||||||
|
for (let i = 1; i <= str1.length; i++) {
|
||||||
|
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||||
|
matrix[j][i] = Math.min(
|
||||||
|
matrix[j][i - 1] + 1,
|
||||||
|
matrix[j - 1][i] + 1,
|
||||||
|
matrix[j - 1][i - 1] + indicator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[str2.length][str1.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSimilarity = (query: string, target: string): number => {
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
const t = target.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Don't match very short queries to avoid noise
|
||||||
|
if (q.length < 2) return 0;
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (q === t) return 1.0;
|
||||||
|
|
||||||
|
// Starts with match (high priority)
|
||||||
|
if (t.startsWith(q)) return 0.95;
|
||||||
|
|
||||||
|
// Contains match (word boundary preferred)
|
||||||
|
if (t.includes(q)) {
|
||||||
|
// Higher score if it's a word boundary match
|
||||||
|
const words = t.split(/\s+|[-_]/);
|
||||||
|
if (words.some(word => word.startsWith(q))) return 0.85;
|
||||||
|
return 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common typo patterns
|
||||||
|
// 1. Adjacent character swaps (e.g., "sicfi" -> "scifi")
|
||||||
|
if (Math.abs(q.length - t.length) <= 1) {
|
||||||
|
for (let i = 0; i < Math.min(q.length - 1, t.length - 1); i++) {
|
||||||
|
const swapped = q.substring(0, i) + q[i + 1] + q[i] + q.substring(i + 2);
|
||||||
|
if (swapped === t) return 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Missing character (e.g., "fantsy" -> "fantasy")
|
||||||
|
if (t.length === q.length + 1) {
|
||||||
|
for (let i = 0; i <= t.length; i++) {
|
||||||
|
const withMissing = t.substring(0, i) + t.substring(i + 1);
|
||||||
|
if (withMissing === q) return 0.88;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Extra character (e.g., "fantasy" -> "fantassy")
|
||||||
|
if (q.length === t.length + 1) {
|
||||||
|
for (let i = 0; i <= q.length; i++) {
|
||||||
|
const withExtra = q.substring(0, i) + q.substring(i + 1);
|
||||||
|
if (withExtra === t) return 0.88;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Double letter corrections (e.g., "scii" -> "sci", "romace" -> "romance")
|
||||||
|
const qNormalized = q.replace(/(.)\1+/g, '$1');
|
||||||
|
const tNormalized = t.replace(/(.)\1+/g, '$1');
|
||||||
|
if (qNormalized === tNormalized) return 0.87;
|
||||||
|
|
||||||
|
// 5. Common letter substitutions (keyboard layout)
|
||||||
|
const keyboardSubs: { [key: string]: string[] } = {
|
||||||
|
'a': ['s', 'q'], 's': ['a', 'd', 'w'], 'd': ['s', 'f', 'e'], 'f': ['d', 'g', 'r'],
|
||||||
|
'q': ['w', 'a'], 'w': ['q', 'e', 's'], 'e': ['w', 'r', 'd'], 'r': ['e', 't', 'f'],
|
||||||
|
'z': ['x', 'a'], 'x': ['z', 'c', 's'], 'c': ['x', 'v', 'd'], 'v': ['c', 'b', 'f'],
|
||||||
|
'i': ['u', 'o'], 'o': ['i', 'p'], 'u': ['y', 'i'], 'y': ['t', 'u'],
|
||||||
|
'n': ['b', 'm'], 'm': ['n', 'j'], 'j': ['h', 'k'], 'k': ['j', 'l']
|
||||||
|
};
|
||||||
|
|
||||||
|
let substitutionScore = 0;
|
||||||
|
if (q.length === t.length) {
|
||||||
|
let matches = 0;
|
||||||
|
for (let i = 0; i < q.length; i++) {
|
||||||
|
if (q[i] === t[i]) {
|
||||||
|
matches++;
|
||||||
|
} else if (keyboardSubs[q[i]]?.includes(t[i]) || keyboardSubs[t[i]]?.includes(q[i])) {
|
||||||
|
matches += 0.8; // Partial credit for keyboard mistakes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
substitutionScore = matches / q.length;
|
||||||
|
if (substitutionScore > 0.8) return substitutionScore * 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Levenshtein distance-based similarity (fallback)
|
||||||
|
const distance = levenshteinDistance(q, t);
|
||||||
|
const maxLength = Math.max(q.length, t.length);
|
||||||
|
const similarity = 1 - (distance / maxLength);
|
||||||
|
|
||||||
|
// Boost score for shorter strings with fewer differences
|
||||||
|
if (maxLength <= 6 && distance <= 2) {
|
||||||
|
return Math.min(0.8, similarity + 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only consider it a match if similarity is high enough
|
||||||
|
return similarity > 0.65 ? similarity * 0.7 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }: TagInputProps) {
|
export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }: TagInputProps) {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
const [suggestions, setSuggestions] = useState<{name: string, similarity: number}[]>([]);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
|
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
|
||||||
|
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load all tags once for fuzzy matching
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllTags = async () => {
|
||||||
|
try {
|
||||||
|
const response = await tagApi.getTags({ page: 0, size: 1000 });
|
||||||
|
setAllTags(response.content || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load all tags:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAllTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSuggestions = async () => {
|
const fetchSuggestions = async () => {
|
||||||
if (inputValue.length > 0) {
|
if (inputValue.length > 0) {
|
||||||
try {
|
try {
|
||||||
const suggestionList = await tagApi.getTagAutocomplete(inputValue);
|
// First try backend autocomplete for exact/prefix matches
|
||||||
// Filter out already selected tags
|
const backendSuggestions = await tagApi.getTagAutocomplete(inputValue);
|
||||||
const filteredSuggestions = suggestionList.filter(
|
|
||||||
suggestion => !tags.includes(suggestion)
|
// Apply fuzzy matching to all tags for better results
|
||||||
);
|
const fuzzyMatches = allTags
|
||||||
setSuggestions(filteredSuggestions);
|
.map(tag => ({
|
||||||
setShowSuggestions(filteredSuggestions.length > 0);
|
name: tag.name,
|
||||||
|
similarity: calculateSimilarity(inputValue, tag.name)
|
||||||
|
}))
|
||||||
|
.filter(match => match.similarity > 0 && !tags.includes(match.name))
|
||||||
|
.sort((a, b) => b.similarity - a.similarity);
|
||||||
|
|
||||||
|
// Combine backend results with fuzzy matches, prioritizing backend results
|
||||||
|
const combinedResults = new Map<string, {name: string, similarity: number}>();
|
||||||
|
|
||||||
|
// Add backend results with high priority
|
||||||
|
backendSuggestions.forEach(name => {
|
||||||
|
if (!tags.includes(name)) {
|
||||||
|
combinedResults.set(name, { name, similarity: 0.99 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add fuzzy matches that aren't already from backend
|
||||||
|
fuzzyMatches.forEach(match => {
|
||||||
|
if (!combinedResults.has(match.name)) {
|
||||||
|
combinedResults.set(match.name, match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and limit results
|
||||||
|
const finalSuggestions = Array.from(combinedResults.values())
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
setSuggestions(finalSuggestions);
|
||||||
|
setShowSuggestions(finalSuggestions.length > 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tag suggestions:', error);
|
console.error('Failed to fetch tag suggestions:', error);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
@@ -41,13 +195,29 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
|||||||
|
|
||||||
const debounce = setTimeout(fetchSuggestions, 300);
|
const debounce = setTimeout(fetchSuggestions, 300);
|
||||||
return () => clearTimeout(debounce);
|
return () => clearTimeout(debounce);
|
||||||
}, [inputValue, tags]);
|
}, [inputValue, tags, allTags]);
|
||||||
|
|
||||||
const addTag = (tag: string) => {
|
const addTag = async (tag: string) => {
|
||||||
const trimmedTag = tag.trim().toLowerCase();
|
const trimmedTag = tag.trim().toLowerCase();
|
||||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
if (!trimmedTag) return;
|
||||||
onChange([...tags, trimmedTag]);
|
|
||||||
|
try {
|
||||||
|
// Resolve tag alias to canonical name
|
||||||
|
const resolvedTag = await tagApi.resolveTag(trimmedTag);
|
||||||
|
const finalTag = resolvedTag ? resolvedTag.name.toLowerCase() : trimmedTag;
|
||||||
|
|
||||||
|
// Only add if not already present
|
||||||
|
if (!tags.includes(finalTag)) {
|
||||||
|
onChange([...tags, finalTag]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resolve tag alias:', error);
|
||||||
|
// Fall back to original tag if resolution fails
|
||||||
|
if (!tags.includes(trimmedTag)) {
|
||||||
|
onChange([...tags, trimmedTag]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
setActiveSuggestionIndex(-1);
|
setActiveSuggestionIndex(-1);
|
||||||
@@ -63,7 +233,7 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
|||||||
case ',':
|
case ',':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeSuggestionIndex >= 0 && suggestions[activeSuggestionIndex]) {
|
if (activeSuggestionIndex >= 0 && suggestions[activeSuggestionIndex]) {
|
||||||
addTag(suggestions[activeSuggestionIndex]);
|
addTag(suggestions[activeSuggestionIndex].name);
|
||||||
} else if (inputValue.trim()) {
|
} else if (inputValue.trim()) {
|
||||||
addTag(inputValue);
|
addTag(inputValue);
|
||||||
}
|
}
|
||||||
@@ -94,8 +264,8 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuggestionClick = (suggestion: string) => {
|
const handleSuggestionClick = (suggestionName: string) => {
|
||||||
addTag(suggestion);
|
addTag(suggestionName);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,9 +313,9 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
|||||||
>
|
>
|
||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
key={suggestion}
|
key={suggestion.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
onClick={() => handleSuggestionClick(suggestion.name)}
|
||||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||||
index === activeSuggestionIndex
|
index === activeSuggestionIndex
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
|
||||||
@@ -154,14 +324,21 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
|||||||
index === suggestions.length - 1 ? 'rounded-b-lg' : ''
|
index === suggestions.length - 1 ? 'rounded-b-lg' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{suggestion}
|
<div className="flex justify-between items-center">
|
||||||
|
<span>{suggestion.name}</span>
|
||||||
|
{suggestion.similarity < 0.95 && (
|
||||||
|
<span className="text-xs opacity-50 ml-2">
|
||||||
|
{(suggestion.similarity * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Type and press Enter or comma to add tags
|
Type and press Enter or comma to add tags. Supports fuzzy matching for typos.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
104
frontend/src/components/tags/TagDisplay.tsx
Normal file
104
frontend/src/components/tags/TagDisplay.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Tag } from '../../types/api';
|
||||||
|
|
||||||
|
interface TagDisplayProps {
|
||||||
|
tag: Tag;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showAliasesTooltip?: boolean;
|
||||||
|
clickable?: boolean;
|
||||||
|
onClick?: (tag: Tag) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagDisplay({
|
||||||
|
tag,
|
||||||
|
size = 'md',
|
||||||
|
showAliasesTooltip = true,
|
||||||
|
clickable = false,
|
||||||
|
onClick,
|
||||||
|
className = ''
|
||||||
|
}: TagDisplayProps) {
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-2 py-1 text-xs',
|
||||||
|
md: 'px-3 py-1 text-sm',
|
||||||
|
lg: 'px-4 py-2 text-base'
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
inline-flex items-center gap-1 rounded-full font-medium transition-all
|
||||||
|
${sizeClasses[size]}
|
||||||
|
${clickable ? 'cursor-pointer hover:scale-105' : ''}
|
||||||
|
${className}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Determine tag styling based on color
|
||||||
|
const tagStyle = tag.color ? {
|
||||||
|
backgroundColor: tag.color + '20', // Add 20% opacity
|
||||||
|
borderColor: tag.color,
|
||||||
|
color: tag.color
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
const defaultClasses = !tag.color ?
|
||||||
|
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600' :
|
||||||
|
'border';
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (clickable && onClick) {
|
||||||
|
onClick(tag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (showAliasesTooltip && tag.aliases && tag.aliases.length > 0) {
|
||||||
|
setShowTooltip(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setShowTooltip(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<span
|
||||||
|
className={`${baseClasses} ${defaultClasses}`}
|
||||||
|
style={tag.color ? tagStyle : {}}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
title={tag.description || undefined}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
{(tag.aliasCount ?? 0) > 0 && (
|
||||||
|
<span className="text-xs opacity-75">+{tag.aliasCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Tooltip for aliases */}
|
||||||
|
{showTooltip && showAliasesTooltip && tag.aliases && tag.aliases.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50">
|
||||||
|
<div className="bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg px-3 py-2 max-w-xs">
|
||||||
|
<div className="font-medium mb-1">{tag.name}</div>
|
||||||
|
<div className="border-t border-gray-700 dark:border-gray-300 pt-1">
|
||||||
|
<div className="text-gray-300 dark:text-gray-600 mb-1">Aliases:</div>
|
||||||
|
{tag.aliases.map((alias, index) => (
|
||||||
|
<div key={alias.id} className="text-gray-100 dark:text-gray-800">
|
||||||
|
{alias.aliasName}
|
||||||
|
{index < tag.aliases!.length - 1 && ', '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Tooltip arrow */}
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
|
||||||
|
<div className="border-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
frontend/src/components/tags/TagEditModal.tsx
Normal file
324
frontend/src/components/tags/TagEditModal.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Tag, TagAlias } from '../../types/api';
|
||||||
|
import { tagApi } from '../../lib/api';
|
||||||
|
import { Input, Textarea } from '../ui/Input';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import ColorPicker from '../ui/ColorPicker';
|
||||||
|
|
||||||
|
interface TagEditModalProps {
|
||||||
|
tag?: Tag;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (tag: Tag) => void;
|
||||||
|
onDelete?: (tag: Tag) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagEditModal({ tag, isOpen, onClose, onSave, onDelete }: TagEditModalProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
color: '',
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
const [aliases, setAliases] = useState<TagAlias[]>([]);
|
||||||
|
const [newAlias, setNewAlias] = useState('');
|
||||||
|
const [loading, setSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Reset form when modal opens/closes or tag changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && tag) {
|
||||||
|
setFormData({
|
||||||
|
name: tag.name || '',
|
||||||
|
color: tag.color || '',
|
||||||
|
description: tag.description || ''
|
||||||
|
});
|
||||||
|
setAliases(tag.aliases || []);
|
||||||
|
} else if (isOpen && !tag) {
|
||||||
|
// New tag
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
color: '',
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
setAliases([]);
|
||||||
|
}
|
||||||
|
setNewAlias('');
|
||||||
|
setErrors({});
|
||||||
|
setDeleteConfirm(false);
|
||||||
|
}, [isOpen, tag]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAlias = async () => {
|
||||||
|
if (!newAlias.trim() || !tag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if alias already exists
|
||||||
|
if (aliases.some(alias => alias.aliasName.toLowerCase() === newAlias.toLowerCase())) {
|
||||||
|
setErrors({ alias: 'This alias already exists for this tag' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create alias via API
|
||||||
|
const newAliasData = await tagApi.addAlias(tag.id, newAlias.trim());
|
||||||
|
setAliases(prev => [...prev, newAliasData]);
|
||||||
|
setNewAlias('');
|
||||||
|
setErrors(prev => ({ ...prev, alias: '' }));
|
||||||
|
} catch (error) {
|
||||||
|
setErrors({ alias: 'Failed to add alias' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAlias = async (aliasId: string) => {
|
||||||
|
if (!tag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tagApi.removeAlias(tag.id, aliasId);
|
||||||
|
setAliases(prev => prev.filter(alias => alias.id !== aliasId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove alias:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setErrors({});
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
color: formData.color || undefined,
|
||||||
|
description: formData.description || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
let savedTag: Tag;
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
// Update existing tag
|
||||||
|
savedTag = await tagApi.updateTag(tag.id, payload);
|
||||||
|
} else {
|
||||||
|
// Create new tag
|
||||||
|
savedTag = await tagApi.createTag(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include aliases in the saved tag
|
||||||
|
savedTag.aliases = aliases;
|
||||||
|
onSave(savedTag);
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrors({ submit: error.message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!tag || !onDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await tagApi.deleteTag(tag.id);
|
||||||
|
onDelete(tag);
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrors({ submit: error.message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b theme-border">
|
||||||
|
<h2 className="text-xl font-semibold theme-header">
|
||||||
|
{tag ? `Edit Tag: "${tag.name}"` : 'Create New Tag'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Tag Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
|
error={errors.name}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Enter tag name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ColorPicker
|
||||||
|
label="Color (Optional)"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(color) => handleInputChange('color', color || '')}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Description (Optional)"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
error={errors.description}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Optional description for this tag"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aliases Section (only for existing tags) */}
|
||||||
|
{tag && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium theme-header">
|
||||||
|
Aliases ({aliases.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{aliases.length > 0 && (
|
||||||
|
<div className="space-y-2 max-h-32 overflow-y-auto border theme-border rounded-lg p-3">
|
||||||
|
{aliases.map((alias) => (
|
||||||
|
<div key={alias.id} className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-sm theme-text">
|
||||||
|
{alias.aliasName}
|
||||||
|
{alias.createdFromMerge && (
|
||||||
|
<span className="ml-2 text-xs theme-text-muted">(from merge)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveAlias(alias.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-xs text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newAlias}
|
||||||
|
onChange={(e) => setNewAlias(e.target.value)}
|
||||||
|
placeholder="Add new alias"
|
||||||
|
error={errors.alias}
|
||||||
|
disabled={loading}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleAddAlias()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleAddAlias}
|
||||||
|
disabled={loading || !newAlias.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story Information (for existing tags) */}
|
||||||
|
{tag && (
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium theme-header mb-2">Usage Statistics</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="theme-text-muted">Stories:</span>
|
||||||
|
<span className="ml-2 font-medium">{tag.storyCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="theme-text-muted">Collections:</span>
|
||||||
|
<span className="ml-2 font-medium">{tag.collectionCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tag.storyCount && tag.storyCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 text-xs"
|
||||||
|
onClick={() => window.open(`/library?tags=${encodeURIComponent(tag.name)}`, '_blank')}
|
||||||
|
>
|
||||||
|
View Stories →
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{errors.submit}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="p-6 border-t theme-border flex justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{tag && onDelete && (
|
||||||
|
<>
|
||||||
|
{!deleteConfirm ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete Tag
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDeleteConfirm(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{loading ? 'Deleting...' : 'Confirm Delete'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || !formData.name.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : (tag ? 'Save Changes' : 'Create Tag')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
frontend/src/components/tags/TagSuggestions.tsx
Normal file
146
frontend/src/components/tags/TagSuggestions.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { tagApi } from '../../lib/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||||
|
|
||||||
|
interface TagSuggestion {
|
||||||
|
tagName: string;
|
||||||
|
confidence: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagSuggestionsProps {
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
summary?: string;
|
||||||
|
currentTags: string[];
|
||||||
|
onAddTag: (tagName: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagSuggestions({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
currentTags,
|
||||||
|
onAddTag,
|
||||||
|
disabled = false
|
||||||
|
}: TagSuggestionsProps) {
|
||||||
|
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastAnalyzed, setLastAnalyzed] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const analyzeContent = async () => {
|
||||||
|
// Only analyze if we have meaningful content and it has changed
|
||||||
|
const contentKey = `${title}|${summary}`;
|
||||||
|
if (!title.trim() || contentKey === lastAnalyzed || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tagSuggestions = await tagApi.suggestTags(title, content, summary, 8);
|
||||||
|
|
||||||
|
// Filter out suggestions that are already selected
|
||||||
|
const filteredSuggestions = tagSuggestions.filter(
|
||||||
|
suggestion => !currentTags.some(tag =>
|
||||||
|
tag.toLowerCase() === suggestion.tagName.toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setSuggestions(filteredSuggestions);
|
||||||
|
setLastAnalyzed(contentKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get tag suggestions:', error);
|
||||||
|
setSuggestions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the analysis
|
||||||
|
const debounce = setTimeout(analyzeContent, 1000);
|
||||||
|
return () => clearTimeout(debounce);
|
||||||
|
}, [title, content, summary, currentTags, lastAnalyzed, disabled]);
|
||||||
|
|
||||||
|
const handleAddTag = (tagName: string) => {
|
||||||
|
onAddTag(tagName);
|
||||||
|
// Remove the added tag from suggestions
|
||||||
|
setSuggestions(prev => prev.filter(s => s.tagName !== tagName));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: number) => {
|
||||||
|
if (confidence >= 0.7) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (confidence >= 0.5) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfidenceLabel = (confidence: number) => {
|
||||||
|
if (confidence >= 0.7) return 'High';
|
||||||
|
if (confidence >= 0.5) return 'Medium';
|
||||||
|
return 'Low';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (disabled || (!title.trim() && !summary?.trim())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<h3 className="text-sm font-medium theme-text">Suggested Tags</h3>
|
||||||
|
{loading && <LoadingSpinner size="sm" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggestions.length === 0 && !loading ? (
|
||||||
|
<p className="text-sm theme-text-muted">
|
||||||
|
{title.trim() ? 'No tag suggestions found for this content' : 'Enter a title to get tag suggestions'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<div
|
||||||
|
key={suggestion.tagName}
|
||||||
|
className="flex items-center justify-between p-3 border theme-border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium theme-text">{suggestion.tagName}</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full border ${getConfidenceColor(suggestion.confidence)}`}>
|
||||||
|
{getConfidenceLabel(suggestion.confidence)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs theme-text-muted mt-1">{suggestion.reason}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddTag(suggestion.tagName)}
|
||||||
|
className="ml-3"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
suggestions.forEach(s => handleAddTag(s.tagName));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add All Suggestions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
frontend/src/components/ui/ColorPicker.tsx
Normal file
171
frontend/src/components/ui/ColorPicker.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
interface ColorPickerProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (color: string | undefined) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-compatible color palette
|
||||||
|
const THEME_COLORS = [
|
||||||
|
// Primary blues
|
||||||
|
{ hex: '#3B82F6', name: 'Theme Blue' },
|
||||||
|
{ hex: '#1D4ED8', name: 'Deep Blue' },
|
||||||
|
{ hex: '#60A5FA', name: 'Light Blue' },
|
||||||
|
|
||||||
|
// Greens
|
||||||
|
{ hex: '#10B981', name: 'Emerald' },
|
||||||
|
{ hex: '#059669', name: 'Forest Green' },
|
||||||
|
{ hex: '#34D399', name: 'Light Green' },
|
||||||
|
|
||||||
|
// Purples
|
||||||
|
{ hex: '#8B5CF6', name: 'Purple' },
|
||||||
|
{ hex: '#7C3AED', name: 'Deep Purple' },
|
||||||
|
{ hex: '#A78BFA', name: 'Light Purple' },
|
||||||
|
|
||||||
|
// Warm tones
|
||||||
|
{ hex: '#F59E0B', name: 'Amber' },
|
||||||
|
{ hex: '#D97706', name: 'Orange' },
|
||||||
|
{ hex: '#F97316', name: 'Bright Orange' },
|
||||||
|
|
||||||
|
// Reds/Pinks
|
||||||
|
{ hex: '#EF4444', name: 'Red' },
|
||||||
|
{ hex: '#F472B6', name: 'Pink' },
|
||||||
|
{ hex: '#EC4899', name: 'Hot Pink' },
|
||||||
|
|
||||||
|
// Neutrals
|
||||||
|
{ hex: '#6B7280', name: 'Gray' },
|
||||||
|
{ hex: '#4B5563', name: 'Dark Gray' },
|
||||||
|
{ hex: '#9CA3AF', name: 'Light Gray' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ColorPicker({ value, onChange, disabled, label }: ColorPickerProps) {
|
||||||
|
const [showCustomPicker, setShowCustomPicker] = useState(false);
|
||||||
|
const [customColor, setCustomColor] = useState(value || '#3B82F6');
|
||||||
|
|
||||||
|
const handleThemeColorSelect = (color: string) => {
|
||||||
|
onChange(color);
|
||||||
|
setShowCustomPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomColorChange = (color: string) => {
|
||||||
|
setCustomColor(color);
|
||||||
|
onChange(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveColor = () => {
|
||||||
|
onChange(undefined);
|
||||||
|
setShowCustomPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium theme-header">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Color Display */}
|
||||||
|
{value && (
|
||||||
|
<div className="flex items-center gap-2 p-2 border theme-border rounded-lg">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
|
||||||
|
style={{ backgroundColor: value }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm theme-text font-mono">{value}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveColor}
|
||||||
|
disabled={disabled}
|
||||||
|
className="ml-auto text-xs"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Color Palette */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium theme-header">Theme Colors</h4>
|
||||||
|
<div className="grid grid-cols-6 gap-2 p-3 border theme-border rounded-lg">
|
||||||
|
{THEME_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.hex}
|
||||||
|
type="button"
|
||||||
|
className={`
|
||||||
|
w-8 h-8 rounded-md border-2 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-theme-accent
|
||||||
|
${value === color.hex ? 'border-gray-800 dark:border-white scale-110' : 'border-gray-300 dark:border-gray-600'}
|
||||||
|
`}
|
||||||
|
style={{ backgroundColor: color.hex }}
|
||||||
|
onClick={() => handleThemeColorSelect(color.hex)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Color Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium theme-header">Custom Color</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCustomPicker(!showCustomPicker)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{showCustomPicker ? 'Hide' : 'Show'} Custom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCustomPicker && (
|
||||||
|
<div className="p-3 border theme-border rounded-lg space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={customColor}
|
||||||
|
onChange={(e) => handleCustomColorChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-12 h-8 rounded border border-gray-300 dark:border-gray-600 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const color = e.target.value;
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||||
|
setCustomColor(color);
|
||||||
|
onChange(color);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 px-3 py-1 text-sm border theme-border rounded font-mono"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange(customColor)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs theme-text-muted">
|
||||||
|
Enter a hex color code or use the color picker
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/ui/LibrarySwitchLoader.tsx
Normal file
106
frontend/src/components/ui/LibrarySwitchLoader.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
|
||||||
|
interface LibrarySwitchLoaderProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
targetLibraryName?: string;
|
||||||
|
onComplete: () => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LibrarySwitchLoader({
|
||||||
|
isVisible,
|
||||||
|
targetLibraryName,
|
||||||
|
onComplete,
|
||||||
|
onError
|
||||||
|
}: LibrarySwitchLoaderProps) {
|
||||||
|
const [dots, setDots] = useState('');
|
||||||
|
const [timeElapsed, setTimeElapsed] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
// Animate dots
|
||||||
|
const dotsInterval = setInterval(() => {
|
||||||
|
setDots(prev => prev.length >= 3 ? '' : prev + '.');
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Track time elapsed
|
||||||
|
const timeInterval = setInterval(() => {
|
||||||
|
setTimeElapsed(prev => prev + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Poll for completion
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/switch/status');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.ready) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
clearInterval(dotsInterval);
|
||||||
|
clearInterval(timeInterval);
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error polling switch status:', error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Timeout after 30 seconds
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
clearInterval(dotsInterval);
|
||||||
|
clearInterval(timeInterval);
|
||||||
|
onError('Library switch timed out. Please try again.');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(dotsInterval);
|
||||||
|
clearInterval(timeInterval);
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [isVisible, onComplete, onError]);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 max-w-sm w-full mx-4 text-center shadow-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold mb-2 text-gray-900 dark:text-white">
|
||||||
|
Switching Libraries
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
{targetLibraryName ?
|
||||||
|
`Loading "${targetLibraryName}"${dots}` :
|
||||||
|
`Preparing your library${dots}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<p>This may take a few seconds...</p>
|
||||||
|
{timeElapsed > 5 && (
|
||||||
|
<p className="mt-2 text-orange-600 dark:text-orange-400">
|
||||||
|
Still working ({timeElapsed}s)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
💡 Libraries are completely separate datasets with their own stories, authors, and settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { authApi, setGlobalAuthFailureHandler } from '../lib/api';
|
import { authApi, setGlobalAuthFailureHandler, setCurrentLibraryId } from '../lib/api';
|
||||||
import { preloadSanitizationConfig } from '../lib/sanitization';
|
import { preloadSanitizationConfig } from '../lib/sanitization';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@@ -34,6 +34,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const authenticated = authApi.isAuthenticated();
|
const authenticated = authApi.isAuthenticated();
|
||||||
setIsAuthenticated(authenticated);
|
setIsAuthenticated(authenticated);
|
||||||
|
|
||||||
|
// If authenticated, also load current library for image URLs
|
||||||
|
if (authenticated) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/current');
|
||||||
|
if (response.ok) {
|
||||||
|
const library = await response.json();
|
||||||
|
setCurrentLibraryId(library.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load current library:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check failed:', error);
|
console.error('Auth check failed:', error);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
@@ -59,6 +72,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await authApi.login(password);
|
await authApi.login(password);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
|
// Load current library after successful login
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/current');
|
||||||
|
if (response.ok) {
|
||||||
|
const library = await response.json();
|
||||||
|
setCurrentLibraryId(library.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load current library after login:', error);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error);
|
console.error('Login failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
29
frontend/src/hooks/useLibraryLayout.ts
Normal file
29
frontend/src/hooks/useLibraryLayout.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type LibraryLayoutType = 'sidebar' | 'toolbar' | 'minimal';
|
||||||
|
|
||||||
|
const LAYOUT_STORAGE_KEY = 'storycove-library-layout';
|
||||||
|
|
||||||
|
export function useLibraryLayout() {
|
||||||
|
const [layout, setLayoutState] = useState<LibraryLayoutType>('sidebar');
|
||||||
|
|
||||||
|
// Load layout from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const savedLayout = localStorage.getItem(LAYOUT_STORAGE_KEY) as LibraryLayoutType;
|
||||||
|
if (savedLayout && ['sidebar', 'toolbar', 'minimal'].includes(savedLayout)) {
|
||||||
|
setLayoutState(savedLayout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save layout to localStorage when it changes
|
||||||
|
const setLayout = (newLayout: LibraryLayoutType) => {
|
||||||
|
setLayoutState(newLayout);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(LAYOUT_STORAGE_KEY, newLayout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { layout, setLayout };
|
||||||
|
}
|
||||||
118
frontend/src/hooks/useLibrarySwitch.ts
Normal file
118
frontend/src/hooks/useLibrarySwitch.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface LibrarySwitchState {
|
||||||
|
isLoading: boolean;
|
||||||
|
targetLibraryName: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LibrarySwitchResult {
|
||||||
|
state: LibrarySwitchState;
|
||||||
|
switchLibrary: (password: string) => Promise<boolean>;
|
||||||
|
clearError: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLibrarySwitch(): LibrarySwitchResult {
|
||||||
|
const [state, setState] = useState<LibrarySwitchState>({
|
||||||
|
isLoading: false,
|
||||||
|
targetLibraryName: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchLibrary = useCallback(async (password: string): Promise<boolean> => {
|
||||||
|
setState({
|
||||||
|
isLoading: true,
|
||||||
|
targetLibraryName: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/libraries/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: data.error || 'Failed to switch library',
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'already_active') {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: data.message,
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'switching') {
|
||||||
|
// Get library name if available
|
||||||
|
try {
|
||||||
|
const librariesResponse = await fetch('/api/libraries');
|
||||||
|
if (librariesResponse.ok) {
|
||||||
|
const libraries = await librariesResponse.json();
|
||||||
|
const targetLibrary = libraries.find((lib: any) => lib.id === data.targetLibrary);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
targetLibraryName: targetLibrary?.name || data.targetLibrary,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Continue without library name
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // Switch initiated successfully
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Unexpected response from server',
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Network error occurred',
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
isLoading: false,
|
||||||
|
targetLibraryName: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
switchLibrary,
|
||||||
|
clearError,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
|
import { AuthResponse, Story, Author, Tag, TagAlias, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||||
|
|
||||||
@@ -152,6 +152,18 @@ export const storyApi = {
|
|||||||
await api.delete(`/stories/${id}/cover`);
|
await api.delete(`/stories/${id}/cover`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
processContentImages: async (id: string, htmlContent: string): Promise<{
|
||||||
|
processedContent: string;
|
||||||
|
warnings?: string[];
|
||||||
|
downloadedImages: string[];
|
||||||
|
hasWarnings: boolean;
|
||||||
|
}> => {
|
||||||
|
const response = await api.post(`/stories/${id}/process-content-images`, {
|
||||||
|
htmlContent
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
addTag: async (storyId: string, tagId: string): Promise<Story> => {
|
addTag: async (storyId: string, tagId: string): Promise<Story> => {
|
||||||
const response = await api.post(`/stories/${storyId}/tags/${tagId}`);
|
const response = await api.post(`/stories/${storyId}/tags/${tagId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -192,6 +204,68 @@ export const storyApi = {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRandomStory: async (filters?: {
|
||||||
|
searchQuery?: string;
|
||||||
|
tags?: string[];
|
||||||
|
minWordCount?: number;
|
||||||
|
maxWordCount?: number;
|
||||||
|
createdAfter?: string;
|
||||||
|
createdBefore?: string;
|
||||||
|
lastReadAfter?: string;
|
||||||
|
lastReadBefore?: string;
|
||||||
|
minRating?: number;
|
||||||
|
maxRating?: number;
|
||||||
|
unratedOnly?: boolean;
|
||||||
|
readingStatus?: string;
|
||||||
|
hasReadingProgress?: boolean;
|
||||||
|
hasCoverImage?: boolean;
|
||||||
|
sourceDomain?: string;
|
||||||
|
seriesFilter?: string;
|
||||||
|
minTagCount?: number;
|
||||||
|
popularOnly?: boolean;
|
||||||
|
hiddenGemsOnly?: boolean;
|
||||||
|
}): Promise<Story | null> => {
|
||||||
|
try {
|
||||||
|
// Create URLSearchParams to properly handle array parameters like tags
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters?.searchQuery) {
|
||||||
|
searchParams.append('searchQuery', filters.searchQuery);
|
||||||
|
}
|
||||||
|
if (filters?.tags && filters.tags.length > 0) {
|
||||||
|
filters.tags.forEach(tag => searchParams.append('tags', tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advanced filters
|
||||||
|
if (filters?.minWordCount !== undefined) searchParams.append('minWordCount', filters.minWordCount.toString());
|
||||||
|
if (filters?.maxWordCount !== undefined) searchParams.append('maxWordCount', filters.maxWordCount.toString());
|
||||||
|
if (filters?.createdAfter) searchParams.append('createdAfter', filters.createdAfter);
|
||||||
|
if (filters?.createdBefore) searchParams.append('createdBefore', filters.createdBefore);
|
||||||
|
if (filters?.lastReadAfter) searchParams.append('lastReadAfter', filters.lastReadAfter);
|
||||||
|
if (filters?.lastReadBefore) searchParams.append('lastReadBefore', filters.lastReadBefore);
|
||||||
|
if (filters?.minRating !== undefined) searchParams.append('minRating', filters.minRating.toString());
|
||||||
|
if (filters?.maxRating !== undefined) searchParams.append('maxRating', filters.maxRating.toString());
|
||||||
|
if (filters?.unratedOnly !== undefined) searchParams.append('unratedOnly', filters.unratedOnly.toString());
|
||||||
|
if (filters?.readingStatus) searchParams.append('readingStatus', filters.readingStatus);
|
||||||
|
if (filters?.hasReadingProgress !== undefined) searchParams.append('hasReadingProgress', filters.hasReadingProgress.toString());
|
||||||
|
if (filters?.hasCoverImage !== undefined) searchParams.append('hasCoverImage', filters.hasCoverImage.toString());
|
||||||
|
if (filters?.sourceDomain) searchParams.append('sourceDomain', filters.sourceDomain);
|
||||||
|
if (filters?.seriesFilter) searchParams.append('seriesFilter', filters.seriesFilter);
|
||||||
|
if (filters?.minTagCount !== undefined) searchParams.append('minTagCount', filters.minTagCount.toString());
|
||||||
|
if (filters?.popularOnly !== undefined) searchParams.append('popularOnly', filters.popularOnly.toString());
|
||||||
|
if (filters?.hiddenGemsOnly !== undefined) searchParams.append('hiddenGemsOnly', filters.hiddenGemsOnly.toString());
|
||||||
|
|
||||||
|
const response = await api.get(`/stories/random?${searchParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 204) {
|
||||||
|
// No content - no stories match filters
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Author endpoints
|
// Author endpoints
|
||||||
@@ -277,6 +351,33 @@ export const tagApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTag: async (id: string): Promise<Tag> => {
|
||||||
|
const response = await api.get(`/tags/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTag: async (tagData: {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<Tag> => {
|
||||||
|
const response = await api.post('/tags', tagData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTag: async (id: string, tagData: {
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<Tag> => {
|
||||||
|
const response = await api.put(`/tags/${id}`, tagData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTag: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/tags/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
getTagAutocomplete: async (query: string): Promise<string[]> => {
|
getTagAutocomplete: async (query: string): Promise<string[]> => {
|
||||||
const response = await api.get('/tags/autocomplete', { params: { query } });
|
const response = await api.get('/tags/autocomplete', { params: { query } });
|
||||||
// Backend returns TagDto[], extract just the names
|
// Backend returns TagDto[], extract just the names
|
||||||
@@ -287,6 +388,76 @@ export const tagApi = {
|
|||||||
const response = await api.get('/tags/collections');
|
const response = await api.get('/tags/collections');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Alias operations
|
||||||
|
addAlias: async (tagId: string, aliasName: string): Promise<TagAlias> => {
|
||||||
|
const response = await api.post(`/tags/${tagId}/aliases`, { aliasName });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAlias: async (tagId: string, aliasId: string): Promise<void> => {
|
||||||
|
await api.delete(`/tags/${tagId}/aliases/${aliasId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveTag: async (name: string): Promise<Tag | null> => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/tags/resolve/${encodeURIComponent(name)}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Batch resolve multiple tag names to their canonical forms
|
||||||
|
resolveTags: async (names: string[]): Promise<string[]> => {
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
names.map(async (name) => {
|
||||||
|
const tag = await tagApi.resolveTag(name);
|
||||||
|
return tag ? tag.name : name; // Return canonical name or original if not found
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return resolved;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Merge operations
|
||||||
|
previewMerge: async (sourceTagIds: string[], targetTagId: string): Promise<{
|
||||||
|
targetTagName: string;
|
||||||
|
targetStoryCount: number;
|
||||||
|
totalResultStoryCount: number;
|
||||||
|
aliasesToCreate: string[];
|
||||||
|
}> => {
|
||||||
|
const response = await api.post('/tags/merge/preview', {
|
||||||
|
sourceTagIds,
|
||||||
|
targetTagId
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeTags: async (sourceTagIds: string[], targetTagId: string): Promise<Tag> => {
|
||||||
|
const response = await api.post('/tags/merge', {
|
||||||
|
sourceTagIds,
|
||||||
|
targetTagId
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tag suggestions
|
||||||
|
suggestTags: async (title: string, content?: string, summary?: string, limit?: number): Promise<{
|
||||||
|
tagName: string;
|
||||||
|
confidence: number;
|
||||||
|
reason: string;
|
||||||
|
}[]> => {
|
||||||
|
const response = await api.post('/tags/suggest', {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
limit: limit || 10
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Series endpoints
|
// Series endpoints
|
||||||
@@ -320,7 +491,35 @@ export const searchApi = {
|
|||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortDir?: string;
|
sortDir?: string;
|
||||||
facetBy?: string[];
|
facetBy?: string[];
|
||||||
|
// Advanced filters
|
||||||
|
minWordCount?: number;
|
||||||
|
maxWordCount?: number;
|
||||||
|
createdAfter?: string;
|
||||||
|
createdBefore?: string;
|
||||||
|
lastReadAfter?: string;
|
||||||
|
lastReadBefore?: string;
|
||||||
|
unratedOnly?: boolean;
|
||||||
|
readingStatus?: string;
|
||||||
|
hasReadingProgress?: boolean;
|
||||||
|
hasCoverImage?: boolean;
|
||||||
|
sourceDomain?: string;
|
||||||
|
seriesFilter?: string;
|
||||||
|
minTagCount?: number;
|
||||||
|
popularOnly?: boolean;
|
||||||
|
hiddenGemsOnly?: boolean;
|
||||||
}): Promise<SearchResult> => {
|
}): Promise<SearchResult> => {
|
||||||
|
// Resolve tag aliases to canonical names for expanded search
|
||||||
|
let resolvedTags = params.tags;
|
||||||
|
if (params.tags && params.tags.length > 0) {
|
||||||
|
try {
|
||||||
|
resolvedTags = await tagApi.resolveTags(params.tags);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resolve tag aliases during search:', error);
|
||||||
|
// Fall back to original tags if resolution fails
|
||||||
|
resolvedTags = params.tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create URLSearchParams to properly handle array parameters
|
// Create URLSearchParams to properly handle array parameters
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
@@ -333,12 +532,29 @@ export const searchApi = {
|
|||||||
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
|
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
|
||||||
if (params.sortDir) searchParams.append('sortDir', params.sortDir);
|
if (params.sortDir) searchParams.append('sortDir', params.sortDir);
|
||||||
|
|
||||||
|
// Advanced filters
|
||||||
|
if (params.minWordCount !== undefined) searchParams.append('minWordCount', params.minWordCount.toString());
|
||||||
|
if (params.maxWordCount !== undefined) searchParams.append('maxWordCount', params.maxWordCount.toString());
|
||||||
|
if (params.createdAfter) searchParams.append('createdAfter', params.createdAfter);
|
||||||
|
if (params.createdBefore) searchParams.append('createdBefore', params.createdBefore);
|
||||||
|
if (params.lastReadAfter) searchParams.append('lastReadAfter', params.lastReadAfter);
|
||||||
|
if (params.lastReadBefore) searchParams.append('lastReadBefore', params.lastReadBefore);
|
||||||
|
if (params.unratedOnly !== undefined) searchParams.append('unratedOnly', params.unratedOnly.toString());
|
||||||
|
if (params.readingStatus) searchParams.append('readingStatus', params.readingStatus);
|
||||||
|
if (params.hasReadingProgress !== undefined) searchParams.append('hasReadingProgress', params.hasReadingProgress.toString());
|
||||||
|
if (params.hasCoverImage !== undefined) searchParams.append('hasCoverImage', params.hasCoverImage.toString());
|
||||||
|
if (params.sourceDomain) searchParams.append('sourceDomain', params.sourceDomain);
|
||||||
|
if (params.seriesFilter) searchParams.append('seriesFilter', params.seriesFilter);
|
||||||
|
if (params.minTagCount !== undefined) searchParams.append('minTagCount', params.minTagCount.toString());
|
||||||
|
if (params.popularOnly !== undefined) searchParams.append('popularOnly', params.popularOnly.toString());
|
||||||
|
if (params.hiddenGemsOnly !== undefined) searchParams.append('hiddenGemsOnly', params.hiddenGemsOnly.toString());
|
||||||
|
|
||||||
// Add array parameters - each element gets its own parameter
|
// Add array parameters - each element gets its own parameter
|
||||||
if (params.authors && params.authors.length > 0) {
|
if (params.authors && params.authors.length > 0) {
|
||||||
params.authors.forEach(author => searchParams.append('authors', author));
|
params.authors.forEach(author => searchParams.append('authors', author));
|
||||||
}
|
}
|
||||||
if (params.tags && params.tags.length > 0) {
|
if (resolvedTags && resolvedTags.length > 0) {
|
||||||
params.tags.forEach(tag => searchParams.append('tags', tag));
|
resolvedTags.forEach(tag => searchParams.append('tags', tag));
|
||||||
}
|
}
|
||||||
if (params.facetBy && params.facetBy.length > 0) {
|
if (params.facetBy && params.facetBy.length > 0) {
|
||||||
params.facetBy.forEach(facet => searchParams.append('facetBy', facet));
|
params.facetBy.forEach(facet => searchParams.append('facetBy', facet));
|
||||||
@@ -543,9 +759,34 @@ export const databaseApi = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Image utility
|
// Library context for images - will be set by a React context provider
|
||||||
|
let currentLibraryId: string | null = null;
|
||||||
|
|
||||||
|
// Set the current library ID (called by library context or components)
|
||||||
|
export const setCurrentLibraryId = (libraryId: string | null): void => {
|
||||||
|
currentLibraryId = libraryId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current library ID synchronously (fallback to 'default')
|
||||||
|
export const getCurrentLibraryId = (): string => {
|
||||||
|
return currentLibraryId || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear library cache when switching libraries
|
||||||
|
export const clearLibraryCache = (): void => {
|
||||||
|
currentLibraryId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image utility - now library-aware
|
||||||
export const getImageUrl = (path: string): string => {
|
export const getImageUrl = (path: string): string => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
// Images are served directly by nginx at /images/
|
|
||||||
return `/images/${path}`;
|
// For compatibility during transition, handle both patterns
|
||||||
|
if (path.startsWith('http')) {
|
||||||
|
return path; // External URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use library-aware API endpoint
|
||||||
|
const libraryId = getCurrentLibraryId();
|
||||||
|
return `/api/files/images/${libraryId}/${path}`;
|
||||||
};
|
};
|
||||||
@@ -72,7 +72,7 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
|||||||
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||||
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code',
|
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code',
|
||||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
|
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'img',
|
||||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
|
||||||
'blockquote', 'cite', 'q', 'hr'
|
'blockquote', 'cite', 'q', 'hr'
|
||||||
],
|
],
|
||||||
@@ -87,6 +87,7 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
|||||||
'h5': ['class', 'style'],
|
'h5': ['class', 'style'],
|
||||||
'h6': ['class', 'style'],
|
'h6': ['class', 'style'],
|
||||||
'a': ['class'],
|
'a': ['class'],
|
||||||
|
'img': ['src', 'alt', 'width', 'height', 'class', 'style'],
|
||||||
'table': ['class'],
|
'table': ['class'],
|
||||||
'td': ['class', 'colspan', 'rowspan'],
|
'td': ['class', 'colspan', 'rowspan'],
|
||||||
'th': ['class', 'colspan', 'rowspan']
|
'th': ['class', 'colspan', 'rowspan']
|
||||||
@@ -99,6 +100,9 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
|||||||
allowedProtocols: {
|
allowedProtocols: {
|
||||||
'a': {
|
'a': {
|
||||||
'href': ['http', 'https', '#', '/']
|
'href': ['http', 'https', '#', '/']
|
||||||
|
},
|
||||||
|
'img': {
|
||||||
|
'src': ['http', 'https', 'data', '/']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
description: 'Fallback sanitization configuration'
|
description: 'Fallback sanitization configuration'
|
||||||
@@ -237,12 +241,12 @@ export function sanitizeHtmlSync(html: string): string {
|
|||||||
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||||
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code', 'kbd', 'samp', 'var',
|
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code', 'kbd', 'samp', 'var',
|
||||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
|
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'img',
|
||||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
|
||||||
'blockquote', 'cite', 'q', 'hr', 'details', 'summary'
|
'blockquote', 'cite', 'q', 'hr', 'details', 'summary'
|
||||||
],
|
],
|
||||||
ALLOWED_ATTR: [
|
ALLOWED_ATTR: [
|
||||||
'class', 'style', 'colspan', 'rowspan'
|
'class', 'style', 'colspan', 'rowspan', 'src', 'alt', 'width', 'height'
|
||||||
],
|
],
|
||||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||||
SANITIZE_DOM: true,
|
SANITIZE_DOM: true,
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ export class StoryScraper {
|
|||||||
if (siteConfig.story.tags) {
|
if (siteConfig.story.tags) {
|
||||||
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
|
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
|
||||||
if (Array.isArray(tagsResult)) {
|
if (Array.isArray(tagsResult)) {
|
||||||
story.tags = tagsResult;
|
// Resolve tag aliases to canonical names
|
||||||
|
story.tags = await this.resolveTagAliases(tagsResult);
|
||||||
} else if (typeof tagsResult === 'string' && tagsResult) {
|
} else if (typeof tagsResult === 'string' && tagsResult) {
|
||||||
story.tags = [tagsResult];
|
// Resolve tag aliases to canonical names
|
||||||
|
story.tags = await this.resolveTagAliases([tagsResult]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,4 +381,21 @@ export class StoryScraper {
|
|||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveTagAliases(tags: string[]): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// Import the tagApi dynamically to avoid circular dependencies
|
||||||
|
const { tagApi } = await import('../api');
|
||||||
|
|
||||||
|
// Resolve all tags to their canonical names
|
||||||
|
const resolvedTags = await tagApi.resolveTags(tags);
|
||||||
|
|
||||||
|
// Filter out empty tags
|
||||||
|
return resolvedTags.filter(tag => tag && tag.trim().length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resolve tag aliases during scraping:', error);
|
||||||
|
// Fall back to original tags if resolution fails
|
||||||
|
return tags.filter(tag => tag && tag.trim().length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,10 @@ export function extractResponsiveImage(
|
|||||||
return { url, width };
|
return { url, width };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return img.attr('src') || '';
|
||||||
|
}
|
||||||
|
|
||||||
const largest = sources.reduce((prev: any, current: any) =>
|
const largest = sources.reduce((prev: any, current: any) =>
|
||||||
prev.width > current.width ? prev : current
|
prev.width > current.width ? prev : current
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ export function extractTextBlocks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to largest block
|
// Fallback to largest block
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
const largestBlock = blocks.reduce((prev, current) =>
|
const largestBlock = blocks.reduce((prev, current) =>
|
||||||
prev.text.length > current.text.length ? prev : current
|
prev.text.length > current.text.length ? prev : current
|
||||||
);
|
);
|
||||||
@@ -86,6 +90,20 @@ export function extractDeviantArtContent(
|
|||||||
$: cheerio.CheerioAPI,
|
$: cheerio.CheerioAPI,
|
||||||
config: TextBlockStrategy
|
config: TextBlockStrategy
|
||||||
): string {
|
): string {
|
||||||
|
// Check for mature content warning or login requirement
|
||||||
|
const matureWarning = $('.deviation-overlay.blocked.mature, .mature-filter, .ismature').first();
|
||||||
|
if (matureWarning.length > 0) {
|
||||||
|
throw new Error('Content is restricted by mature content filter. Login may be required to access this story.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginRequired = $('a[href*="join"][href*="mature"], a[href*="login"]').filter((_, elem) => {
|
||||||
|
const text = $(elem).text().toLowerCase();
|
||||||
|
return text.includes('log in') || text.includes('sign up');
|
||||||
|
});
|
||||||
|
if (loginRequired.length > 0) {
|
||||||
|
throw new Error('Login is required to access this DeviantArt content.');
|
||||||
|
}
|
||||||
|
|
||||||
// Remove excluded elements first
|
// Remove excluded elements first
|
||||||
if (config.excludeSelectors) {
|
if (config.excludeSelectors) {
|
||||||
config.excludeSelectors.forEach(selector => {
|
config.excludeSelectors.forEach(selector => {
|
||||||
@@ -93,9 +111,10 @@ export function extractDeviantArtContent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviantArt has two main content structures:
|
// DeviantArt has multiple content structures:
|
||||||
// 1. Old format: <div class="text"> containing the full story
|
// 1. Old format: <div class="text"> containing the full story
|
||||||
// 2. New format: <div class="_83r8m _2CKTq"> or similar classes containing multiple <p> elements
|
// 2. New format: <div class="_83r8m _2CKTq"> or similar classes containing multiple <p> elements
|
||||||
|
// 3. Legacy journal format: .legacy-journal .text
|
||||||
|
|
||||||
// Try the old format first (single text div)
|
// Try the old format first (single text div)
|
||||||
const textDiv = $('.text');
|
const textDiv = $('.text');
|
||||||
@@ -103,6 +122,12 @@ export function extractDeviantArtContent(
|
|||||||
return textDiv.html() || '';
|
return textDiv.html() || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try legacy journal format
|
||||||
|
const legacyJournal = $('.legacy-journal .text, .legacy-journal .journal-text');
|
||||||
|
if (legacyJournal.length > 0 && legacyJournal.text().trim().length >= (config.minLength || 200)) {
|
||||||
|
return legacyJournal.html() || '';
|
||||||
|
}
|
||||||
|
|
||||||
// Try the new format (multiple paragraphs in specific containers)
|
// Try the new format (multiple paragraphs in specific containers)
|
||||||
const newFormatSelectors = [
|
const newFormatSelectors = [
|
||||||
'div[class*="_83r8m"] p', // Main story content container
|
'div[class*="_83r8m"] p', // Main story content container
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface Story {
|
|||||||
authorId: string;
|
authorId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
contentHtml: string;
|
contentHtml: string;
|
||||||
contentPlain: string;
|
contentPlain?: string; // Optional - only included in reading/detail views
|
||||||
sourceUrl?: string;
|
sourceUrl?: string;
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
seriesId?: string;
|
seriesId?: string;
|
||||||
@@ -43,12 +43,25 @@ export interface AuthorUrl {
|
|||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
color?: string; // hex color like #3B82F6
|
||||||
|
description?: string;
|
||||||
storyCount?: number;
|
storyCount?: number;
|
||||||
collectionCount?: number;
|
collectionCount?: number;
|
||||||
|
aliasCount?: number;
|
||||||
|
aliases?: TagAlias[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagAlias {
|
||||||
|
id: string;
|
||||||
|
aliasName: string;
|
||||||
|
canonicalTagId: string;
|
||||||
|
canonicalTag?: Tag;
|
||||||
|
createdFromMerge: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Series {
|
export interface Series {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -146,4 +159,49 @@ export interface CollectionStatistics {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
storyCount: number;
|
storyCount: number;
|
||||||
}>;
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advanced filter interfaces
|
||||||
|
export interface AdvancedFilters {
|
||||||
|
// Word count filters
|
||||||
|
minWordCount?: number;
|
||||||
|
maxWordCount?: number;
|
||||||
|
|
||||||
|
// Date filters
|
||||||
|
createdAfter?: string; // ISO date string
|
||||||
|
createdBefore?: string; // ISO date string
|
||||||
|
lastReadAfter?: string;
|
||||||
|
lastReadBefore?: string;
|
||||||
|
|
||||||
|
// Rating filters (extending existing)
|
||||||
|
minRating?: number;
|
||||||
|
maxRating?: number;
|
||||||
|
unratedOnly?: boolean;
|
||||||
|
|
||||||
|
// Reading status filters
|
||||||
|
readingStatus?: 'all' | 'unread' | 'started' | 'completed';
|
||||||
|
hasReadingProgress?: boolean;
|
||||||
|
|
||||||
|
// Content filters
|
||||||
|
hasCoverImage?: boolean;
|
||||||
|
sourceDomain?: string;
|
||||||
|
|
||||||
|
// Series filters
|
||||||
|
seriesFilter?: 'all' | 'standalone' | 'series' | 'firstInSeries' | 'lastInSeries';
|
||||||
|
|
||||||
|
// Organization filters
|
||||||
|
minTagCount?: number;
|
||||||
|
|
||||||
|
// Quality filters
|
||||||
|
popularOnly?: boolean; // Stories with above-average ratings
|
||||||
|
hiddenGemsOnly?: boolean; // Unrated or low-rated stories
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preset filter configurations
|
||||||
|
export interface FilterPreset {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
filters: Partial<AdvancedFilters>;
|
||||||
|
category: 'length' | 'date' | 'rating' | 'reading' | 'content' | 'organization';
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@ http {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
client_max_body_size 10M;
|
client_max_body_size 256M;
|
||||||
|
|
||||||
# Frontend routes
|
# Frontend routes
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
2467
package-lock.json
generated
2467
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-code": "^1.0.70",
|
"@anthropic-ai/claude-code": "^1.0.70",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"g": "^2.0.1"
|
"g": "^2.0.1",
|
||||||
|
"npm": "^11.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user