diff --git a/TAG_ENHANCEMENT_SPECIFICATION.md b/TAG_ENHANCEMENT_SPECIFICATION.md new file mode 100644 index 0000000..5648f8c --- /dev/null +++ b/TAG_ENHANCEMENT_SPECIFICATION.md @@ -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.* \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/controller/AuthorController.java b/backend/src/main/java/com/storycove/controller/AuthorController.java index 89cc1c2..f1f8dd9 100644 --- a/backend/src/main/java/com/storycove/controller/AuthorController.java +++ b/backend/src/main/java/com/storycove/controller/AuthorController.java @@ -335,6 +335,44 @@ public class AuthorController { } } + @PostMapping("/clean-author-names") + public ResponseEntity> cleanAuthorNames() { + try { + List 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> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) { Pageable pageable = PageRequest.of(0, limit); diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 6267972..0828b38 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -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()) { - Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim()); - story.setSeries(series); + } else if (updateReq.getSeriesName() != null) { + logger.info("Processing series update: seriesName='{}', isEmpty={}", updateReq.getSeriesName(), updateReq.getSeriesName().trim().isEmpty()); + if (updateReq.getSeriesName().trim().isEmpty()) { + // Empty series name means remove from series + logger.info("Removing story from series"); + if (story.getSeries() != null) { + story.getSeries().removeStory(story); + story.setSeries(null); + story.setVolume(null); + logger.info("Story removed from series"); + } + } else { + // Non-empty series name means add to series + logger.info("Adding story to series: '{}', volume: {}", updateReq.getSeriesName().trim(), updateReq.getVolume()); + Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim()); + story.setSeries(series); + // Set volume only if series is being set + if (updateReq.getVolume() != null) { + story.setVolume(updateReq.getVolume()); + logger.info("Story added to series: {} with volume: {}", series.getName(), updateReq.getVolume()); + } else { + logger.info("Story added to series: {} with no volume", series.getName()); + } + } } // Note: Tags are now handled in StoryService.updateWithTagNames() @@ -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; } diff --git a/backend/src/main/java/com/storycove/controller/TagController.java b/backend/src/main/java/com/storycove/controller/TagController.java index a5201a1..51f0b03 100644 --- a/backend/src/main/java/com/storycove/controller/TagController.java +++ b/backend/src/main/java/com/storycove/controller/TagController.java @@ -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 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 tags = tagService.findByNameStartingWith(query, limit); + List tags = tagService.findByNameOrAliasStartingWith(query, limit); List 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 addAlias(@PathVariable UUID tagId, + @RequestBody Map 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 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> suggestTags(@RequestBody TagSuggestionRequest request) { + try { + List 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 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 sourceTagIds; + private String targetTagId; + + public List getSourceTagIds() { return sourceTagIds; } + public void setSourceTagIds(List 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 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 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 getAliasesToCreate() { return aliasesToCreate; } + public void setAliasesToCreate(List 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; } } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/TagAliasDto.java b/backend/src/main/java/com/storycove/dto/TagAliasDto.java new file mode 100644 index 0000000..9402316 --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/TagAliasDto.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/TagDto.java b/backend/src/main/java/com/storycove/dto/TagDto.java index aa8f152..641489b 100644 --- a/backend/src/main/java/com/storycove/dto/TagDto.java +++ b/backend/src/main/java/com/storycove/dto/TagDto.java @@ -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 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 getAliases() { + return aliases; + } + + public void setAliases(List aliases) { + this.aliases = aliases; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/storycove/entity/Tag.java b/backend/src/main/java/com/storycove/entity/Tag.java index 4b57944..3d35c4f 100644 --- a/backend/src/main/java/com/storycove/entity/Tag.java +++ b/backend/src/main/java/com/storycove/entity/Tag.java @@ -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 collections = new HashSet<>(); + @OneToMany(mappedBy = "canonicalTag", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference("tag-aliases") + private Set 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 getStories() { return stories; @@ -79,6 +114,14 @@ public class Tag { this.collections = collections; } + public Set getAliases() { + return aliases; + } + + public void setAliases(Set aliases) { + this.aliases = aliases; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/storycove/entity/TagAlias.java b/backend/src/main/java/com/storycove/entity/TagAlias.java new file mode 100644 index 0000000..9fac4ca --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/TagAlias.java @@ -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 + + '}'; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/TagAliasRepository.java b/backend/src/main/java/com/storycove/repository/TagAliasRepository.java new file mode 100644 index 0000000..e6ba34b --- /dev/null +++ b/backend/src/main/java/com/storycove/repository/TagAliasRepository.java @@ -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 { + + /** + * Find alias by exact alias name (case-insensitive) + */ + @Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) = LOWER(:aliasName)") + Optional findByAliasNameIgnoreCase(@Param("aliasName") String aliasName); + + /** + * Find all aliases for a specific canonical tag + */ + List findByCanonicalTag(Tag canonicalTag); + + /** + * Find all aliases for a specific canonical tag ID + */ + @Query("SELECT ta FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId") + List findByCanonicalTagId(@Param("tagId") UUID tagId); + + /** + * Find aliases created from merge operations + */ + List 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 findByAliasNameStartingWithIgnoreCase(@Param("prefix") String prefix); +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/repository/TagRepository.java b/backend/src/main/java/com/storycove/repository/TagRepository.java index 63d50b7..d39a22f 100644 --- a/backend/src/main/java/com/storycove/repository/TagRepository.java +++ b/backend/src/main/java/com/storycove/repository/TagRepository.java @@ -17,8 +17,12 @@ public interface TagRepository extends JpaRepository { Optional findByName(String name); + Optional findByNameIgnoreCase(String name); + boolean existsByName(String name); + boolean existsByNameIgnoreCase(String name); + List findByNameContainingIgnoreCase(String name); Page findByNameContainingIgnoreCase(String name, Pageable pageable); diff --git a/backend/src/main/java/com/storycove/service/StoryService.java b/backend/src/main/java/com/storycove/service/StoryService.java index d8dbb2f..ed40aad 100644 --- a/backend/src/main/java/com/storycove/service/StoryService.java +++ b/backend/src/main/java/com/storycove/service/StoryService.java @@ -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); + } } } } diff --git a/backend/src/main/java/com/storycove/service/TagService.java b/backend/src/main/java/com/storycove/service/TagService.java index 1bab88c..6fa1707 100644 --- a/backend/src/main/java/com/storycove/service/TagService.java +++ b/backend/src/main/java/com/storycove/service/TagService.java @@ -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 directMatch = tagRepository.findByNameIgnoreCase(name); + if (directMatch.isPresent()) { + return directMatch.get(); + } + + // Then try to find by alias + Optional aliasMatch = tagAliasRepository.findByAliasNameIgnoreCase(name); + if (aliasMatch.isPresent()) { + return aliasMatch.get().getCanonicalTag(); + } + + return null; + } + + @Transactional + public Tag mergeTags(List sourceTagIds, UUID targetTagId) { + // Validate target tag exists + Tag targetTag = findById(targetTagId); + + // Validate source tags exist and are different from target + List 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 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 findByNameOrAliasStartingWith(String query, int limit) { + // Find tags that start with the query + List directMatches = tagRepository.findByNameStartingWithIgnoreCase(query.toLowerCase()); + + // Find tags via aliases that start with the query + List aliasMatches = tagAliasRepository.findByAliasNameStartingWithIgnoreCase(query.toLowerCase()); + List aliasTagMatches = aliasMatches.stream() + .map(TagAlias::getCanonicalTag) + .distinct() + .toList(); + + // Combine and deduplicate + Set 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 sourceTagIds, UUID targetTagId) { + // Validate target tag exists + Tag targetTag = findById(targetTagId); + + // Validate source tags exist and are different from target + List 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 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 suggestTags(String title, String content, String summary, int limit) { + List suggestions = new ArrayList<>(); + + // Get all existing tags for matching + List 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(); } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/TypesenseService.java b/backend/src/main/java/com/storycove/service/TypesenseService.java index 5c17b60..52d1e1a 100644 --- a/backend/src/main/java/com/storycove/service/TypesenseService.java +++ b/backend/src/main/java/com/storycove/service/TypesenseService.java @@ -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 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,11 +962,16 @@ 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() diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..c31d989 --- /dev/null +++ b/cookies.txt @@ -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. + diff --git a/frontend/src/app/library/page.tsx b/frontend/src/app/library/page.tsx index 7ca2c8e..a577e37 100644 --- a/frontend/src/app/library/page.tsx +++ b/frontend/src/app/library/page.tsx @@ -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([]); const [tags, setTags] = useState([]); @@ -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); - // Convert facet counts to Tag objects for the UI + // 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([]); + + // Fetch full tag data for enrichment + useEffect(() => { + const fetchFullTags = async () => { + try { + const result = await tagApi.getTags({ size: 1000 }); // Get all tags + setFullTags(result.content || []); + } catch (error) { + console.error('Failed to fetch full tag data:', error); + setFullTags([]); + } + }; + + fetchFullTags(); + }, []); + const convertFacetsToTags = (facets?: Record): Tag[] => { if (!facets || !facets.tagNames) { return []; } - return facets.tagNames.map(facet => ({ - id: facet.value, // Use tag name as ID since we don't have actual IDs from search results - name: facet.value, - storyCount: facet.count - })); + return facets.tagNames.map(facet => { + // Find the full tag data by name + const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase()); + + return { + id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name + name: facet.value, + storyCount: facet.count, + // Include color and other metadata from the full tag data + color: fullTag?.color, + description: fullTag?.description, + aliasCount: fullTag?.aliasCount, + createdAt: fullTag?.createdAt, + aliases: fullTag?.aliases + }; + }); }; + // Enrich existing tags when fullTags are loaded + useEffect(() => { + if (fullTags.length > 0 && 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) => { setSearchQuery(e.target.value); @@ -167,6 +239,7 @@ export default function LibraryPage() { const layoutProps = { stories, tags, + totalElements, searchQuery, selectedTags, viewMode, diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 949b35d..8d98123 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -774,6 +774,21 @@ export default function SettingsPage() { + {/* Tag Management */} +
+

Tag Management

+

+ Manage your story tags with colors, descriptions, and aliases. Use the Tag Maintenance page to organize and customize your tags. +

+ +
+ {/* Actions */}
+ +
+ + + {/* Statistics */} +
+

Tag Statistics

+
+
+
{tagStats.total}
+
Total Tags
+
+
+
{tagStats.withColors}
+
With Colors
+
+
+
{tagStats.withDescriptions}
+
With Descriptions
+
+
+
{tagStats.withAliases}
+
With Aliases
+
+
+
{tagStats.unused}
+
Unused
+
+
+
+ + {/* Controls */} +
+
+
+ setSearchQuery(e.target.value)} + className="w-full" + /> +
+
+ + + +
+
+
+ + {/* Tags List */} +
+
+

+ Tags ({filteredTags.length}) +

+
+ {selectedTagIds.size > 0 && ( + <> + + + + )} +
+
+ + {filteredTags.length > 0 && ( +
+ 0 && selectedTagIds.size === filteredTags.length} + onChange={(e) => handleSelectAll(e.target.checked)} + className="rounded" + /> + +
+ )} + + {filteredTags.length === 0 ? ( +
+

+ {searchQuery ? 'No tags match your search.' : 'No tags found.'} +

+ {!searchQuery && ( + + )} +
+ ) : ( +
+ {filteredTags.map((tag) => ( +
+
+ handleTagSelection(tag.id, e.target.checked)} + className="rounded" + /> + +
+ {tag.description && ( +

+ {tag.description} +

+ )} +
+ {tag.storyCount || 0} stories + {tag.aliasCount && tag.aliasCount > 0 && ( + {tag.aliasCount} aliases + )} + {tag.createdAt && ( + Created {new Date(tag.createdAt).toLocaleDateString()} + )} +
+
+
+
+ +
+
+ ))} +
+ )} +
+ + + {/* Edit Modal */} + { + setIsEditModalOpen(false); + setSelectedTag(null); + }} + onSave={handleTagSave} + onDelete={handleTagDelete} + /> + + {/* Create Modal */} + setIsCreateModalOpen(false)} + onSave={handleTagSave} + /> + + {/* Merge Modal */} + {isMergeModalOpen && ( +
+
+

Merge Tags

+ +
+
+

+ You have selected {selectedTagIds.size} tags to merge. +

+

+ Choose which tag should become the canonical name. All other tags will become aliases. +

+
+ + {/* Target Tag Selection */} +
+ + +
+ + {/* Preview Button */} + {mergeTargetTagId && ( + + )} + + {/* Merge Preview */} + {mergePreview && ( +
+

Merge Preview

+
+

+ Result: "{mergePreview.targetTagName}" with {mergePreview.totalResultStoryCount} stories +

+ {mergePreview.aliasesToCreate && mergePreview.aliasesToCreate.length > 0 && ( +
+ Aliases to create: +
    + {mergePreview.aliasesToCreate.map((alias: string) => ( +
  • {alias}
  • + ))} +
+
+ )} +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/frontend/src/app/stories/[id]/detail/page.tsx b/frontend/src/app/stories/[id]/detail/page.tsx index 9d5a1c7..54afd77 100644 --- a/frontend/src/app/stories/[id]/detail/page.tsx +++ b/frontend/src/app/stories/[id]/detail/page.tsx @@ -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() {

Tags

{story.tags.map((tag) => ( - - {tag.name} - + tag={tag} + size="md" + clickable={false} + /> ))}
diff --git a/frontend/src/app/stories/[id]/edit/page.tsx b/frontend/src/app/stories/[id]/edit/page.tsx index 4b6c2c2..655f38a 100644 --- a/frontend/src/app/stories/[id]/edit/page.tsx +++ b/frontend/src/app/stories/[id]/edit/page.tsx @@ -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 */} + {/* Series and Volume */} diff --git a/frontend/src/app/stories/[id]/page.tsx b/frontend/src/app/stories/[id]/page.tsx index c6a14d7..44ff335 100644 --- a/frontend/src/app/stories/[id]/page.tsx +++ b/frontend/src/app/stories/[id]/page.tsx @@ -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 && (
{story.tags.map((tag) => ( - - {tag.name} - + tag={tag} + size="md" + clickable={false} + /> ))}
)} diff --git a/frontend/src/components/collections/CollectionReadingView.tsx b/frontend/src/components/collections/CollectionReadingView.tsx index 4be38ff..6be0cf4 100644 --- a/frontend/src/components/collections/CollectionReadingView.tsx +++ b/frontend/src/components/collections/CollectionReadingView.tsx @@ -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 && (
{story.tags.map((tag) => ( - - {tag.name} - + tag={tag} + size="sm" + clickable={false} + /> ))}
)} diff --git a/frontend/src/components/library/MinimalLayout.tsx b/frontend/src/components/library/MinimalLayout.tsx index 8e6660e..541d6ba 100644 --- a/frontend/src/components/library/MinimalLayout.tsx +++ b/frontend/src/components/library/MinimalLayout.tsx @@ -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,8 +44,14 @@ 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 = { @@ -62,7 +71,7 @@ export default function MinimalLayout({

Story Library

- Your personal collection of {stories.length} stories + Your personal collection of {totalElements} stories

{popularTags.map((tag) => ( - + +
))}
@@ -173,7 +185,10 @@ export default function MinimalLayout({

Browse All Tags

- {tags.map((tag) => ( -
+ ) : ( + filteredTags.map((tag) => ( +
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}) - - ))} + +
+ )) + )}
+ -
diff --git a/frontend/src/components/library/SidebarLayout.tsx b/frontend/src/components/library/SidebarLayout.tsx index e220bc3..cd3472b 100644 --- a/frontend/src/components/library/SidebarLayout.tsx +++ b/frontend/src/components/library/SidebarLayout.tsx @@ -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 (
{/* Left Sidebar */} @@ -58,7 +68,7 @@ export default function SidebarLayout({ {/* Header */}

Your Library

-

{stories.length} stories total

+

{totalElements} stories total

{/* Search */} @@ -125,6 +135,8 @@ export default function SidebarLayout({ setTagSearch(e.target.value)} className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600" />
@@ -136,9 +148,9 @@ export default function SidebarLayout({ checked={selectedTags.length === 0} onChange={() => onClearFilters()} /> - All Stories ({stories.length}) + All Stories ({totalElements}) - {tags.map((tag) => ( + {filteredTags.map((tag) => ( ))} - {tags.length > 10 && ( + {filteredTags.length === 0 && tagSearch && (
- ... and {tags.length - 10} more tags + No tags match "{tagSearch}" +
+ )} + {filteredTags.length > 10 && !tagSearch && ( +
+ ... and {filteredTags.length - 10} more tags
)} diff --git a/frontend/src/components/library/ToolbarLayout.tsx b/frontend/src/components/library/ToolbarLayout.tsx index 14397af..13425af 100644 --- a/frontend/src/components/library/ToolbarLayout.tsx +++ b/frontend/src/components/library/ToolbarLayout.tsx @@ -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 (
@@ -53,7 +64,7 @@ export default function ToolbarLayout({

Your Story Library

-

{stories.length} stories in your collection

+

{totalElements} stories in your collection

{popularTags.map((tag) => ( - + +
))} {remainingTagsCount > 0 && (
@@ -174,9 +188,15 @@ export default function ToolbarLayout({ setTagSearch(e.target.value)} className="flex-1" /> - + {tagSearch && ( + + )}
- {tags.slice(6).map((tag) => ( -
+ ) : ( + filteredRemainingTags.map((tag) => ( +
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}) - - ))} + +
+ )) + )} )} diff --git a/frontend/src/components/stories/TagInput.tsx b/frontend/src/components/stories/TagInput.tsx index 784d69e..4ece6bb 100644 --- a/frontend/src/components/stories/TagInput.tsx +++ b/frontend/src/components/stories/TagInput.tsx @@ -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)) { - onChange([...tags, trimmedTag]); + if (!trimmedTag) return; + + try { + // Resolve tag alias to canonical name + const resolvedTag = await tagApi.resolveTag(trimmedTag); + const finalTag = resolvedTag ? resolvedTag.name.toLowerCase() : trimmedTag; + + // Only add if not already present + if (!tags.includes(finalTag)) { + onChange([...tags, finalTag]); + } + } catch (error) { + console.warn('Failed to resolve tag alias:', error); + // Fall back to original tag if resolution fails + if (!tags.includes(trimmedTag)) { + onChange([...tags, trimmedTag]); + } } + setInputValue(''); setShowSuggestions(false); setActiveSuggestionIndex(-1); diff --git a/frontend/src/components/tags/TagDisplay.tsx b/frontend/src/components/tags/TagDisplay.tsx new file mode 100644 index 0000000..f05b142 --- /dev/null +++ b/frontend/src/components/tags/TagDisplay.tsx @@ -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 ( +
+ + {tag.name} + {(tag.aliasCount ?? 0) > 0 && ( + +{tag.aliasCount} + )} + + + {/* Tooltip for aliases */} + {showTooltip && showAliasesTooltip && tag.aliases && tag.aliases.length > 0 && ( +
+
+
{tag.name}
+
+
Aliases:
+ {tag.aliases.map((alias, index) => ( +
+ {alias.aliasName} + {index < tag.aliases!.length - 1 && ', '} +
+ ))} +
+ {/* Tooltip arrow */} +
+
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/tags/TagEditModal.tsx b/frontend/src/components/tags/TagEditModal.tsx new file mode 100644 index 0000000..7d61282 --- /dev/null +++ b/frontend/src/components/tags/TagEditModal.tsx @@ -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([]); + const [newAlias, setNewAlias] = useState(''); + const [loading, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + 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 ( +
+
+
+

+ {tag ? `Edit Tag: "${tag.name}"` : 'Create New Tag'} +

+
+ +
+ {/* Basic Information */} +
+ handleInputChange('name', e.target.value)} + error={errors.name} + disabled={loading} + placeholder="Enter tag name" + required + /> + + handleInputChange('color', color || '')} + disabled={loading} + /> + +