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")
|
||||
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
|
||||
@@ -420,9 +420,7 @@ public class StoryController {
|
||||
if (updateReq.getSourceUrl() != null) {
|
||||
story.setSourceUrl(updateReq.getSourceUrl());
|
||||
}
|
||||
if (updateReq.getVolume() != null) {
|
||||
story.setVolume(updateReq.getVolume());
|
||||
}
|
||||
// Volume will be handled in series logic below
|
||||
// Handle author - either by ID or by name
|
||||
if (updateReq.getAuthorId() != null) {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
@@ -431,13 +429,34 @@ public class StoryController {
|
||||
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
||||
story.setAuthor(author);
|
||||
}
|
||||
// Handle series - either by ID or by name
|
||||
// Handle series - either by ID, by name, or remove from series
|
||||
if (updateReq.getSeriesId() != null) {
|
||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||
story.setSeries(series);
|
||||
} else if (updateReq.getSeriesName() != null && !updateReq.getSeriesName().trim().isEmpty()) {
|
||||
} else if (updateReq.getSeriesName() != null) {
|
||||
logger.info("Processing series update: seriesName='{}', isEmpty={}", updateReq.getSeriesName(), updateReq.getSeriesName().trim().isEmpty());
|
||||
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||
// Empty series name means remove from series
|
||||
logger.info("Removing story from series");
|
||||
if (story.getSeries() != null) {
|
||||
story.getSeries().removeStory(story);
|
||||
story.setSeries(null);
|
||||
story.setVolume(null);
|
||||
logger.info("Story removed from series");
|
||||
}
|
||||
} else {
|
||||
// Non-empty series name means add to series
|
||||
logger.info("Adding story to series: '{}', volume: {}", updateReq.getSeriesName().trim(), updateReq.getVolume());
|
||||
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
||||
story.setSeries(series);
|
||||
// Set volume only if series is being set
|
||||
if (updateReq.getVolume() != null) {
|
||||
story.setVolume(updateReq.getVolume());
|
||||
logger.info("Story added to series: {} with volume: {}", series.getName(), updateReq.getVolume());
|
||||
} else {
|
||||
logger.info("Story added to series: {} with no volume", series.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
||||
@@ -559,8 +578,11 @@ public class StoryController {
|
||||
TagDto tagDto = new TagDto();
|
||||
tagDto.setId(tag.getId());
|
||||
tagDto.setName(tag.getName());
|
||||
tagDto.setColor(tag.getColor());
|
||||
tagDto.setDescription(tag.getDescription());
|
||||
tagDto.setCreatedAt(tag.getCreatedAt());
|
||||
// storyCount can be set if needed, but it might be expensive to calculate for each tag
|
||||
tagDto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||
tagDto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||
return tagDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.TagDto;
|
||||
import com.storycove.dto.TagAliasDto;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.service.TagService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -21,6 +25,7 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/tags")
|
||||
public class TagController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TagController.class);
|
||||
private final TagService tagService;
|
||||
|
||||
public TagController(TagService tagService) {
|
||||
@@ -54,6 +59,8 @@ public class TagController {
|
||||
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
|
||||
Tag tag = new Tag();
|
||||
tag.setName(request.getName());
|
||||
tag.setColor(request.getColor());
|
||||
tag.setDescription(request.getDescription());
|
||||
|
||||
Tag savedTag = tagService.create(tag);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
|
||||
@@ -66,6 +73,12 @@ public class TagController {
|
||||
if (request.getName() != null) {
|
||||
existingTag.setName(request.getName());
|
||||
}
|
||||
if (request.getColor() != null) {
|
||||
existingTag.setColor(request.getColor());
|
||||
}
|
||||
if (request.getDescription() != null) {
|
||||
existingTag.setDescription(request.getDescription());
|
||||
}
|
||||
|
||||
Tag updatedTag = tagService.update(id, existingTag);
|
||||
return ResponseEntity.ok(convertToDto(updatedTag));
|
||||
@@ -95,7 +108,7 @@ public class TagController {
|
||||
@RequestParam String query,
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
|
||||
List<Tag> tags = tagService.findByNameStartingWith(query, limit);
|
||||
List<Tag> tags = tagService.findByNameOrAliasStartingWith(query, limit);
|
||||
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(tagDtos);
|
||||
@@ -142,15 +155,124 @@ public class TagController {
|
||||
return ResponseEntity.ok(tagDtos);
|
||||
}
|
||||
|
||||
// Tag alias endpoints
|
||||
@PostMapping("/{tagId}/aliases")
|
||||
public ResponseEntity<TagAliasDto> addAlias(@PathVariable UUID tagId,
|
||||
@RequestBody Map<String, String> request) {
|
||||
String aliasName = request.get("aliasName");
|
||||
if (aliasName == null || aliasName.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
try {
|
||||
TagAlias alias = tagService.addAlias(tagId, aliasName.trim());
|
||||
TagAliasDto dto = new TagAliasDto();
|
||||
dto.setId(alias.getId());
|
||||
dto.setAliasName(alias.getAliasName());
|
||||
dto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||
dto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||
dto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||
dto.setCreatedAt(alias.getCreatedAt());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{tagId}/aliases/{aliasId}")
|
||||
public ResponseEntity<?> removeAlias(@PathVariable UUID tagId, @PathVariable UUID aliasId) {
|
||||
try {
|
||||
tagService.removeAlias(tagId, aliasId);
|
||||
return ResponseEntity.ok(Map.of("message", "Alias removed successfully"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/resolve/{name}")
|
||||
public ResponseEntity<TagDto> resolveTag(@PathVariable String name) {
|
||||
try {
|
||||
Tag resolvedTag = tagService.resolveTagByName(name);
|
||||
if (resolvedTag != null) {
|
||||
return ResponseEntity.ok(convertToDto(resolvedTag));
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/merge")
|
||||
public ResponseEntity<?> mergeTags(@Valid @RequestBody MergeTagsRequest request) {
|
||||
try {
|
||||
Tag resultTag = tagService.mergeTags(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||
return ResponseEntity.ok(convertToDto(resultTag));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to merge tags", e);
|
||||
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/merge/preview")
|
||||
public ResponseEntity<?> previewMerge(@Valid @RequestBody MergeTagsRequest request) {
|
||||
try {
|
||||
MergePreviewResponse preview = tagService.previewMerge(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||
return ResponseEntity.ok(preview);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to preview merge", e);
|
||||
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/suggest")
|
||||
public ResponseEntity<List<TagSuggestion>> suggestTags(@RequestBody TagSuggestionRequest request) {
|
||||
try {
|
||||
List<TagSuggestion> suggestions = tagService.suggestTags(
|
||||
request.getTitle(),
|
||||
request.getContent(),
|
||||
request.getSummary(),
|
||||
request.getLimit() != null ? request.getLimit() : 10
|
||||
);
|
||||
return ResponseEntity.ok(suggestions);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to suggest tags", e);
|
||||
return ResponseEntity.ok(List.of()); // Return empty list on error
|
||||
}
|
||||
}
|
||||
|
||||
private TagDto convertToDto(Tag tag) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setColor(tag.getColor());
|
||||
dto.setDescription(tag.getDescription());
|
||||
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
||||
dto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||
dto.setCreatedAt(tag.getCreatedAt());
|
||||
// updatedAt field not present in Tag entity per spec
|
||||
|
||||
// Convert aliases to DTOs for full context
|
||||
if (tag.getAliases() != null && !tag.getAliases().isEmpty()) {
|
||||
List<TagAliasDto> aliaseDtos = tag.getAliases().stream()
|
||||
.map(alias -> {
|
||||
TagAliasDto aliasDto = new TagAliasDto();
|
||||
aliasDto.setId(alias.getId());
|
||||
aliasDto.setAliasName(alias.getAliasName());
|
||||
aliasDto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||
aliasDto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||
aliasDto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||
aliasDto.setCreatedAt(alias.getCreatedAt());
|
||||
return aliasDto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
dto.setAliases(aliaseDtos);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -168,15 +290,112 @@ public class TagController {
|
||||
// Request DTOs
|
||||
public static class CreateTagRequest {
|
||||
private String name;
|
||||
private String color;
|
||||
private String description;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getColor() { return color; }
|
||||
public void setColor(String color) { this.color = color; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
}
|
||||
|
||||
public static class UpdateTagRequest {
|
||||
private String name;
|
||||
private String color;
|
||||
private String description;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getColor() { return color; }
|
||||
public void setColor(String color) { this.color = color; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
}
|
||||
|
||||
public static class MergeTagsRequest {
|
||||
private List<String> sourceTagIds;
|
||||
private String targetTagId;
|
||||
|
||||
public List<String> getSourceTagIds() { return sourceTagIds; }
|
||||
public void setSourceTagIds(List<String> sourceTagIds) { this.sourceTagIds = sourceTagIds; }
|
||||
|
||||
public String getTargetTagId() { return targetTagId; }
|
||||
public void setTargetTagId(String targetTagId) { this.targetTagId = targetTagId; }
|
||||
|
||||
// Helper methods to convert to UUID
|
||||
public List<UUID> getSourceTagUUIDs() {
|
||||
return sourceTagIds != null ? sourceTagIds.stream().map(UUID::fromString).toList() : null;
|
||||
}
|
||||
|
||||
public UUID getTargetTagUUID() {
|
||||
return targetTagId != null ? UUID.fromString(targetTagId) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class MergePreviewResponse {
|
||||
private String targetTagName;
|
||||
private int targetStoryCount;
|
||||
private int totalResultStoryCount;
|
||||
private List<String> aliasesToCreate;
|
||||
|
||||
public String getTargetTagName() { return targetTagName; }
|
||||
public void setTargetTagName(String targetTagName) { this.targetTagName = targetTagName; }
|
||||
|
||||
public int getTargetStoryCount() { return targetStoryCount; }
|
||||
public void setTargetStoryCount(int targetStoryCount) { this.targetStoryCount = targetStoryCount; }
|
||||
|
||||
public int getTotalResultStoryCount() { return totalResultStoryCount; }
|
||||
public void setTotalResultStoryCount(int totalResultStoryCount) { this.totalResultStoryCount = totalResultStoryCount; }
|
||||
|
||||
public List<String> getAliasesToCreate() { return aliasesToCreate; }
|
||||
public void setAliasesToCreate(List<String> aliasesToCreate) { this.aliasesToCreate = aliasesToCreate; }
|
||||
}
|
||||
|
||||
public static class TagSuggestionRequest {
|
||||
private String title;
|
||||
private String content;
|
||||
private String summary;
|
||||
private Integer limit;
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
|
||||
public String getSummary() { return summary; }
|
||||
public void setSummary(String summary) { this.summary = summary; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
}
|
||||
|
||||
public static class TagSuggestion {
|
||||
private String tagName;
|
||||
private double confidence;
|
||||
private String reason;
|
||||
|
||||
public TagSuggestion() {}
|
||||
|
||||
public TagSuggestion(String tagName, double confidence, String reason) {
|
||||
this.tagName = tagName;
|
||||
this.confidence = confidence;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public String getTagName() { return tagName; }
|
||||
public void setTagName(String tagName) { this.tagName = tagName; }
|
||||
|
||||
public double getConfidence() { return confidence; }
|
||||
public void setConfidence(double confidence) { this.confidence = confidence; }
|
||||
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
}
|
||||
}
|
||||
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TagAliasDto {
|
||||
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "Alias name is required")
|
||||
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||
private String aliasName;
|
||||
|
||||
private UUID canonicalTagId;
|
||||
private String canonicalTagName; // For convenience in frontend
|
||||
private Boolean createdFromMerge;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public TagAliasDto() {}
|
||||
|
||||
public TagAliasDto(String aliasName, UUID canonicalTagId) {
|
||||
this.aliasName = aliasName;
|
||||
this.canonicalTagId = canonicalTagId;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getAliasName() {
|
||||
return aliasName;
|
||||
}
|
||||
|
||||
public void setAliasName(String aliasName) {
|
||||
this.aliasName = aliasName;
|
||||
}
|
||||
|
||||
public UUID getCanonicalTagId() {
|
||||
return canonicalTagId;
|
||||
}
|
||||
|
||||
public void setCanonicalTagId(UUID canonicalTagId) {
|
||||
this.canonicalTagId = canonicalTagId;
|
||||
}
|
||||
|
||||
public String getCanonicalTagName() {
|
||||
return canonicalTagName;
|
||||
}
|
||||
|
||||
public void setCanonicalTagName(String canonicalTagName) {
|
||||
this.canonicalTagName = canonicalTagName;
|
||||
}
|
||||
|
||||
public Boolean getCreatedFromMerge() {
|
||||
return createdFromMerge;
|
||||
}
|
||||
|
||||
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||
this.createdFromMerge = createdFromMerge;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TagDto {
|
||||
@@ -14,8 +15,16 @@ public class TagDto {
|
||||
@Size(max = 100, message = "Tag name must not exceed 100 characters")
|
||||
private String name;
|
||||
|
||||
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||
private String color;
|
||||
|
||||
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||
private String description;
|
||||
|
||||
private Integer storyCount;
|
||||
private Integer collectionCount;
|
||||
private Integer aliasCount;
|
||||
private List<TagAliasDto> aliases;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -42,6 +51,22 @@ public class TagDto {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(String color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getStoryCount() {
|
||||
return storyCount;
|
||||
}
|
||||
@@ -58,6 +83,22 @@ public class TagDto {
|
||||
this.collectionCount = collectionCount;
|
||||
}
|
||||
|
||||
public Integer getAliasCount() {
|
||||
return aliasCount;
|
||||
}
|
||||
|
||||
public void setAliasCount(Integer aliasCount) {
|
||||
this.aliasCount = aliasCount;
|
||||
}
|
||||
|
||||
public List<TagAliasDto> getAliases() {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
public void setAliases(List<TagAliasDto> aliases) {
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
@@ -24,6 +25,14 @@ public class Tag {
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
|
||||
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||
@Column(length = 7)
|
||||
private String color; // hex color like #3B82F6
|
||||
|
||||
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||
@Column(length = 500)
|
||||
private String description;
|
||||
|
||||
|
||||
@ManyToMany(mappedBy = "tags")
|
||||
@JsonBackReference("story-tags")
|
||||
@@ -33,6 +42,10 @@ public class Tag {
|
||||
@JsonBackReference("collection-tags")
|
||||
private Set<Collection> collections = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "canonicalTag", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JsonManagedReference("tag-aliases")
|
||||
private Set<TagAlias> aliases = new HashSet<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -43,6 +56,12 @@ public class Tag {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Tag(String name, String color, String description) {
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Getters and Setters
|
||||
@@ -62,6 +81,22 @@ public class Tag {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(String color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
public Set<Story> getStories() {
|
||||
return stories;
|
||||
@@ -79,6 +114,14 @@ public class Tag {
|
||||
this.collections = collections;
|
||||
}
|
||||
|
||||
public Set<TagAlias> getAliases() {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
public void setAliases(Set<TagAlias> aliases) {
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
@@ -0,0 +1,113 @@
|
||||
package com.storycove.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tag_aliases")
|
||||
public class TagAlias {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "Alias name is required")
|
||||
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||
@Column(name = "alias_name", nullable = false, unique = true)
|
||||
private String aliasName;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "canonical_tag_id", nullable = false)
|
||||
@JsonManagedReference("tag-aliases")
|
||||
private Tag canonicalTag;
|
||||
|
||||
@Column(name = "created_from_merge", nullable = false)
|
||||
private Boolean createdFromMerge = false;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public TagAlias() {}
|
||||
|
||||
public TagAlias(String aliasName, Tag canonicalTag) {
|
||||
this.aliasName = aliasName;
|
||||
this.canonicalTag = canonicalTag;
|
||||
}
|
||||
|
||||
public TagAlias(String aliasName, Tag canonicalTag, Boolean createdFromMerge) {
|
||||
this.aliasName = aliasName;
|
||||
this.canonicalTag = canonicalTag;
|
||||
this.createdFromMerge = createdFromMerge;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getAliasName() {
|
||||
return aliasName;
|
||||
}
|
||||
|
||||
public void setAliasName(String aliasName) {
|
||||
this.aliasName = aliasName;
|
||||
}
|
||||
|
||||
public Tag getCanonicalTag() {
|
||||
return canonicalTag;
|
||||
}
|
||||
|
||||
public void setCanonicalTag(Tag canonicalTag) {
|
||||
this.canonicalTag = canonicalTag;
|
||||
}
|
||||
|
||||
public Boolean getCreatedFromMerge() {
|
||||
return createdFromMerge;
|
||||
}
|
||||
|
||||
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||
this.createdFromMerge = createdFromMerge;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof TagAlias)) return false;
|
||||
TagAlias tagAlias = (TagAlias) o;
|
||||
return id != null && id.equals(tagAlias.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TagAlias{" +
|
||||
"id=" + id +
|
||||
", aliasName='" + aliasName + '\'' +
|
||||
", canonicalTag=" + (canonicalTag != null ? canonicalTag.getName() : null) +
|
||||
", createdFromMerge=" + createdFromMerge +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.storycove.repository;
|
||||
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.entity.Tag;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface TagAliasRepository extends JpaRepository<TagAlias, UUID> {
|
||||
|
||||
/**
|
||||
* Find alias by exact alias name (case-insensitive)
|
||||
*/
|
||||
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) = LOWER(:aliasName)")
|
||||
Optional<TagAlias> findByAliasNameIgnoreCase(@Param("aliasName") String aliasName);
|
||||
|
||||
/**
|
||||
* Find all aliases for a specific canonical tag
|
||||
*/
|
||||
List<TagAlias> findByCanonicalTag(Tag canonicalTag);
|
||||
|
||||
/**
|
||||
* Find all aliases for a specific canonical tag ID
|
||||
*/
|
||||
@Query("SELECT ta FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||
List<TagAlias> findByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||
|
||||
/**
|
||||
* Find aliases created from merge operations
|
||||
*/
|
||||
List<TagAlias> findByCreatedFromMergeTrue();
|
||||
|
||||
/**
|
||||
* Check if an alias name already exists
|
||||
*/
|
||||
boolean existsByAliasNameIgnoreCase(String aliasName);
|
||||
|
||||
/**
|
||||
* Delete all aliases for a specific tag
|
||||
*/
|
||||
void deleteByCanonicalTag(Tag canonicalTag);
|
||||
|
||||
/**
|
||||
* Count aliases for a specific tag
|
||||
*/
|
||||
@Query("SELECT COUNT(ta) FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||
long countByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||
|
||||
/**
|
||||
* Find aliases that start with the given prefix (case-insensitive)
|
||||
*/
|
||||
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) LIKE LOWER(CONCAT(:prefix, '%'))")
|
||||
List<TagAlias> findByAliasNameStartingWithIgnoreCase(@Param("prefix") String prefix);
|
||||
}
|
||||
@@ -17,8 +17,12 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
|
||||
Optional<Tag> findByName(String name);
|
||||
|
||||
Optional<Tag> findByNameIgnoreCase(String name);
|
||||
|
||||
boolean existsByName(String name);
|
||||
|
||||
boolean existsByNameIgnoreCase(String name);
|
||||
|
||||
List<Tag> findByNameContainingIgnoreCase(String name);
|
||||
|
||||
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||
|
||||
@@ -620,9 +620,24 @@ public class StoryService {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
story.setAuthor(author);
|
||||
}
|
||||
// Handle series - either by ID or by name
|
||||
if (updateReq.getSeriesId() != null) {
|
||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||
story.setSeries(series);
|
||||
} else if (updateReq.getSeriesName() != null) {
|
||||
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||
// Empty series name means remove from series
|
||||
story.setSeries(null);
|
||||
} else {
|
||||
// Find or create series by name
|
||||
Series series = seriesService.findByNameOptional(updateReq.getSeriesName().trim())
|
||||
.orElseGet(() -> {
|
||||
Series newSeries = new Series();
|
||||
newSeries.setName(updateReq.getSeriesName().trim());
|
||||
return seriesService.create(newSeries);
|
||||
});
|
||||
story.setSeries(series);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.repository.TagRepository;
|
||||
import com.storycove.repository.TagAliasRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -12,8 +15,11 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@@ -22,10 +28,12 @@ import java.util.UUID;
|
||||
public class TagService {
|
||||
|
||||
private final TagRepository tagRepository;
|
||||
private final TagAliasRepository tagAliasRepository;
|
||||
|
||||
@Autowired
|
||||
public TagService(TagRepository tagRepository) {
|
||||
public TagService(TagRepository tagRepository, TagAliasRepository tagAliasRepository) {
|
||||
this.tagRepository = tagRepository;
|
||||
this.tagAliasRepository = tagAliasRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -207,5 +215,269 @@ public class TagService {
|
||||
if (updates.getName() != null) {
|
||||
existing.setName(updates.getName());
|
||||
}
|
||||
if (updates.getColor() != null) {
|
||||
existing.setColor(updates.getColor());
|
||||
}
|
||||
if (updates.getDescription() != null) {
|
||||
existing.setDescription(updates.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
// Tag alias management methods
|
||||
|
||||
public TagAlias addAlias(UUID tagId, String aliasName) {
|
||||
Tag canonicalTag = findById(tagId);
|
||||
|
||||
// Check if alias already exists (case-insensitive)
|
||||
if (tagAliasRepository.existsByAliasNameIgnoreCase(aliasName)) {
|
||||
throw new DuplicateResourceException("Tag alias", aliasName);
|
||||
}
|
||||
|
||||
// Check if alias name conflicts with existing tag names
|
||||
if (tagRepository.existsByNameIgnoreCase(aliasName)) {
|
||||
throw new DuplicateResourceException("Tag alias conflicts with existing tag name", aliasName);
|
||||
}
|
||||
|
||||
TagAlias alias = new TagAlias();
|
||||
alias.setAliasName(aliasName);
|
||||
alias.setCanonicalTag(canonicalTag);
|
||||
alias.setCreatedFromMerge(false);
|
||||
|
||||
return tagAliasRepository.save(alias);
|
||||
}
|
||||
|
||||
public void removeAlias(UUID tagId, UUID aliasId) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Index authors one by one for now (can optimize later)
|
||||
for (Author author : authors) {
|
||||
@@ -860,6 +865,8 @@ public class TypesenseService {
|
||||
document.put("id", author.getId().toString());
|
||||
document.put("name", author.getName());
|
||||
document.put("notes", author.getNotes() != null ? author.getNotes() : "");
|
||||
|
||||
logger.info("INDEXING AUTHOR: '{}' with ID: {}", author.getName(), author.getId());
|
||||
document.put("authorRating", author.getAuthorRating());
|
||||
|
||||
// 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) {
|
||||
logger.info("AUTHOR SEARCH DEBUG: query='{}', page={}, perPage={}", query, page, perPage);
|
||||
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()
|
||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||
.q(searchQuery)
|
||||
.queryBy("name,notes")
|
||||
.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
|
||||
if (sortBy != null && !sortBy.trim().isEmpty()) {
|
||||
@@ -925,9 +947,11 @@ public class TypesenseService {
|
||||
|
||||
SearchResult searchResult;
|
||||
try {
|
||||
logger.info("AUTHOR SEARCH DEBUG: Executing search with final parameters");
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
logger.info("AUTHOR SEARCH DEBUG: Search completed. Found {} results", searchResult.getHits().size());
|
||||
} catch (Exception sortException) {
|
||||
// If sorting fails (likely due to schema issues), retry without sorting
|
||||
logger.warn("Sorting failed for authors search, retrying without sort: " + sortException.getMessage());
|
||||
@@ -938,12 +962,17 @@ public class TypesenseService {
|
||||
} catch (Exception debugException) {
|
||||
}
|
||||
|
||||
// Use wildcard approach for fallback to handle substring search
|
||||
String fallbackSearchQuery = query != null && !query.trim().isEmpty() ? "*" + query.trim() + "*" : "*";
|
||||
|
||||
searchParameters = new SearchParameters()
|
||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||
.q(fallbackSearchQuery)
|
||||
.queryBy("name,notes")
|
||||
.page(page + 1)
|
||||
.perPage(perPage);
|
||||
|
||||
logger.info("AUTHOR SEARCH DEBUG: Using fallback infix search query: '{}'", fallbackSearchQuery);
|
||||
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
.documents()
|
||||
.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';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { searchApi, storyApi, tagApi } from '../../lib/api';
|
||||
import { Story, Tag, FacetCount } from '../../types/api';
|
||||
import AppLayout from '../../components/layout/AppLayout';
|
||||
@@ -20,6 +20,7 @@ type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount'
|
||||
|
||||
export default function LibraryPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { layout } = useLibraryLayout();
|
||||
const [stories, setStories] = useState<Story[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
@@ -35,27 +36,95 @@ export default function LibraryPage() {
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalElements, setTotalElements] = 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[] => {
|
||||
if (!facets || !facets.tagNames) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return facets.tagNames.map(facet => ({
|
||||
id: facet.value, // Use tag name as ID since we don't have actual IDs from search results
|
||||
return facets.tagNames.map(facet => {
|
||||
// Find the full tag data by name
|
||||
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
|
||||
|
||||
return {
|
||||
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
|
||||
name: facet.value,
|
||||
storyCount: facet.count
|
||||
}));
|
||||
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
|
||||
useEffect(() => {
|
||||
// Don't run search until URL parameters have been processed
|
||||
if (!urlParamsProcessed) return;
|
||||
|
||||
const debounceTimer = setTimeout(() => {
|
||||
const performSearch = async () => {
|
||||
try {
|
||||
// Use searchLoading for background search, loading only for initial load
|
||||
const isInitialLoad = stories.length === 0 && !searchQuery && selectedTags.length === 0;
|
||||
const isInitialLoad = stories.length === 0 && !searchQuery;
|
||||
if (isInitialLoad) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
@@ -63,7 +132,7 @@ export default function LibraryPage() {
|
||||
}
|
||||
|
||||
// Always use search API for consistency - use '*' for match-all when no query
|
||||
const result = await searchApi.search({
|
||||
const apiParams = {
|
||||
query: searchQuery.trim() || '*',
|
||||
page: page, // Use 0-based pagination consistently
|
||||
size: 20,
|
||||
@@ -71,7 +140,10 @@ export default function LibraryPage() {
|
||||
sortBy: sortOption,
|
||||
sortDir: sortDirection,
|
||||
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 || [];
|
||||
setStories(currentStories);
|
||||
@@ -96,7 +168,7 @@ export default function LibraryPage() {
|
||||
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
|
||||
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger]);
|
||||
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed]);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -167,6 +239,7 @@ export default function LibraryPage() {
|
||||
const layoutProps = {
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
|
||||
@@ -774,6 +774,21 @@ export default function SettingsPage() {
|
||||
</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 */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<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 Button from '../../../../components/ui/Button';
|
||||
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||
import TagDisplay from '../../../../components/tags/TagDisplay';
|
||||
import { calculateReadingTime } from '../../../../lib/settings';
|
||||
|
||||
export default function StoryDetailPage() {
|
||||
@@ -371,12 +372,12 @@ export default function StoryDetailPage() {
|
||||
<h3 className="font-semibold theme-header mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="md"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppLayout from '../../../../components/layout/AppLayout';
|
||||
import { Input, Textarea } from '../../../../components/ui/Input';
|
||||
import Button from '../../../../components/ui/Button';
|
||||
import TagInput from '../../../../components/stories/TagInput';
|
||||
import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
||||
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
||||
import ImageUpload from '../../../../components/ui/ImageUpload';
|
||||
import AuthorSelector from '../../../../components/stories/AuthorSelector';
|
||||
@@ -94,6 +95,15 @@ export default function EditStoryPage() {
|
||||
setFormData(prev => ({ ...prev, tags }));
|
||||
};
|
||||
|
||||
const handleAddSuggestedTag = (tagName: string) => {
|
||||
if (!formData.tags.includes(tagName.toLowerCase())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...prev.tags, tagName.toLowerCase()]
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthorChange = (authorName: string, authorId?: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -150,8 +160,8 @@ export default function EditStoryPage() {
|
||||
summary: formData.summary || undefined,
|
||||
contentHtml: formData.contentHtml,
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
seriesName: formData.seriesName || undefined,
|
||||
volume: formData.seriesName && formData.volume ? parseInt(formData.volume) : undefined,
|
||||
seriesName: formData.seriesName, // Send empty string to explicitly clear series
|
||||
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||
tagNames: formData.tags,
|
||||
@@ -301,6 +311,16 @@ export default function EditStoryPage() {
|
||||
onChange={handleTagsChange}
|
||||
placeholder="Edit tags to categorize your story..."
|
||||
/>
|
||||
|
||||
{/* Tag Suggestions */}
|
||||
<TagSuggestions
|
||||
title={formData.title}
|
||||
content={formData.contentHtml}
|
||||
summary={formData.summary}
|
||||
currentTags={formData.tags}
|
||||
onAddTag={handleAddSuggestedTag}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Series and Volume */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Story } from '../../../types/api';
|
||||
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
|
||||
import Button from '../../../components/ui/Button';
|
||||
import StoryRating from '../../../components/stories/StoryRating';
|
||||
import TagDisplay from '../../../components/tags/TagDisplay';
|
||||
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
|
||||
|
||||
export default function StoryReadingPage() {
|
||||
@@ -314,12 +315,12 @@ export default function StoryReadingPage() {
|
||||
{story.tags && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-4">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="px-3 py-1 text-sm theme-accent-bg text-white rounded-full"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="md"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { StoryWithCollectionContext } from '../../types/api';
|
||||
import { storyApi } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface CollectionReadingViewProps {
|
||||
@@ -255,12 +256,12 @@ export default function CollectionReadingView({
|
||||
{story.tags && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface MinimalLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
totalElements: number;
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -26,6 +28,7 @@ interface MinimalLayoutProps {
|
||||
export default function MinimalLayout({
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
@@ -41,9 +44,15 @@ export default function MinimalLayout({
|
||||
children
|
||||
}: MinimalLayoutProps) {
|
||||
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
|
||||
const [tagSearch, setTagSearch] = useState('');
|
||||
|
||||
const popularTags = tags.slice(0, 5);
|
||||
|
||||
// Filter tags based on search query
|
||||
const filteredTags = tagSearch
|
||||
? tags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||
: tags;
|
||||
|
||||
const getSortDisplayText = () => {
|
||||
const sortLabels: Record<string, string> = {
|
||||
lastRead: 'Last Read',
|
||||
@@ -62,7 +71,7 @@ export default function MinimalLayout({
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl font-light theme-header mb-2">Story Library</h1>
|
||||
<p className="theme-text text-lg mb-8">
|
||||
Your personal collection of {stories.length} stories
|
||||
Your personal collection of {totalElements} stories
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="primary" onClick={onRandomStory}>
|
||||
@@ -139,17 +148,20 @@ export default function MinimalLayout({
|
||||
All
|
||||
</button>
|
||||
{popularTags.map((tag) => (
|
||||
<button
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
|
||||
selectedTags.includes(tag.name)
|
||||
? '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'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-2' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<TagDisplay
|
||||
tag={tag}
|
||||
size="md"
|
||||
clickable={true}
|
||||
className={`${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
@@ -173,7 +185,10 @@ export default function MinimalLayout({
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h3 className="text-xl font-semibold theme-header">Browse All Tags</h3>
|
||||
<button
|
||||
onClick={() => setTagBrowserOpen(false)}
|
||||
onClick={() => {
|
||||
setTagBrowserOpen(false);
|
||||
setTagSearch('');
|
||||
}}
|
||||
className="text-2xl theme-text hover:theme-accent transition-colors"
|
||||
>
|
||||
✕
|
||||
@@ -184,31 +199,48 @@ export default function MinimalLayout({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
{filteredTags.length === 0 && tagSearch ? (
|
||||
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
||||
No tags match "{tagSearch}"
|
||||
</div>
|
||||
) : (
|
||||
filteredTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left ${
|
||||
selectedTags.includes(tag.name)
|
||||
? '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'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={`w-full text-left ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||
Clear Search
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClearFilters}>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setTagBrowserOpen(false)}>
|
||||
<Button variant="primary" onClick={() => {
|
||||
setTagBrowserOpen(false);
|
||||
setTagSearch('');
|
||||
}}>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface SidebarLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
totalElements: number;
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -26,6 +28,7 @@ interface SidebarLayoutProps {
|
||||
export default function SidebarLayout({
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
@@ -40,6 +43,13 @@ export default function SidebarLayout({
|
||||
onClearFilters,
|
||||
children
|
||||
}: SidebarLayoutProps) {
|
||||
const [tagSearch, setTagSearch] = useState('');
|
||||
|
||||
// Filter tags based on search query
|
||||
const filteredTags = tags.filter(tag =>
|
||||
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Sidebar */}
|
||||
@@ -58,7 +68,7 @@ export default function SidebarLayout({
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<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>
|
||||
|
||||
{/* Search */}
|
||||
@@ -125,6 +135,8 @@ export default function SidebarLayout({
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
@@ -136,9 +148,9 @@ export default function SidebarLayout({
|
||||
checked={selectedTags.length === 0}
|
||||
onChange={() => onClearFilters()}
|
||||
/>
|
||||
<span className="text-xs">All Stories ({stories.length})</span>
|
||||
<span className="text-xs">All Stories ({totalElements})</span>
|
||||
</label>
|
||||
{tags.map((tag) => (
|
||||
{filteredTags.map((tag) => (
|
||||
<label
|
||||
key={tag.id}
|
||||
className="flex items-center gap-2 py-1 cursor-pointer"
|
||||
@@ -148,14 +160,27 @@ export default function SidebarLayout({
|
||||
checked={selectedTags.includes(tag.name)}
|
||||
onChange={() => onTagToggle(tag.name)}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{tag.name} ({tag.storyCount})
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<TagDisplay
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">
|
||||
({tag.storyCount})
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{tags.length > 10 && (
|
||||
{filteredTags.length === 0 && tagSearch && (
|
||||
<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>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface ToolbarLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
totalElements: number;
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -26,6 +28,7 @@ interface ToolbarLayoutProps {
|
||||
export default function ToolbarLayout({
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
@@ -41,9 +44,17 @@ export default function ToolbarLayout({
|
||||
children
|
||||
}: ToolbarLayoutProps) {
|
||||
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
|
||||
const [tagSearch, setTagSearch] = useState('');
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<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 className="max-md:self-end">
|
||||
<Button variant="secondary" onClick={onRandomStory}>
|
||||
@@ -142,17 +153,20 @@ export default function ToolbarLayout({
|
||||
All Stories
|
||||
</button>
|
||||
{popularTags.map((tag) => (
|
||||
<button
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingTagsCount > 0 && (
|
||||
<button
|
||||
@@ -163,7 +177,7 @@ export default function ToolbarLayout({
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto text-sm theme-text">
|
||||
Showing {stories.length} stories
|
||||
Showing {stories.length} of {totalElements} stories
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,9 +188,15 @@ export default function ToolbarLayout({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search from all available tags..."
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="secondary">Search</Button>
|
||||
{tagSearch && (
|
||||
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setTagSearchExpanded(false)}
|
||||
@@ -185,19 +205,28 @@ export default function ToolbarLayout({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
||||
{tags.slice(6).map((tag) => (
|
||||
<button
|
||||
{filteredRemainingTags.length === 0 && tagSearch ? (
|
||||
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
||||
No tags match "{tagSearch}"
|
||||
</div>
|
||||
) : (
|
||||
filteredRemainingTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={`w-full ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -43,11 +43,27 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
return () => clearTimeout(debounce);
|
||||
}, [inputValue, tags]);
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const addTag = async (tag: string) => {
|
||||
const trimmedTag = tag.trim().toLowerCase();
|
||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
||||
if (!trimmedTag) return;
|
||||
|
||||
try {
|
||||
// Resolve tag alias to canonical name
|
||||
const resolvedTag = await tagApi.resolveTag(trimmedTag);
|
||||
const finalTag = resolvedTag ? resolvedTag.name.toLowerCase() : trimmedTag;
|
||||
|
||||
// Only add if not already present
|
||||
if (!tags.includes(finalTag)) {
|
||||
onChange([...tags, finalTag]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to resolve tag alias:', error);
|
||||
// Fall back to original tag if resolution fails
|
||||
if (!tags.includes(trimmedTag)) {
|
||||
onChange([...tags, trimmedTag]);
|
||||
}
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
setShowSuggestions(false);
|
||||
setActiveSuggestionIndex(-1);
|
||||
|
||||
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 { 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';
|
||||
|
||||
@@ -303,6 +303,33 @@ export const tagApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTag: async (id: string): Promise<Tag> => {
|
||||
const response = await api.get(`/tags/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createTag: async (tagData: {
|
||||
name: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
}): Promise<Tag> => {
|
||||
const response = await api.post('/tags', tagData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTag: async (id: string, tagData: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
}): Promise<Tag> => {
|
||||
const response = await api.put(`/tags/${id}`, tagData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteTag: async (id: string): Promise<void> => {
|
||||
await api.delete(`/tags/${id}`);
|
||||
},
|
||||
|
||||
getTagAutocomplete: async (query: string): Promise<string[]> => {
|
||||
const response = await api.get('/tags/autocomplete', { params: { query } });
|
||||
// Backend returns TagDto[], extract just the names
|
||||
@@ -313,6 +340,76 @@ export const tagApi = {
|
||||
const response = await api.get('/tags/collections');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Alias operations
|
||||
addAlias: async (tagId: string, aliasName: string): Promise<TagAlias> => {
|
||||
const response = await api.post(`/tags/${tagId}/aliases`, { aliasName });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeAlias: async (tagId: string, aliasId: string): Promise<void> => {
|
||||
await api.delete(`/tags/${tagId}/aliases/${aliasId}`);
|
||||
},
|
||||
|
||||
resolveTag: async (name: string): Promise<Tag | null> => {
|
||||
try {
|
||||
const response = await api.get(`/tags/resolve/${encodeURIComponent(name)}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Batch resolve multiple tag names to their canonical forms
|
||||
resolveTags: async (names: string[]): Promise<string[]> => {
|
||||
const resolved = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const tag = await tagApi.resolveTag(name);
|
||||
return tag ? tag.name : name; // Return canonical name or original if not found
|
||||
})
|
||||
);
|
||||
return resolved;
|
||||
},
|
||||
|
||||
// Merge operations
|
||||
previewMerge: async (sourceTagIds: string[], targetTagId: string): Promise<{
|
||||
targetTagName: string;
|
||||
targetStoryCount: number;
|
||||
totalResultStoryCount: number;
|
||||
aliasesToCreate: string[];
|
||||
}> => {
|
||||
const response = await api.post('/tags/merge/preview', {
|
||||
sourceTagIds,
|
||||
targetTagId
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
mergeTags: async (sourceTagIds: string[], targetTagId: string): Promise<Tag> => {
|
||||
const response = await api.post('/tags/merge', {
|
||||
sourceTagIds,
|
||||
targetTagId
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Tag suggestions
|
||||
suggestTags: async (title: string, content?: string, summary?: string, limit?: number): Promise<{
|
||||
tagName: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}[]> => {
|
||||
const response = await api.post('/tags/suggest', {
|
||||
title,
|
||||
content,
|
||||
summary,
|
||||
limit: limit || 10
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Series endpoints
|
||||
@@ -347,6 +444,18 @@ export const searchApi = {
|
||||
sortDir?: string;
|
||||
facetBy?: string[];
|
||||
}): Promise<SearchResult> => {
|
||||
// Resolve tag aliases to canonical names for expanded search
|
||||
let resolvedTags = params.tags;
|
||||
if (params.tags && params.tags.length > 0) {
|
||||
try {
|
||||
resolvedTags = await tagApi.resolveTags(params.tags);
|
||||
} catch (error) {
|
||||
console.warn('Failed to resolve tag aliases during search:', error);
|
||||
// Fall back to original tags if resolution fails
|
||||
resolvedTags = params.tags;
|
||||
}
|
||||
}
|
||||
|
||||
// Create URLSearchParams to properly handle array parameters
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -363,8 +472,8 @@ export const searchApi = {
|
||||
if (params.authors && params.authors.length > 0) {
|
||||
params.authors.forEach(author => searchParams.append('authors', author));
|
||||
}
|
||||
if (params.tags && params.tags.length > 0) {
|
||||
params.tags.forEach(tag => searchParams.append('tags', tag));
|
||||
if (resolvedTags && resolvedTags.length > 0) {
|
||||
resolvedTags.forEach(tag => searchParams.append('tags', tag));
|
||||
}
|
||||
if (params.facetBy && params.facetBy.length > 0) {
|
||||
params.facetBy.forEach(facet => searchParams.append('facetBy', facet));
|
||||
|
||||
@@ -82,9 +82,11 @@ export class StoryScraper {
|
||||
if (siteConfig.story.tags) {
|
||||
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
|
||||
if (Array.isArray(tagsResult)) {
|
||||
story.tags = tagsResult;
|
||||
// Resolve tag aliases to canonical names
|
||||
story.tags = await this.resolveTagAliases(tagsResult);
|
||||
} else if (typeof tagsResult === 'string' && tagsResult) {
|
||||
story.tags = [tagsResult];
|
||||
// Resolve tag aliases to canonical names
|
||||
story.tags = await this.resolveTagAliases([tagsResult]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,4 +381,21 @@ export class StoryScraper {
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private async resolveTagAliases(tags: string[]): Promise<string[]> {
|
||||
try {
|
||||
// Import the tagApi dynamically to avoid circular dependencies
|
||||
const { tagApi } = await import('../api');
|
||||
|
||||
// Resolve all tags to their canonical names
|
||||
const resolvedTags = await tagApi.resolveTags(tags);
|
||||
|
||||
// Filter out empty tags
|
||||
return resolvedTags.filter(tag => tag && tag.trim().length > 0);
|
||||
} catch (error) {
|
||||
console.warn('Failed to resolve tag aliases during scraping:', error);
|
||||
// Fall back to original tags if resolution fails
|
||||
return tags.filter(tag => tag && tag.trim().length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,25 @@ export interface AuthorUrl {
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string; // hex color like #3B82F6
|
||||
description?: string;
|
||||
storyCount?: number;
|
||||
collectionCount?: number;
|
||||
aliasCount?: number;
|
||||
aliases?: TagAlias[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TagAlias {
|
||||
id: string;
|
||||
aliasName: string;
|
||||
canonicalTagId: string;
|
||||
canonicalTag?: Tag;
|
||||
createdFromMerge: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
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