25 Commits

Author SHA1 Message Date
Stefan Hardegger
f92dcc5314 Advanced Filters - Build optimizations 2025-09-04 15:49:24 +02:00
Stefan Hardegger
702fcb33c1 Improvements to Editor 2025-09-02 09:28:06 +02:00
Stefan Hardegger
11b2a8b071 revert postgres version 2025-09-01 16:19:14 +02:00
Stefan Hardegger
d1289bd616 Security Updates and random improvement. 2025-09-01 16:02:19 +02:00
Stefan Hardegger
15708b5ab2 Table of Content functionality 2025-08-22 09:03:21 +02:00
Stefan Hardegger
a660056003 Various improvements 2025-08-21 13:55:38 +02:00
Stefan Hardegger
35a5825e76 Fix cover images display 2025-08-21 12:38:48 +02:00
Stefan Hardegger
87a4999ffe Fixing Database switching functionality. 2025-08-21 08:54:28 +02:00
Stefan Hardegger
4ee5fa2330 fix 2025-08-20 15:11:41 +02:00
Stefan Hardegger
6128d61349 Library Switching functionality 2025-08-20 15:10:40 +02:00
Stefan Hardegger
5e347f2e2e Incrase permitted upload size 2025-08-20 08:11:36 +02:00
Stefan Hardegger
8eb126a304 performance 2025-08-18 19:27:57 +02:00
Stefan Hardegger
3dc02420fe performance optimization in library view 2025-08-18 19:03:42 +02:00
Stefan Hardegger
241a15a174 Series auto complete 2025-08-18 14:19:14 +02:00
Stefan Hardegger
6b97c0a70f fix loop 2025-08-18 10:41:32 +02:00
Stefan Hardegger
e952241e3c fix 2025-08-18 10:32:02 +02:00
Stefan Hardegger
65f1c6edc7 fix 2025-08-18 10:16:20 +02:00
Stefan Hardegger
40fe3fdb80 Improvements, Fixes. 2025-08-18 10:04:38 +02:00
Stefan Hardegger
95ce5fb532 Bugfixes and Improvements Tag Management 2025-08-18 08:54:18 +02:00
Stefan Hardegger
1a99d9830d Tag Enhancement + bugfixes 2025-08-17 17:16:40 +02:00
Stefan Hardegger
6b83783381 Small improvements 2025-08-15 07:58:36 +02:00
Stefan Hardegger
460ec358ca New Switchable Library Layout 2025-08-14 19:46:50 +02:00
Stefan Hardegger
1d14d3d7aa Fix for Random Story Function 2025-08-14 13:14:46 +02:00
Stefan Hardegger
4357351ec8 randomized 2025-08-13 14:49:57 +02:00
Stefan Hardegger
4ab03953ae random story selector 2025-08-13 14:48:40 +02:00
100 changed files with 15285 additions and 1200 deletions

3
.gitignore vendored
View File

@@ -46,4 +46,5 @@ Thumbs.db
# Application data
images/
data/
data/
backend/cookies.txt

122
README.md
View File

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

View 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.*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()) {

View File

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

View File

@@ -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);
@@ -251,12 +293,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
@@ -401,9 +463,7 @@ public class StoryController {
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 +472,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 +621,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;
}

View File

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

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

View File

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

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

View File

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

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

View File

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

View 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 +
'}';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package com.storycove.service;
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;
@@ -28,7 +29,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;
@@ -61,7 +70,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 +97,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 +105,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 +116,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");

View File

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

View 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);
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -61,7 +62,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 +76,7 @@ volumes:
postgres_data:
typesense_data:
images_data:
library_config:
configs:
nginx_config:
@@ -91,7 +93,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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,39 +1,482 @@
'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 [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;
};
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);
// 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}
/>
</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"
/>
{/* Submit Error */}
{errors.submit && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-4 pt-6">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
loading={loading}
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
>
Add Story
</Button>
</div>
</form>
</ImportLayout>
);
}

View File

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

View File

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

View File

