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
|
||||
images/
|
||||
data/
|
||||
data/
|
||||
backend/cookies.txt
|
||||
|
||||
122
README.md
122
README.md
@@ -161,43 +161,75 @@ cd backend
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **[API Documentation](docs/API.md)**: Complete REST API reference with examples
|
||||
- **[Data Model](docs/DATA_MODEL.md)**: Detailed database schema and relationships
|
||||
- **[Technical Specification](storycove-spec.md)**: Comprehensive technical specification
|
||||
- **[Technical Specification](storycove-spec.md)**: Complete technical specification with API documentation, data models, and all feature specifications
|
||||
- **[Web Scraper Specification](storycove-scraper-spec.md)**: URL content grabbing functionality
|
||||
- **Environment Configuration**: Multi-environment deployment setup (see above)
|
||||
- **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
|
||||
|
||||
StoryCove uses a PostgreSQL database with the following core entities:
|
||||
|
||||
### **Stories**
|
||||
- **Primary Key**: UUID
|
||||
- **Fields**: title, summary, description, content_html, content_plain, source_url, word_count, rating, volume, cover_path, reading_position, last_read_at
|
||||
- **Relationships**: Many-to-One with Author, Many-to-One with Series, Many-to-Many with Tags
|
||||
- **Features**: Automatic word count calculation, HTML sanitization, plain text extraction, reading progress tracking
|
||||
- **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, One-to-Many with ReadingPositions
|
||||
- **Features**: Automatic word count calculation, HTML sanitization, plain text extraction, reading progress tracking, duplicate detection
|
||||
|
||||
### **Authors**
|
||||
- **Primary Key**: UUID
|
||||
- **Fields**: name, notes, author_rating, avatar_image_path
|
||||
- **Relationships**: One-to-Many with Stories, One-to-Many with Author URLs
|
||||
- **Features**: URL collection storage, rating system, statistics calculation
|
||||
- **Fields**: name, notes, author_rating, avatar_image_path, created_at, updated_at
|
||||
- **Relationships**: One-to-Many with Stories, One-to-Many with Author URLs (via @ElementCollection)
|
||||
- **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**
|
||||
- **Primary Key**: UUID
|
||||
- **Fields**: name, description
|
||||
- **Fields**: name, description, created_at
|
||||
- **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**
|
||||
- **Primary Key**: UUID
|
||||
- **Fields**: name (unique)
|
||||
- **Relationships**: Many-to-Many with Stories
|
||||
- **Features**: Autocomplete support, usage statistics
|
||||
- **Fields**: name (unique), color (hex), description, created_at
|
||||
- **Relationships**: Many-to-Many with Stories, Many-to-Many with Collections, One-to-Many with TagAliases
|
||||
- **Features**: Color coding, alias system, autocomplete support, usage statistics, AI-powered suggestions
|
||||
|
||||
### **Join Tables**
|
||||
- **story_tags**: Links stories to tags
|
||||
- **author_urls**: Stores multiple URLs per author
|
||||
### **TagAliases**
|
||||
- **Primary Key**: UUID
|
||||
- **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
|
||||
|
||||
@@ -209,6 +241,7 @@ StoryCove uses a PostgreSQL database with the following core entities:
|
||||
### **Stories** (`/api/stories`)
|
||||
- `GET /` - List stories (paginated)
|
||||
- `GET /{id}` - Get specific story
|
||||
- `GET /{id}/read` - Get story for reading interface
|
||||
- `POST /` - Create new story
|
||||
- `PUT /{id}` - Update 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
|
||||
- `DELETE /{id}/tags/{tagId}` - Remove tag from story
|
||||
- `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/suggestions` - Get search suggestions
|
||||
- `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 /recent` - Recent 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`)
|
||||
- `GET /` - List authors (paginated)
|
||||
@@ -244,14 +291,49 @@ StoryCove uses a PostgreSQL database with the following core entities:
|
||||
### **Tags** (`/api/tags`)
|
||||
- `GET /` - List tags (paginated)
|
||||
- `GET /{id}` - Get specific tag
|
||||
- `POST /` - Create new tag
|
||||
- `PUT /{id}` - Update tag
|
||||
- `POST /` - Create new tag (with color and description)
|
||||
- `PUT /{id}` - Update tag (name, color, description)
|
||||
- `DELETE /{id}` - Delete tag
|
||||
- `GET /search` - Search tags
|
||||
- `GET /autocomplete` - Tag autocomplete
|
||||
- `GET /autocomplete` - Tag autocomplete with alias resolution
|
||||
- `GET /popular` - Most used tags
|
||||
- `GET /unused` - Unused tags
|
||||
- `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`)
|
||||
- `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
|
||||
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
# Install Maven
|
||||
RUN apt-get update && apt-get install -y maven && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get install -y maven && \
|
||||
mvn clean package -DskipTests && \
|
||||
apt-get remove -y maven && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN mvn clean package -DskipTests
|
||||
|
||||
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>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<version>3.5.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<testcontainers.version>1.19.3</testcontainers.version>
|
||||
<testcontainers.version>1.21.3</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -56,18 +56,18 @@
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.3</version>
|
||||
<version>0.13.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.3</version>
|
||||
<version>0.13.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.3</version>
|
||||
<version>0.13.0</version>
|
||||
<scope>runtime</scope>
|
||||
</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;
|
||||
|
||||
import com.storycove.service.LibraryService;
|
||||
import com.storycove.service.PasswordAuthenticationService;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -18,18 +19,21 @@ import java.time.Duration;
|
||||
public class AuthController {
|
||||
|
||||
private final PasswordAuthenticationService passwordService;
|
||||
private final LibraryService libraryService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public AuthController(PasswordAuthenticationService passwordService, JwtUtil jwtUtil) {
|
||||
public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil) {
|
||||
this.passwordService = passwordService;
|
||||
this.libraryService = libraryService;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
|
||||
if (passwordService.authenticate(request.getPassword())) {
|
||||
String token = jwtUtil.generateToken();
|
||||
|
||||
// Use new library-aware authentication
|
||||
String token = passwordService.authenticateAndSwitchLibrary(request.getPassword());
|
||||
|
||||
if (token != null) {
|
||||
// Set httpOnly cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("token", token)
|
||||
.httpOnly(true)
|
||||
@@ -40,7 +44,8 @@ public class AuthController {
|
||||
|
||||
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 {
|
||||
return ResponseEntity.status(401).body(new ErrorResponse("Invalid password"));
|
||||
}
|
||||
@@ -48,6 +53,9 @@ public class AuthController {
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
// Clear authentication state
|
||||
libraryService.clearAuthentication();
|
||||
|
||||
// Clear the cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
||||
.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")
|
||||
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.service.ImageService;
|
||||
import com.storycove.service.LibraryService;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -10,6 +11,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -21,9 +23,17 @@ import java.util.Map;
|
||||
public class FileController {
|
||||
|
||||
private final ImageService imageService;
|
||||
private final LibraryService libraryService;
|
||||
|
||||
public FileController(ImageService imageService) {
|
||||
public FileController(ImageService imageService, LibraryService libraryService) {
|
||||
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")
|
||||
@@ -34,7 +44,11 @@ public class FileController {
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("message", "Cover uploaded successfully");
|
||||
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);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -53,7 +67,8 @@ public class FileController {
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("message", "Avatar uploaded successfully");
|
||||
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);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -64,17 +79,18 @@ public class FileController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/images/**")
|
||||
public ResponseEntity<Resource> serveImage(@RequestParam String path) {
|
||||
@GetMapping("/images/{libraryId}/**")
|
||||
public ResponseEntity<Resource> serveImage(@PathVariable String libraryId, HttpServletRequest request) {
|
||||
try {
|
||||
// Extract path from the URL
|
||||
String imagePath = path.replace("/api/files/images/", "");
|
||||
// Extract the full request path after /api/files/images/{libraryId}/
|
||||
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();
|
||||
}
|
||||
|
||||
Path fullPath = imageService.getImagePath(imagePath);
|
||||
Path fullPath = imageService.getImagePathInLibrary(imagePath, libraryId);
|
||||
Resource resource = new FileSystemResource(fullPath);
|
||||
|
||||
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.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
@@ -25,6 +26,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -84,6 +86,46 @@ public class StoryController {
|
||||
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}")
|
||||
public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) {
|
||||
Story story = storyService.findById(id);
|
||||
@@ -186,6 +228,38 @@ public class StoryController {
|
||||
Story story = storyService.updateReadingStatus(id, request.getIsRead());
|
||||
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")
|
||||
public ResponseEntity<String> manualReindex() {
|
||||
@@ -251,12 +325,32 @@ public class StoryController {
|
||||
@RequestParam(required = false) Integer minRating,
|
||||
@RequestParam(required = false) Integer maxRating,
|
||||
@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) {
|
||||
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);
|
||||
} else {
|
||||
// Fallback to basic search if Typesense is not available
|
||||
@@ -396,14 +490,19 @@ public class StoryController {
|
||||
story.setDescription(updateReq.getDescription());
|
||||
}
|
||||
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) {
|
||||
story.setSourceUrl(updateReq.getSourceUrl());
|
||||
}
|
||||
if (updateReq.getVolume() != null) {
|
||||
story.setVolume(updateReq.getVolume());
|
||||
}
|
||||
// Volume will be handled in series logic below
|
||||
// Handle author - either by ID or by name
|
||||
if (updateReq.getAuthorId() != null) {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
@@ -412,13 +511,34 @@ public class StoryController {
|
||||
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
||||
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) {
|
||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||
story.setSeries(series);
|
||||
} else if (updateReq.getSeriesName() != null && !updateReq.getSeriesName().trim().isEmpty()) {
|
||||
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
||||
story.setSeries(series);
|
||||
} else if (updateReq.getSeriesName() != null) {
|
||||
logger.info("Processing series update: seriesName='{}', isEmpty={}", updateReq.getSeriesName(), updateReq.getSeriesName().trim().isEmpty());
|
||||
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()
|
||||
@@ -540,8 +660,11 @@ public class StoryController {
|
||||
TagDto tagDto = new TagDto();
|
||||
tagDto.setId(tag.getId());
|
||||
tagDto.setName(tag.getName());
|
||||
tagDto.setColor(tag.getColor());
|
||||
tagDto.setDescription(tag.getDescription());
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.TagDto;
|
||||
import com.storycove.dto.TagAliasDto;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.service.TagService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -21,6 +25,7 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/tags")
|
||||
public class TagController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TagController.class);
|
||||
private final TagService tagService;
|
||||
|
||||
public TagController(TagService tagService) {
|
||||
@@ -54,6 +59,8 @@ public class TagController {
|
||||
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
|
||||
Tag tag = new Tag();
|
||||
tag.setName(request.getName());
|
||||
tag.setColor(request.getColor());
|
||||
tag.setDescription(request.getDescription());
|
||||
|
||||
Tag savedTag = tagService.create(tag);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
|
||||
@@ -66,6 +73,12 @@ public class TagController {
|
||||
if (request.getName() != null) {
|
||||
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);
|
||||
return ResponseEntity.ok(convertToDto(updatedTag));
|
||||
@@ -95,7 +108,7 @@ public class TagController {
|
||||
@RequestParam String query,
|
||||
@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());
|
||||
|
||||
return ResponseEntity.ok(tagDtos);
|
||||
@@ -142,15 +155,124 @@ public class TagController {
|
||||
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) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setColor(tag.getColor());
|
||||
dto.setDescription(tag.getDescription());
|
||||
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
||||
dto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||
dto.setCreatedAt(tag.getCreatedAt());
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -168,15 +290,112 @@ public class TagController {
|
||||
// Request DTOs
|
||||
public static class CreateTagRequest {
|
||||
private String name;
|
||||
private String color;
|
||||
private String description;
|
||||
|
||||
public String getName() { return 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 {
|
||||
private String name;
|
||||
private String color;
|
||||
private String description;
|
||||
|
||||
public String getName() { return 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 String title;
|
||||
private String description;
|
||||
private String contentPlain;
|
||||
private String sourceUrl;
|
||||
private String coverPath;
|
||||
private Integer wordCount;
|
||||
@@ -65,13 +64,6 @@ public class StorySearchDto {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getContentPlain() {
|
||||
return contentPlain;
|
||||
}
|
||||
|
||||
public void setContentPlain(String contentPlain) {
|
||||
this.contentPlain = contentPlain;
|
||||
}
|
||||
|
||||
public String getSourceUrl() {
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TagDto {
|
||||
@@ -14,8 +15,16 @@ public class TagDto {
|
||||
@Size(max = 100, message = "Tag name must not exceed 100 characters")
|
||||
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 collectionCount;
|
||||
private Integer aliasCount;
|
||||
private List<TagAliasDto> aliases;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -42,6 +51,22 @@ public class TagDto {
|
||||
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() {
|
||||
return storyCount;
|
||||
}
|
||||
@@ -58,6 +83,22 @@ public class TagDto {
|
||||
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() {
|
||||
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 org.hibernate.annotations.CreationTimestamp;
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
@@ -24,6 +25,14 @@ public class Tag {
|
||||
@Column(nullable = false, unique = true)
|
||||
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")
|
||||
@JsonBackReference("story-tags")
|
||||
@@ -33,6 +42,10 @@ public class Tag {
|
||||
@JsonBackReference("collection-tags")
|
||||
private Set<Collection> collections = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "canonicalTag", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JsonManagedReference("tag-aliases")
|
||||
private Set<TagAlias> aliases = new HashSet<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -43,6 +56,12 @@ public class Tag {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Tag(String name, String color, String description) {
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Getters and Setters
|
||||
@@ -62,6 +81,22 @@ public class Tag {
|
||||
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() {
|
||||
return stories;
|
||||
@@ -79,6 +114,14 @@ public class Tag {
|
||||
this.collections = collections;
|
||||
}
|
||||
|
||||
public Set<TagAlias> getAliases() {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
public void setAliases(Set<TagAlias> aliases) {
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
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.Pageable;
|
||||
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.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.storycove.repository;
|
||||
|
||||
import com.storycove.entity.Collection;
|
||||
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.repository.query.Param;
|
||||
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.Pageable;
|
||||
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.repository.query.Param;
|
||||
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)")
|
||||
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> findByNameIgnoreCase(String name);
|
||||
|
||||
boolean existsByName(String name);
|
||||
|
||||
boolean existsByNameIgnoreCase(String name);
|
||||
|
||||
List<Tag> findByNameContainingIgnoreCase(String name);
|
||||
|
||||
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.storycove.security;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -28,13 +29,27 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String token = null;
|
||||
|
||||
// First try to get token from Authorization header
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
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)) {
|
||||
String subject = jwtUtil.getSubjectFromToken(token);
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ public class AuthorService {
|
||||
rating, author.getName(), author.getAuthorRating());
|
||||
|
||||
author.setAuthorRating(rating);
|
||||
Author savedAuthor = authorRepository.save(author);
|
||||
authorRepository.save(author);
|
||||
|
||||
// Flush and refresh to ensure the entity is up-to-date
|
||||
authorRepository.flush();
|
||||
|
||||
@@ -11,14 +11,10 @@ import com.storycove.repository.CollectionRepository;
|
||||
import com.storycove.repository.CollectionStoryRepository;
|
||||
import com.storycove.repository.StoryRepository;
|
||||
import com.storycove.repository.TagRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.transaction.annotation.Transactional;
|
||||
|
||||
@@ -266,7 +262,7 @@ public class CollectionService {
|
||||
*/
|
||||
@Transactional
|
||||
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:
|
||||
// Phase 1: Set all positions to negative values (temporary)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.storycove.entity.*;
|
||||
import com.storycove.repository.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
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.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,10 +25,16 @@ import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@Service
|
||||
public class DatabaseManagementService {
|
||||
public class DatabaseManagementService implements ApplicationContextAware {
|
||||
|
||||
@Autowired
|
||||
@Qualifier("dataSource") // Use the primary routing datasource
|
||||
private DataSource dataSource;
|
||||
|
||||
// Use the routing datasource which automatically handles library switching
|
||||
private DataSource getDataSource() {
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private StoryRepository storyRepository;
|
||||
@@ -45,12 +53,22 @@ public class DatabaseManagementService {
|
||||
|
||||
@Autowired
|
||||
private TypesenseService typesenseService;
|
||||
|
||||
@Autowired
|
||||
private LibraryService libraryService;
|
||||
|
||||
@Autowired
|
||||
private ReadingPositionRepository readingPositionRepository;
|
||||
|
||||
@Value("${storycove.images.upload-dir:/app/images}")
|
||||
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
|
||||
@@ -80,7 +98,12 @@ public class DatabaseManagementService {
|
||||
* Restore from complete backup (ZIP format)
|
||||
*/
|
||||
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");
|
||||
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.");
|
||||
}
|
||||
|
||||
// 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.");
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -139,7 +173,7 @@ public class DatabaseManagementService {
|
||||
public Resource createBackup() throws SQLException, IOException {
|
||||
StringBuilder sqlDump = new StringBuilder();
|
||||
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
try (Connection connection = getDataSource().getConnection()) {
|
||||
// Add header
|
||||
sqlDump.append("-- StoryCove Database Backup\n");
|
||||
sqlDump.append("-- Generated at: ").append(new java.util.Date()).append("\n\n");
|
||||
@@ -225,10 +259,13 @@ public class DatabaseManagementService {
|
||||
}
|
||||
|
||||
// Execute the SQL statements
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
try (Connection connection = getDataSource().getConnection()) {
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
// Ensure database schema exists before restoring data
|
||||
ensureDatabaseSchemaExists(connection);
|
||||
|
||||
// Parse SQL statements properly (handle semicolons inside string literals)
|
||||
List<String> statements = parseStatements(sqlContent.toString());
|
||||
|
||||
@@ -261,11 +298,19 @@ public class DatabaseManagementService {
|
||||
|
||||
// Reindex search after successful restore
|
||||
try {
|
||||
System.err.println("Starting Typesense reindex after successful restore...");
|
||||
typesenseService.recreateStoriesCollection();
|
||||
typesenseService.recreateAuthorsCollection();
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
System.err.println("Starting Typesense reindex after successful restore for library: " + currentLibraryId);
|
||||
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
|
||||
System.err.println("Typesense reindex completed successfully.");
|
||||
System.err.println("Typesense reindex completed successfully for library: " + currentLibraryId);
|
||||
} catch (Exception e) {
|
||||
// Log the error but don't fail the restore
|
||||
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() {
|
||||
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)) {
|
||||
try {
|
||||
@@ -431,6 +480,7 @@ public class DatabaseManagementService {
|
||||
.forEach(filePath -> {
|
||||
try {
|
||||
Files.deleteIfExists(filePath);
|
||||
System.err.println("Deleted file: " + filePath);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Warning: Failed to delete file: " + filePath + " - " + e.getMessage());
|
||||
}
|
||||
@@ -438,19 +488,28 @@ public class DatabaseManagementService {
|
||||
} catch (IOException e) {
|
||||
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() {
|
||||
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.recreateAuthorsCollection();
|
||||
// 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) {
|
||||
// Log the error but don't fail the clear operation
|
||||
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
|
||||
*/
|
||||
@@ -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 {
|
||||
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)) {
|
||||
System.err.println("Library image directory does not exist, skipping file backup: " + imagesPath);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -499,6 +776,7 @@ public class DatabaseManagementService {
|
||||
zipOut.putNextEntry(entry);
|
||||
Files.copy(filePath, zipOut);
|
||||
zipOut.closeEntry();
|
||||
System.err.println("Added file to backup: " + zipEntryName);
|
||||
} catch (IOException 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("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
|
||||
Map<String, Object> stats = new HashMap<>();
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
try (Connection connection = getDataSource().getConnection()) {
|
||||
stats.put("stories", getTableCount(connection, "stories"));
|
||||
stats.put("authors", getTableCount(connection, "authors"));
|
||||
stats.put("collections", getTableCount(connection, "collections"));
|
||||
@@ -526,8 +814,9 @@ public class DatabaseManagementService {
|
||||
}
|
||||
metadata.put("statistics", stats);
|
||||
|
||||
// Count files
|
||||
Path imagesPath = Paths.get(uploadDir);
|
||||
// Count files for current library
|
||||
String libraryImagePath = libraryService.getCurrentImagePath();
|
||||
Path imagesPath = Paths.get(uploadDir + libraryImagePath);
|
||||
int fileCount = 0;
|
||||
if (Files.exists(imagesPath)) {
|
||||
fileCount = (int) Files.walk(imagesPath).filter(Files::isRegularFile).count();
|
||||
@@ -587,6 +876,7 @@ public class DatabaseManagementService {
|
||||
// Validate metadata
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> metadata = mapper.readValue(Files.newInputStream(metadataFile), Map.class);
|
||||
|
||||
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 {
|
||||
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.walk(filesDir)
|
||||
@@ -620,6 +914,7 @@ public class DatabaseManagementService {
|
||||
|
||||
Files.createDirectories(targetFile.getParent());
|
||||
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
System.err.println("Restored file: " + relativePath + " to " + targetFile);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to restore file: " + sourceFile, e);
|
||||
}
|
||||
@@ -655,4 +950,169 @@ public class DatabaseManagementService {
|
||||
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 java.io.ByteArrayOutputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@@ -26,8 +26,6 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
|
||||
@@ -54,7 +54,7 @@ public class HtmlSanitizationService {
|
||||
"p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
||||
"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",
|
||||
"blockquote", "cite", "q", "hr"
|
||||
));
|
||||
@@ -65,13 +65,13 @@ public class HtmlSanitizationService {
|
||||
}
|
||||
|
||||
private void createSafelist() {
|
||||
this.allowlist = new Safelist();
|
||||
|
||||
this.allowlist = Safelist.relaxed();
|
||||
|
||||
// Add allowed tags
|
||||
if (config.getAllowedTags() != null) {
|
||||
config.getAllowedTags().forEach(allowlist::addTags);
|
||||
}
|
||||
|
||||
|
||||
// Add allowed attributes
|
||||
if (config.getAllowedAttributes() != null) {
|
||||
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) {
|
||||
for (Map.Entry<String, Map<String, List<String>>> tagEntry : config.getAllowedProtocols().entrySet()) {
|
||||
String tag = tagEntry.getKey();
|
||||
Map<String, List<String>> attributeProtocols = tagEntry.getValue();
|
||||
|
||||
|
||||
if (attributeProtocols != null) {
|
||||
for (Map.Entry<String, List<String>> attrEntry : attributeProtocols.entrySet()) {
|
||||
String attribute = attrEntry.getKey();
|
||||
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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Configured Jsoup Safelist with preserveRelativeLinks=true for local image URLs");
|
||||
|
||||
// Remove specific attributes if needed (deprecated in favor of protocol control)
|
||||
if (config.getRemovedAttributes() != null) {
|
||||
@@ -133,8 +141,10 @@ public class HtmlSanitizationService {
|
||||
if (html == null || html.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Jsoup.clean(html, allowlist);
|
||||
logger.info("Content before sanitization: "+html);
|
||||
String saniztedHtml = Jsoup.clean(html, allowlist.preserveRelativeLinks(true));
|
||||
logger.info("Content after sanitization: "+saniztedHtml);
|
||||
return saniztedHtml;
|
||||
}
|
||||
|
||||
public String extractPlainText(String html) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -7,18 +10,22 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
public class ImageService {
|
||||
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
|
||||
|
||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||
"image/jpeg", "image/jpg", "image/png"
|
||||
);
|
||||
@@ -28,7 +35,15 @@ public class ImageService {
|
||||
);
|
||||
|
||||
@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}")
|
||||
private int coverMaxWidth;
|
||||
@@ -44,14 +59,15 @@ public class ImageService {
|
||||
|
||||
public enum ImageType {
|
||||
COVER("covers"),
|
||||
AVATAR("avatars");
|
||||
|
||||
AVATAR("avatars"),
|
||||
CONTENT("content");
|
||||
|
||||
private final String directory;
|
||||
|
||||
|
||||
ImageType(String directory) {
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
|
||||
public String getDirectory() {
|
||||
return directory;
|
||||
}
|
||||
@@ -61,7 +77,7 @@ public class ImageService {
|
||||
validateFile(file);
|
||||
|
||||
// Create directories if they don't exist
|
||||
Path typeDir = Paths.get(uploadDir, imageType.getDirectory());
|
||||
Path typeDir = Paths.get(getUploadDir(), imageType.getDirectory());
|
||||
Files.createDirectories(typeDir);
|
||||
|
||||
// Generate unique filename
|
||||
@@ -88,7 +104,7 @@ public class ImageService {
|
||||
}
|
||||
|
||||
try {
|
||||
Path fullPath = Paths.get(uploadDir, imagePath);
|
||||
Path fullPath = Paths.get(getUploadDir(), imagePath);
|
||||
return Files.deleteIfExists(fullPath);
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
@@ -96,7 +112,7 @@ public class ImageService {
|
||||
}
|
||||
|
||||
public Path getImagePath(String imagePath) {
|
||||
return Paths.get(uploadDir, imagePath);
|
||||
return Paths.get(getUploadDir(), imagePath);
|
||||
}
|
||||
|
||||
public boolean imageExists(String imagePath) {
|
||||
@@ -107,6 +123,19 @@ public class ImageService {
|
||||
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 {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File is empty");
|
||||
@@ -160,6 +189,9 @@ public class ImageService {
|
||||
maxWidth = avatarMaxSize;
|
||||
maxHeight = avatarMaxSize;
|
||||
break;
|
||||
case CONTENT:
|
||||
// Content images are not resized
|
||||
return new Dimension(originalWidth, originalHeight);
|
||||
default:
|
||||
return new Dimension(originalWidth, originalHeight);
|
||||
}
|
||||
@@ -206,4 +238,224 @@ public class ImageService {
|
||||
String extension = getFileExtension(filename);
|
||||
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;
|
||||
|
||||
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.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class PasswordAuthenticationService {
|
||||
|
||||
@Value("${storycove.auth.password}")
|
||||
private String applicationPassword;
|
||||
private static final Logger logger = LoggerFactory.getLogger(PasswordAuthenticationService.class);
|
||||
|
||||
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.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()) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// If application password starts with {bcrypt}, it's already encoded
|
||||
if (applicationPassword.startsWith("{bcrypt}") || applicationPassword.startsWith("$2")) {
|
||||
return passwordEncoder.matches(providedPassword, applicationPassword);
|
||||
// Find which library this password belongs to
|
||||
String libraryId = libraryService.authenticateAndGetLibrary(providedPassword);
|
||||
if (libraryId == null) {
|
||||
logger.warn("Authentication failed - invalid password");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise, compare directly (for development/testing)
|
||||
return applicationPassword.equals(providedPassword);
|
||||
try {
|
||||
// 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) {
|
||||
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.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -20,6 +22,8 @@ import java.util.UUID;
|
||||
@Validated
|
||||
@Transactional
|
||||
public class SeriesService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SeriesService.class);
|
||||
|
||||
private final SeriesRepository seriesRepository;
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ import com.storycove.repository.TagRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,11 +26,14 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Validated
|
||||
@Transactional
|
||||
public class StoryService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StoryService.class);
|
||||
|
||||
private final StoryRepository storyRepository;
|
||||
private final TagRepository tagRepository;
|
||||
@@ -79,11 +83,13 @@ public class StoryService {
|
||||
return storyRepository.findById(id)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Story> findByIdOptional(UUID id) {
|
||||
return storyRepository.findById(id);
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Story> findByTitle(String title) {
|
||||
@@ -119,7 +125,7 @@ public class StoryService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Story> findBySeries(UUID seriesId) {
|
||||
Series series = seriesService.findById(seriesId);
|
||||
seriesService.findById(seriesId); // Validate series exists
|
||||
return storyRepository.findBySeriesOrderByVolume(seriesId);
|
||||
}
|
||||
|
||||
@@ -615,9 +621,24 @@ public class StoryService {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
story.setAuthor(author);
|
||||
}
|
||||
// Handle series - either by ID or by name
|
||||
if (updateReq.getSeriesId() != null) {
|
||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.repository.TagRepository;
|
||||
import com.storycove.repository.TagAliasRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -12,20 +17,27 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Validated
|
||||
@Transactional
|
||||
public class TagService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TagService.class);
|
||||
|
||||
private final TagRepository tagRepository;
|
||||
private final TagAliasRepository tagAliasRepository;
|
||||
|
||||
@Autowired
|
||||
public TagService(TagRepository tagRepository) {
|
||||
public TagService(TagRepository tagRepository, TagAliasRepository tagAliasRepository) {
|
||||
this.tagRepository = tagRepository;
|
||||
this.tagAliasRepository = tagAliasRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -207,5 +219,273 @@ public class TagService {
|
||||
if (updates.getName() != null) {
|
||||
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.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import javax.crypto.SecretKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
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;
|
||||
|
||||
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default
|
||||
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() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||
}
|
||||
|
||||
public String generateToken() {
|
||||
return generateToken("user", null);
|
||||
}
|
||||
|
||||
public String generateToken(String subject, String libraryId) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + expiration);
|
||||
|
||||
return Jwts.builder()
|
||||
.subject("user")
|
||||
var builder = Jwts.builder()
|
||||
.subject(subject)
|
||||
.issuedAt(now)
|
||||
.expiration(expiryDate)
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
.expiration(expiryDate);
|
||||
|
||||
// Add library context if provided
|
||||
if (libraryId != null) {
|
||||
builder.claim("libraryId", libraryId);
|
||||
}
|
||||
|
||||
return builder.signWith(getSigningKey()).compact();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
@@ -62,4 +91,13 @@ public class JwtUtil {
|
||||
public String getSubjectFromToken(String token) {
|
||||
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:
|
||||
multipart:
|
||||
max-file-size: 10MB # Reduced for security (was 250MB)
|
||||
max-request-size: 15MB # Slightly higher to account for form data
|
||||
max-file-size: 256MB # Increased for backup restore
|
||||
max-request-size: 260MB # Slightly higher to account for form data
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins",
|
||||
"sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var",
|
||||
"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"
|
||||
],
|
||||
"allowedAttributes": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"h5": ["class", "style"],
|
||||
"h6": ["class", "style"],
|
||||
"a": ["class", "href", "title"],
|
||||
"img": ["src", "alt", "width", "height", "class", "style"],
|
||||
"table": ["class", "style"],
|
||||
"th": ["class", "style", "colspan", "rowspan"],
|
||||
"td": ["class", "style", "colspan", "rowspan"],
|
||||
@@ -41,6 +42,9 @@
|
||||
"allowedProtocols": {
|
||||
"a": {
|
||||
"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."
|
||||
|
||||
@@ -15,10 +15,12 @@ public abstract class BaseRepositoryTest {
|
||||
private static final PostgreSQLContainer<?> postgres;
|
||||
|
||||
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")
|
||||
.withUsername("test")
|
||||
.withPassword("test");
|
||||
postgres = container;
|
||||
postgres.start();
|
||||
|
||||
// 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.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -23,7 +22,6 @@ import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
||||
@@ -46,7 +44,7 @@ class AuthorServiceTest {
|
||||
testAuthor.setId(testId);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -176,7 +174,7 @@ class AuthorServiceTest {
|
||||
when(authorRepository.existsByName("Updated Author")).thenReturn(false);
|
||||
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 notes", testAuthor.getNotes());
|
||||
@@ -318,7 +316,7 @@ class AuthorServiceTest {
|
||||
when(authorRepository.findById(testId)).thenReturn(Optional.of(testAuthor));
|
||||
when(authorRepository.save(any(Author.class))).thenReturn(testAuthor);
|
||||
|
||||
Author result = authorService.setRating(testId, 4);
|
||||
authorService.setRating(testId, 4);
|
||||
|
||||
assertEquals(4, testAuthor.getAuthorRating());
|
||||
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.save(any(Author.class))).thenReturn(testAuthor);
|
||||
|
||||
Author result = authorService.setRating(testId, null);
|
||||
authorService.setRating(testId, null);
|
||||
|
||||
assertNull(testAuthor.getAuthorRating());
|
||||
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}
|
||||
volumes:
|
||||
- images_data:/app/images
|
||||
- library_config:/app/config
|
||||
depends_on:
|
||||
- postgres
|
||||
- typesense
|
||||
@@ -51,6 +52,8 @@ services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
# No port mapping - only accessible within the Docker network
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_DB=storycove
|
||||
- POSTGRES_USER=storycove
|
||||
@@ -61,7 +64,7 @@ services:
|
||||
- storycove-network
|
||||
|
||||
typesense:
|
||||
image: typesense/typesense:0.25.0
|
||||
image: typesense/typesense:29.0
|
||||
# No port mapping - only accessible within the Docker network
|
||||
environment:
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
|
||||
@@ -75,6 +78,7 @@ volumes:
|
||||
postgres_data:
|
||||
typesense_data:
|
||||
images_data:
|
||||
library_config:
|
||||
|
||||
configs:
|
||||
nginx_config:
|
||||
@@ -91,7 +95,7 @@ configs:
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
client_max_body_size 10M;
|
||||
client_max_body_size 256M;
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -1,40 +1,58 @@
|
||||
# Use node 18 alpine for smaller image size
|
||||
FROM node:18-alpine
|
||||
|
||||
# Multi-stage build for better layer caching and smaller final image
|
||||
FROM node:18-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
# Install dumb-init early
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Copy package files
|
||||
# Copy package files first to leverage Docker layer caching
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies needed for build)
|
||||
# Set npm config for better CI performance
|
||||
RUN npm ci --prefer-offline --no-audit
|
||||
# Install dependencies with optimized settings
|
||||
RUN npm ci --prefer-offline --no-audit --frozen-lockfile
|
||||
|
||||
# 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 . .
|
||||
|
||||
# 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 NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Remove devDependencies after build to reduce image size
|
||||
RUN npm prune --omit=dev
|
||||
# Production stage
|
||||
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
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nextjs -u 1001
|
||||
|
||||
# Change ownership of the app directory
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
# Copy necessary files from builder stage
|
||||
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
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Use dumb-init to handle signals properly
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,5 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Enable standalone output for optimized Docker builds
|
||||
output: 'standalone',
|
||||
// Removed Next.js rewrites since nginx handles all API routing
|
||||
webpack: (config, { isServer }) => {
|
||||
// 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": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.6.0",
|
||||
"axios": "^1.11.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dompurify": "^3.0.5",
|
||||
"dompurify": "^3.2.6",
|
||||
"next": "14.0.0",
|
||||
"postcss": "^8.4.31",
|
||||
"react": "^18",
|
||||
@@ -1372,13 +1372,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.6.0",
|
||||
"axios": "^1.11.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dompurify": "^3.0.5",
|
||||
"dompurify": "^3.2.6",
|
||||
"next": "14.0.0",
|
||||
"postcss": "^8.4.31",
|
||||
"react": "^18",
|
||||
|
||||
@@ -1,39 +1,554 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
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 searchParams = useSearchParams();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// Handle URL parameters
|
||||
useEffect(() => {
|
||||
// Redirect to the new /import route while preserving query parameters
|
||||
const mode = searchParams.get('mode');
|
||||
const authorId = searchParams.get('authorId');
|
||||
const from = searchParams.get('from');
|
||||
|
||||
let redirectUrl = '/import';
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (mode) queryParams.set('mode', mode);
|
||||
if (authorId) queryParams.set('authorId', authorId);
|
||||
if (from) queryParams.set('from', from);
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
if (queryString) {
|
||||
redirectUrl += '?' + queryString;
|
||||
// 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();
|
||||
}
|
||||
|
||||
router.replace(redirectUrl);
|
||||
}, [router, searchParams]);
|
||||
// Handle URL import data
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Redirecting...</p>
|
||||
</div>
|
||||
</div>
|
||||
<ImportLayout
|
||||
title="Add New Story"
|
||||
description="Add a story to your personal collection"
|
||||
>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{stories.length} {stories.length === 1 ? 'story' : 'stories'}
|
||||
</p>
|
||||
<Button href={`/import?authorId=${authorId}`}>
|
||||
<Button href={`/add-story?authorId=${authorId}`}>
|
||||
Add Story
|
||||
</Button>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ export default function AuthorDetailPage() {
|
||||
{stories.length === 0 ? (
|
||||
<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>
|
||||
<Button href="/import">Add a Story</Button>
|
||||
<Button href="/add-story">Add a Story</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function AuthorsPage() {
|
||||
const [authors, setAuthors] = useState<Author[]>([]);
|
||||
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuthors = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
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);
|
||||
const debounceTimer = setTimeout(() => {
|
||||
const loadAuthors = async () => {
|
||||
try {
|
||||
// Use searchLoading for background search, loading only for initial load
|
||||
const isInitialLoad = authors.length === 0 && !searchQuery && currentPage === 0;
|
||||
if (isInitialLoad) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setSearchLoading(true);
|
||||
}
|
||||
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]);
|
||||
|
||||
// Reset pagination when search or sort changes
|
||||
@@ -133,13 +145,18 @@ export default function AuthorsPage() {
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<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
|
||||
type="search"
|
||||
placeholder="Search authors..."
|
||||
value={searchQuery}
|
||||
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 className="flex gap-2">
|
||||
|
||||
@@ -85,13 +85,28 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.reading-content h1,
|
||||
.reading-content h2,
|
||||
.reading-content h3,
|
||||
.reading-content h4,
|
||||
.reading-content h5,
|
||||
.reading-content h1 {
|
||||
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@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 {
|
||||
@@ -118,4 +133,107 @@
|
||||
.reading-content em {
|
||||
@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) {
|
||||
// For combine mode, redirect to import page with the combined content
|
||||
localStorage.setItem('pendingStory', JSON.stringify(data.combinedStory));
|
||||
router.push('/import?from=bulk-combine');
|
||||
router.push('/add-story?from=bulk-combine');
|
||||
return;
|
||||
} else if (data.results && data.summary) {
|
||||
// For individual mode, show the results
|
||||
|
||||
@@ -1,188 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 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() {
|
||||
const [importMode, setImportMode] = useState<'manual' | 'url'>('manual');
|
||||
export default function ImportFromUrlPage() {
|
||||
const [importUrl, setImportUrl] = useState('');
|
||||
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 [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 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 () => {
|
||||
if (!importUrl.trim()) {
|
||||
@@ -209,25 +38,18 @@ export default function AddStoryPage() {
|
||||
|
||||
const scrapedStory = await response.json();
|
||||
|
||||
// Pre-fill the form with scraped data
|
||||
setFormData({
|
||||
// Redirect to add-story page with pre-filled data
|
||||
const queryParams = new URLSearchParams({
|
||||
from: 'url-import',
|
||||
title: scrapedStory.title || '',
|
||||
summary: scrapedStory.summary || '',
|
||||
authorName: scrapedStory.author || '',
|
||||
authorId: undefined, // Reset author ID when importing from URL (likely new author)
|
||||
contentHtml: scrapedStory.content || '',
|
||||
author: scrapedStory.author || '',
|
||||
sourceUrl: scrapedStory.sourceUrl || importUrl,
|
||||
tags: scrapedStory.tags || [],
|
||||
seriesName: '',
|
||||
volume: '',
|
||||
tags: JSON.stringify(scrapedStory.tags || []),
|
||||
content: scrapedStory.content || ''
|
||||
});
|
||||
|
||||
// Switch to manual mode so user can edit the pre-filled data
|
||||
setImportMode('manual');
|
||||
setImportUrl('');
|
||||
|
||||
// Show success message
|
||||
setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' });
|
||||
router.push(`/add-story?${queryParams.toString()}`);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to import story:', error);
|
||||
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 (
|
||||
<ImportLayout
|
||||
title="Add New Story"
|
||||
description="Add a story to your personal collection"
|
||||
title="Import Story from URL"
|
||||
description="Import a single story from a website"
|
||||
>
|
||||
{/* URL Import Section */}
|
||||
{importMode === 'url' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium theme-header mb-4">Import Story from URL</h3>
|
||||
<p className="theme-text text-sm mb-4">
|
||||
Enter a URL from a supported story site to automatically extract the story content, title, author, and other metadata.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium theme-header mb-4">Import Story from URL</h3>
|
||||
<p className="theme-text text-sm mb-4">
|
||||
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>
|
||||
|
||||
<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">
|
||||
<Input
|
||||
label="Story URL"
|
||||
type="url"
|
||||
value={importUrl}
|
||||
onChange={(e) => setImportUrl(e.target.value)}
|
||||
placeholder="https://example.com/story-url"
|
||||
error={errors.importUrl}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImportFromUrl}
|
||||
loading={scraping}
|
||||
disabled={!importUrl.trim() || scraping}
|
||||
>
|
||||
{scraping ? 'Importing...' : 'Import Story'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
href="/add-story"
|
||||
disabled={scraping}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImportFromUrl}
|
||||
loading={scraping}
|
||||
disabled={!importUrl.trim() || scraping}
|
||||
>
|
||||
{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>
|
||||
>
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</ImportLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { searchApi } from '../../lib/api';
|
||||
import { Story, Tag, FacetCount } from '../../types/api';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
||||
import { Story, Tag, FacetCount, AdvancedFilters } from '../../types/api';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
import { Input } from '../../components/ui/Input';
|
||||
import Button from '../../components/ui/Button';
|
||||
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
|
||||
import TagFilter from '../../components/stories/TagFilter';
|
||||
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 SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
|
||||
|
||||
export default function LibraryPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { layout } = useLibraryLayout();
|
||||
const [stories, setStories] = useState<Story[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [randomLoading, setRandomLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
@@ -27,29 +36,101 @@ export default function LibraryPage() {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalElements, setTotalElements] = 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
|
||||
// Convert facet counts to Tag objects for the UI, enriched with full tag data
|
||||
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[] => {
|
||||
if (!facets || !facets.tagNames) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return facets.tagNames.map(facet => ({
|
||||
id: facet.value, // Use tag name as ID since we don't have actual IDs from search results
|
||||
name: facet.value,
|
||||
storyCount: facet.count
|
||||
}));
|
||||
return facets.tagNames.map(facet => {
|
||||
// Find the full tag data by name
|
||||
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
// Don't run search until URL parameters have been processed
|
||||
if (!urlParamsProcessed) return;
|
||||
|
||||
const debounceTimer = setTimeout(() => {
|
||||
const performSearch = async () => {
|
||||
try {
|
||||
// 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) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
@@ -57,7 +138,7 @@ export default function LibraryPage() {
|
||||
}
|
||||
|
||||
// Always use search API for consistency - use '*' for match-all when no query
|
||||
const result = await searchApi.search({
|
||||
const apiParams = {
|
||||
query: searchQuery.trim() || '*',
|
||||
page: page, // Use 0-based pagination consistently
|
||||
size: 20,
|
||||
@@ -65,7 +146,12 @@ export default function LibraryPage() {
|
||||
sortBy: sortOption,
|
||||
sortDir: sortDirection,
|
||||
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 || [];
|
||||
setStories(currentStories);
|
||||
@@ -75,67 +161,80 @@ export default function LibraryPage() {
|
||||
// Update tags from facets - these represent all matching stories, not just current page
|
||||
const resultTags = convertFacetsToTags(result?.facets);
|
||||
setTags(resultTags);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load stories:', error);
|
||||
setStories([]);
|
||||
setTags([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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);
|
||||
}, [searchQuery, selectedTags, page, sortOption, sortDirection, refreshTrigger]);
|
||||
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
resetPage();
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortOption: SortOption) => {
|
||||
setSortOption(newSortOption);
|
||||
// Set appropriate default direction for the sort option
|
||||
if (newSortOption === 'title' || newSortOption === 'authorName') {
|
||||
setSortDirection('asc'); // Alphabetical fields default to ascending
|
||||
} else {
|
||||
setSortDirection('desc'); // Numeric/date fields default to descending
|
||||
const handleStoryUpdate = () => {
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleRandomStory = async () => {
|
||||
if (totalElements === 0) return;
|
||||
|
||||
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 = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedTags([]);
|
||||
resetPage();
|
||||
setAdvancedFilters({});
|
||||
setPage(0);
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleStoryUpdate = () => {
|
||||
// Trigger reload by incrementing refresh trigger
|
||||
const handleTagToggle = (tagName: string) => {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -149,148 +248,62 @@ export default function LibraryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
|
||||
<p className="theme-text mt-1">
|
||||
{totalElements} {totalElements === 1 ? 'story' : 'stories'}
|
||||
{searchQuery || selectedTags.length > 0 ? ` found` : ` total`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button href="/import">
|
||||
Add New Story
|
||||
const handleSortChange = (option: string) => {
|
||||
setSortOption(option as SortOption);
|
||||
};
|
||||
|
||||
const layoutProps = {
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
sortOption,
|
||||
sortDirection,
|
||||
advancedFilters,
|
||||
onSearchChange: handleSearchChange,
|
||||
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 href="/import/epub" variant="secondary">
|
||||
📖 Import EPUB
|
||||
) : (
|
||||
<Button href="/add-story">
|
||||
Add Your First Story
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
type="search"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StoryMultiSelect
|
||||
stories={stories}
|
||||
viewMode={viewMode}
|
||||
onUpdate={handleStoryUpdate}
|
||||
allowMultiSelect={true}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
@@ -315,7 +328,19 @@ export default function LibraryPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutComponent = layout === 'sidebar' ? SidebarLayout :
|
||||
layout === 'toolbar' ? ToolbarLayout :
|
||||
MinimalLayout;
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<LayoutComponent {...layoutProps}>
|
||||
{renderContent()}
|
||||
</LayoutComponent>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import AppLayout from '../../components/layout/AppLayout';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
import Button from '../../components/ui/Button';
|
||||
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 FontSize = 'small' | 'medium' | 'large' | 'extra-large';
|
||||
@@ -28,6 +30,7 @@ const defaultSettings: Settings = {
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { layout, setLayout } = useLibraryLayout();
|
||||
const [settings, setSettings] = useState<Settings>(defaultSettings);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [typesenseStatus, setTypesenseStatus] = useState<{
|
||||
@@ -350,6 +353,60 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -718,6 +775,24 @@ export default function SettingsPage() {
|
||||
</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 */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<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 Button from '../../../../components/ui/Button';
|
||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
import TagDisplay from '../../../../components/tags/TagDisplay';
|
||||
import TableOfContents from '../../../../components/stories/TableOfContents';
|
||||
import { calculateReadingTime } from '../../../../lib/settings';
|
||||
|
||||
export default function StoryDetailPage() {
|
||||
@@ -365,18 +367,27 @@ export default function StoryDetailPage() {
|
||||
</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 */}
|
||||
{story.tags && story.tags.length > 0 && (
|
||||
<div className="theme-card theme-shadow rounded-lg p-4">
|
||||
<h3 className="font-semibold theme-header mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="md"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,11 @@ import AppLayout from '../../../../components/layout/AppLayout';
|
||||
import { Input, Textarea } from '../../../../components/ui/Input';
|
||||
import Button from '../../../../components/ui/Button';
|
||||
import TagInput from '../../../../components/stories/TagInput';
|
||||
import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
||||
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 LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
import { storyApi } from '../../../../lib/api';
|
||||
import { Story } from '../../../../types/api';
|
||||
@@ -21,6 +23,7 @@ export default function EditStoryPage() {
|
||||
const [story, setStory] = useState<Story | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetingPosition, setResetingPosition] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -32,6 +35,7 @@ export default function EditStoryPage() {
|
||||
sourceUrl: '',
|
||||
tags: [] as string[],
|
||||
seriesName: '',
|
||||
seriesId: undefined as string | undefined,
|
||||
volume: '',
|
||||
});
|
||||
|
||||
@@ -54,6 +58,7 @@ export default function EditStoryPage() {
|
||||
sourceUrl: storyData.sourceUrl || '',
|
||||
tags: storyData.tags?.map(tag => tag.name) || [],
|
||||
seriesName: storyData.seriesName || '',
|
||||
seriesId: storyData.seriesId,
|
||||
volume: storyData.volume?.toString() || '',
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -94,6 +99,15 @@ export default function EditStoryPage() {
|
||||
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) => {
|
||||
setFormData(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 newErrors: Record<string, string> = {};
|
||||
|
||||
@@ -150,8 +177,9 @@ export default function EditStoryPage() {
|
||||
summary: formData.summary || undefined,
|
||||
contentHtml: formData.contentHtml,
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
seriesName: formData.seriesName || undefined,
|
||||
volume: formData.seriesName && formData.volume ? parseInt(formData.volume) : 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)
|
||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||
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 () => {
|
||||
if (!story || !confirm('Are you sure you want to delete this story? This action cannot be undone.')) {
|
||||
return;
|
||||
@@ -288,6 +342,8 @@ export default function EditStoryPage() {
|
||||
onChange={handleContentChange}
|
||||
placeholder="Edit your story content here..."
|
||||
error={errors.contentHtml}
|
||||
storyId={storyId}
|
||||
enableImageProcessing={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -301,17 +357,28 @@ export default function EditStoryPage() {
|
||||
onChange={handleTagsChange}
|
||||
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>
|
||||
|
||||
{/* Series and Volume */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
<SeriesSelector
|
||||
label="Series (optional)"
|
||||
value={formData.seriesName}
|
||||
onChange={handleInputChange('seriesName')}
|
||||
placeholder="Enter series name if part of a series"
|
||||
onChange={handleSeriesChange}
|
||||
placeholder="Select or enter series name if part of a series"
|
||||
error={errors.seriesName}
|
||||
authorId={formData.authorId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -336,6 +403,38 @@ export default function EditStoryPage() {
|
||||
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 */}
|
||||
{errors.submit && (
|
||||
<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 Button from '../../../components/ui/Button';
|
||||
import StoryRating from '../../../components/stories/StoryRating';
|
||||
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||
import TableOfContents from '../../../components/stories/TableOfContents';
|
||||
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
||||
|
||||
export default function StoryReadingPage() {
|
||||
@@ -20,6 +22,11 @@ export default function StoryReadingPage() {
|
||||
const [readingProgress, setReadingProgress] = useState(0);
|
||||
const [sanitizedContent, setSanitizedContent] = useState<string>('');
|
||||
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 saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -111,9 +118,32 @@ export default function StoryReadingPage() {
|
||||
|
||||
setStory(storyData);
|
||||
|
||||
// Sanitize story content
|
||||
// Sanitize story content and add IDs to headings
|
||||
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
|
||||
if (storyData.seriesId) {
|
||||
@@ -133,12 +163,29 @@ export default function StoryReadingPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (story && sanitizedContent && !hasScrolledToPosition) {
|
||||
// Use a small delay to ensure content is rendered
|
||||
const timeout = setTimeout(() => {
|
||||
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) {
|
||||
console.log('Auto-scrolling to saved position:', story.readingPosition);
|
||||
scrollToCharacterPosition(story.readingPosition);
|
||||
@@ -162,13 +209,41 @@ export default function StoryReadingPage() {
|
||||
const articleTop = article.offsetTop;
|
||||
const articleHeight = article.scrollHeight;
|
||||
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
|
||||
));
|
||||
|
||||
|
||||
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)
|
||||
if (hasScrolledToPosition) { // Only save after initial auto-scroll
|
||||
const characterPosition = getCharacterPositionFromScroll();
|
||||
@@ -188,11 +263,11 @@ export default function StoryReadingPage() {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition]);
|
||||
}, [story, hasScrolledToPosition, getCharacterPositionFromScroll, debouncedSavePosition, hasReachedEnd]);
|
||||
|
||||
const handleRatingUpdate = async (newRating: number) => {
|
||||
if (!story) return;
|
||||
|
||||
|
||||
try {
|
||||
await storyApi.updateRating(story.id, newRating);
|
||||
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 => {
|
||||
if (!story?.seriesId || seriesStories.length <= 1) return null;
|
||||
@@ -265,6 +359,16 @@ export default function StoryReadingPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
rating={story.rating || 0}
|
||||
onRatingChange={handleRatingUpdate}
|
||||
@@ -279,6 +383,76 @@ export default function StoryReadingPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||
<article data-reading-content>
|
||||
@@ -314,12 +488,12 @@ export default function StoryReadingPage() {
|
||||
{story.tags && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-3 py-1 text-sm theme-accent-bg text-white rounded-full"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="md"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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 { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
@@ -239,7 +239,7 @@ export default function CollectionForm({
|
||||
{(coverImagePreview || initialData?.coverImagePath) && (
|
||||
<div className="w-20 h-24 rounded overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={coverImagePreview || (initialData?.coverImagePath ? `/images/${initialData.coverImagePath}` : '')}
|
||||
src={coverImagePreview || (initialData?.coverImagePath ? getImageUrl(initialData.coverImagePath) : '')}
|
||||
alt="Cover preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { StoryWithCollectionContext } from '../../types/api';
|
||||
import { storyApi } from '../../lib/api';
|
||||
import { storyApi, getImageUrl } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface CollectionReadingViewProps {
|
||||
@@ -211,7 +212,7 @@ export default function CollectionReadingView({
|
||||
{story.coverPath && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={`/images/${story.coverPath}`}
|
||||
src={getImageUrl(story.coverPath)}
|
||||
alt={`${story.title} cover`}
|
||||
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 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,12 +17,12 @@ export default function Header() {
|
||||
|
||||
const addStoryItems = [
|
||||
{
|
||||
href: '/import',
|
||||
href: '/add-story',
|
||||
label: 'Manual Entry',
|
||||
description: 'Add a story by manually entering details'
|
||||
},
|
||||
{
|
||||
href: '/import?mode=url',
|
||||
href: '/import',
|
||||
label: 'Import from URL',
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
@@ -156,34 +156,16 @@ export default function Header() {
|
||||
<div className="px-2 py-1">
|
||||
<div className="font-medium theme-text mb-1">Add Story</div>
|
||||
<div className="pl-4 space-y-1">
|
||||
<Link
|
||||
href="/import"
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Manual Entry
|
||||
</Link>
|
||||
<Link
|
||||
href="/import?mode=url"
|
||||
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>
|
||||
{addStoryItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
|
||||
@@ -22,13 +22,13 @@ const importTabs: ImportTab[] = [
|
||||
{
|
||||
id: 'manual',
|
||||
label: 'Manual Entry',
|
||||
href: '/import',
|
||||
href: '/add-story',
|
||||
description: 'Add a story by manually entering details'
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
label: 'Import from URL',
|
||||
href: '/import?mode=url',
|
||||
href: '/import',
|
||||
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
|
||||
const getActiveTab = () => {
|
||||
if (pathname === '/import') {
|
||||
return mode === 'url' ? 'url' : 'manual';
|
||||
if (pathname === '/add-story') {
|
||||
return 'manual';
|
||||
} else if (pathname === '/import') {
|
||||
return 'url';
|
||||
} else if (pathname === '/import/epub') {
|
||||
return 'epub';
|
||||
} 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';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Textarea } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||
import { storyApi } from '../../lib/api';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
storyId?: string; // Optional - for image processing (undefined for new stories)
|
||||
enableImageProcessing?: boolean; // Enable background image processing
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Write your story here...',
|
||||
error
|
||||
error,
|
||||
storyId,
|
||||
enableImageProcessing = false
|
||||
}: RichTextEditorProps) {
|
||||
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||
const [htmlValue, setHtmlValue] = useState(value);
|
||||
@@ -28,6 +33,12 @@ export default function RichTextEditor({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
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
|
||||
const saveCursorPosition = () => {
|
||||
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
|
||||
const toggleMaximize = () => {
|
||||
if (!isMaximized) {
|
||||
@@ -74,6 +161,108 @@ export default function RichTextEditor({
|
||||
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
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (isMaximized) return; // Don't allow resize when maximized
|
||||
@@ -97,16 +286,43 @@ export default function RichTextEditor({
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Escape key handler for maximized mode
|
||||
// Keyboard shortcuts handler
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Escape key to exit maximized mode
|
||||
if (e.key === 'Escape' && isMaximized) {
|
||||
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) {
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
// Prevent body from scrolling when maximized
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
@@ -114,10 +330,19 @@ export default function RichTextEditor({
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -159,6 +384,8 @@ export default function RichTextEditor({
|
||||
if (newHtml !== value) {
|
||||
onChange(newHtml);
|
||||
setHtmlValue(newHtml);
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(newHtml);
|
||||
}
|
||||
|
||||
// Reset typing state after a short delay
|
||||
@@ -232,13 +459,38 @@ export default function RichTextEditor({
|
||||
if (htmlContent && htmlContent.trim().length > 0) {
|
||||
console.log('Processing HTML content...');
|
||||
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 preview:', sanitizedHtml.substring(0, 500));
|
||||
|
||||
|
||||
// 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));
|
||||
if (ratio < 0.1) {
|
||||
console.warn('Sanitization removed >90% of content - this might be too aggressive');
|
||||
@@ -279,9 +531,12 @@ export default function RichTextEditor({
|
||||
|
||||
// Update the state
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
const newContent = visualDiv.innerHTML;
|
||||
onChange(newContent);
|
||||
setHtmlValue(newContent);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
|
||||
// Note: Image processing already completed during paste, no need to trigger again
|
||||
} else if (textarea) {
|
||||
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
|
||||
const start = textarea.selectionStart;
|
||||
@@ -368,6 +623,9 @@ export default function RichTextEditor({
|
||||
const html = e.target.value;
|
||||
setHtmlValue(html);
|
||||
onChange(html);
|
||||
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(html);
|
||||
};
|
||||
|
||||
const getPlainText = (html: string): string => {
|
||||
@@ -380,97 +638,6 @@ export default function RichTextEditor({
|
||||
.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 (
|
||||
<div className="space-y-2">
|
||||
@@ -498,6 +665,24 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -514,7 +699,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('strong')}
|
||||
title="Bold"
|
||||
title="Bold (Ctrl+B)"
|
||||
className="font-bold"
|
||||
>
|
||||
B
|
||||
@@ -524,7 +709,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('em')}
|
||||
title="Italic"
|
||||
title="Italic (Ctrl+I)"
|
||||
className="italic"
|
||||
>
|
||||
I
|
||||
@@ -535,7 +720,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h1')}
|
||||
title="Heading 1"
|
||||
title="Heading 1 (Ctrl+Shift+1)"
|
||||
className="text-lg font-bold"
|
||||
>
|
||||
H1
|
||||
@@ -545,7 +730,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h2')}
|
||||
title="Heading 2"
|
||||
title="Heading 2 (Ctrl+Shift+2)"
|
||||
className="text-base font-bold"
|
||||
>
|
||||
H2
|
||||
@@ -555,11 +740,41 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h3')}
|
||||
title="Heading 3"
|
||||
title="Heading 3 (Ctrl+Shift+3)"
|
||||
className="text-sm font-bold"
|
||||
>
|
||||
H3
|
||||
</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" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -609,6 +824,24 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -625,7 +858,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('strong')}
|
||||
title="Bold"
|
||||
title="Bold (Ctrl+B)"
|
||||
className="font-bold"
|
||||
>
|
||||
B
|
||||
@@ -635,7 +868,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('em')}
|
||||
title="Italic"
|
||||
title="Italic (Ctrl+I)"
|
||||
className="italic"
|
||||
>
|
||||
I
|
||||
@@ -646,7 +879,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h1')}
|
||||
title="Heading 1"
|
||||
title="Heading 1 (Ctrl+Shift+1)"
|
||||
className="text-lg font-bold"
|
||||
>
|
||||
H1
|
||||
@@ -656,7 +889,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h2')}
|
||||
title="Heading 2"
|
||||
title="Heading 2 (Ctrl+Shift+2)"
|
||||
className="text-base font-bold"
|
||||
>
|
||||
H2
|
||||
@@ -666,11 +899,41 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h3')}
|
||||
title="Heading 3"
|
||||
title="Heading 3 (Ctrl+Shift+3)"
|
||||
className="text-sm font-bold"
|
||||
>
|
||||
H3
|
||||
</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" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -694,7 +957,7 @@ export default function RichTextEditor({
|
||||
contentEditable
|
||||
onInput={handleVisualContentChange}
|
||||
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}
|
||||
/>
|
||||
{!value && (
|
||||
@@ -732,7 +995,7 @@ export default function RichTextEditor({
|
||||
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
||||
<div
|
||||
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 }}
|
||||
/>
|
||||
</div>
|
||||
@@ -751,6 +1014,9 @@ export default function RichTextEditor({
|
||||
<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.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
|
||||
</p>
|
||||
<p>
|
||||
<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.
|
||||
|
||||
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 { storyApi, getImageUrl } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
|
||||
interface StoryCardProps {
|
||||
story: Story;
|
||||
@@ -27,7 +28,11 @@ export default function StoryCard({
|
||||
const [rating, setRating] = useState(story.rating || 0);
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -106,12 +111,12 @@ export default function StoryCard({
|
||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{story.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
{story.tags.length > 3 && (
|
||||
<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) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
onClick={(e) => handleRatingClick(e, star)}
|
||||
className={`text-lg ${
|
||||
star <= rating
|
||||
? 'text-yellow-400'
|
||||
@@ -207,7 +212,7 @@ export default function StoryCard({
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => handleRatingClick(star)}
|
||||
onClick={(e) => handleRatingClick(e, star)}
|
||||
className={`text-sm ${
|
||||
star <= rating
|
||||
? 'text-yellow-400'
|
||||
@@ -237,12 +242,12 @@ export default function StoryCard({
|
||||
{Array.isArray(story.tags) && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{story.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-2 py-1 text-xs rounded theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
{story.tags.length > 2 && (
|
||||
<span className="px-2 py-1 text-xs theme-text">
|
||||
|
||||
@@ -101,7 +101,10 @@ export default function StoryMultiSelect({
|
||||
<input
|
||||
type="checkbox"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,7 +115,6 @@ export default function StoryMultiSelect({
|
||||
className={`transition-all duration-200 ${
|
||||
selectedStoryIds.includes(story.id) ? 'ring-2 ring-blue-500 ring-opacity-50' : ''
|
||||
}`}
|
||||
onDoubleClick={() => allowMultiSelect && handleStorySelect(story.id)}
|
||||
>
|
||||
<StoryCard
|
||||
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 { tagApi } from '../../lib/api';
|
||||
import { Tag } from '../../types/api';
|
||||
|
||||
interface TagInputProps {
|
||||
tags: string[];
|
||||
@@ -9,25 +10,178 @@ interface TagInputProps {
|
||||
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) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<{name: string, similarity: number}[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1);
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(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(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
if (inputValue.length > 0) {
|
||||
try {
|
||||
const suggestionList = await tagApi.getTagAutocomplete(inputValue);
|
||||
// Filter out already selected tags
|
||||
const filteredSuggestions = suggestionList.filter(
|
||||
suggestion => !tags.includes(suggestion)
|
||||
);
|
||||
setSuggestions(filteredSuggestions);
|
||||
setShowSuggestions(filteredSuggestions.length > 0);
|
||||
// First try backend autocomplete for exact/prefix matches
|
||||
const backendSuggestions = await tagApi.getTagAutocomplete(inputValue);
|
||||
|
||||
// Apply fuzzy matching to all tags for better results
|
||||
const fuzzyMatches = allTags
|
||||
.map(tag => ({
|
||||
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) {
|
||||
console.error('Failed to fetch tag suggestions:', error);
|
||||
setSuggestions([]);
|
||||
@@ -41,13 +195,29 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
|
||||
const debounce = setTimeout(fetchSuggestions, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [inputValue, tags]);
|
||||
}, [inputValue, tags, allTags]);
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const addTag = async (tag: string) => {
|
||||
const trimmedTag = tag.trim().toLowerCase();
|
||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
||||
onChange([...tags, trimmedTag]);
|
||||
if (!trimmedTag) return;
|
||||
|
||||
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('');
|
||||
setShowSuggestions(false);
|
||||
setActiveSuggestionIndex(-1);
|
||||
@@ -63,7 +233,7 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
if (activeSuggestionIndex >= 0 && suggestions[activeSuggestionIndex]) {
|
||||
addTag(suggestions[activeSuggestionIndex]);
|
||||
addTag(suggestions[activeSuggestionIndex].name);
|
||||
} else if (inputValue.trim()) {
|
||||
addTag(inputValue);
|
||||
}
|
||||
@@ -94,8 +264,8 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
addTag(suggestion);
|
||||
const handleSuggestionClick = (suggestionName: string) => {
|
||||
addTag(suggestionName);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
@@ -143,9 +313,9 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
key={suggestion.name}
|
||||
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 ${
|
||||
index === activeSuggestionIndex
|
||||
? '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' : ''
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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 { useRouter } from 'next/navigation';
|
||||
import { authApi, setGlobalAuthFailureHandler } from '../lib/api';
|
||||
import { authApi, setGlobalAuthFailureHandler, setCurrentLibraryId } from '../lib/api';
|
||||
import { preloadSanitizationConfig } from '../lib/sanitization';
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -34,6 +34,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
const authenticated = authApi.isAuthenticated();
|
||||
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) {
|
||||
console.error('Auth check failed:', error);
|
||||
setIsAuthenticated(false);
|
||||
@@ -59,6 +72,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
await authApi.login(password);
|
||||
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) {
|
||||
console.error('Login failed:', 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 { 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';
|
||||
|
||||
@@ -152,6 +152,18 @@ export const storyApi = {
|
||||
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> => {
|
||||
const response = await api.post(`/stories/${storyId}/tags/${tagId}`);
|
||||
return response.data;
|
||||
@@ -192,6 +204,68 @@ export const storyApi = {
|
||||
});
|
||||
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
|
||||
@@ -277,6 +351,33 @@ export const tagApi = {
|
||||
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[]> => {
|
||||
const response = await api.get('/tags/autocomplete', { params: { query } });
|
||||
// Backend returns TagDto[], extract just the names
|
||||
@@ -287,6 +388,76 @@ export const tagApi = {
|
||||
const response = await api.get('/tags/collections');
|
||||
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
|
||||
@@ -320,7 +491,35 @@ export const searchApi = {
|
||||
sortBy?: string;
|
||||
sortDir?: 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> => {
|
||||
// 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
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -333,12 +532,29 @@ export const searchApi = {
|
||||
if (params.sortBy) searchParams.append('sortBy', params.sortBy);
|
||||
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
|
||||
if (params.authors && params.authors.length > 0) {
|
||||
params.authors.forEach(author => searchParams.append('authors', author));
|
||||
}
|
||||
if (params.tags && params.tags.length > 0) {
|
||||
params.tags.forEach(tag => searchParams.append('tags', tag));
|
||||
if (resolvedTags && resolvedTags.length > 0) {
|
||||
resolvedTags.forEach(tag => searchParams.append('tags', tag));
|
||||
}
|
||||
if (params.facetBy && params.facetBy.length > 0) {
|
||||
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 => {
|
||||
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',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||
'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',
|
||||
'blockquote', 'cite', 'q', 'hr'
|
||||
],
|
||||
@@ -87,6 +87,7 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
'h5': ['class', 'style'],
|
||||
'h6': ['class', 'style'],
|
||||
'a': ['class'],
|
||||
'img': ['src', 'alt', 'width', 'height', 'class', 'style'],
|
||||
'table': ['class'],
|
||||
'td': ['class', 'colspan', 'rowspan'],
|
||||
'th': ['class', 'colspan', 'rowspan']
|
||||
@@ -99,6 +100,9 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
allowedProtocols: {
|
||||
'a': {
|
||||
'href': ['http', 'https', '#', '/']
|
||||
},
|
||||
'img': {
|
||||
'src': ['http', 'https', 'data', '/']
|
||||
}
|
||||
},
|
||||
description: 'Fallback sanitization configuration'
|
||||
@@ -237,12 +241,12 @@ export function sanitizeHtmlSync(html: string): string {
|
||||
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||
'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',
|
||||
'blockquote', 'cite', 'q', 'hr', 'details', 'summary'
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'class', 'style', 'colspan', 'rowspan'
|
||||
'class', 'style', 'colspan', 'rowspan', 'src', 'alt', 'width', 'height'
|
||||
],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
SANITIZE_DOM: true,
|
||||
|
||||
@@ -82,9 +82,11 @@ export class StoryScraper {
|
||||
if (siteConfig.story.tags) {
|
||||
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
|
||||
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) {
|
||||
story.tags = [tagsResult];
|
||||
// Resolve tag aliases to canonical names
|
||||
story.tags = await this.resolveTagAliases([tagsResult]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,4 +381,21 @@ export class StoryScraper {
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
if (sources.length === 0) {
|
||||
return img.attr('src') || '';
|
||||
}
|
||||
|
||||
const largest = sources.reduce((prev: any, current: any) =>
|
||||
prev.width > current.width ? prev : current
|
||||
);
|
||||
|
||||
@@ -75,6 +75,10 @@ export function extractTextBlocks(
|
||||
}
|
||||
|
||||
// Fallback to largest block
|
||||
if (blocks.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const largestBlock = blocks.reduce((prev, current) =>
|
||||
prev.text.length > current.text.length ? prev : current
|
||||
);
|
||||
@@ -86,6 +90,20 @@ export function extractDeviantArtContent(
|
||||
$: cheerio.CheerioAPI,
|
||||
config: TextBlockStrategy
|
||||
): 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
|
||||
if (config.excludeSelectors) {
|
||||
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
|
||||
// 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)
|
||||
const textDiv = $('.text');
|
||||
@@ -103,6 +122,12 @@ export function extractDeviantArtContent(
|
||||
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)
|
||||
const newFormatSelectors = [
|
||||
'div[class*="_83r8m"] p', // Main story content container
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface Story {
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
contentHtml: string;
|
||||
contentPlain: string;
|
||||
contentPlain?: string; // Optional - only included in reading/detail views
|
||||
sourceUrl?: string;
|
||||
wordCount: number;
|
||||
seriesId?: string;
|
||||
@@ -43,12 +43,25 @@ export interface AuthorUrl {
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string; // hex color like #3B82F6
|
||||
description?: string;
|
||||
storyCount?: number;
|
||||
collectionCount?: number;
|
||||
aliasCount?: number;
|
||||
aliases?: TagAlias[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TagAlias {
|
||||
id: string;
|
||||
aliasName: string;
|
||||
canonicalTagId: string;
|
||||
canonicalTag?: Tag;
|
||||
createdFromMerge: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -146,4 +159,49 @@ export interface CollectionStatistics {
|
||||
authorName: string;
|
||||
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 {
|
||||
listen 80;
|
||||
client_max_body_size 10M;
|
||||
client_max_body_size 256M;
|
||||
|
||||
# Frontend routes
|
||||
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": {
|
||||
"@anthropic-ai/claude-code": "^1.0.70",
|
||||
"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