Tag Enhancement + bugfixes

This commit is contained in:
Stefan Hardegger
2025-08-17 17:16:40 +02:00
parent 6b83783381
commit 1a99d9830d
34 changed files with 2996 additions and 97 deletions

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

View File

@@ -335,6 +335,44 @@ public class AuthorController {
}
}
@PostMapping("/clean-author-names")
public ResponseEntity<Map<String, Object>> cleanAuthorNames() {
try {
List<Author> allAuthors = authorService.findAllWithStories();
int cleanedCount = 0;
for (Author author : allAuthors) {
String originalName = author.getName();
String cleanedName = originalName != null ? originalName.trim() : "";
if (!cleanedName.equals(originalName)) {
logger.info("Cleaning author name: '{}' -> '{}'", originalName, cleanedName);
author.setName(cleanedName);
authorService.update(author.getId(), author);
cleanedCount++;
}
}
// Reindex all authors after cleaning
if (cleanedCount > 0) {
typesenseService.reindexAllAuthors(allAuthors);
}
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Cleaned " + cleanedCount + " author names and reindexed",
"cleanedCount", cleanedCount,
"totalAuthors", allAuthors.size()
));
} catch (Exception e) {
logger.error("Failed to clean author names", e);
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@GetMapping("/top-rated")
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit);

View File

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

View File