@@ -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,54 @@
.reading-content em {
@apply italic;
}
/* 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;
}
}

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

@@ -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';
@@ -32,6 +34,7 @@ export default function EditStoryPage() {
sourceUrl: '',
tags: [] as string[],
seriesName: '',
seriesId: undefined as string | undefined,
volume: '',
});
@@ -54,6 +57,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 +98,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 +120,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 +176,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,
@@ -301,17 +328,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>

View File

@@ -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,8 @@ 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 contentRef = useRef<HTMLDivElement>(null);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -111,9 +115,20 @@ 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);
// Parse and add IDs to headings for TOC functionality
const parser = new DOMParser();
const doc = parser.parseFromString(sanitized, 'text/html');
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach((heading, index) => {
heading.id = `heading-${index}`;
});
setSanitizedContent(doc.body.innerHTML);
setHasHeadings(headings.length > 0);
// Load series stories if part of a series
if (storyData.seriesId) {
@@ -133,12 +148,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);
@@ -265,6 +297,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 +321,35 @@ 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>
</>
)}
{/* Story Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
<article data-reading-content>
@@ -314,12 +385,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>
)}

View File

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

View File

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

View File

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

View File

@@ -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') {

View 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>
);
}

View 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}
/>
</>
);
}

View 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">
{filteredTags.length === 0 && tagSearch ? (
<div className="col-span-4 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>
);
}

View File

@@ -0,0 +1,245 @@
'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">
{/* Left Sidebar */}
<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:w-full max-md:min-w-full max-md:max-w-full max-md:h-auto max-md:static max-md:border-r-0 max-md:border-b max-md:max-h-96">
{/* 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>
{/* Main Content */}
<div className="flex-1 p-4 max-md:p-4">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,316 @@
'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>
<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"
>
<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 & Clear */}
<div className="flex gap-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"
>
Grid
</Button>
<Button
variant={viewMode === 'list' ? 'primary' : 'ghost'}
onClick={() => onViewModeChange('list')}
className="rounded-none border-0"
>
List
</Button>
</div>
{(searchQuery || selectedTags.length > 0) && (
<Button variant="ghost" onClick={onClearFilters}>
Clear
</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>
))}
{/* Filter expand button with counts */}
<button
onClick={() => setFilterExpanded(!filterExpanded)}
className={`px-3 py-1 rounded-full text-xs font-medium border-2 border-dashed transition-colors ${
filterExpanded || activeAdvancedFiltersCount > 0 || remainingTagsCount > 0
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500 text-blue-700 dark:text-blue-300'
: 'bg-gray-50 dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500'
}`}
>
{remainingTagsCount > 0 && `+${remainingTagsCount} tags`}
{remainingTagsCount > 0 && activeAdvancedFiltersCount > 0 && ' • '}
{activeAdvancedFiltersCount > 0 && `${activeAdvancedFiltersCount} filters`}
{remainingTagsCount === 0 && activeAdvancedFiltersCount === 0 && 'More Filters'}
</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('tags')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'tags'
? 'bg-white dark:bg-gray-700 theme-text shadow-sm'
: 'theme-text hover:bg-white/50 dark:hover:bg-gray-700/50'
}`}
>
📋 Tags
{remainingTagsCount > 0 && (
<span className="ml-1 text-xs bg-gray-200 dark:bg-gray-600 px-1 rounded">
{remainingTagsCount}
</span>
)}
</button>
<button
onClick={() => setActiveTab('advanced')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'advanced'
? 'bg-white dark:bg-gray-700 theme-text shadow-sm'
: 'theme-text hover:bg-white/50 dark:hover:bg-gray-700/50'
}`}
>
Advanced
{activeAdvancedFiltersCount > 0 && (
<span className="ml-1 text-xs bg-blue-500 text-white px-1 rounded">
{activeAdvancedFiltersCount}
</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">
{filteredRemainingTags.length === 0 && tagSearch ? (
<div className="col-span-4 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>
);
}

View File

@@ -1,6 +1,6 @@
'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';
@@ -74,6 +74,104 @@ 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);
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 === '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 +195,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 +239,10 @@ export default function RichTextEditor({
}
return () => {
document.removeEventListener('keydown', handleEscapeKey);
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isMaximized]);
}, [isMaximized, formatText]);
// Set initial content when component mounts
useEffect(() => {
@@ -380,97 +505,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">
@@ -514,7 +548,7 @@ export default function RichTextEditor({
size="sm"
variant="ghost"
onClick={() => formatText('strong')}
title="Bold"
title="Bold (Ctrl+B)"
className="font-bold"
>
B
@@ -524,7 +558,7 @@ export default function RichTextEditor({
size="sm"
variant="ghost"
onClick={() => formatText('em')}
title="Italic"
title="Italic (Ctrl+I)"
className="italic"
>
I
@@ -535,7 +569,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 +579,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 +589,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"
@@ -625,7 +689,7 @@ export default function RichTextEditor({
size="sm"
variant="ghost"
onClick={() => formatText('strong')}
title="Bold"
title="Bold (Ctrl+B)"
className="font-bold"
>
B
@@ -635,7 +699,7 @@ export default function RichTextEditor({
size="sm"
variant="ghost"
onClick={() => formatText('em')}
title="Italic"
title="Italic (Ctrl+I)"
className="italic"
>
I
@@ -646,7 +710,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 +720,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 +730,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 +788,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 +826,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 +845,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.

View 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: &quot;{inputValue.trim()}&quot;
</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 &quot;{inputValue.trim()}&quot;
</button>
)}
</>
)}
</div>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
}

View File

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

View File

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

View 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>
);
}

View File

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

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

View 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,
};
}

View File

@@ -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';
@@ -192,6 +192,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 +339,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 +376,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 +479,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 +520,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 +747,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}`;
};

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

View File

@@ -1,5 +1,9 @@
# StoryCove - Story Collections Feature Specification
> **✅ Implementation Status: COMPLETED**
> This feature has been fully implemented and is available in the system.
> Last updated: January 2025
## 1. Feature Overview
Story Collections allow users to organize stories into ordered lists for better content management and reading workflows. Collections support custom ordering, metadata, and provide an enhanced reading experience for grouped content.
@@ -234,7 +238,55 @@ Response:
}
```
### 3.3 Batch Operations
### 3.3 Collection EPUB Export
#### GET /api/collections/{id}/epub
Export collection as EPUB file with default settings
- Includes all stories in collection order
- Includes collection metadata as book metadata
- Includes cover image if available
- Generates table of contents
#### POST /api/collections/{id}/epub
Export collection as EPUB with custom options
```json
Request:
{
"includeCoverImage": true,
"includeMetadata": true,
"includeTableOfContents": true
}
Response: EPUB file download with filename format:
{collection-name}-{export-date}.epub
```
### 3.4 Collection Statistics
#### GET /api/collections/{id}/stats
Get detailed collection statistics
```json
Response:
{
"totalStories": 15,
"totalWordCount": 125000,
"estimatedReadingTime": 625,
"averageStoryRating": 4.2,
"tagFrequency": {
"fantasy": 12,
"adventure": 8
},
"authorDistribution": [
{"authorName": "string", "storyCount": 5}
],
"readingProgress": {
"storiesRead": 8,
"percentComplete": 53.3
}
}
```
### 3.5 Batch Operations
#### POST /api/stories/batch/add-to-collection
Add multiple stories to a collection

