Tag Enhancement + bugfixes
This commit is contained in:
300
TAG_ENHANCEMENT_SPECIFICATION.md
Normal file
300
TAG_ENHANCEMENT_SPECIFICATION.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Tag Enhancement Specification
|
||||||
|
|
||||||
|
## 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.*
|
||||||
@@ -335,6 +335,44 @@ public class AuthorController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/clean-author-names")
|
||||||
|
public ResponseEntity<Map<String, Object>> cleanAuthorNames() {
|
||||||
|
try {
|
||||||
|
List<Author> allAuthors = authorService.findAllWithStories();
|
||||||
|
int cleanedCount = 0;
|
||||||
|
|
||||||
|
for (Author author : allAuthors) {
|
||||||
|
String originalName = author.getName();
|
||||||
|
String cleanedName = originalName != null ? originalName.trim() : "";
|
||||||
|
|
||||||
|
if (!cleanedName.equals(originalName)) {
|
||||||
|
logger.info("Cleaning author name: '{}' -> '{}'", originalName, cleanedName);
|
||||||
|
author.setName(cleanedName);
|
||||||
|
authorService.update(author.getId(), author);
|
||||||
|
cleanedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex all authors after cleaning
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
typesenseService.reindexAllAuthors(allAuthors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "Cleaned " + cleanedCount + " author names and reindexed",
|
||||||
|
"cleanedCount", cleanedCount,
|
||||||
|
"totalAuthors", allAuthors.size()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to clean author names", e);
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/top-rated")
|
@GetMapping("/top-rated")
|
||||||
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||||
Pageable pageable = PageRequest.of(0, limit);
|
Pageable pageable = PageRequest.of(0, limit);
|
||||||
|
|||||||
@@ -420,9 +420,7 @@ public class StoryController {
|
|||||||
if (updateReq.getSourceUrl() != null) {
|
if (updateReq.getSourceUrl() != null) {
|
||||||
story.setSourceUrl(updateReq.getSourceUrl());
|
story.setSourceUrl(updateReq.getSourceUrl());
|
||||||
}
|
}
|
||||||
if (updateReq.getVolume() != null) {
|
// Volume will be handled in series logic below
|
||||||
story.setVolume(updateReq.getVolume());
|
|
||||||
}
|
|
||||||
// Handle author - either by ID or by name
|
// Handle author - either by ID or by name
|
||||||
if (updateReq.getAuthorId() != null) {
|
if (updateReq.getAuthorId() != null) {
|
||||||
Author author = authorService.findById(updateReq.getAuthorId());
|
Author author = authorService.findById(updateReq.getAuthorId());
|
||||||
@@ -431,13 +429,34 @@ public class StoryController {
|
|||||||
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
||||||
story.setAuthor(author);
|
story.setAuthor(author);
|
||||||
}
|
}
|
||||||
// Handle series - either by ID or by name
|
// Handle series - either by ID, by name, or remove from series
|
||||||
if (updateReq.getSeriesId() != null) {
|
if (updateReq.getSeriesId() != null) {
|
||||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||||
story.setSeries(series);
|
story.setSeries(series);
|
||||||
} else if (updateReq.getSeriesName() != null && !updateReq.getSeriesName().trim().isEmpty()) {
|
} else if (updateReq.getSeriesName() != null) {
|
||||||
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
logger.info("Processing series update: seriesName='{}', isEmpty={}", updateReq.getSeriesName(), updateReq.getSeriesName().trim().isEmpty());
|
||||||
story.setSeries(series);
|
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||||
|
// Empty series name means remove from series
|
||||||
|
logger.info("Removing story from series");
|
||||||
|
if (story.getSeries() != null) {
|
||||||
|
story.getSeries().removeStory(story);
|
||||||
|
story.setSeries(null);
|
||||||
|
story.setVolume(null);
|
||||||
|
logger.info("Story removed from series");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-empty series name means add to series
|
||||||
|
logger.info("Adding story to series: '{}', volume: {}", updateReq.getSeriesName().trim(), updateReq.getVolume());
|
||||||
|
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
||||||
|
story.setSeries(series);
|
||||||
|
// Set volume only if series is being set
|
||||||
|
if (updateReq.getVolume() != null) {
|
||||||
|
story.setVolume(updateReq.getVolume());
|
||||||
|
logger.info("Story added to series: {} with volume: {}", series.getName(), updateReq.getVolume());
|
||||||
|
} else {
|
||||||
|
logger.info("Story added to series: {} with no volume", series.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
||||||
@@ -559,8 +578,11 @@ public class StoryController {
|
|||||||
TagDto tagDto = new TagDto();
|
TagDto tagDto = new TagDto();
|
||||||
tagDto.setId(tag.getId());
|
tagDto.setId(tag.getId());
|
||||||
tagDto.setName(tag.getName());
|
tagDto.setName(tag.getName());
|
||||||
|
tagDto.setColor(tag.getColor());
|
||||||
|
tagDto.setDescription(tag.getDescription());
|
||||||
tagDto.setCreatedAt(tag.getCreatedAt());
|
tagDto.setCreatedAt(tag.getCreatedAt());
|
||||||
// storyCount can be set if needed, but it might be expensive to calculate for each tag
|
tagDto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||||
|
tagDto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||||
return tagDto;
|
return tagDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.dto.TagDto;
|
import com.storycove.dto.TagDto;
|
||||||
|
import com.storycove.dto.TagAliasDto;
|
||||||
import com.storycove.entity.Tag;
|
import com.storycove.entity.Tag;
|
||||||
|
import com.storycove.entity.TagAlias;
|
||||||
import com.storycove.service.TagService;
|
import com.storycove.service.TagService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -21,6 +25,7 @@ import java.util.stream.Collectors;
|
|||||||
@RequestMapping("/api/tags")
|
@RequestMapping("/api/tags")
|
||||||
public class TagController {
|
public class TagController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TagController.class);
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
|
|
||||||
public TagController(TagService tagService) {
|
public TagController(TagService tagService) {
|
||||||
@@ -54,6 +59,8 @@ public class TagController {
|
|||||||
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
|
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
|
||||||
Tag tag = new Tag();
|
Tag tag = new Tag();
|
||||||
tag.setName(request.getName());
|
tag.setName(request.getName());
|
||||||
|
tag.setColor(request.getColor());
|
||||||
|
tag.setDescription(request.getDescription());
|
||||||
|
|
||||||
Tag savedTag = tagService.create(tag);
|
Tag savedTag = tagService.create(tag);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
|
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
|
||||||
@@ -66,6 +73,12 @@ public class TagController {
|
|||||||
if (request.getName() != null) {
|
if (request.getName() != null) {
|
||||||
existingTag.setName(request.getName());
|
existingTag.setName(request.getName());
|
||||||
}
|
}
|
||||||
|
if (request.getColor() != null) {
|
||||||
|
existingTag.setColor(request.getColor());
|
||||||
|
}
|
||||||
|
if (request.getDescription() != null) {
|
||||||
|
existingTag.setDescription(request.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
Tag updatedTag = tagService.update(id, existingTag);
|
Tag updatedTag = tagService.update(id, existingTag);
|
||||||
return ResponseEntity.ok(convertToDto(updatedTag));
|
return ResponseEntity.ok(convertToDto(updatedTag));
|
||||||
@@ -95,7 +108,7 @@ public class TagController {
|
|||||||
@RequestParam String query,
|
@RequestParam String query,
|
||||||
@RequestParam(defaultValue = "10") int limit) {
|
@RequestParam(defaultValue = "10") int limit) {
|
||||||
|
|
||||||
List<Tag> tags = tagService.findByNameStartingWith(query, limit);
|
List<Tag> tags = tagService.findByNameOrAliasStartingWith(query, limit);
|
||||||
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
|
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
|
||||||
|
|
||||||
return ResponseEntity.ok(tagDtos);
|
return ResponseEntity.ok(tagDtos);
|
||||||
@@ -142,15 +155,124 @@ public class TagController {
|
|||||||
return ResponseEntity.ok(tagDtos);
|
return ResponseEntity.ok(tagDtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag alias endpoints
|
||||||
|
@PostMapping("/{tagId}/aliases")
|
||||||
|
public ResponseEntity<TagAliasDto> addAlias(@PathVariable UUID tagId,
|
||||||
|
@RequestBody Map<String, String> request) {
|
||||||
|
String aliasName = request.get("aliasName");
|
||||||
|
if (aliasName == null || aliasName.trim().isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
TagAlias alias = tagService.addAlias(tagId, aliasName.trim());
|
||||||
|
TagAliasDto dto = new TagAliasDto();
|
||||||
|
dto.setId(alias.getId());
|
||||||
|
dto.setAliasName(alias.getAliasName());
|
||||||
|
dto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||||
|
dto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||||
|
dto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||||
|
dto.setCreatedAt(alias.getCreatedAt());
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{tagId}/aliases/{aliasId}")
|
||||||
|
public ResponseEntity<?> removeAlias(@PathVariable UUID tagId, @PathVariable UUID aliasId) {
|
||||||
|
try {
|
||||||
|
tagService.removeAlias(tagId, aliasId);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Alias removed successfully"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/resolve/{name}")
|
||||||
|
public ResponseEntity<TagDto> resolveTag(@PathVariable String name) {
|
||||||
|
try {
|
||||||
|
Tag resolvedTag = tagService.resolveTagByName(name);
|
||||||
|
if (resolvedTag != null) {
|
||||||
|
return ResponseEntity.ok(convertToDto(resolvedTag));
|
||||||
|
} else {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/merge")
|
||||||
|
public ResponseEntity<?> mergeTags(@Valid @RequestBody MergeTagsRequest request) {
|
||||||
|
try {
|
||||||
|
Tag resultTag = tagService.mergeTags(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||||
|
return ResponseEntity.ok(convertToDto(resultTag));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to merge tags", e);
|
||||||
|
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/merge/preview")
|
||||||
|
public ResponseEntity<?> previewMerge(@Valid @RequestBody MergeTagsRequest request) {
|
||||||
|
try {
|
||||||
|
MergePreviewResponse preview = tagService.previewMerge(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||||
|
return ResponseEntity.ok(preview);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to preview merge", e);
|
||||||
|
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/suggest")
|
||||||
|
public ResponseEntity<List<TagSuggestion>> suggestTags(@RequestBody TagSuggestionRequest request) {
|
||||||
|
try {
|
||||||
|
List<TagSuggestion> suggestions = tagService.suggestTags(
|
||||||
|
request.getTitle(),
|
||||||
|
request.getContent(),
|
||||||
|
request.getSummary(),
|
||||||
|
request.getLimit() != null ? request.getLimit() : 10
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(suggestions);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to suggest tags", e);
|
||||||
|
return ResponseEntity.ok(List.of()); // Return empty list on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private TagDto convertToDto(Tag tag) {
|
private TagDto convertToDto(Tag tag) {
|
||||||
TagDto dto = new TagDto();
|
TagDto dto = new TagDto();
|
||||||
dto.setId(tag.getId());
|
dto.setId(tag.getId());
|
||||||
dto.setName(tag.getName());
|
dto.setName(tag.getName());
|
||||||
|
dto.setColor(tag.getColor());
|
||||||
|
dto.setDescription(tag.getDescription());
|
||||||
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||||
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
||||||
|
dto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||||
dto.setCreatedAt(tag.getCreatedAt());
|
dto.setCreatedAt(tag.getCreatedAt());
|
||||||
// updatedAt field not present in Tag entity per spec
|
// updatedAt field not present in Tag entity per spec
|
||||||
|
|
||||||
|
// Convert aliases to DTOs for full context
|
||||||
|
if (tag.getAliases() != null && !tag.getAliases().isEmpty()) {
|
||||||
|
List<TagAliasDto> aliaseDtos = tag.getAliases().stream()
|
||||||
|
.map(alias -> {
|
||||||
|
TagAliasDto aliasDto = new TagAliasDto();
|
||||||
|
aliasDto.setId(alias.getId());
|
||||||
|
aliasDto.setAliasName(alias.getAliasName());
|
||||||
|
aliasDto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||||
|
aliasDto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||||
|
aliasDto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||||
|
aliasDto.setCreatedAt(alias.getCreatedAt());
|
||||||
|
return aliasDto;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
dto.setAliases(aliaseDtos);
|
||||||
|
}
|
||||||
|
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,15 +290,112 @@ public class TagController {
|
|||||||
// Request DTOs
|
// Request DTOs
|
||||||
public static class CreateTagRequest {
|
public static class CreateTagRequest {
|
||||||
private String name;
|
private String name;
|
||||||
|
private String color;
|
||||||
|
private String description;
|
||||||
|
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public void setName(String name) { this.name = name; }
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getColor() { return color; }
|
||||||
|
public void setColor(String color) { this.color = color; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class UpdateTagRequest {
|
public static class UpdateTagRequest {
|
||||||
private String name;
|
private String name;
|
||||||
|
private String color;
|
||||||
|
private String description;
|
||||||
|
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public void setName(String name) { this.name = name; }
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getColor() { return color; }
|
||||||
|
public void setColor(String color) { this.color = color; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MergeTagsRequest {
|
||||||
|
private List<String> sourceTagIds;
|
||||||
|
private String targetTagId;
|
||||||
|
|
||||||
|
public List<String> getSourceTagIds() { return sourceTagIds; }
|
||||||
|
public void setSourceTagIds(List<String> sourceTagIds) { this.sourceTagIds = sourceTagIds; }
|
||||||
|
|
||||||
|
public String getTargetTagId() { return targetTagId; }
|
||||||
|
public void setTargetTagId(String targetTagId) { this.targetTagId = targetTagId; }
|
||||||
|
|
||||||
|
// Helper methods to convert to UUID
|
||||||
|
public List<UUID> getSourceTagUUIDs() {
|
||||||
|
return sourceTagIds != null ? sourceTagIds.stream().map(UUID::fromString).toList() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getTargetTagUUID() {
|
||||||
|
return targetTagId != null ? UUID.fromString(targetTagId) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class MergePreviewResponse {
|
||||||
|
private String targetTagName;
|
||||||
|
private int targetStoryCount;
|
||||||
|
private int totalResultStoryCount;
|
||||||
|
private List<String> aliasesToCreate;
|
||||||
|
|
||||||
|
public String getTargetTagName() { return targetTagName; }
|
||||||
|
public void setTargetTagName(String targetTagName) { this.targetTagName = targetTagName; }
|
||||||
|
|
||||||
|
public int getTargetStoryCount() { return targetStoryCount; }
|
||||||
|
public void setTargetStoryCount(int targetStoryCount) { this.targetStoryCount = targetStoryCount; }
|
||||||
|
|
||||||
|
public int getTotalResultStoryCount() { return totalResultStoryCount; }
|
||||||
|
public void setTotalResultStoryCount(int totalResultStoryCount) { this.totalResultStoryCount = totalResultStoryCount; }
|
||||||
|
|
||||||
|
public List<String> getAliasesToCreate() { return aliasesToCreate; }
|
||||||
|
public void setAliasesToCreate(List<String> aliasesToCreate) { this.aliasesToCreate = aliasesToCreate; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TagSuggestionRequest {
|
||||||
|
private String title;
|
||||||
|
private String content;
|
||||||
|
private String summary;
|
||||||
|
private Integer limit;
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getContent() { return content; }
|
||||||
|
public void setContent(String content) { this.content = content; }
|
||||||
|
|
||||||
|
public String getSummary() { return summary; }
|
||||||
|
public void setSummary(String summary) { this.summary = summary; }
|
||||||
|
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TagSuggestion {
|
||||||
|
private String tagName;
|
||||||
|
private double confidence;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public TagSuggestion() {}
|
||||||
|
|
||||||
|
public TagSuggestion(String tagName, double confidence, String reason) {
|
||||||
|
this.tagName = tagName;
|
||||||
|
this.confidence = confidence;
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTagName() { return tagName; }
|
||||||
|
public void setTagName(String tagName) { this.tagName = tagName; }
|
||||||
|
|
||||||
|
public double getConfidence() { return confidence; }
|
||||||
|
public void setConfidence(double confidence) { this.confidence = confidence; }
|
||||||
|
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
public void setReason(String reason) { this.reason = reason; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package com.storycove.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class TagAliasDto {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@NotBlank(message = "Alias name is required")
|
||||||
|
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||||
|
private String aliasName;
|
||||||
|
|
||||||
|
private UUID canonicalTagId;
|
||||||
|
private String canonicalTagName; // For convenience in frontend
|
||||||
|
private Boolean createdFromMerge;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public TagAliasDto() {}
|
||||||
|
|
||||||
|
public TagAliasDto(String aliasName, UUID canonicalTagId) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
this.canonicalTagId = canonicalTagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAliasName() {
|
||||||
|
return aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliasName(String aliasName) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getCanonicalTagId() {
|
||||||
|
return canonicalTagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanonicalTagId(UUID canonicalTagId) {
|
||||||
|
this.canonicalTagId = canonicalTagId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCanonicalTagName() {
|
||||||
|
return canonicalTagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanonicalTagName(String canonicalTagName) {
|
||||||
|
this.canonicalTagName = canonicalTagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getCreatedFromMerge() {
|
||||||
|
return createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||||
|
this.createdFromMerge = createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public class TagDto {
|
public class TagDto {
|
||||||
@@ -14,8 +15,16 @@ public class TagDto {
|
|||||||
@Size(max = 100, message = "Tag name must not exceed 100 characters")
|
@Size(max = 100, message = "Tag name must not exceed 100 characters")
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||||
|
private String color;
|
||||||
|
|
||||||
|
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||||
|
private String description;
|
||||||
|
|
||||||
private Integer storyCount;
|
private Integer storyCount;
|
||||||
private Integer collectionCount;
|
private Integer collectionCount;
|
||||||
|
private Integer aliasCount;
|
||||||
|
private List<TagAliasDto> aliases;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
@@ -42,6 +51,22 @@ public class TagDto {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColor() {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColor(String color) {
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getStoryCount() {
|
public Integer getStoryCount() {
|
||||||
return storyCount;
|
return storyCount;
|
||||||
}
|
}
|
||||||
@@ -58,6 +83,22 @@ public class TagDto {
|
|||||||
this.collectionCount = collectionCount;
|
this.collectionCount = collectionCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Integer getAliasCount() {
|
||||||
|
return aliasCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliasCount(Integer aliasCount) {
|
||||||
|
this.aliasCount = aliasCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TagAliasDto> getAliases() {
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliases(List<TagAliasDto> aliases) {
|
||||||
|
this.aliases = aliases;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -24,6 +25,14 @@ public class Tag {
|
|||||||
@Column(nullable = false, unique = true)
|
@Column(nullable = false, unique = true)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||||
|
@Column(length = 7)
|
||||||
|
private String color; // hex color like #3B82F6
|
||||||
|
|
||||||
|
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||||
|
@Column(length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
|
||||||
@ManyToMany(mappedBy = "tags")
|
@ManyToMany(mappedBy = "tags")
|
||||||
@JsonBackReference("story-tags")
|
@JsonBackReference("story-tags")
|
||||||
@@ -33,6 +42,10 @@ public class Tag {
|
|||||||
@JsonBackReference("collection-tags")
|
@JsonBackReference("collection-tags")
|
||||||
private Set<Collection> collections = new HashSet<>();
|
private Set<Collection> collections = new HashSet<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "canonicalTag", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@JsonManagedReference("tag-aliases")
|
||||||
|
private Set<TagAlias> aliases = new HashSet<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
@@ -43,6 +56,12 @@ public class Tag {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Tag(String name, String color, String description) {
|
||||||
|
this.name = name;
|
||||||
|
this.color = color;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Getters and Setters
|
// Getters and Setters
|
||||||
@@ -62,6 +81,22 @@ public class Tag {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColor() {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColor(String color) {
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public Set<Story> getStories() {
|
public Set<Story> getStories() {
|
||||||
return stories;
|
return stories;
|
||||||
@@ -79,6 +114,14 @@ public class Tag {
|
|||||||
this.collections = collections;
|
this.collections = collections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Set<TagAlias> getAliases() {
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliases(Set<TagAlias> aliases) {
|
||||||
|
this.aliases = aliases;
|
||||||
|
}
|
||||||
|
|
||||||
public LocalDateTime getCreatedAt() {
|
public LocalDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package com.storycove.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "tag_aliases")
|
||||||
|
public class TagAlias {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@NotBlank(message = "Alias name is required")
|
||||||
|
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||||
|
@Column(name = "alias_name", nullable = false, unique = true)
|
||||||
|
private String aliasName;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "canonical_tag_id", nullable = false)
|
||||||
|
@JsonManagedReference("tag-aliases")
|
||||||
|
private Tag canonicalTag;
|
||||||
|
|
||||||
|
@Column(name = "created_from_merge", nullable = false)
|
||||||
|
private Boolean createdFromMerge = false;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public TagAlias() {}
|
||||||
|
|
||||||
|
public TagAlias(String aliasName, Tag canonicalTag) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
this.canonicalTag = canonicalTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TagAlias(String aliasName, Tag canonicalTag, Boolean createdFromMerge) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
this.canonicalTag = canonicalTag;
|
||||||
|
this.createdFromMerge = createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAliasName() {
|
||||||
|
return aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAliasName(String aliasName) {
|
||||||
|
this.aliasName = aliasName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Tag getCanonicalTag() {
|
||||||
|
return canonicalTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanonicalTag(Tag canonicalTag) {
|
||||||
|
this.canonicalTag = canonicalTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getCreatedFromMerge() {
|
||||||
|
return createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||||
|
this.createdFromMerge = createdFromMerge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) return true;
|
||||||
|
if (!(o instanceof TagAlias)) return false;
|
||||||
|
TagAlias tagAlias = (TagAlias) o;
|
||||||
|
return id != null && id.equals(tagAlias.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return getClass().hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "TagAlias{" +
|
||||||
|
"id=" + id +
|
||||||
|
", aliasName='" + aliasName + '\'' +
|
||||||
|
", canonicalTag=" + (canonicalTag != null ? canonicalTag.getName() : null) +
|
||||||
|
", createdFromMerge=" + createdFromMerge +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.storycove.repository;
|
||||||
|
|
||||||
|
import com.storycove.entity.TagAlias;
|
||||||
|
import com.storycove.entity.Tag;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TagAliasRepository extends JpaRepository<TagAlias, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find alias by exact alias name (case-insensitive)
|
||||||
|
*/
|
||||||
|
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) = LOWER(:aliasName)")
|
||||||
|
Optional<TagAlias> findByAliasNameIgnoreCase(@Param("aliasName") String aliasName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all aliases for a specific canonical tag
|
||||||
|
*/
|
||||||
|
List<TagAlias> findByCanonicalTag(Tag canonicalTag);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all aliases for a specific canonical tag ID
|
||||||
|
*/
|
||||||
|
@Query("SELECT ta FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||||
|
List<TagAlias> findByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find aliases created from merge operations
|
||||||
|
*/
|
||||||
|
List<TagAlias> findByCreatedFromMergeTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an alias name already exists
|
||||||
|
*/
|
||||||
|
boolean existsByAliasNameIgnoreCase(String aliasName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all aliases for a specific tag
|
||||||
|
*/
|
||||||
|
void deleteByCanonicalTag(Tag canonicalTag);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count aliases for a specific tag
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(ta) FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||||
|
long countByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find aliases that start with the given prefix (case-insensitive)
|
||||||
|
*/
|
||||||
|
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) LIKE LOWER(CONCAT(:prefix, '%'))")
|
||||||
|
List<TagAlias> findByAliasNameStartingWithIgnoreCase(@Param("prefix") String prefix);
|
||||||
|
}
|
||||||
@@ -17,8 +17,12 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
|||||||
|
|
||||||
Optional<Tag> findByName(String name);
|
Optional<Tag> findByName(String name);
|
||||||
|
|
||||||
|
Optional<Tag> findByNameIgnoreCase(String name);
|
||||||
|
|
||||||
boolean existsByName(String name);
|
boolean existsByName(String name);
|
||||||
|
|
||||||
|
boolean existsByNameIgnoreCase(String name);
|
||||||
|
|
||||||
List<Tag> findByNameContainingIgnoreCase(String name);
|
List<Tag> findByNameContainingIgnoreCase(String name);
|
||||||
|
|
||||||
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||||
|
|||||||
@@ -620,9 +620,24 @@ public class StoryService {
|
|||||||
Author author = authorService.findById(updateReq.getAuthorId());
|
Author author = authorService.findById(updateReq.getAuthorId());
|
||||||
story.setAuthor(author);
|
story.setAuthor(author);
|
||||||
}
|
}
|
||||||
|
// Handle series - either by ID or by name
|
||||||
if (updateReq.getSeriesId() != null) {
|
if (updateReq.getSeriesId() != null) {
|
||||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||||
story.setSeries(series);
|
story.setSeries(series);
|
||||||
|
} else if (updateReq.getSeriesName() != null) {
|
||||||
|
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||||
|
// Empty series name means remove from series
|
||||||
|
story.setSeries(null);
|
||||||
|
} else {
|
||||||
|
// Find or create series by name
|
||||||
|
Series series = seriesService.findByNameOptional(updateReq.getSeriesName().trim())
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Series newSeries = new Series();
|
||||||
|
newSeries.setName(updateReq.getSeriesName().trim());
|
||||||
|
return seriesService.create(newSeries);
|
||||||
|
});
|
||||||
|
story.setSeries(series);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.storycove.service;
|
package com.storycove.service;
|
||||||
|
|
||||||
|
import com.storycove.entity.Story;
|
||||||
import com.storycove.entity.Tag;
|
import com.storycove.entity.Tag;
|
||||||
|
import com.storycove.entity.TagAlias;
|
||||||
import com.storycove.repository.TagRepository;
|
import com.storycove.repository.TagRepository;
|
||||||
|
import com.storycove.repository.TagAliasRepository;
|
||||||
import com.storycove.service.exception.DuplicateResourceException;
|
import com.storycove.service.exception.DuplicateResourceException;
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -12,8 +15,11 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -22,10 +28,12 @@ import java.util.UUID;
|
|||||||
public class TagService {
|
public class TagService {
|
||||||
|
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
|
private final TagAliasRepository tagAliasRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public TagService(TagRepository tagRepository) {
|
public TagService(TagRepository tagRepository, TagAliasRepository tagAliasRepository) {
|
||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
|
this.tagAliasRepository = tagAliasRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -207,5 +215,269 @@ public class TagService {
|
|||||||
if (updates.getName() != null) {
|
if (updates.getName() != null) {
|
||||||
existing.setName(updates.getName());
|
existing.setName(updates.getName());
|
||||||
}
|
}
|
||||||
|
if (updates.getColor() != null) {
|
||||||
|
existing.setColor(updates.getColor());
|
||||||
|
}
|
||||||
|
if (updates.getDescription() != null) {
|
||||||
|
existing.setDescription(updates.getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag alias management methods
|
||||||
|
|
||||||
|
public TagAlias addAlias(UUID tagId, String aliasName) {
|
||||||
|
Tag canonicalTag = findById(tagId);
|
||||||
|
|
||||||
|
// Check if alias already exists (case-insensitive)
|
||||||
|
if (tagAliasRepository.existsByAliasNameIgnoreCase(aliasName)) {
|
||||||
|
throw new DuplicateResourceException("Tag alias", aliasName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if alias name conflicts with existing tag names
|
||||||
|
if (tagRepository.existsByNameIgnoreCase(aliasName)) {
|
||||||
|
throw new DuplicateResourceException("Tag alias conflicts with existing tag name", aliasName);
|
||||||
|
}
|
||||||
|
|
||||||
|
TagAlias alias = new TagAlias();
|
||||||
|
alias.setAliasName(aliasName);
|
||||||
|
alias.setCanonicalTag(canonicalTag);
|
||||||
|
alias.setCreatedFromMerge(false);
|
||||||
|
|
||||||
|
return tagAliasRepository.save(alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeAlias(UUID tagId, UUID aliasId) {
|
||||||
|
Tag canonicalTag = findById(tagId);
|
||||||
|
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();
|
||||||
|
int totalStories = targetStoryCount + sourceTags.stream()
|
||||||
|
.mapToInt(tag -> tag.getStories().size())
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -827,6 +827,11 @@ public class TypesenseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("AUTHOR INDEX DEBUG: Starting to index {} authors", authors.size());
|
||||||
|
for (Author author : authors) {
|
||||||
|
logger.info("AUTHOR INDEX DEBUG: Indexing author: '{}'", author.getName());
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Index authors one by one for now (can optimize later)
|
// Index authors one by one for now (can optimize later)
|
||||||
for (Author author : authors) {
|
for (Author author : authors) {
|
||||||
@@ -860,6 +865,8 @@ public class TypesenseService {
|
|||||||
document.put("id", author.getId().toString());
|
document.put("id", author.getId().toString());
|
||||||
document.put("name", author.getName());
|
document.put("name", author.getName());
|
||||||
document.put("notes", author.getNotes() != null ? author.getNotes() : "");
|
document.put("notes", author.getNotes() != null ? author.getNotes() : "");
|
||||||
|
|
||||||
|
logger.info("INDEXING AUTHOR: '{}' with ID: {}", author.getName(), author.getId());
|
||||||
document.put("authorRating", author.getAuthorRating());
|
document.put("authorRating", author.getAuthorRating());
|
||||||
|
|
||||||
// Safely handle potentially lazy-loaded stories collection
|
// Safely handle potentially lazy-loaded stories collection
|
||||||
@@ -908,12 +915,27 @@ public class TypesenseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public SearchResultDto<AuthorSearchDto> searchAuthors(String query, int page, int perPage, String sortBy, String sortOrder) {
|
public SearchResultDto<AuthorSearchDto> searchAuthors(String query, int page, int perPage, String sortBy, String sortOrder) {
|
||||||
|
logger.info("AUTHOR SEARCH DEBUG: query='{}', page={}, perPage={}", query, page, perPage);
|
||||||
try {
|
try {
|
||||||
|
// For substring search, try the raw query and let Typesense handle partial matching
|
||||||
|
String searchQuery;
|
||||||
|
if (query != null && !query.trim().isEmpty()) {
|
||||||
|
// Use the raw query - Typesense should handle partial matching
|
||||||
|
searchQuery = query.trim();
|
||||||
|
} else {
|
||||||
|
searchQuery = "*";
|
||||||
|
}
|
||||||
|
|
||||||
SearchParameters searchParameters = new SearchParameters()
|
SearchParameters searchParameters = new SearchParameters()
|
||||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
.q(searchQuery)
|
||||||
.queryBy("name,notes")
|
.queryBy("name,notes")
|
||||||
.page(page + 1) // Typesense pages are 1-indexed
|
.page(page + 1) // Typesense pages are 1-indexed
|
||||||
.perPage(perPage);
|
.perPage(perPage)
|
||||||
|
.numTypos("2"); // Allow typos to increase matching flexibility
|
||||||
|
|
||||||
|
logger.info("AUTHOR SEARCH DEBUG: Using fuzzy search with query: '{}' for original query: '{}'", searchQuery, query);
|
||||||
|
|
||||||
|
logger.info("AUTHOR SEARCH DEBUG: searchParameters={}", searchParameters);
|
||||||
|
|
||||||
// Add sorting if specified, with fallback if sorting fails
|
// Add sorting if specified, with fallback if sorting fails
|
||||||
if (sortBy != null && !sortBy.trim().isEmpty()) {
|
if (sortBy != null && !sortBy.trim().isEmpty()) {
|
||||||
@@ -925,9 +947,11 @@ public class TypesenseService {
|
|||||||
|
|
||||||
SearchResult searchResult;
|
SearchResult searchResult;
|
||||||
try {
|
try {
|
||||||
|
logger.info("AUTHOR SEARCH DEBUG: Executing search with final parameters");
|
||||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||||
.documents()
|
.documents()
|
||||||
.search(searchParameters);
|
.search(searchParameters);
|
||||||
|
logger.info("AUTHOR SEARCH DEBUG: Search completed. Found {} results", searchResult.getHits().size());
|
||||||
} catch (Exception sortException) {
|
} catch (Exception sortException) {
|
||||||
// If sorting fails (likely due to schema issues), retry without sorting
|
// If sorting fails (likely due to schema issues), retry without sorting
|
||||||
logger.warn("Sorting failed for authors search, retrying without sort: " + sortException.getMessage());
|
logger.warn("Sorting failed for authors search, retrying without sort: " + sortException.getMessage());
|
||||||
@@ -938,12 +962,17 @@ public class TypesenseService {
|
|||||||
} catch (Exception debugException) {
|
} catch (Exception debugException) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use wildcard approach for fallback to handle substring search
|
||||||
|
String fallbackSearchQuery = query != null && !query.trim().isEmpty() ? "*" + query.trim() + "*" : "*";
|
||||||
|
|
||||||
searchParameters = new SearchParameters()
|
searchParameters = new SearchParameters()
|
||||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
.q(fallbackSearchQuery)
|
||||||
.queryBy("name,notes")
|
.queryBy("name,notes")
|
||||||
.page(page + 1)
|
.page(page + 1)
|
||||||
.perPage(perPage);
|
.perPage(perPage);
|
||||||
|
|
||||||
|
logger.info("AUTHOR SEARCH DEBUG: Using fallback infix search query: '{}'", fallbackSearchQuery);
|
||||||
|
|
||||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||||
.documents()
|
.documents()
|
||||||
.search(searchParameters);
|
.search(searchParameters);
|
||||||
|
|||||||
4
cookies.txt
Normal file
4
cookies.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
||||||
import { Story, Tag, FacetCount } from '../../types/api';
|
import { Story, Tag, FacetCount } from '../../types/api';
|
||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
@@ -20,6 +20,7 @@ type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount'
|
|||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { layout } = useLibraryLayout();
|
const { layout } = useLibraryLayout();
|
||||||
const [stories, setStories] = useState<Story[]>([]);
|
const [stories, setStories] = useState<Story[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
@@ -35,27 +36,95 @@ export default function LibraryPage() {
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalElements, setTotalElements] = useState(0);
|
const [totalElements, setTotalElements] = useState(0);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
|
||||||
|
|
||||||
|
// Initialize filters from URL parameters
|
||||||
|
useEffect(() => {
|
||||||
|
const tagsParam = searchParams.get('tags');
|
||||||
|
if (tagsParam) {
|
||||||
|
console.log('URL tag filter detected:', tagsParam);
|
||||||
|
// Use functional updates to ensure all state changes happen together
|
||||||
|
setSelectedTags([tagsParam]);
|
||||||
|
setPage(0); // Reset to first page when applying URL filter
|
||||||
|
}
|
||||||
|
setUrlParamsProcessed(true);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Convert facet counts to Tag objects for the UI, enriched with full tag data
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Convert facet counts to Tag objects for the UI
|
|
||||||
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
|
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
|
||||||
if (!facets || !facets.tagNames) {
|
if (!facets || !facets.tagNames) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return facets.tagNames.map(facet => ({
|
return facets.tagNames.map(facet => {
|
||||||
id: facet.value, // Use tag name as ID since we don't have actual IDs from search results
|
// Find the full tag data by name
|
||||||
name: facet.value,
|
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
|
||||||
storyCount: facet.count
|
|
||||||
}));
|
return {
|
||||||
|
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
|
||||||
|
name: facet.value,
|
||||||
|
storyCount: facet.count,
|
||||||
|
// Include color and other metadata from the full tag data
|
||||||
|
color: fullTag?.color,
|
||||||
|
description: fullTag?.description,
|
||||||
|
aliasCount: fullTag?.aliasCount,
|
||||||
|
createdAt: fullTag?.createdAt,
|
||||||
|
aliases: fullTag?.aliases
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Enrich existing tags when fullTags are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (fullTags.length > 0 && tags.length > 0) {
|
||||||
|
// Check if tags already have color data to avoid infinite loops
|
||||||
|
const hasColors = tags.some(tag => tag.color);
|
||||||
|
if (!hasColors) {
|
||||||
|
// Re-enrich existing tags with color data
|
||||||
|
const enrichedTags = tags.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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setTags(enrichedTags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fullTags, tags]); // Run when fullTags or tags change
|
||||||
|
|
||||||
// Debounce search to avoid too many API calls
|
// Debounce search to avoid too many API calls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Don't run search until URL parameters have been processed
|
||||||
|
if (!urlParamsProcessed) return;
|
||||||
|
|
||||||
const debounceTimer = setTimeout(() => {
|
const debounceTimer = setTimeout(() => {
|
||||||
const performSearch = async () => {
|
const performSearch = async () => {
|
||||||
try {
|
try {
|
||||||
// Use searchLoading for background search, loading only for initial load
|
// Use searchLoading for background search, loading only for initial load
|
||||||
const isInitialLoad = stories.length === 0 && !searchQuery && selectedTags.length === 0;
|
const isInitialLoad = stories.length === 0 && !searchQuery;
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -63,7 +132,7 @@ export default function LibraryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always use search API for consistency - use '*' for match-all when no query
|
// Always use search API for consistency - use '*' for match-all when no query
|
||||||
const result = await searchApi.search({
|
const apiParams = {
|
||||||
query: searchQuery.trim() || '*',
|
query: searchQuery.trim() || '*',
|
||||||
page: page, // Use 0-based pagination consistently
|
page: page, // Use 0-based pagination consistently
|
||||||
size: 20,
|
size: 20,
|
||||||
@@ -71,7 +140,10 @@ export default function LibraryPage() {
|
|||||||
sortBy: sortOption,
|
sortBy: sortOption,
|
||||||
sortDir: sortDirection,
|
sortDir: sortDirection,
|
||||||
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
facetBy: ['tagNames'], // Request tag facets for the filter UI
|
||||||
});
|
};
|
||||||
|
|
||||||
|
console.log('Performing search with params:', apiParams);
|
||||||
|
const result = await searchApi.search(apiParams);
|
||||||
|
|
||||||
const currentStories = result?.results || [];
|
const currentStories = result?.results || [];
|
||||||
setStories(currentStories);
|
setStories(currentStories);
|
||||||
@@ -96,7 +168,7 @@ export default function LibraryPage() {
|
|||||||
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
||||||
|
|
||||||
return () => clearTimeout(debounceTimer);
|
return () => clearTimeout(debounceTimer);
|
||||||
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger]);
|
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed]);
|
||||||
|
|
||||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
@@ -167,6 +239,7 @@ export default function LibraryPage() {
|
|||||||
const layoutProps = {
|
const layoutProps = {
|
||||||
stories,
|
stories,
|
||||||
tags,
|
tags,
|
||||||
|
totalElements,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
viewMode,
|
viewMode,
|
||||||
|
|||||||
@@ -774,6 +774,21 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tag Management */}
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold theme-header mb-4">Tag Management</h2>
|
||||||
|
<p className="theme-text mb-6">
|
||||||
|
Manage your story tags with colors, descriptions, and aliases. Use the Tag Maintenance page to organize and customize your tags.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href="/settings/tag-maintenance"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
🏷️ Open Tag Maintenance
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
519
frontend/src/app/settings/tag-maintenance/page.tsx
Normal file
519
frontend/src/app/settings/tag-maintenance/page.tsx
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
'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);
|
||||||
|
|
||||||
|
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 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">
|
||||||
|
{selectedTagIds.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTagIds(new Set())}
|
||||||
|
>
|
||||||
|
Clear Selection ({selectedTagIds.size})
|
||||||
|
</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-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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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">
|
||||||
|
<span>{tag.storyCount || 0} stories</span>
|
||||||
|
{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={!mergePreview || merging}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{merging ? 'Merging...' : 'Confirm Merge'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Story, Collection } from '../../../../types/api';
|
|||||||
import AppLayout from '../../../../components/layout/AppLayout';
|
import AppLayout from '../../../../components/layout/AppLayout';
|
||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||||
|
import TagDisplay from '../../../../components/tags/TagDisplay';
|
||||||
import { calculateReadingTime } from '../../../../lib/settings';
|
import { calculateReadingTime } from '../../../../lib/settings';
|
||||||
|
|
||||||
export default function StoryDetailPage() {
|
export default function StoryDetailPage() {
|
||||||
@@ -371,12 +372,12 @@ export default function StoryDetailPage() {
|
|||||||
<h3 className="font-semibold theme-header mb-3">Tags</h3>
|
<h3 className="font-semibold theme-header mb-3">Tags</h3>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{story.tags.map((tag) => (
|
{story.tags.map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
|
tag={tag}
|
||||||
>
|
size="md"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AppLayout from '../../../../components/layout/AppLayout';
|
|||||||
import { Input, Textarea } from '../../../../components/ui/Input';
|
import { Input, Textarea } from '../../../../components/ui/Input';
|
||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
import TagInput from '../../../../components/stories/TagInput';
|
import TagInput from '../../../../components/stories/TagInput';
|
||||||
|
import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
||||||
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
||||||
import ImageUpload from '../../../../components/ui/ImageUpload';
|
import ImageUpload from '../../../../components/ui/ImageUpload';
|
||||||
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
||||||
@@ -94,6 +95,15 @@ export default function EditStoryPage() {
|
|||||||
setFormData(prev => ({ ...prev, tags }));
|
setFormData(prev => ({ ...prev, tags }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddSuggestedTag = (tagName: string) => {
|
||||||
|
if (!formData.tags.includes(tagName.toLowerCase())) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
tags: [...prev.tags, tagName.toLowerCase()]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -150,8 +160,8 @@ export default function EditStoryPage() {
|
|||||||
summary: formData.summary || undefined,
|
summary: formData.summary || undefined,
|
||||||
contentHtml: formData.contentHtml,
|
contentHtml: formData.contentHtml,
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
volume: formData.seriesName && formData.volume ? parseInt(formData.volume) : undefined,
|
||||||
seriesName: formData.seriesName || undefined,
|
seriesName: formData.seriesName, // Send empty string to explicitly clear series
|
||||||
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
||||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||||
tagNames: formData.tags,
|
tagNames: formData.tags,
|
||||||
@@ -301,6 +311,16 @@ export default function EditStoryPage() {
|
|||||||
onChange={handleTagsChange}
|
onChange={handleTagsChange}
|
||||||
placeholder="Edit tags to categorize your story..."
|
placeholder="Edit tags to categorize your story..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Tag Suggestions */}
|
||||||
|
<TagSuggestions
|
||||||
|
title={formData.title}
|
||||||
|
content={formData.contentHtml}
|
||||||
|
summary={formData.summary}
|
||||||
|
currentTags={formData.tags}
|
||||||
|
onAddTag={handleAddSuggestedTag}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Series and Volume */}
|
{/* Series and Volume */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Story } from '../../../types/api';
|
|||||||
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
||||||
import Button from '../../../components/ui/Button';
|
import Button from '../../../components/ui/Button';
|
||||||
import StoryRating from '../../../components/stories/StoryRating';
|
import StoryRating from '../../../components/stories/StoryRating';
|
||||||
|
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||||
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
||||||
|
|
||||||
export default function StoryReadingPage() {
|
export default function StoryReadingPage() {
|
||||||
@@ -314,12 +315,12 @@ export default function StoryReadingPage() {
|
|||||||
{story.tags && story.tags.length > 0 && (
|
{story.tags && story.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||||
{story.tags.map((tag) => (
|
{story.tags.map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="px-3 py-1 text-sm theme-accent-bg text-white rounded-full"
|
tag={tag}
|
||||||
>
|
size="md"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { StoryWithCollectionContext } from '../../types/api';
|
import { StoryWithCollectionContext } from '../../types/api';
|
||||||
import { storyApi } from '../../lib/api';
|
import { storyApi } from '../../lib/api';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface CollectionReadingViewProps {
|
interface CollectionReadingViewProps {
|
||||||
@@ -255,12 +256,12 @@ export default function CollectionReadingView({
|
|||||||
{story.tags && story.tags.length > 0 && (
|
{story.tags && story.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{story.tags.map((tag) => (
|
{story.tags.map((tag) => (
|
||||||
<span
|
<TagDisplay
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
tag={tag}
|
||||||
>
|
size="sm"
|
||||||
{tag.name}
|
clickable={false}
|
||||||
</span>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import { Story, Tag } from '../../types/api';
|
import { Story, Tag } from '../../types/api';
|
||||||
|
|
||||||
interface MinimalLayoutProps {
|
interface MinimalLayoutProps {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
totalElements: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
@@ -26,6 +28,7 @@ interface MinimalLayoutProps {
|
|||||||
export default function MinimalLayout({
|
export default function MinimalLayout({
|
||||||
stories,
|
stories,
|
||||||
tags,
|
tags,
|
||||||
|
totalElements,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -41,9 +44,15 @@ export default function MinimalLayout({
|
|||||||
children
|
children
|
||||||
}: MinimalLayoutProps) {
|
}: MinimalLayoutProps) {
|
||||||
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
|
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
|
||||||
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
|
||||||
const popularTags = tags.slice(0, 5);
|
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;
|
||||||
|
|
||||||
const getSortDisplayText = () => {
|
const getSortDisplayText = () => {
|
||||||
const sortLabels: Record<string, string> = {
|
const sortLabels: Record<string, string> = {
|
||||||
lastRead: 'Last Read',
|
lastRead: 'Last Read',
|
||||||
@@ -62,7 +71,7 @@ export default function MinimalLayout({
|
|||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
<h1 className="text-4xl font-light theme-header mb-2">Story Library</h1>
|
<h1 className="text-4xl font-light theme-header mb-2">Story Library</h1>
|
||||||
<p className="theme-text text-lg mb-8">
|
<p className="theme-text text-lg mb-8">
|
||||||
Your personal collection of {stories.length} stories
|
Your personal collection of {totalElements} stories
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Button variant="primary" onClick={onRandomStory}>
|
<Button variant="primary" onClick={onRandomStory}>
|
||||||
@@ -139,17 +148,20 @@ export default function MinimalLayout({
|
|||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
{popularTags.map((tag) => (
|
{popularTags.map((tag) => (
|
||||||
<button
|
<div
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
onClick={() => onTagToggle(tag.name)}
|
onClick={() => onTagToggle(tag.name)}
|
||||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
selectedTags.includes(tag.name)
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-2' : ''
|
||||||
? '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'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.name}
|
<TagDisplay
|
||||||
</button>
|
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>
|
||||||
<div>
|
<div>
|
||||||
@@ -173,7 +185,10 @@ export default function MinimalLayout({
|
|||||||
<div className="flex justify-between items-center mb-5">
|
<div className="flex justify-between items-center mb-5">
|
||||||
<h3 className="text-xl font-semibold theme-header">Browse All Tags</h3>
|
<h3 className="text-xl font-semibold theme-header">Browse All Tags</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTagBrowserOpen(false)}
|
onClick={() => {
|
||||||
|
setTagBrowserOpen(false);
|
||||||
|
setTagSearch('');
|
||||||
|
}}
|
||||||
className="text-2xl theme-text hover:theme-accent transition-colors"
|
className="text-2xl theme-text hover:theme-accent transition-colors"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -184,31 +199,48 @@ export default function MinimalLayout({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search tags..."
|
placeholder="Search tags..."
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">
|
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">
|
||||||
{tags.map((tag) => (
|
{filteredTags.length === 0 && tagSearch ? (
|
||||||
<button
|
<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}
|
key={tag.id}
|
||||||
onClick={() => onTagToggle(tag.name)}
|
onClick={() => onTagToggle(tag.name)}
|
||||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left ${
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
selectedTags.includes(tag.name)
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||||
? 'bg-blue-500 text-white border-blue-500'
|
|
||||||
: 'bg-white dark:bg-gray-700 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.name} ({tag.storyCount})
|
<TagDisplay
|
||||||
</button>
|
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>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||||
|
Clear Search
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" onClick={onClearFilters}>
|
<Button variant="ghost" onClick={onClearFilters}>
|
||||||
Clear All
|
Clear All
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => setTagBrowserOpen(false)}>
|
<Button variant="primary" onClick={() => {
|
||||||
|
setTagBrowserOpen(false);
|
||||||
|
setTagSearch('');
|
||||||
|
}}>
|
||||||
Apply Filters
|
Apply Filters
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import { Story, Tag } from '../../types/api';
|
import { Story, Tag } from '../../types/api';
|
||||||
|
|
||||||
interface SidebarLayoutProps {
|
interface SidebarLayoutProps {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
totalElements: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
@@ -26,6 +28,7 @@ interface SidebarLayoutProps {
|
|||||||
export default function SidebarLayout({
|
export default function SidebarLayout({
|
||||||
stories,
|
stories,
|
||||||
tags,
|
tags,
|
||||||
|
totalElements,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -40,6 +43,13 @@ export default function SidebarLayout({
|
|||||||
onClearFilters,
|
onClearFilters,
|
||||||
children
|
children
|
||||||
}: SidebarLayoutProps) {
|
}: SidebarLayoutProps) {
|
||||||
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
|
||||||
|
// Filter tags based on search query
|
||||||
|
const filteredTags = tags.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
@@ -58,7 +68,7 @@ export default function SidebarLayout({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold theme-header">Your Library</h1>
|
<h1 className="text-2xl font-bold theme-header">Your Library</h1>
|
||||||
<p className="theme-text mt-1">{stories.length} stories total</p>
|
<p className="theme-text mt-1">{totalElements} stories total</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -125,6 +135,8 @@ export default function SidebarLayout({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search tags..."
|
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"
|
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,9 +148,9 @@ export default function SidebarLayout({
|
|||||||
checked={selectedTags.length === 0}
|
checked={selectedTags.length === 0}
|
||||||
onChange={() => onClearFilters()}
|
onChange={() => onClearFilters()}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">All Stories ({stories.length})</span>
|
<span className="text-xs">All Stories ({totalElements})</span>
|
||||||
</label>
|
</label>
|
||||||
{tags.map((tag) => (
|
{filteredTags.map((tag) => (
|
||||||
<label
|
<label
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="flex items-center gap-2 py-1 cursor-pointer"
|
className="flex items-center gap-2 py-1 cursor-pointer"
|
||||||
@@ -148,14 +160,27 @@ export default function SidebarLayout({
|
|||||||
checked={selectedTags.includes(tag.name)}
|
checked={selectedTags.includes(tag.name)}
|
||||||
onChange={() => onTagToggle(tag.name)}
|
onChange={() => onTagToggle(tag.name)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{tag.name} ({tag.storyCount})
|
<TagDisplay
|
||||||
</span>
|
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>
|
</label>
|
||||||
))}
|
))}
|
||||||
{tags.length > 10 && (
|
{filteredTags.length === 0 && tagSearch && (
|
||||||
<div className="text-center text-xs text-gray-500 py-2">
|
<div className="text-center text-xs text-gray-500 py-2">
|
||||||
... and {tags.length - 10} more tags
|
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>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Input } from '../ui/Input';
|
import { Input } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
import TagDisplay from '../tags/TagDisplay';
|
||||||
import { Story, Tag } from '../../types/api';
|
import { Story, Tag } from '../../types/api';
|
||||||
|
|
||||||
interface ToolbarLayoutProps {
|
interface ToolbarLayoutProps {
|
||||||
stories: Story[];
|
stories: Story[];
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
totalElements: number;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
@@ -26,6 +28,7 @@ interface ToolbarLayoutProps {
|
|||||||
export default function ToolbarLayout({
|
export default function ToolbarLayout({
|
||||||
stories,
|
stories,
|
||||||
tags,
|
tags,
|
||||||
|
totalElements,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -41,9 +44,17 @@ export default function ToolbarLayout({
|
|||||||
children
|
children
|
||||||
}: ToolbarLayoutProps) {
|
}: ToolbarLayoutProps) {
|
||||||
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
|
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
|
||||||
|
const [tagSearch, setTagSearch] = useState('');
|
||||||
|
|
||||||
const popularTags = tags.slice(0, 6);
|
const popularTags = tags.slice(0, 6);
|
||||||
const remainingTagsCount = Math.max(0, tags.length - 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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
|
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
|
||||||
@@ -53,7 +64,7 @@ export default function ToolbarLayout({
|
|||||||
<div className="flex justify-between items-start mb-6 max-md:flex-col max-md:gap-4">
|
<div className="flex justify-between items-start mb-6 max-md:flex-col max-md:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
|
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
|
||||||
<p className="theme-text mt-1">{stories.length} stories in your collection</p>
|
<p className="theme-text mt-1">{totalElements} stories in your collection</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-md:self-end">
|
<div className="max-md:self-end">
|
||||||
<Button variant="secondary" onClick={onRandomStory}>
|
<Button variant="secondary" onClick={onRandomStory}>
|
||||||
@@ -142,17 +153,20 @@ export default function ToolbarLayout({
|
|||||||
All Stories
|
All Stories
|
||||||
</button>
|
</button>
|
||||||
{popularTags.map((tag) => (
|
{popularTags.map((tag) => (
|
||||||
<button
|
<div
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
onClick={() => onTagToggle(tag.name)}
|
onClick={() => onTagToggle(tag.name)}
|
||||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
selectedTags.includes(tag.name)
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.name} ({tag.storyCount})
|
<TagDisplay
|
||||||
</button>
|
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>
|
||||||
))}
|
))}
|
||||||
{remainingTagsCount > 0 && (
|
{remainingTagsCount > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -163,7 +177,7 @@ export default function ToolbarLayout({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto text-sm theme-text">
|
<div className="ml-auto text-sm theme-text">
|
||||||
Showing {stories.length} stories
|
Showing {stories.length} of {totalElements} stories
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,9 +188,15 @@ export default function ToolbarLayout({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search from all available tags..."
|
placeholder="Search from all available tags..."
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={(e) => setTagSearch(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary">Search</Button>
|
{tagSearch && (
|
||||||
|
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setTagSearchExpanded(false)}
|
onClick={() => setTagSearchExpanded(false)}
|
||||||
@@ -185,19 +205,28 @@ export default function ToolbarLayout({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
||||||
{tags.slice(6).map((tag) => (
|
{filteredRemainingTags.length === 0 && tagSearch ? (
|
||||||
<button
|
<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}
|
key={tag.id}
|
||||||
onClick={() => onTagToggle(tag.name)}
|
onClick={() => onTagToggle(tag.name)}
|
||||||
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||||
selectedTags.includes(tag.name)
|
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-white dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.name} ({tag.storyCount})
|
<TagDisplay
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,11 +43,27 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
|||||||
return () => clearTimeout(debounce);
|
return () => clearTimeout(debounce);
|
||||||
}, [inputValue, tags]);
|
}, [inputValue, tags]);
|
||||||
|
|
||||||
const addTag = (tag: string) => {
|
const addTag = async (tag: string) => {
|
||||||
const trimmedTag = tag.trim().toLowerCase();
|
const trimmedTag = tag.trim().toLowerCase();
|
||||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
if (!trimmedTag) return;
|
||||||
onChange([...tags, trimmedTag]);
|
|
||||||
|
try {
|
||||||
|
// Resolve tag alias to canonical name
|
||||||
|
const resolvedTag = await tagApi.resolveTag(trimmedTag);
|
||||||
|
const finalTag = resolvedTag ? resolvedTag.name.toLowerCase() : trimmedTag;
|
||||||
|
|
||||||
|
// Only add if not already present
|
||||||
|
if (!tags.includes(finalTag)) {
|
||||||
|
onChange([...tags, finalTag]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resolve tag alias:', error);
|
||||||
|
// Fall back to original tag if resolution fails
|
||||||
|
if (!tags.includes(trimmedTag)) {
|
||||||
|
onChange([...tags, trimmedTag]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
setActiveSuggestionIndex(-1);
|
setActiveSuggestionIndex(-1);
|
||||||
|
|||||||
104
frontend/src/components/tags/TagDisplay.tsx
Normal file
104
frontend/src/components/tags/TagDisplay.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Tag } from '../../types/api';
|
||||||
|
|
||||||
|
interface TagDisplayProps {
|
||||||
|
tag: Tag;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showAliasesTooltip?: boolean;
|
||||||
|
clickable?: boolean;
|
||||||
|
onClick?: (tag: Tag) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagDisplay({
|
||||||
|
tag,
|
||||||
|
size = 'md',
|
||||||
|
showAliasesTooltip = true,
|
||||||
|
clickable = false,
|
||||||
|
onClick,
|
||||||
|
className = ''
|
||||||
|
}: TagDisplayProps) {
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-2 py-1 text-xs',
|
||||||
|
md: 'px-3 py-1 text-sm',
|
||||||
|
lg: 'px-4 py-2 text-base'
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `
|
||||||
|
inline-flex items-center gap-1 rounded-full font-medium transition-all
|
||||||
|
${sizeClasses[size]}
|
||||||
|
${clickable ? 'cursor-pointer hover:scale-105' : ''}
|
||||||
|
${className}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Determine tag styling based on color
|
||||||
|
const tagStyle = tag.color ? {
|
||||||
|
backgroundColor: tag.color + '20', // Add 20% opacity
|
||||||
|
borderColor: tag.color,
|
||||||
|
color: tag.color
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
const defaultClasses = !tag.color ?
|
||||||
|
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600' :
|
||||||
|
'border';
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (clickable && onClick) {
|
||||||
|
onClick(tag);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (showAliasesTooltip && tag.aliases && tag.aliases.length > 0) {
|
||||||
|
setShowTooltip(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setShowTooltip(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<span
|
||||||
|
className={`${baseClasses} ${defaultClasses}`}
|
||||||
|
style={tag.color ? tagStyle : {}}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
title={tag.description || undefined}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
{(tag.aliasCount ?? 0) > 0 && (
|
||||||
|
<span className="text-xs opacity-75">+{tag.aliasCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Tooltip for aliases */}
|
||||||
|
{showTooltip && showAliasesTooltip && tag.aliases && tag.aliases.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50">
|
||||||
|
<div className="bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg px-3 py-2 max-w-xs">
|
||||||
|
<div className="font-medium mb-1">{tag.name}</div>
|
||||||
|
<div className="border-t border-gray-700 dark:border-gray-300 pt-1">
|
||||||
|
<div className="text-gray-300 dark:text-gray-600 mb-1">Aliases:</div>
|
||||||
|
{tag.aliases.map((alias, index) => (
|
||||||
|
<div key={alias.id} className="text-gray-100 dark:text-gray-800">
|
||||||
|
{alias.aliasName}
|
||||||
|
{index < tag.aliases!.length - 1 && ', '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Tooltip arrow */}
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
|
||||||
|
<div className="border-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
frontend/src/components/tags/TagEditModal.tsx
Normal file
324
frontend/src/components/tags/TagEditModal.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Tag, TagAlias } from '../../types/api';
|
||||||
|
import { tagApi } from '../../lib/api';
|
||||||
|
import { Input, Textarea } from '../ui/Input';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import ColorPicker from '../ui/ColorPicker';
|
||||||
|
|
||||||
|
interface TagEditModalProps {
|
||||||
|
tag?: Tag;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (tag: Tag) => void;
|
||||||
|
onDelete?: (tag: Tag) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagEditModal({ tag, isOpen, onClose, onSave, onDelete }: TagEditModalProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
color: '',
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
const [aliases, setAliases] = useState<TagAlias[]>([]);
|
||||||
|
const [newAlias, setNewAlias] = useState('');
|
||||||
|
const [loading, setSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Reset form when modal opens/closes or tag changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && tag) {
|
||||||
|
setFormData({
|
||||||
|
name: tag.name || '',
|
||||||
|
color: tag.color || '',
|
||||||
|
description: tag.description || ''
|
||||||
|
});
|
||||||
|
setAliases(tag.aliases || []);
|
||||||
|
} else if (isOpen && !tag) {
|
||||||
|
// New tag
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
color: '',
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
setAliases([]);
|
||||||
|
}
|
||||||
|
setNewAlias('');
|
||||||
|
setErrors({});
|
||||||
|
setDeleteConfirm(false);
|
||||||
|
}, [isOpen, tag]);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAlias = async () => {
|
||||||
|
if (!newAlias.trim() || !tag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if alias already exists
|
||||||
|
if (aliases.some(alias => alias.aliasName.toLowerCase() === newAlias.toLowerCase())) {
|
||||||
|
setErrors({ alias: 'This alias already exists for this tag' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create alias via API
|
||||||
|
const newAliasData = await tagApi.addAlias(tag.id, newAlias.trim());
|
||||||
|
setAliases(prev => [...prev, newAliasData]);
|
||||||
|
setNewAlias('');
|
||||||
|
setErrors(prev => ({ ...prev, alias: '' }));
|
||||||
|
} catch (error) {
|
||||||
|
setErrors({ alias: 'Failed to add alias' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAlias = async (aliasId: string) => {
|
||||||
|
if (!tag) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tagApi.removeAlias(tag.id, aliasId);
|
||||||
|
setAliases(prev => prev.filter(alias => alias.id !== aliasId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove alias:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setErrors({});
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
color: formData.color || undefined,
|
||||||
|
description: formData.description || undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
let savedTag: Tag;
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
// Update existing tag
|
||||||
|
savedTag = await tagApi.updateTag(tag.id, payload);
|
||||||
|
} else {
|
||||||
|
// Create new tag
|
||||||
|
savedTag = await tagApi.createTag(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include aliases in the saved tag
|
||||||
|
savedTag.aliases = aliases;
|
||||||
|
onSave(savedTag);
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrors({ submit: error.message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!tag || !onDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await tagApi.deleteTag(tag.id);
|
||||||
|
onDelete(tag);
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
setErrors({ submit: error.message });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6 border-b theme-border">
|
||||||
|
<h2 className="text-xl font-semibold theme-header">
|
||||||
|
{tag ? `Edit Tag: "${tag.name}"` : 'Create New Tag'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Tag Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
|
error={errors.name}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Enter tag name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ColorPicker
|
||||||
|
label="Color (Optional)"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(color) => handleInputChange('color', color || '')}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Description (Optional)"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
error={errors.description}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Optional description for this tag"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aliases Section (only for existing tags) */}
|
||||||
|
{tag && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium theme-header">
|
||||||
|
Aliases ({aliases.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{aliases.length > 0 && (
|
||||||
|
<div className="space-y-2 max-h-32 overflow-y-auto border theme-border rounded-lg p-3">
|
||||||
|
{aliases.map((alias) => (
|
||||||
|
<div key={alias.id} className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-sm theme-text">
|
||||||
|
{alias.aliasName}
|
||||||
|
{alias.createdFromMerge && (
|
||||||
|
<span className="ml-2 text-xs theme-text-muted">(from merge)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveAlias(alias.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-xs text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newAlias}
|
||||||
|
onChange={(e) => setNewAlias(e.target.value)}
|
||||||
|
placeholder="Add new alias"
|
||||||
|
error={errors.alias}
|
||||||
|
disabled={loading}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleAddAlias()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleAddAlias}
|
||||||
|
disabled={loading || !newAlias.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Story Information (for existing tags) */}
|
||||||
|
{tag && (
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<h3 className="text-sm font-medium theme-header mb-2">Usage Statistics</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="theme-text-muted">Stories:</span>
|
||||||
|
<span className="ml-2 font-medium">{tag.storyCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="theme-text-muted">Collections:</span>
|
||||||
|
<span className="ml-2 font-medium">{tag.collectionCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tag.storyCount && tag.storyCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2 text-xs"
|
||||||
|
onClick={() => window.open(`/library?tags=${encodeURIComponent(tag.name)}`, '_blank')}
|
||||||
|
>
|
||||||
|
View Stories →
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{errors.submit && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{errors.submit}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="p-6 border-t theme-border flex justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{tag && onDelete && (
|
||||||
|
<>
|
||||||
|
{!deleteConfirm ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Delete Tag
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDeleteConfirm(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-sm bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{loading ? 'Deleting...' : 'Confirm Delete'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || !formData.name.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : (tag ? 'Save Changes' : 'Create Tag')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
frontend/src/components/tags/TagSuggestions.tsx
Normal file
146
frontend/src/components/tags/TagSuggestions.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { tagApi } from '../../lib/api';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||||
|
|
||||||
|
interface TagSuggestion {
|
||||||
|
tagName: string;
|
||||||
|
confidence: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagSuggestionsProps {
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
summary?: string;
|
||||||
|
currentTags: string[];
|
||||||
|
onAddTag: (tagName: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagSuggestions({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
currentTags,
|
||||||
|
onAddTag,
|
||||||
|
disabled = false
|
||||||
|
}: TagSuggestionsProps) {
|
||||||
|
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastAnalyzed, setLastAnalyzed] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const analyzeContent = async () => {
|
||||||
|
// Only analyze if we have meaningful content and it has changed
|
||||||
|
const contentKey = `${title}|${summary}`;
|
||||||
|
if (!title.trim() || contentKey === lastAnalyzed || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tagSuggestions = await tagApi.suggestTags(title, content, summary, 8);
|
||||||
|
|
||||||
|
// Filter out suggestions that are already selected
|
||||||
|
const filteredSuggestions = tagSuggestions.filter(
|
||||||
|
suggestion => !currentTags.some(tag =>
|
||||||
|
tag.toLowerCase() === suggestion.tagName.toLowerCase()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setSuggestions(filteredSuggestions);
|
||||||
|
setLastAnalyzed(contentKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get tag suggestions:', error);
|
||||||
|
setSuggestions([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the analysis
|
||||||
|
const debounce = setTimeout(analyzeContent, 1000);
|
||||||
|
return () => clearTimeout(debounce);
|
||||||
|
}, [title, content, summary, currentTags, lastAnalyzed, disabled]);
|
||||||
|
|
||||||
|
const handleAddTag = (tagName: string) => {
|
||||||
|
onAddTag(tagName);
|
||||||
|
// Remove the added tag from suggestions
|
||||||
|
setSuggestions(prev => prev.filter(s => s.tagName !== tagName));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: number) => {
|
||||||
|
if (confidence >= 0.7) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (confidence >= 0.5) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfidenceLabel = (confidence: number) => {
|
||||||
|
if (confidence >= 0.7) return 'High';
|
||||||
|
if (confidence >= 0.5) return 'Medium';
|
||||||
|
return 'Low';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (disabled || (!title.trim() && !summary?.trim())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<h3 className="text-sm font-medium theme-text">Suggested Tags</h3>
|
||||||
|
{loading && <LoadingSpinner size="sm" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggestions.length === 0 && !loading ? (
|
||||||
|
<p className="text-sm theme-text-muted">
|
||||||
|
{title.trim() ? 'No tag suggestions found for this content' : 'Enter a title to get tag suggestions'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<div
|
||||||
|
key={suggestion.tagName}
|
||||||
|
className="flex items-center justify-between p-3 border theme-border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium theme-text">{suggestion.tagName}</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full border ${getConfidenceColor(suggestion.confidence)}`}>
|
||||||
|
{getConfidenceLabel(suggestion.confidence)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs theme-text-muted mt-1">{suggestion.reason}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddTag(suggestion.tagName)}
|
||||||
|
className="ml-3"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
suggestions.forEach(s => handleAddTag(s.tagName));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add All Suggestions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
frontend/src/components/ui/ColorPicker.tsx
Normal file
171
frontend/src/components/ui/ColorPicker.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Button from './Button';
|
||||||
|
|
||||||
|
interface ColorPickerProps {
|
||||||
|
value?: string;
|
||||||
|
onChange: (color: string | undefined) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-compatible color palette
|
||||||
|
const THEME_COLORS = [
|
||||||
|
// Primary blues
|
||||||
|
{ hex: '#3B82F6', name: 'Theme Blue' },
|
||||||
|
{ hex: '#1D4ED8', name: 'Deep Blue' },
|
||||||
|
{ hex: '#60A5FA', name: 'Light Blue' },
|
||||||
|
|
||||||
|
// Greens
|
||||||
|
{ hex: '#10B981', name: 'Emerald' },
|
||||||
|
{ hex: '#059669', name: 'Forest Green' },
|
||||||
|
{ hex: '#34D399', name: 'Light Green' },
|
||||||
|
|
||||||
|
// Purples
|
||||||
|
{ hex: '#8B5CF6', name: 'Purple' },
|
||||||
|
{ hex: '#7C3AED', name: 'Deep Purple' },
|
||||||
|
{ hex: '#A78BFA', name: 'Light Purple' },
|
||||||
|
|
||||||
|
// Warm tones
|
||||||
|
{ hex: '#F59E0B', name: 'Amber' },
|
||||||
|
{ hex: '#D97706', name: 'Orange' },
|
||||||
|
{ hex: '#F97316', name: 'Bright Orange' },
|
||||||
|
|
||||||
|
// Reds/Pinks
|
||||||
|
{ hex: '#EF4444', name: 'Red' },
|
||||||
|
{ hex: '#F472B6', name: 'Pink' },
|
||||||
|
{ hex: '#EC4899', name: 'Hot Pink' },
|
||||||
|
|
||||||
|
// Neutrals
|
||||||
|
{ hex: '#6B7280', name: 'Gray' },
|
||||||
|
{ hex: '#4B5563', name: 'Dark Gray' },
|
||||||
|
{ hex: '#9CA3AF', name: 'Light Gray' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ColorPicker({ value, onChange, disabled, label }: ColorPickerProps) {
|
||||||
|
const [showCustomPicker, setShowCustomPicker] = useState(false);
|
||||||
|
const [customColor, setCustomColor] = useState(value || '#3B82F6');
|
||||||
|
|
||||||
|
const handleThemeColorSelect = (color: string) => {
|
||||||
|
onChange(color);
|
||||||
|
setShowCustomPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomColorChange = (color: string) => {
|
||||||
|
setCustomColor(color);
|
||||||
|
onChange(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveColor = () => {
|
||||||
|
onChange(undefined);
|
||||||
|
setShowCustomPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium theme-header">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Color Display */}
|
||||||
|
{value && (
|
||||||
|
<div className="flex items-center gap-2 p-2 border theme-border rounded-lg">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
|
||||||
|
style={{ backgroundColor: value }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm theme-text font-mono">{value}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemoveColor}
|
||||||
|
disabled={disabled}
|
||||||
|
className="ml-auto text-xs"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Color Palette */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium theme-header">Theme Colors</h4>
|
||||||
|
<div className="grid grid-cols-6 gap-2 p-3 border theme-border rounded-lg">
|
||||||
|
{THEME_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.hex}
|
||||||
|
type="button"
|
||||||
|
className={`
|
||||||
|
w-8 h-8 rounded-md border-2 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-theme-accent
|
||||||
|
${value === color.hex ? 'border-gray-800 dark:border-white scale-110' : 'border-gray-300 dark:border-gray-600'}
|
||||||
|
`}
|
||||||
|
style={{ backgroundColor: color.hex }}
|
||||||
|
onClick={() => handleThemeColorSelect(color.hex)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Color Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium theme-header">Custom Color</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowCustomPicker(!showCustomPicker)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{showCustomPicker ? 'Hide' : 'Show'} Custom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCustomPicker && (
|
||||||
|
<div className="p-3 border theme-border rounded-lg space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={customColor}
|
||||||
|
onChange={(e) => handleCustomColorChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-12 h-8 rounded border border-gray-300 dark:border-gray-600 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const color = e.target.value;
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||||
|
setCustomColor(color);
|
||||||
|
onChange(color);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 px-3 py-1 text-sm border theme-border rounded font-mono"
|
||||||
|
placeholder="#3B82F6"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange(customColor)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs theme-text-muted">
|
||||||
|
Enter a hex color code or use the color picker
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
|
import { AuthResponse, Story, Author, Tag, TagAlias, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||||
|
|
||||||
@@ -303,6 +303,33 @@ export const tagApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTag: async (id: string): Promise<Tag> => {
|
||||||
|
const response = await api.get(`/tags/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createTag: async (tagData: {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<Tag> => {
|
||||||
|
const response = await api.post('/tags', tagData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTag: async (id: string, tagData: {
|
||||||
|
name?: string;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<Tag> => {
|
||||||
|
const response = await api.put(`/tags/${id}`, tagData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTag: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/tags/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
getTagAutocomplete: async (query: string): Promise<string[]> => {
|
getTagAutocomplete: async (query: string): Promise<string[]> => {
|
||||||
const response = await api.get('/tags/autocomplete', { params: { query } });
|
const response = await api.get('/tags/autocomplete', { params: { query } });
|
||||||
// Backend returns TagDto[], extract just the names
|
// Backend returns TagDto[], extract just the names
|
||||||
@@ -313,6 +340,76 @@ export const tagApi = {
|
|||||||
const response = await api.get('/tags/collections');
|
const response = await api.get('/tags/collections');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Alias operations
|
||||||
|
addAlias: async (tagId: string, aliasName: string): Promise<TagAlias> => {
|
||||||
|
const response = await api.post(`/tags/${tagId}/aliases`, { aliasName });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAlias: async (tagId: string, aliasId: string): Promise<void> => {
|
||||||
|
await api.delete(`/tags/${tagId}/aliases/${aliasId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveTag: async (name: string): Promise<Tag | null> => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/tags/resolve/${encodeURIComponent(name)}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Batch resolve multiple tag names to their canonical forms
|
||||||
|
resolveTags: async (names: string[]): Promise<string[]> => {
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
names.map(async (name) => {
|
||||||
|
const tag = await tagApi.resolveTag(name);
|
||||||
|
return tag ? tag.name : name; // Return canonical name or original if not found
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return resolved;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Merge operations
|
||||||
|
previewMerge: async (sourceTagIds: string[], targetTagId: string): Promise<{
|
||||||
|
targetTagName: string;
|
||||||
|
targetStoryCount: number;
|
||||||
|
totalResultStoryCount: number;
|
||||||
|
aliasesToCreate: string[];
|
||||||
|
}> => {
|
||||||
|
const response = await api.post('/tags/merge/preview', {
|
||||||
|
sourceTagIds,
|
||||||
|
targetTagId
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeTags: async (sourceTagIds: string[], targetTagId: string): Promise<Tag> => {
|
||||||
|
const response = await api.post('/tags/merge', {
|
||||||
|
sourceTagIds,
|
||||||
|
targetTagId
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tag suggestions
|
||||||
|
suggestTags: async (title: string, content?: string, summary?: string, limit?: number): Promise<{
|
||||||
|
tagName: string;
|
||||||
|
confidence: number;
|
||||||
|
reason: string;
|
||||||
|
}[]> => {
|
||||||
|
const response = await api.post('/tags/suggest', {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
limit: limit || 10
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Series endpoints
|
// Series endpoints
|
||||||
@@ -347,6 +444,18 @@ export const searchApi = {
|
|||||||
sortDir?: string;
|
sortDir?: string;
|
||||||
facetBy?: string[];
|
facetBy?: string[];
|
||||||
}): Promise<SearchResult> => {
|
}): Promise<SearchResult> => {
|
||||||
|
// Resolve tag aliases to canonical names for expanded search
|
||||||
|
let resolvedTags = params.tags;
|
||||||
|
if (params.tags && params.tags.length > 0) {
|
||||||
|
try {
|
||||||
|
resolvedTags = await tagApi.resolveTags(params.tags);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resolve tag aliases during search:', error);
|
||||||
|
// Fall back to original tags if resolution fails
|
||||||
|
resolvedTags = params.tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create URLSearchParams to properly handle array parameters
|
// Create URLSearchParams to properly handle array parameters
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
@@ -363,8 +472,8 @@ export const searchApi = {
|
|||||||
if (params.authors && params.authors.length > 0) {
|
if (params.authors && params.authors.length > 0) {
|
||||||
params.authors.forEach(author => searchParams.append('authors', author));
|
params.authors.forEach(author => searchParams.append('authors', author));
|
||||||
}
|
}
|
||||||
if (params.tags && params.tags.length > 0) {
|
if (resolvedTags && resolvedTags.length > 0) {
|
||||||
params.tags.forEach(tag => searchParams.append('tags', tag));
|
resolvedTags.forEach(tag => searchParams.append('tags', tag));
|
||||||
}
|
}
|
||||||
if (params.facetBy && params.facetBy.length > 0) {
|
if (params.facetBy && params.facetBy.length > 0) {
|
||||||
params.facetBy.forEach(facet => searchParams.append('facetBy', facet));
|
params.facetBy.forEach(facet => searchParams.append('facetBy', facet));
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ export class StoryScraper {
|
|||||||
if (siteConfig.story.tags) {
|
if (siteConfig.story.tags) {
|
||||||
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
|
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
|
||||||
if (Array.isArray(tagsResult)) {
|
if (Array.isArray(tagsResult)) {
|
||||||
story.tags = tagsResult;
|
// Resolve tag aliases to canonical names
|
||||||
|
story.tags = await this.resolveTagAliases(tagsResult);
|
||||||
} else if (typeof tagsResult === 'string' && tagsResult) {
|
} else if (typeof tagsResult === 'string' && tagsResult) {
|
||||||
story.tags = [tagsResult];
|
// Resolve tag aliases to canonical names
|
||||||
|
story.tags = await this.resolveTagAliases([tagsResult]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,4 +381,21 @@ export class StoryScraper {
|
|||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveTagAliases(tags: string[]): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// Import the tagApi dynamically to avoid circular dependencies
|
||||||
|
const { tagApi } = await import('../api');
|
||||||
|
|
||||||
|
// Resolve all tags to their canonical names
|
||||||
|
const resolvedTags = await tagApi.resolveTags(tags);
|
||||||
|
|
||||||
|
// Filter out empty tags
|
||||||
|
return resolvedTags.filter(tag => tag && tag.trim().length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to resolve tag aliases during scraping:', error);
|
||||||
|
// Fall back to original tags if resolution fails
|
||||||
|
return tags.filter(tag => tag && tag.trim().length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -43,12 +43,25 @@ export interface AuthorUrl {
|
|||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
color?: string; // hex color like #3B82F6
|
||||||
|
description?: string;
|
||||||
storyCount?: number;
|
storyCount?: number;
|
||||||
collectionCount?: number;
|
collectionCount?: number;
|
||||||
|
aliasCount?: number;
|
||||||
|
aliases?: TagAlias[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagAlias {
|
||||||
|
id: string;
|
||||||
|
aliasName: string;
|
||||||
|
canonicalTagId: string;
|
||||||
|
canonicalTag?: Tag;
|
||||||
|
createdFromMerge: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Series {
|
export interface Series {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
44
test_author_search.js
Normal file
44
test_author_search.js
Normal 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();
|
||||||
Reference in New Issue
Block a user