@@ -1,9 +1,13 @@
package com.storycove.controller;
import com.storycove.dto.TagDto;
import com.storycove.dto.TagAliasDto;
import com.storycove.entity.Tag;
import com.storycove.entity.TagAlias;
import com.storycove.service.TagService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -21,6 +25,7 @@ import java.util.stream.Collectors;
@RequestMapping("/api/tags")
public class TagController {
private static final Logger logger = LoggerFactory.getLogger(TagController.class);
private final TagService tagService;
public TagController(TagService tagService) {
@@ -54,6 +59,8 @@ public class TagController {
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
Tag tag = new Tag();
tag.setName(request.getName());
tag.setColor(request.getColor());
tag.setDescription(request.getDescription());
Tag savedTag = tagService.create(tag);
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
@@ -66,6 +73,12 @@ public class TagController {
if (request.getName() != null) {
existingTag.setName(request.getName());
}
if (request.getColor() != null) {
existingTag.setColor(request.getColor());
}
if (request.getDescription() != null) {
existingTag.setDescription(request.getDescription());
}
Tag updatedTag = tagService.update(id, existingTag);
return ResponseEntity.ok(convertToDto(updatedTag));
@@ -95,7 +108,7 @@ public class TagController {
@RequestParam String query,
@RequestParam(defaultValue = "10") int limit) {
List<Tag> tags = tagService.findByNameStartingWith(query, limit);
List<Tag> tags = tagService.findByNameOrAliasStartingWith(query, limit);
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(tagDtos);
@@ -142,15 +155,124 @@ public class TagController {
return ResponseEntity.ok(tagDtos);
}
// Tag alias endpoints
@PostMapping("/{tagId}/aliases")
public ResponseEntity<TagAliasDto> addAlias(@PathVariable UUID tagId,
@RequestBody Map<String, String> request) {
String aliasName = request.get("aliasName");
if (aliasName == null || aliasName.trim().isEmpty()) {
return ResponseEntity.badRequest().build();
}
try {
TagAlias alias = tagService.addAlias(tagId, aliasName.trim());
TagAliasDto dto = new TagAliasDto();
dto.setId(alias.getId());
dto.setAliasName(alias.getAliasName());
dto.setCanonicalTagId(alias.getCanonicalTag().getId());
dto.setCanonicalTagName(alias.getCanonicalTag().getName());
dto.setCreatedFromMerge(alias.getCreatedFromMerge());
dto.setCreatedAt(alias.getCreatedAt());
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
}
@DeleteMapping("/{tagId}/aliases/{aliasId}")
public ResponseEntity<?> removeAlias(@PathVariable UUID tagId, @PathVariable UUID aliasId) {
try {
tagService.removeAlias(tagId, aliasId);
return ResponseEntity.ok(Map.of("message", "Alias removed successfully"));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/resolve/{name}")
public ResponseEntity<TagDto> resolveTag(@PathVariable String name) {
try {
Tag resolvedTag = tagService.resolveTagByName(name);
if (resolvedTag != null) {
return ResponseEntity.ok(convertToDto(resolvedTag));
} else {
return ResponseEntity.notFound().build();
}
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping("/merge")
public ResponseEntity<?> mergeTags(@Valid @RequestBody MergeTagsRequest request) {
try {
Tag resultTag = tagService.mergeTags(request.getSourceTagUUIDs(), request.getTargetTagUUID());
return ResponseEntity.ok(convertToDto(resultTag));
} catch (Exception e) {
logger.error("Failed to merge tags", e);
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
}
}
@PostMapping("/merge/preview")
public ResponseEntity<?> previewMerge(@Valid @RequestBody MergeTagsRequest request) {
try {
MergePreviewResponse preview = tagService.previewMerge(request.getSourceTagUUIDs(), request.getTargetTagUUID());
return ResponseEntity.ok(preview);
} catch (Exception e) {
logger.error("Failed to preview merge", e);
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
}
}
@PostMapping("/suggest")
public ResponseEntity<List<TagSuggestion>> suggestTags(@RequestBody TagSuggestionRequest request) {
try {
List<TagSuggestion> suggestions = tagService.suggestTags(
request.getTitle(),
request.getContent(),
request.getSummary(),
request.getLimit() != null ? request.getLimit() : 10
);
return ResponseEntity.ok(suggestions);
} catch (Exception e) {
logger.error("Failed to suggest tags", e);
return ResponseEntity.ok(List.of()); // Return empty list on error
}
}
private TagDto convertToDto(Tag tag) {
TagDto dto = new TagDto();
dto.setId(tag.getId());
dto.setName(tag.getName());
dto.setColor(tag.getColor());
dto.setDescription(tag.getDescription());
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
dto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
dto.setCreatedAt(tag.getCreatedAt());
// updatedAt field not present in Tag entity per spec
// Convert aliases to DTOs for full context
if (tag.getAliases() != null && !tag.getAliases().isEmpty()) {
List<TagAliasDto> aliaseDtos = tag.getAliases().stream()
.map(alias -> {
TagAliasDto aliasDto = new TagAliasDto();
aliasDto.setId(alias.getId());
aliasDto.setAliasName(alias.getAliasName());
aliasDto.setCanonicalTagId(alias.getCanonicalTag().getId());
aliasDto.setCanonicalTagName(alias.getCanonicalTag().getName());
aliasDto.setCreatedFromMerge(alias.getCreatedFromMerge());
aliasDto.setCreatedAt(alias.getCreatedAt());
return aliasDto;
})
.collect(Collectors.toList());
dto.setAliases(aliaseDtos);
}
return dto;
}
@@ -168,15 +290,112 @@ public class TagController {
// Request DTOs
public static class CreateTagRequest {
private String name;
private String color;
private String description;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getColor() { return color; }
public void setColor(String color) { this.color = color; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
public static class UpdateTagRequest {
private String name;
private String color;
private String description;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getColor() { return color; }
public void setColor(String color) { this.color = color; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
public static class MergeTagsRequest {
private List<String> sourceTagIds;
private String targetTagId;
public List<String> getSourceTagIds() { return sourceTagIds; }
public void setSourceTagIds(List<String> sourceTagIds) { this.sourceTagIds = sourceTagIds; }
public String getTargetTagId() { return targetTagId; }
public void setTargetTagId(String targetTagId) { this.targetTagId = targetTagId; }
// Helper methods to convert to UUID
public List<UUID> getSourceTagUUIDs() {
return sourceTagIds != null ? sourceTagIds.stream().map(UUID::fromString).toList() : null;
}
public UUID getTargetTagUUID() {
return targetTagId != null ? UUID.fromString(targetTagId) : null;
}
}
public static class MergePreviewResponse {
private String targetTagName;
private int targetStoryCount;
private int totalResultStoryCount;
private List<String> aliasesToCreate;
public String getTargetTagName() { return targetTagName; }
public void setTargetTagName(String targetTagName) { this.targetTagName = targetTagName; }
public int getTargetStoryCount() { return targetStoryCount; }
public void setTargetStoryCount(int targetStoryCount) { this.targetStoryCount = targetStoryCount; }
public int getTotalResultStoryCount() { return totalResultStoryCount; }
public void setTotalResultStoryCount(int totalResultStoryCount) { this.totalResultStoryCount = totalResultStoryCount; }
public List<String> getAliasesToCreate() { return aliasesToCreate; }
public void setAliasesToCreate(List<String> aliasesToCreate) { this.aliasesToCreate = aliasesToCreate; }
}
public static class TagSuggestionRequest {
private String title;
private String content;
private String summary;
private Integer limit;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
}
public static class TagSuggestion {
private String tagName;
private double confidence;
private String reason;
public TagSuggestion() {}
public TagSuggestion(String tagName, double confidence, String reason) {
this.tagName = tagName;
this.confidence = confidence;
this.reason = reason;
}
public String getTagName() { return tagName; }
public void setTagName(String tagName) { this.tagName = tagName; }
public double getConfidence() { return confidence; }
public void setConfidence(double confidence) { this.confidence = confidence; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
}
}

View File

@@ -0,0 +1,77 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.UUID;
public class TagAliasDto {
private UUID id;
@NotBlank(message = "Alias name is required")
@Size(max = 100, message = "Alias name must not exceed 100 characters")
private String aliasName;
private UUID canonicalTagId;
private String canonicalTagName; // For convenience in frontend
private Boolean createdFromMerge;
private LocalDateTime createdAt;
public TagAliasDto() {}
public TagAliasDto(String aliasName, UUID canonicalTagId) {
this.aliasName = aliasName;
this.canonicalTagId = canonicalTagId;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getAliasName() {
return aliasName;
}
public void setAliasName(String aliasName) {
this.aliasName = aliasName;
}
public UUID getCanonicalTagId() {
return canonicalTagId;
}
public void setCanonicalTagId(UUID canonicalTagId) {
this.canonicalTagId = canonicalTagId;
}
public String getCanonicalTagName() {
return canonicalTagName;
}
public void setCanonicalTagName(String canonicalTagName) {
this.canonicalTagName = canonicalTagName;
}
public Boolean getCreatedFromMerge() {
return createdFromMerge;
}
public void setCreatedFromMerge(Boolean createdFromMerge) {
this.createdFromMerge = createdFromMerge;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public class TagDto {
@@ -14,8 +15,16 @@ public class TagDto {
@Size(max = 100, message = "Tag name must not exceed 100 characters")
private String name;
@Size(max = 7, message = "Color must be a valid hex color code")
private String color;
@Size(max = 500, message = "Description must not exceed 500 characters")
private String description;
private Integer storyCount;
private Integer collectionCount;
private Integer aliasCount;
private List<TagAliasDto> aliases;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -42,6 +51,22 @@ public class TagDto {
this.name = name;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStoryCount() {
return storyCount;
}
@@ -58,6 +83,22 @@ public class TagDto {
this.collectionCount = collectionCount;
}
public Integer getAliasCount() {
return aliasCount;
}
public void setAliasCount(Integer aliasCount) {
this.aliasCount = aliasCount;
}
public List<TagAliasDto> getAliases() {
return aliases;
}
public void setAliases(List<TagAliasDto> aliases) {
this.aliases = aliases;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import com.fasterxml.jackson.annotation.JsonBackReference;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -24,6 +25,14 @@ public class Tag {
@Column(nullable = false, unique = true)
private String name;
@Size(max = 7, message = "Color must be a valid hex color code")
@Column(length = 7)
private String color; // hex color like #3B82F6
@Size(max = 500, message = "Description must not exceed 500 characters")
@Column(length = 500)
private String description;
@ManyToMany(mappedBy = "tags")
@JsonBackReference("story-tags")
@@ -33,6 +42,10 @@ public class Tag {
@JsonBackReference("collection-tags")
private Set<Collection> collections = new HashSet<>();
@OneToMany(mappedBy = "canonicalTag", cascade = CascadeType.ALL, orphanRemoval = true)
@JsonManagedReference("tag-aliases")
private Set<TagAlias> aliases = new HashSet<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -43,6 +56,12 @@ public class Tag {
this.name = name;
}
public Tag(String name, String color, String description) {
this.name = name;
this.color = color;
this.description = description;
}
// Getters and Setters
@@ -62,6 +81,22 @@ public class Tag {
this.name = name;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Set<Story> getStories() {
return stories;
@@ -79,6 +114,14 @@ public class Tag {
this.collections = collections;
}
public Set<TagAlias> getAliases() {
return aliases;
}
public void setAliases(Set<TagAlias> aliases) {
this.aliases = aliases;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -0,0 +1,113 @@
package com.storycove.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "tag_aliases")
public class TagAlias {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotBlank(message = "Alias name is required")
@Size(max = 100, message = "Alias name must not exceed 100 characters")
@Column(name = "alias_name", nullable = false, unique = true)
private String aliasName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "canonical_tag_id", nullable = false)
@JsonManagedReference("tag-aliases")
private Tag canonicalTag;
@Column(name = "created_from_merge", nullable = false)
private Boolean createdFromMerge = false;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public TagAlias() {}
public TagAlias(String aliasName, Tag canonicalTag) {
this.aliasName = aliasName;
this.canonicalTag = canonicalTag;
}
public TagAlias(String aliasName, Tag canonicalTag, Boolean createdFromMerge) {
this.aliasName = aliasName;
this.canonicalTag = canonicalTag;
this.createdFromMerge = createdFromMerge;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getAliasName() {
return aliasName;
}
public void setAliasName(String aliasName) {
this.aliasName = aliasName;
}
public Tag getCanonicalTag() {
return canonicalTag;
}
public void setCanonicalTag(Tag canonicalTag) {
this.canonicalTag = canonicalTag;
}
public Boolean getCreatedFromMerge() {
return createdFromMerge;
}
public void setCreatedFromMerge(Boolean createdFromMerge) {
this.createdFromMerge = createdFromMerge;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TagAlias)) return false;
TagAlias tagAlias = (TagAlias) o;
return id != null && id.equals(tagAlias.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "TagAlias{" +
"id=" + id +
", aliasName='" + aliasName + '\'' +
", canonicalTag=" + (canonicalTag != null ? canonicalTag.getName() : null) +
", createdFromMerge=" + createdFromMerge +
'}';
}
}

View File

@@ -0,0 +1,60 @@
package com.storycove.repository;
import com.storycove.entity.TagAlias;
import com.storycove.entity.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface TagAliasRepository extends JpaRepository<TagAlias, UUID> {
/**
* Find alias by exact alias name (case-insensitive)
*/
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) = LOWER(:aliasName)")
Optional<TagAlias> findByAliasNameIgnoreCase(@Param("aliasName") String aliasName);
/**
* Find all aliases for a specific canonical tag
*/
List<TagAlias> findByCanonicalTag(Tag canonicalTag);
/**
* Find all aliases for a specific canonical tag ID
*/
@Query("SELECT ta FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
List<TagAlias> findByCanonicalTagId(@Param("tagId") UUID tagId);
/**
* Find aliases created from merge operations
*/
List<TagAlias> findByCreatedFromMergeTrue();
/**
* Check if an alias name already exists
*/
boolean existsByAliasNameIgnoreCase(String aliasName);
/**
* Delete all aliases for a specific tag
*/
void deleteByCanonicalTag(Tag canonicalTag);
/**
* Count aliases for a specific tag
*/
@Query("SELECT COUNT(ta) FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
long countByCanonicalTagId(@Param("tagId") UUID tagId);
/**
* Find aliases that start with the given prefix (case-insensitive)
*/
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) LIKE LOWER(CONCAT(:prefix, '%'))")
List<TagAlias> findByAliasNameStartingWithIgnoreCase(@Param("prefix") String prefix);
}

View File

@@ -17,8 +17,12 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
Optional<Tag> findByName(String name);
Optional<Tag> findByNameIgnoreCase(String name);
boolean existsByName(String name);
boolean existsByNameIgnoreCase(String name);
List<Tag> findByNameContainingIgnoreCase(String name);
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);

View File

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

View File

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

View File

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

@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
'use client';
import { useState } from 'react';
import { Tag } from '../../types/api';
interface TagDisplayProps {
tag: Tag;
size?: 'sm' | 'md' | 'lg';
showAliasesTooltip?: boolean;
clickable?: boolean;
onClick?: (tag: Tag) => void;
className?: string;
}
export default function TagDisplay({
tag,
size = 'md',
showAliasesTooltip = true,
clickable = false,
onClick,
className = ''
}: TagDisplayProps) {
const [showTooltip, setShowTooltip] = useState(false);
const sizeClasses = {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-2 text-base'
};
const baseClasses = `
inline-flex items-center gap-1 rounded-full font-medium transition-all
${sizeClasses[size]}
${clickable ? 'cursor-pointer hover:scale-105' : ''}
${className}
`;
// Determine tag styling based on color
const tagStyle = tag.color ? {
backgroundColor: tag.color + '20', // Add 20% opacity
borderColor: tag.color,
color: tag.color
} : {};
const defaultClasses = !tag.color ?
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600' :
'border';
const handleClick = () => {
if (clickable && onClick) {
onClick(tag);
}
};
const handleMouseEnter = () => {
if (showAliasesTooltip && tag.aliases && tag.aliases.length > 0) {
setShowTooltip(true);
}
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<div className="relative inline-block">
<span
className={`${baseClasses} ${defaultClasses}`}
style={tag.color ? tagStyle : {}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title={tag.description || undefined}
>
{tag.name}
{(tag.aliasCount ?? 0) > 0 && (
<span className="text-xs opacity-75">+{tag.aliasCount}</span>
)}
</span>
{/* Tooltip for aliases */}
{showTooltip && showAliasesTooltip && tag.aliases && tag.aliases.length > 0 && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50">
<div className="bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg px-3 py-2 max-w-xs">
<div className="font-medium mb-1">{tag.name}</div>
<div className="border-t border-gray-700 dark:border-gray-300 pt-1">
<div className="text-gray-300 dark:text-gray-600 mb-1">Aliases:</div>
{tag.aliases.map((alias, index) => (
<div key={alias.id} className="text-gray-100 dark:text-gray-800">
{alias.aliasName}
{index < tag.aliases!.length - 1 && ', '}
</div>
))}
</div>
{/* Tooltip arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
<div className="border-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,324 @@
'use client';
import { useState, useEffect } from 'react';
import { Tag, TagAlias } from '../../types/api';
import { tagApi } from '../../lib/api';
import { Input, Textarea } from '../ui/Input';
import Button from '../ui/Button';
import ColorPicker from '../ui/ColorPicker';
interface TagEditModalProps {
tag?: Tag;
isOpen: boolean;
onClose: () => void;
onSave: (tag: Tag) => void;
onDelete?: (tag: Tag) => void;
}
export default function TagEditModal({ tag, isOpen, onClose, onSave, onDelete }: TagEditModalProps) {
const [formData, setFormData] = useState({
name: '',
color: '',
description: ''
});
const [aliases, setAliases] = useState<TagAlias[]>([]);
const [newAlias, setNewAlias] = useState('');
const [loading, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [deleteConfirm, setDeleteConfirm] = useState(false);
// Reset form when modal opens/closes or tag changes
useEffect(() => {
if (isOpen && tag) {
setFormData({
name: tag.name || '',
color: tag.color || '',
description: tag.description || ''
});
setAliases(tag.aliases || []);
} else if (isOpen && !tag) {
// New tag
setFormData({
name: '',
color: '',
description: ''
});
setAliases([]);
}
setNewAlias('');
setErrors({});
setDeleteConfirm(false);
}, [isOpen, tag]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleAddAlias = async () => {
if (!newAlias.trim() || !tag) return;
try {
// Check if alias already exists
if (aliases.some(alias => alias.aliasName.toLowerCase() === newAlias.toLowerCase())) {
setErrors({ alias: 'This alias already exists for this tag' });
return;
}
// Create alias via API
const newAliasData = await tagApi.addAlias(tag.id, newAlias.trim());
setAliases(prev => [...prev, newAliasData]);
setNewAlias('');
setErrors(prev => ({ ...prev, alias: '' }));
} catch (error) {
setErrors({ alias: 'Failed to add alias' });
}
};
const handleRemoveAlias = async (aliasId: string) => {
if (!tag) return;
try {
await tagApi.removeAlias(tag.id, aliasId);
setAliases(prev => prev.filter(alias => alias.id !== aliasId));
} catch (error) {
console.error('Failed to remove alias:', error);
}
};
const handleSave = async () => {
setErrors({});
setSaving(true);
try {
const payload = {
name: formData.name.trim(),
color: formData.color || undefined,
description: formData.description || undefined
};
let savedTag: Tag;
if (tag) {
// Update existing tag
savedTag = await tagApi.updateTag(tag.id, payload);
} else {
// Create new tag
savedTag = await tagApi.createTag(payload);
}
// Include aliases in the saved tag
savedTag.aliases = aliases;
onSave(savedTag);
onClose();
} catch (error: any) {
setErrors({ submit: error.message });
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!tag || !onDelete) return;
try {
setSaving(true);
await tagApi.deleteTag(tag.id);
onDelete(tag);
onClose();
} catch (error: any) {
setErrors({ submit: error.message });
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b theme-border">
<h2 className="text-xl font-semibold theme-header">
{tag ? `Edit Tag: "${tag.name}"` : 'Create New Tag'}
</h2>
</div>
<div className="p-6 space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<Input
label="Tag Name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
disabled={loading}
placeholder="Enter tag name"
required
/>
<ColorPicker
label="Color (Optional)"
value={formData.color}
onChange={(color) => handleInputChange('color', color || '')}
disabled={loading}
/>
<Textarea
label="Description (Optional)"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
error={errors.description}
disabled={loading}
placeholder="Optional description for this tag"
rows={3}
/>
</div>
{/* Aliases Section (only for existing tags) */}
{tag && (
<div className="space-y-4">
<h3 className="text-lg font-medium theme-header">
Aliases ({aliases.length})
</h3>
{aliases.length > 0 && (
<div className="space-y-2 max-h-32 overflow-y-auto border theme-border rounded-lg p-3">
{aliases.map((alias) => (
<div key={alias.id} className="flex items-center justify-between py-1">
<span className="text-sm theme-text">
{alias.aliasName}
{alias.createdFromMerge && (
<span className="ml-2 text-xs theme-text-muted">(from merge)</span>
)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAlias(alias.id)}
disabled={loading}
className="text-xs text-red-600 hover:text-red-800"
>
Remove
</Button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Input
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
placeholder="Add new alias"
error={errors.alias}
disabled={loading}
onKeyPress={(e) => e.key === 'Enter' && handleAddAlias()}
/>
<Button
variant="secondary"
onClick={handleAddAlias}
disabled={loading || !newAlias.trim()}
>
Add
</Button>
</div>
</div>
)}
{/* Story Information (for existing tags) */}
{tag && (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h3 className="text-sm font-medium theme-header mb-2">Usage Statistics</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="theme-text-muted">Stories:</span>
<span className="ml-2 font-medium">{tag.storyCount || 0}</span>
</div>
<div>
<span className="theme-text-muted">Collections:</span>
<span className="ml-2 font-medium">{tag.collectionCount || 0}</span>
</div>
</div>
{tag.storyCount && tag.storyCount > 0 && (
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={() => window.open(`/library?tags=${encodeURIComponent(tag.name)}`, '_blank')}
>
View Stories
</Button>
)}
</div>
)}
{/* Error Display */}
{errors.submit && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{errors.submit}</p>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 border-t theme-border flex justify-between">
<div className="flex gap-2">
{tag && onDelete && (
<>
{!deleteConfirm ? (
<Button
variant="ghost"
onClick={() => setDeleteConfirm(true)}
disabled={loading}
className="text-red-600 hover:text-red-800"
>
Delete Tag
</Button>
) : (
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => setDeleteConfirm(false)}
disabled={loading}
className="text-sm"
>
Cancel
</Button>
<Button
variant="ghost"
onClick={handleDelete}
disabled={loading}
className="text-sm bg-red-600 text-white hover:bg-red-700"
>
{loading ? 'Deleting...' : 'Confirm Delete'}
</Button>
</div>
)}
</>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onClose}
disabled={loading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={loading || !formData.name.trim()}
>
{loading ? 'Saving...' : (tag ? 'Save Changes' : 'Create Tag')}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useState, useEffect } from 'react';
import { tagApi } from '../../lib/api';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface TagSuggestion {
tagName: string;
confidence: number;
reason: string;
}
interface TagSuggestionsProps {
title: string;
content?: string;
summary?: string;
currentTags: string[];
onAddTag: (tagName: string) => void;
disabled?: boolean;
}
export default function TagSuggestions({
title,
content,
summary,
currentTags,
onAddTag,
disabled = false
}: TagSuggestionsProps) {
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [lastAnalyzed, setLastAnalyzed] = useState<string>('');
useEffect(() => {
const analyzeContent = async () => {
// Only analyze if we have meaningful content and it has changed
const contentKey = `${title}|${summary}`;
if (!title.trim() || contentKey === lastAnalyzed || disabled) {
return;
}
setLoading(true);
try {
const tagSuggestions = await tagApi.suggestTags(title, content, summary, 8);
// Filter out suggestions that are already selected
const filteredSuggestions = tagSuggestions.filter(
suggestion => !currentTags.some(tag =>
tag.toLowerCase() === suggestion.tagName.toLowerCase()
)
);
setSuggestions(filteredSuggestions);
setLastAnalyzed(contentKey);
} catch (error) {
console.error('Failed to get tag suggestions:', error);
setSuggestions([]);
} finally {
setLoading(false);
}
};
// Debounce the analysis
const debounce = setTimeout(analyzeContent, 1000);
return () => clearTimeout(debounce);
}, [title, content, summary, currentTags, lastAnalyzed, disabled]);
const handleAddTag = (tagName: string) => {
onAddTag(tagName);
// Remove the added tag from suggestions
setSuggestions(prev => prev.filter(s => s.tagName !== tagName));
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 0.7) return 'text-green-600 dark:text-green-400';
if (confidence >= 0.5) return 'text-yellow-600 dark:text-yellow-400';
return 'text-gray-600 dark:text-gray-400';
};
const getConfidenceLabel = (confidence: number) => {
if (confidence >= 0.7) return 'High';
if (confidence >= 0.5) return 'Medium';
return 'Low';
};
if (disabled || (!title.trim() && !summary?.trim())) {
return null;
}
return (
<div className="mt-4">
<div className="flex items-center gap-2 mb-3">
<h3 className="text-sm font-medium theme-text">Suggested Tags</h3>
{loading && <LoadingSpinner size="sm" />}
</div>
{suggestions.length === 0 && !loading ? (
<p className="text-sm theme-text-muted">
{title.trim() ? 'No tag suggestions found for this content' : 'Enter a title to get tag suggestions'}
</p>
) : (
<div className="space-y-2">
{suggestions.map((suggestion) => (
<div
key={suggestion.tagName}
className="flex items-center justify-between p-3 border theme-border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium theme-text">{suggestion.tagName}</span>
<span className={`text-xs px-2 py-1 rounded-full border ${getConfidenceColor(suggestion.confidence)}`}>
{getConfidenceLabel(suggestion.confidence)}
</span>
</div>
<p className="text-xs theme-text-muted mt-1">{suggestion.reason}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleAddTag(suggestion.tagName)}
className="ml-3"
>
Add
</Button>
</div>
))}
</div>
)}
{suggestions.length > 0 && (
<div className="mt-3 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => {
suggestions.forEach(s => handleAddTag(s.tagName));
}}
>
Add All Suggestions
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import Button from './Button';
interface ColorPickerProps {
value?: string;
onChange: (color: string | undefined) => void;
disabled?: boolean;
label?: string;
}
// Theme-compatible color palette
const THEME_COLORS = [
// Primary blues
{ hex: '#3B82F6', name: 'Theme Blue' },
{ hex: '#1D4ED8', name: 'Deep Blue' },
{ hex: '#60A5FA', name: 'Light Blue' },
// Greens
{ hex: '#10B981', name: 'Emerald' },
{ hex: '#059669', name: 'Forest Green' },
{ hex: '#34D399', name: 'Light Green' },
// Purples
{ hex: '#8B5CF6', name: 'Purple' },
{ hex: '#7C3AED', name: 'Deep Purple' },
{ hex: '#A78BFA', name: 'Light Purple' },
// Warm tones
{ hex: '#F59E0B', name: 'Amber' },
{ hex: '#D97706', name: 'Orange' },
{ hex: '#F97316', name: 'Bright Orange' },
// Reds/Pinks
{ hex: '#EF4444', name: 'Red' },
{ hex: '#F472B6', name: 'Pink' },
{ hex: '#EC4899', name: 'Hot Pink' },
// Neutrals
{ hex: '#6B7280', name: 'Gray' },
{ hex: '#4B5563', name: 'Dark Gray' },
{ hex: '#9CA3AF', name: 'Light Gray' }
];
export default function ColorPicker({ value, onChange, disabled, label }: ColorPickerProps) {
const [showCustomPicker, setShowCustomPicker] = useState(false);
const [customColor, setCustomColor] = useState(value || '#3B82F6');
const handleThemeColorSelect = (color: string) => {
onChange(color);
setShowCustomPicker(false);
};
const handleCustomColorChange = (color: string) => {
setCustomColor(color);
onChange(color);
};
const handleRemoveColor = () => {
onChange(undefined);
setShowCustomPicker(false);
};
return (
<div className="space-y-3">
{label && (
<label className="block text-sm font-medium theme-header">
{label}
</label>
)}
{/* Current Color Display */}
{value && (
<div className="flex items-center gap-2 p-2 border theme-border rounded-lg">
<div
className="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: value }}
/>
<span className="text-sm theme-text font-mono">{value}</span>
<Button
variant="ghost"
size="sm"
onClick={handleRemoveColor}
disabled={disabled}
className="ml-auto text-xs"
>
Remove
</Button>
</div>
)}
{/* Theme Color Palette */}
<div className="space-y-2">
<h4 className="text-sm font-medium theme-header">Theme Colors</h4>
<div className="grid grid-cols-6 gap-2 p-3 border theme-border rounded-lg">
{THEME_COLORS.map((color) => (
<button
key={color.hex}
type="button"
className={`
w-8 h-8 rounded-md border-2 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-theme-accent
${value === color.hex ? 'border-gray-800 dark:border-white scale-110' : 'border-gray-300 dark:border-gray-600'}
`}
style={{ backgroundColor: color.hex }}
onClick={() => handleThemeColorSelect(color.hex)}
disabled={disabled}
title={color.name}
/>
))}
</div>
</div>
{/* Custom Color Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium theme-header">Custom Color</h4>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCustomPicker(!showCustomPicker)}
disabled={disabled}
className="text-xs"
>
{showCustomPicker ? 'Hide' : 'Show'} Custom
</Button>
</div>
{showCustomPicker && (
<div className="p-3 border theme-border rounded-lg space-y-3">
<div className="flex items-center gap-3">
<input
type="color"
value={customColor}
onChange={(e) => handleCustomColorChange(e.target.value)}
disabled={disabled}
className="w-12 h-8 rounded border border-gray-300 dark:border-gray-600 cursor-pointer disabled:cursor-not-allowed"
/>
<input
type="text"
value={customColor}
onChange={(e) => {
const color = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
setCustomColor(color);
onChange(color);
}
}}
disabled={disabled}
className="flex-1 px-3 py-1 text-sm border theme-border rounded font-mono"
placeholder="#3B82F6"
/>
<Button
variant="primary"
size="sm"
onClick={() => onChange(customColor)}
disabled={disabled}
className="text-xs"
>
Apply
</Button>
</div>
<p className="text-xs theme-text-muted">
Enter a hex color code or use the color picker
</p>
</div>
)}
</div>
</div>
);
}

View File

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

View File

@@ -82,9 +82,11 @@ export class StoryScraper {
if (siteConfig.story.tags) {
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
if (Array.isArray(tagsResult)) {
story.tags = tagsResult;
// Resolve tag aliases to canonical names
story.tags = await this.resolveTagAliases(tagsResult);
} else if (typeof tagsResult === 'string' && tagsResult) {
story.tags = [tagsResult];
// Resolve tag aliases to canonical names
story.tags = await this.resolveTagAliases([tagsResult]);
}
}
@@ -379,4 +381,21 @@ export class StoryScraper {
return text;
}
private async resolveTagAliases(tags: string[]): Promise<string[]> {
try {
// Import the tagApi dynamically to avoid circular dependencies
const { tagApi } = await import('../api');
// Resolve all tags to their canonical names
const resolvedTags = await tagApi.resolveTags(tags);
// Filter out empty tags
return resolvedTags.filter(tag => tag && tag.trim().length > 0);
} catch (error) {
console.warn('Failed to resolve tag aliases during scraping:', error);
// Fall back to original tags if resolution fails
return tags.filter(tag => tag && tag.trim().length > 0);
}
}
}

View File

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

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