View File

@@ -1,17 +1,35 @@
# StoryCove - Software Requirements Specification
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [System Architecture](#2-system-architecture)
3. [Data Models](#3-data-models)
4. [API Specification](#4-api-specification)
5. [UI/UX Specifications](#5-uiux-specifications)
6. [Security Requirements](#6-security-requirements)
7. [Deployment](#7-deployment)
8. [Testing Strategy](#8-testing-strategy)
9. [Advanced Features (✅ IMPLEMENTED)](#9-advanced-features--implemented)
- [9.1 Collections Feature](#91-collections-feature--completed)
- [9.2 Enhanced Tag System](#92-enhanced-tag-system--completed)
- [9.3 EPUB Import/Export System](#93-epub-importexport-system--completed)
10. [Future Roadmap](#10-future-roadmap)
## 1. Executive Summary
StoryCove is a self-hosted web application designed to store, organize, and read short stories collected from various internet sources. The application provides a clean, responsive interface for managing a personal library of stories with advanced search capabilities, author management, and rating systems.
### 1.1 Key Features (MVP)
- Story management with HTML content support
- Author profiles with ratings and metadata
- Tag-based categorization
- Full-text search capabilities
- Responsive reading interface
- JWT-based authentication
- Docker-based deployment
### 1.1 Key Features (✅ IMPLEMENTED)
- **Story Management**: Complete CRUD operations with HTML content support
- **Author Profiles**: Full author management with ratings, metadata, and URL collections
- **Enhanced Tag System**: Color tags, aliases, merging, and AI-powered suggestions
- **Collections**: Organized story lists with reading flow and EPUB export
- **EPUB Import/Export**: Full EPUB support with metadata and reading position preservation
- **Advanced Search**: Typesense-powered full-text search with faceting
- **Reading Position Tracking**: Character-level position tracking with EPUB CFI support
- **Responsive Reading Interface**: Distraction-free reading with progress tracking
- **JWT Authentication**: Secure single-password authentication system
- **Docker Deployment**: Complete containerized deployment with Docker Compose
### 1.2 Technology Stack
- **Frontend**: Next.js
@@ -58,21 +76,23 @@ StoryCove is a self-hosted web application designed to store, organize, and read
```sql
CREATE TABLE stories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(500) NOT NULL,
title VARCHAR(255) NOT NULL,
summary TEXT,
description VARCHAR(1000),
author_id UUID NOT NULL,
content_html TEXT NOT NULL,
content_plain TEXT NOT NULL,
source_url VARCHAR(1000),
word_count INTEGER NOT NULL,
word_count INTEGER DEFAULT 0,
series_id UUID,
volume INTEGER,
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
cover_image_path VARCHAR(500), -- Phase 2: Consider storing base filename without size suffix
cover_path VARCHAR(500), -- Updated field name
is_read BOOLEAN DEFAULT FALSE, -- Reading status
reading_position INTEGER DEFAULT 0, -- Character position for reading progress
last_read_at TIMESTAMP, -- Last time story was accessed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (author_id) REFERENCES authors(id),
FOREIGN KEY (series_id) REFERENCES series(id)
);
@@ -91,13 +111,13 @@ CREATE TABLE authors (
);
```
#### Author URLs Table
#### Author URLs Collection Table
```sql
-- Note: In the actual implementation, this is managed as an @ElementCollection
-- This table is created automatically by Hibernate
CREATE TABLE author_urls (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID NOT NULL,
url VARCHAR(1000) NOT NULL,
description VARCHAR(255),
FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE
);
```
@@ -116,7 +136,98 @@ CREATE TABLE series (
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
color VARCHAR(7), -- Hex color code like #3B82F6
description VARCHAR(500), -- Tag description
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
```
#### Collections Table
```sql
CREATE TABLE collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(500) NOT NULL,
description TEXT,
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
cover_image_path VARCHAR(500),
is_archived BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX idx_collections_archived ON collections(is_archived);
```
#### Collection Stories Junction Table
```sql
CREATE TABLE collection_stories (
collection_id UUID NOT NULL,
story_id UUID NOT NULL,
position INTEGER NOT NULL,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (collection_id, story_id),
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE,
UNIQUE(collection_id, position)
);
CREATE INDEX idx_collection_stories_position ON collection_stories(collection_id, position);
```
#### Collection Tags Junction Table
```sql
CREATE TABLE collection_tags (
collection_id UUID NOT NULL,
tag_id UUID NOT NULL,
PRIMARY KEY (collection_id, tag_id),
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
```
#### Tag Aliases Table
```sql
CREATE TABLE tag_aliases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alias_name VARCHAR(100) UNIQUE NOT NULL,
canonical_tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_from_merge BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX idx_tag_aliases_name ON tag_aliases(alias_name);
```
#### Reading Positions Table
```sql
CREATE TABLE reading_positions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
story_id UUID NOT NULL,
chapter_index INTEGER,
chapter_title VARCHAR(255),
word_position INTEGER,
character_position INTEGER,
percentage_complete DECIMAL(5,2),
epub_cfi TEXT,
context_before VARCHAR(500),
context_after VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
);
CREATE INDEX idx_reading_position_story ON reading_positions(story_id);
```
#### Libraries Table
```sql
CREATE TABLE libraries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
```
@@ -241,6 +352,108 @@ Response:
}
```
#### POST /api/stories/{id}/reading-status
```json
Request:
{
"isRead": true
}
Response:
{
"id": "uuid",
"isRead": true,
"readingPosition": 0,
"lastReadAt": "2024-01-01T12:00:00Z"
}
```
#### GET /api/stories/{id}/read
Returns story content optimized for reading interface
#### GET /api/stories/random
Returns a random story with optional filtering
Query parameters:
- `searchQuery` (string): Filter search
- `tags` (string[]): Filter by tags
#### GET /api/stories/check-duplicate
Check for potential duplicate stories
Query parameters:
- `title` (string): Story title to check
- `authorName` (string): Author name to check
#### GET /api/stories/{id}/collections
Get all collections containing this story
### 4.2.1 EPUB Import/Export Endpoints
#### POST /api/stories/epub/import
Import story from EPUB file
```json
Request (multipart/form-data):
{
"file": "epub file",
"authorId": "uuid (optional)",
"authorName": "string (optional)",
"seriesId": "uuid (optional)",
"seriesName": "string (optional)",
"seriesVolume": "integer (optional)",
"tags": ["string"],
"preserveReadingPosition": "boolean",
"overwriteExisting": "boolean",
"createMissingAuthor": "boolean",
"createMissingSeries": "boolean"
}
Response:
{
"success": true,
"storyId": "uuid",
"storyTitle": "string",
"message": "EPUB imported successfully",
"extractedData": {
"chapterCount": 12,
"wordCount": 45000,
"hasCovers": true
}
}
```
#### GET /api/stories/{id}/epub
Export story as EPUB (simple download)
#### POST /api/stories/epub/export
Export story as EPUB with custom options
```json
Request:
{
"storyId": "uuid",
"includeReadingPosition": true,
"includeCoverImage": true,
"includeMetadata": true
}
Response: EPUB file download
```
#### POST /api/stories/epub/validate
Validate EPUB file before import
```json
Request (multipart/form-data):
{
"file": "epub file"
}
Response:
{
"valid": true,
"errors": [],
"filename": "story.epub",
"size": 1024000
}
```
### 4.3 Author Endpoints
#### GET /api/authors
@@ -298,12 +511,261 @@ Remove avatar from author
### 4.4 Tag Endpoints
#### GET /api/tags
Returns tag cloud data with usage counts
Returns paginated list of tags with enhanced features
Query parameters:
- `page` (integer): Page number
- `size` (integer): Items per page
- `sortBy` (string): Sort field (name, usage)
- `sortDir` (string): Sort direction (asc, desc)
#### GET /api/tags/autocomplete?q={query}
Autocomplete for tag input
#### GET /api/tags/{id}
Get specific tag with full details including aliases
### 4.5 Series Endpoints
#### POST /api/tags
Create new tag with color and description
```json
Request:
{
"name": "string",
"color": "#3B82F6",
"description": "string"
}
```
#### PUT /api/tags/{id}
Update tag properties
```json
Request:
{
"name": "string",
"color": "#3B82F6",
"description": "string"
}
```
#### DELETE /api/tags/{id}
Delete tag with safety checks
#### GET /api/tags/autocomplete?query={query}
Enhanced autocomplete with alias resolution
#### GET /api/tags/popular
Most used tags
#### GET /api/tags/unused
Tags not assigned to any stories
#### GET /api/tags/stats
Tag usage statistics
#### GET /api/tags/collections
Tags used by collections
#### GET /api/tags/resolve/{name}
Resolve tag name through alias system
### 4.4.1 Tag Alias Management
#### POST /api/tags/{tagId}/aliases
Add alias to tag
```json
Request:
{
"aliasName": "string"
}
```
#### DELETE /api/tags/{tagId}/aliases/{aliasId}
Remove alias from tag
### 4.4.2 Tag Management Operations
#### POST /api/tags/merge
Merge multiple tags into one
```json
Request:
{
"sourceTagIds": ["uuid1", "uuid2"],
"targetTagId": "uuid3"
}
Response:
{
"resultTag": "TagDto",
"aliasesCreated": ["alias1", "alias2"]
}
```
#### POST /api/tags/merge/preview
Preview tag merge operation without executing
#### POST /api/tags/suggest
AI-powered tag suggestions for content
```json
Request:
{
"title": "string",
"content": "string",
"summary": "string",
"limit": 10
}
Response:
[
{
"tagName": "fantasy",
"confidence": 0.85,
"reason": "Content contains magical elements"
}
]
```
### 4.5 Collections Endpoints
#### GET /api/collections
Search and list collections with pagination (Typesense-powered)
Query parameters:
- `page` (integer): Page number
- `limit` (integer): Items per page
- `search` (string): Search query
- `tags` (string[]): Filter by tags
- `archived` (boolean): Include archived collections
#### GET /api/collections/{id}
Get collection details with story list
#### POST /api/collections
Create new collection
```json
Request (JSON or multipart/form-data):
{
"name": "string",
"description": "string",
"tagNames": ["string"],
"storyIds": ["uuid"],
"coverImage": "file (optional for multipart)"
}
```
#### PUT /api/collections/{id}
Update collection metadata
```json
Request:
{
"name": "string",
"description": "string",
"tagNames": ["string"],
"rating": 1-5
}
```
#### DELETE /api/collections/{id}
Delete collection (stories remain in system)
#### PUT /api/collections/{id}/archive
Archive or unarchive collection
```json
Request:
{
"archived": true
}
```
#### POST /api/collections/{id}/cover
Upload collection cover image
#### DELETE /api/collections/{id}/cover
Remove collection cover image
#### GET /api/collections/{id}/stats
Get detailed collection statistics
```json
Response:
{
"totalStories": 15,
"totalWordCount": 125000,
"estimatedReadingTime": 625,
"averageStoryRating": 4.2,
"tagFrequency": {
"fantasy": 12,
"adventure": 8
},
"authorDistribution": [
{"authorName": "string", "storyCount": 5}
]
}
```
### 4.5.1 Collection Story Management
#### POST /api/collections/{id}/stories
Add stories to collection with optional positioning
```json
Request:
{
"storyIds": ["uuid"],
"position": 3
}
Response:
{
"added": 3,
"skipped": 1,
"totalStories": 15
}
```
#### DELETE /api/collections/{id}/stories/{storyId}
Remove story from collection
#### PUT /api/collections/{id}/stories/order
Reorder stories in collection
```json
Request:
{
"storyOrders": [
{"storyId": "uuid", "position": 1},
{"storyId": "uuid", "position": 2}
]
}
```
#### GET /api/collections/{id}/read/{storyId}
Get story content with collection navigation context
```json
Response:
{
"story": { /* full story data */ },
"collection": {
"id": "uuid",
"name": "string",
"currentPosition": 3,
"totalStories": 10,
"previousStoryId": "uuid",
"nextStoryId": "uuid"
}
}
```
### 4.5.2 Collection EPUB Export
#### GET /api/collections/{id}/epub
Export collection as EPUB file
#### POST /api/collections/{id}/epub
Export collection as EPUB with custom options
```json
Request:
{
"includeCoverImage": true,
"includeMetadata": true,
"includeTableOfContents": true
}
Response: EPUB file download
```
### 4.6 Series Endpoints
#### GET /api/series
List all series with story counts
@@ -587,15 +1049,130 @@ APP_PASSWORD=application_password_here
### 8.3 E2E Tests
- Cypress or Playwright for critical user flows
## 9. Phase 2 Roadmap
## 9. Advanced Features (✅ IMPLEMENTED)
### 9.1 URL Content GrabbingIMPLEMENTED
### 9.1 Collections FeatureCOMPLETED
Story Collections allow users to organize stories into ordered lists for better content management and reading workflows. Collections support custom ordering, metadata, and provide an enhanced reading experience for grouped content.
#### Core Capabilities
- Create and manage ordered lists of stories
- Stories can belong to multiple collections
- Drag-and-drop reordering with gap-based positioning
- Collection-level metadata and ratings
- Dedicated collection reading flow with navigation
- Batch operations on collection contents
- EPUB export for entire collections
- Archive functionality for collection management
#### Collection Data Model
Collections use a junction table approach with position-based ordering:
- **Collections Table**: Core collection metadata with archiving support
- **collection_stories**: Junction table with gap-based positioning (1000, 2000, 3000...)
- **collection_tags**: Many-to-many relationship with tags for categorization
#### Collection Reading Flow
Enhanced reading experience with collection context:
- Navigate between stories in collection order
- Show position within collection (Story 3 of 10)
- Previous/Next story navigation
- Collection-specific reading statistics
#### Collection EPUB Export
Export entire collections as single EPUB files:
- Maintains story order from collection
- Includes collection metadata as book metadata
- Generates table of contents with story titles
- Preserves individual story formatting and structure
### 9.2 Enhanced Tag System ✅ COMPLETED
Comprehensive enhancement of the tagging functionality including color tags, tag deletion, merging, aliases, and AI-powered suggestions.
#### Color Tags
- **Visual Organization**: Assign hex colors to tags for better visual distinction
- **Predefined Palette**: Theme-compatible color selection
- **Custom Colors**: Full color picker for advanced customization
- **Accessibility**: All colors ensure sufficient contrast ratios
#### Tag Aliases System
- **Automatic Resolution**: Users type "magictf" and get "magic tf" automatically
- **Transparent Integration**: Aliases work seamlessly in search and autocomplete
- **Merge Integration**: Automatically created when tags are merged
- **Hover Display**: Show all aliases when hovering over canonical tags
#### Tag Merging
Sophisticated tag merging with automatic aliasing:
1. Select multiple tags to merge
2. Choose canonical tag name
3. Preview merge impact (story counts)
4. All associations transfer to canonical tag
5. Source tags automatically become aliases
#### AI-Powered Tag Suggestions
- **Content Analysis**: Analyze story title, content, and summary
- **Confidence Scoring**: Each suggestion includes confidence level
- **Contextual Reasoning**: Explanations for why tags were suggested
- **Integration**: Available during story creation and editing
#### Tag Management Interface
Dedicated tag maintenance accessible through Settings:
- Searchable and filterable tag list
- Sortable by name, usage count, creation date
- Bulk selection for merge/delete operations
- Visual indicators for colors and alias counts
- Tag statistics and usage analytics
### 9.3 EPUB Import/Export System ✅ COMPLETED
Full-featured EPUB import and export system with advanced metadata handling and reading position preservation.
#### EPUB Import Features
- **Format Support**: EPUB 2.0 and 3.x formats, DRM-free
- **Validation**: Comprehensive file format and structure validation
- **Metadata Extraction**: Title, author, description, subjects/tags, cover images
- **Content Processing**: Multi-chapter combination with proper HTML sanitization
- **Duplicate Detection**: Smart detection of potentially duplicate content
- **Batch Import**: Support for importing multiple EPUB files
#### EPUB Export Features
- **Individual Stories**: Export single stories as properly formatted EPUB files
- **Collection Export**: Export entire collections as single EPUB with TOC
- **Metadata Preservation**: Complete metadata including tags, ratings, series info
- **Cover Images**: Automatic inclusion and optimization of cover artwork
- **Reading Positions**: EPUB CFI standard support for position preservation
#### Advanced Metadata Handling
Comprehensive extraction and preservation of:
- **Basic Metadata**: Title, author, description, language
- **Publication Data**: Publisher, publication date, identifiers (ISBN, etc.)
- **Categorization**: Subjects converted to tags automatically
- **Cover Processing**: Automatic extraction, validation, and optimization
- **Custom Metadata**: StoryCove-specific fields for ratings and series
#### Reading Position Integration
- **EPUB CFI Support**: Standard-compliant reading position tracking
- **Cross-Format Preservation**: Maintain positions when importing/exporting
- **Chapter Awareness**: Chapter-level position tracking with context
- **Percentage Calculation**: Accurate progress percentage based on content length
- **Context Preservation**: Before/after text context for position recovery
#### File Handling & Validation
- **Size Limits**: 50MB maximum file size with configurable limits
- **Security**: Comprehensive validation to prevent malicious content
- **Error Recovery**: Graceful handling of corrupted or invalid EPUB files
- **Progress Feedback**: Real-time progress indicators during import/export
- **Batch Operations**: Support for processing multiple files simultaneously
## 10. Future Roadmap
### 10.1 URL Content Grabbing ✅ IMPLEMENTED
- Configurable scrapers for specific sites
- Site configuration stored in JSON files
- Content extraction rules per site (DeviantArt support added)
- Adaptive content extraction for varying HTML structures
### 9.2 Enhanced Image Processing & Optimization
### 10.2 Enhanced Image Processing & Optimization
- **Multi-size generation during upload**
- Cover images: thumbnail (200x300), medium (400x600), full (800x1200)
- Avatar images: small (64x64), medium (200x200), full (400x400)

44
test_author_search.js Normal file
View File

@@ -0,0 +1,44 @@
const axios = require('axios');
// Test script to simulate frontend author search behavior
async function testAuthorSearch() {
const BASE_URL = 'http://localhost:6925';
try {
// First, try to login to get a token
console.log('Attempting to login...');
const loginResponse = await axios.post(`${BASE_URL}/api/auth/login`, {
password: 'kLJq8QJx-@.eCk.uZJwPdbQ!JyJ6Yy_8'
});
console.log('Login response:', loginResponse.data);
// Extract token from response
const token = loginResponse.data.token;
if (token) {
console.log('Token received, testing author search...');
// Test author search with "shop" query
const searchResponse = await axios.get(`${BASE_URL}/api/authors/search-typesense`, {
params: {
q: 'shop',
page: 0,
size: 20
},
headers: {
'Authorization': `Bearer ${token}`
}
});
console.log('Search results for "shop":');
console.log('Total hits:', searchResponse.data.totalHits);
console.log('Results:', searchResponse.data.results.map(r => r.name));
}
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
}
testAuthorSearch();