Tag Enhancement + bugfixes
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { StoryWithCollectionContext } from '../../types/api';
|
||||
import { storyApi } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface CollectionReadingViewProps {
|
||||
@@ -255,12 +256,12 @@ export default function CollectionReadingView({
|
||||
{story.tags && story.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{story.tags.map((tag) => (
|
||||
<span
|
||||
<TagDisplay
|
||||
key={tag.id}
|
||||
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface MinimalLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
totalElements: number;
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -26,6 +28,7 @@ interface MinimalLayoutProps {
|
||||
export default function MinimalLayout({
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
@@ -41,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<string, string> = {
|
||||
@@ -62,7 +71,7 @@ export default function MinimalLayout({
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl font-light theme-header mb-2">Story Library</h1>
|
||||
<p className="theme-text text-lg mb-8">
|
||||
Your personal collection of {stories.length} stories
|
||||
Your personal collection of {totalElements} stories
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="primary" onClick={onRandomStory}>
|
||||
@@ -139,17 +148,20 @@ export default function MinimalLayout({
|
||||
All
|
||||
</button>
|
||||
{popularTags.map((tag) => (
|
||||
<button
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-white dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-2' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<TagDisplay
|
||||
tag={tag}
|
||||
size="md"
|
||||
clickable={true}
|
||||
className={`${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
@@ -173,7 +185,10 @@ export default function MinimalLayout({
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h3 className="text-xl font-semibold theme-header">Browse All Tags</h3>
|
||||
<button
|
||||
onClick={() => setTagBrowserOpen(false)}
|
||||
onClick={() => {
|
||||
setTagBrowserOpen(false);
|
||||
setTagSearch('');
|
||||
}}
|
||||
className="text-2xl theme-text hover:theme-accent transition-colors"
|
||||
>
|
||||
✕
|
||||
@@ -184,31 +199,48 @@ export default function MinimalLayout({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
{filteredTags.length === 0 && tagSearch ? (
|
||||
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
||||
No tags match "{tagSearch}"
|
||||
</div>
|
||||
) : (
|
||||
filteredTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-white dark:bg-gray-700 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={`w-full text-left ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||
Clear Search
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClearFilters}>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setTagBrowserOpen(false)}>
|
||||
<Button variant="primary" onClick={() => {
|
||||
setTagBrowserOpen(false);
|
||||
setTagSearch('');
|
||||
}}>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface SidebarLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
totalElements: number;
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -26,6 +28,7 @@ interface SidebarLayoutProps {
|
||||
export default function SidebarLayout({
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
@@ -40,6 +43,13 @@ export default function SidebarLayout({
|
||||
onClearFilters,
|
||||
children
|
||||
}: SidebarLayoutProps) {
|
||||
const [tagSearch, setTagSearch] = useState('');
|
||||
|
||||
// Filter tags based on search query
|
||||
const filteredTags = tags.filter(tag =>
|
||||
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Sidebar */}
|
||||
@@ -58,7 +68,7 @@ export default function SidebarLayout({
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold theme-header">Your Library</h1>
|
||||
<p className="theme-text mt-1">{stories.length} stories total</p>
|
||||
<p className="theme-text mt-1">{totalElements} stories total</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
@@ -125,6 +135,8 @@ export default function SidebarLayout({
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
@@ -136,9 +148,9 @@ export default function SidebarLayout({
|
||||
checked={selectedTags.length === 0}
|
||||
onChange={() => onClearFilters()}
|
||||
/>
|
||||
<span className="text-xs">All Stories ({stories.length})</span>
|
||||
<span className="text-xs">All Stories ({totalElements})</span>
|
||||
</label>
|
||||
{tags.map((tag) => (
|
||||
{filteredTags.map((tag) => (
|
||||
<label
|
||||
key={tag.id}
|
||||
className="flex items-center gap-2 py-1 cursor-pointer"
|
||||
@@ -148,14 +160,27 @@ export default function SidebarLayout({
|
||||
checked={selectedTags.includes(tag.name)}
|
||||
onChange={() => onTagToggle(tag.name)}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{tag.name} ({tag.storyCount})
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<TagDisplay
|
||||
tag={tag}
|
||||
size="sm"
|
||||
clickable={false}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">
|
||||
({tag.storyCount})
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
{tags.length > 10 && (
|
||||
{filteredTags.length === 0 && tagSearch && (
|
||||
<div className="text-center text-xs text-gray-500 py-2">
|
||||
... and {tags.length - 10} more tags
|
||||
No tags match "{tagSearch}"
|
||||
</div>
|
||||
)}
|
||||
{filteredTags.length > 10 && !tagSearch && (
|
||||
<div className="text-center text-xs text-gray-500 py-2">
|
||||
... and {filteredTags.length - 10} more tags
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import TagDisplay from '../tags/TagDisplay';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface ToolbarLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
totalElements: number;
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -26,6 +28,7 @@ interface ToolbarLayoutProps {
|
||||
export default function ToolbarLayout({
|
||||
stories,
|
||||
tags,
|
||||
totalElements,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
@@ -41,9 +44,17 @@ export default function ToolbarLayout({
|
||||
children
|
||||
}: ToolbarLayoutProps) {
|
||||
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
|
||||
const [tagSearch, setTagSearch] = useState('');
|
||||
|
||||
const popularTags = tags.slice(0, 6);
|
||||
const remainingTagsCount = Math.max(0, tags.length - 6);
|
||||
|
||||
// Filter remaining tags based on search query
|
||||
const remainingTags = tags.slice(6);
|
||||
const filteredRemainingTags = tagSearch
|
||||
? remainingTags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
|
||||
: remainingTags;
|
||||
|
||||
const remainingTagsCount = Math.max(0, remainingTags.length);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
|
||||
@@ -53,7 +64,7 @@ export default function ToolbarLayout({
|
||||
<div className="flex justify-between items-start mb-6 max-md:flex-col max-md:gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
|
||||
<p className="theme-text mt-1">{stories.length} stories in your collection</p>
|
||||
<p className="theme-text mt-1">{totalElements} stories in your collection</p>
|
||||
</div>
|
||||
<div className="max-md:self-end">
|
||||
<Button variant="secondary" onClick={onRandomStory}>
|
||||
@@ -142,17 +153,20 @@ export default function ToolbarLayout({
|
||||
All Stories
|
||||
</button>
|
||||
{popularTags.map((tag) => (
|
||||
<button
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingTagsCount > 0 && (
|
||||
<button
|
||||
@@ -163,7 +177,7 @@ export default function ToolbarLayout({
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto text-sm theme-text">
|
||||
Showing {stories.length} stories
|
||||
Showing {stories.length} of {totalElements} stories
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,9 +188,15 @@ export default function ToolbarLayout({
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search from all available tags..."
|
||||
value={tagSearch}
|
||||
onChange={(e) => setTagSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="secondary">Search</Button>
|
||||
{tagSearch && (
|
||||
<Button variant="ghost" onClick={() => setTagSearch('')}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setTagSearchExpanded(false)}
|
||||
@@ -185,19 +205,28 @@ export default function ToolbarLayout({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
||||
{tags.slice(6).map((tag) => (
|
||||
<button
|
||||
{filteredRemainingTags.length === 0 && tagSearch ? (
|
||||
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
|
||||
No tags match "{tagSearch}"
|
||||
</div>
|
||||
) : (
|
||||
filteredRemainingTags.map((tag) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||
className={`cursor-pointer transition-all hover:scale-105 ${
|
||||
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
<TagDisplay
|
||||
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
|
||||
size="sm"
|
||||
clickable={true}
|
||||
className={`w-full ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -43,11 +43,27 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
|
||||
return () => clearTimeout(debounce);
|
||||
}, [inputValue, tags]);
|
||||
|
||||
const addTag = (tag: string) => {
|
||||
const addTag = async (tag: string) => {
|
||||
const trimmedTag = tag.trim().toLowerCase();
|
||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
||||
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);
|
||||
|
||||
104
frontend/src/components/tags/TagDisplay.tsx
Normal file
104
frontend/src/components/tags/TagDisplay.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tag } from '../../types/api';
|
||||
|
||||
interface TagDisplayProps {
|
||||
tag: Tag;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showAliasesTooltip?: boolean;
|
||||
clickable?: boolean;
|
||||
onClick?: (tag: Tag) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TagDisplay({
|
||||
tag,
|
||||
size = 'md',
|
||||
showAliasesTooltip = true,
|
||||
clickable = false,
|
||||
onClick,
|
||||
className = ''
|
||||
}: TagDisplayProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-3 py-1 text-sm',
|
||||
lg: 'px-4 py-2 text-base'
|
||||
};
|
||||
|
||||
const baseClasses = `
|
||||
inline-flex items-center gap-1 rounded-full font-medium transition-all
|
||||
${sizeClasses[size]}
|
||||
${clickable ? 'cursor-pointer hover:scale-105' : ''}
|
||||
${className}
|
||||
`;
|
||||
|
||||
// Determine tag styling based on color
|
||||
const tagStyle = tag.color ? {
|
||||
backgroundColor: tag.color + '20', // Add 20% opacity
|
||||
borderColor: tag.color,
|
||||
color: tag.color
|
||||
} : {};
|
||||
|
||||
const defaultClasses = !tag.color ?
|
||||
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600' :
|
||||
'border';
|
||||
|
||||
const handleClick = () => {
|
||||
if (clickable && onClick) {
|
||||
onClick(tag);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (showAliasesTooltip && tag.aliases && tag.aliases.length > 0) {
|
||||
setShowTooltip(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowTooltip(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<span
|
||||
className={`${baseClasses} ${defaultClasses}`}
|
||||
style={tag.color ? tagStyle : {}}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
title={tag.description || undefined}
|
||||
>
|
||||
{tag.name}
|
||||
{(tag.aliasCount ?? 0) > 0 && (
|
||||
<span className="text-xs opacity-75">+{tag.aliasCount}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Tooltip for aliases */}
|
||||
{showTooltip && showAliasesTooltip && tag.aliases && tag.aliases.length > 0 && (
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50">
|
||||
<div className="bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg px-3 py-2 max-w-xs">
|
||||
<div className="font-medium mb-1">{tag.name}</div>
|
||||
<div className="border-t border-gray-700 dark:border-gray-300 pt-1">
|
||||
<div className="text-gray-300 dark:text-gray-600 mb-1">Aliases:</div>
|
||||
{tag.aliases.map((alias, index) => (
|
||||
<div key={alias.id} className="text-gray-100 dark:text-gray-800">
|
||||
{alias.aliasName}
|
||||
{index < tag.aliases!.length - 1 && ', '}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Tooltip arrow */}
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
|
||||
<div className="border-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
frontend/src/components/tags/TagEditModal.tsx
Normal file
324
frontend/src/components/tags/TagEditModal.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tag, TagAlias } from '../../types/api';
|
||||
import { tagApi } from '../../lib/api';
|
||||
import { Input, Textarea } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import ColorPicker from '../ui/ColorPicker';
|
||||
|
||||
interface TagEditModalProps {
|
||||
tag?: Tag;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (tag: Tag) => void;
|
||||
onDelete?: (tag: Tag) => void;
|
||||
}
|
||||
|
||||
export default function TagEditModal({ tag, isOpen, onClose, onSave, onDelete }: TagEditModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
color: '',
|
||||
description: ''
|
||||
});
|
||||
const [aliases, setAliases] = useState<TagAlias[]>([]);
|
||||
const [newAlias, setNewAlias] = useState('');
|
||||
const [loading, setSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
|
||||
// Reset form when modal opens/closes or tag changes
|
||||
useEffect(() => {
|
||||
if (isOpen && tag) {
|
||||
setFormData({
|
||||
name: tag.name || '',
|
||||
color: tag.color || '',
|
||||
description: tag.description || ''
|
||||
});
|
||||
setAliases(tag.aliases || []);
|
||||
} else if (isOpen && !tag) {
|
||||
// New tag
|
||||
setFormData({
|
||||
name: '',
|
||||
color: '',
|
||||
description: ''
|
||||
});
|
||||
setAliases([]);
|
||||
}
|
||||
setNewAlias('');
|
||||
setErrors({});
|
||||
setDeleteConfirm(false);
|
||||
}, [isOpen, tag]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAlias = async () => {
|
||||
if (!newAlias.trim() || !tag) return;
|
||||
|
||||
try {
|
||||
// Check if alias already exists
|
||||
if (aliases.some(alias => alias.aliasName.toLowerCase() === newAlias.toLowerCase())) {
|
||||
setErrors({ alias: 'This alias already exists for this tag' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create alias via API
|
||||
const newAliasData = await tagApi.addAlias(tag.id, newAlias.trim());
|
||||
setAliases(prev => [...prev, newAliasData]);
|
||||
setNewAlias('');
|
||||
setErrors(prev => ({ ...prev, alias: '' }));
|
||||
} catch (error) {
|
||||
setErrors({ alias: 'Failed to add alias' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAlias = async (aliasId: string) => {
|
||||
if (!tag) return;
|
||||
|
||||
try {
|
||||
await tagApi.removeAlias(tag.id, aliasId);
|
||||
setAliases(prev => prev.filter(alias => alias.id !== aliasId));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove alias:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setErrors({});
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
color: formData.color || undefined,
|
||||
description: formData.description || undefined
|
||||
};
|
||||
|
||||
let savedTag: Tag;
|
||||
|
||||
if (tag) {
|
||||
// Update existing tag
|
||||
savedTag = await tagApi.updateTag(tag.id, payload);
|
||||
} else {
|
||||
// Create new tag
|
||||
savedTag = await tagApi.createTag(payload);
|
||||
}
|
||||
|
||||
// Include aliases in the saved tag
|
||||
savedTag.aliases = aliases;
|
||||
onSave(savedTag);
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
setErrors({ submit: error.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!tag || !onDelete) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await tagApi.deleteTag(tag.id);
|
||||
onDelete(tag);
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
setErrors({ submit: error.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b theme-border">
|
||||
<h2 className="text-xl font-semibold theme-header">
|
||||
{tag ? `Edit Tag: "${tag.name}"` : 'Create New Tag'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Tag Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
error={errors.name}
|
||||
disabled={loading}
|
||||
placeholder="Enter tag name"
|
||||
required
|
||||
/>
|
||||
|
||||
<ColorPicker
|
||||
label="Color (Optional)"
|
||||
value={formData.color}
|
||||
onChange={(color) => handleInputChange('color', color || '')}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description (Optional)"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
error={errors.description}
|
||||
disabled={loading}
|
||||
placeholder="Optional description for this tag"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Aliases Section (only for existing tags) */}
|
||||
{tag && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium theme-header">
|
||||
Aliases ({aliases.length})
|
||||
</h3>
|
||||
|
||||
{aliases.length > 0 && (
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto border theme-border rounded-lg p-3">
|
||||
{aliases.map((alias) => (
|
||||
<div key={alias.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm theme-text">
|
||||
{alias.aliasName}
|
||||
{alias.createdFromMerge && (
|
||||
<span className="ml-2 text-xs theme-text-muted">(from merge)</span>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAlias(alias.id)}
|
||||
disabled={loading}
|
||||
className="text-xs text-red-600 hover:text-red-800"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newAlias}
|
||||
onChange={(e) => setNewAlias(e.target.value)}
|
||||
placeholder="Add new alias"
|
||||
error={errors.alias}
|
||||
disabled={loading}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddAlias()}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleAddAlias}
|
||||
disabled={loading || !newAlias.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Story Information (for existing tags) */}
|
||||
{tag && (
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h3 className="text-sm font-medium theme-header mb-2">Usage Statistics</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="theme-text-muted">Stories:</span>
|
||||
<span className="ml-2 font-medium">{tag.storyCount || 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="theme-text-muted">Collections:</span>
|
||||
<span className="ml-2 font-medium">{tag.collectionCount || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
{tag.storyCount && tag.storyCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 text-xs"
|
||||
onClick={() => window.open(`/library?tags=${encodeURIComponent(tag.name)}`, '_blank')}
|
||||
>
|
||||
View Stories →
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{errors.submit && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.submit}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-6 border-t theme-border flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
{tag && onDelete && (
|
||||
<>
|
||||
{!deleteConfirm ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
disabled={loading}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete Tag
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteConfirm(false)}
|
||||
disabled={loading}
|
||||
className="text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="text-sm bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{loading ? 'Deleting...' : 'Confirm Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading || !formData.name.trim()}
|
||||
>
|
||||
{loading ? 'Saving...' : (tag ? 'Save Changes' : 'Create Tag')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/tags/TagSuggestions.tsx
Normal file
146
frontend/src/components/tags/TagSuggestions.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { tagApi } from '../../lib/api';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface TagSuggestion {
|
||||
tagName: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface TagSuggestionsProps {
|
||||
title: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
currentTags: string[];
|
||||
onAddTag: (tagName: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function TagSuggestions({
|
||||
title,
|
||||
content,
|
||||
summary,
|
||||
currentTags,
|
||||
onAddTag,
|
||||
disabled = false
|
||||
}: TagSuggestionsProps) {
|
||||
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastAnalyzed, setLastAnalyzed] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const analyzeContent = async () => {
|
||||
// Only analyze if we have meaningful content and it has changed
|
||||
const contentKey = `${title}|${summary}`;
|
||||
if (!title.trim() || contentKey === lastAnalyzed || disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const tagSuggestions = await tagApi.suggestTags(title, content, summary, 8);
|
||||
|
||||
// Filter out suggestions that are already selected
|
||||
const filteredSuggestions = tagSuggestions.filter(
|
||||
suggestion => !currentTags.some(tag =>
|
||||
tag.toLowerCase() === suggestion.tagName.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
setSuggestions(filteredSuggestions);
|
||||
setLastAnalyzed(contentKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to get tag suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the analysis
|
||||
const debounce = setTimeout(analyzeContent, 1000);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [title, content, summary, currentTags, lastAnalyzed, disabled]);
|
||||
|
||||
const handleAddTag = (tagName: string) => {
|
||||
onAddTag(tagName);
|
||||
// Remove the added tag from suggestions
|
||||
setSuggestions(prev => prev.filter(s => s.tagName !== tagName));
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.7) return 'text-green-600 dark:text-green-400';
|
||||
if (confidence >= 0.5) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
};
|
||||
|
||||
const getConfidenceLabel = (confidence: number) => {
|
||||
if (confidence >= 0.7) return 'High';
|
||||
if (confidence >= 0.5) return 'Medium';
|
||||
return 'Low';
|
||||
};
|
||||
|
||||
if (disabled || (!title.trim() && !summary?.trim())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="text-sm font-medium theme-text">Suggested Tags</h3>
|
||||
{loading && <LoadingSpinner size="sm" />}
|
||||
</div>
|
||||
|
||||
{suggestions.length === 0 && !loading ? (
|
||||
<p className="text-sm theme-text-muted">
|
||||
{title.trim() ? 'No tag suggestions found for this content' : 'Enter a title to get tag suggestions'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.tagName}
|
||||
className="flex items-center justify-between p-3 border theme-border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium theme-text">{suggestion.tagName}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded-full border ${getConfidenceColor(suggestion.confidence)}`}>
|
||||
{getConfidenceLabel(suggestion.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs theme-text-muted mt-1">{suggestion.reason}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleAddTag(suggestion.tagName)}
|
||||
className="ml-3"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
suggestions.forEach(s => handleAddTag(s.tagName));
|
||||
}}
|
||||
>
|
||||
Add All Suggestions
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/ui/ColorPicker.tsx
Normal file
171
frontend/src/components/ui/ColorPicker.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Button from './Button';
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
onChange: (color: string | undefined) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Theme-compatible color palette
|
||||
const THEME_COLORS = [
|
||||
// Primary blues
|
||||
{ hex: '#3B82F6', name: 'Theme Blue' },
|
||||
{ hex: '#1D4ED8', name: 'Deep Blue' },
|
||||
{ hex: '#60A5FA', name: 'Light Blue' },
|
||||
|
||||
// Greens
|
||||
{ hex: '#10B981', name: 'Emerald' },
|
||||
{ hex: '#059669', name: 'Forest Green' },
|
||||
{ hex: '#34D399', name: 'Light Green' },
|
||||
|
||||
// Purples
|
||||
{ hex: '#8B5CF6', name: 'Purple' },
|
||||
{ hex: '#7C3AED', name: 'Deep Purple' },
|
||||
{ hex: '#A78BFA', name: 'Light Purple' },
|
||||
|
||||
// Warm tones
|
||||
{ hex: '#F59E0B', name: 'Amber' },
|
||||
{ hex: '#D97706', name: 'Orange' },
|
||||
{ hex: '#F97316', name: 'Bright Orange' },
|
||||
|
||||
// Reds/Pinks
|
||||
{ hex: '#EF4444', name: 'Red' },
|
||||
{ hex: '#F472B6', name: 'Pink' },
|
||||
{ hex: '#EC4899', name: 'Hot Pink' },
|
||||
|
||||
// Neutrals
|
||||
{ hex: '#6B7280', name: 'Gray' },
|
||||
{ hex: '#4B5563', name: 'Dark Gray' },
|
||||
{ hex: '#9CA3AF', name: 'Light Gray' }
|
||||
];
|
||||
|
||||
export default function ColorPicker({ value, onChange, disabled, label }: ColorPickerProps) {
|
||||
const [showCustomPicker, setShowCustomPicker] = useState(false);
|
||||
const [customColor, setCustomColor] = useState(value || '#3B82F6');
|
||||
|
||||
const handleThemeColorSelect = (color: string) => {
|
||||
onChange(color);
|
||||
setShowCustomPicker(false);
|
||||
};
|
||||
|
||||
const handleCustomColorChange = (color: string) => {
|
||||
setCustomColor(color);
|
||||
onChange(color);
|
||||
};
|
||||
|
||||
const handleRemoveColor = () => {
|
||||
onChange(undefined);
|
||||
setShowCustomPicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium theme-header">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Current Color Display */}
|
||||
{value && (
|
||||
<div className="flex items-center gap-2 p-2 border theme-border rounded-lg">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
<span className="text-sm theme-text font-mono">{value}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveColor}
|
||||
disabled={disabled}
|
||||
className="ml-auto text-xs"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Color Palette */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium theme-header">Theme Colors</h4>
|
||||
<div className="grid grid-cols-6 gap-2 p-3 border theme-border rounded-lg">
|
||||
{THEME_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.hex}
|
||||
type="button"
|
||||
className={`
|
||||
w-8 h-8 rounded-md border-2 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-theme-accent
|
||||
${value === color.hex ? 'border-gray-800 dark:border-white scale-110' : 'border-gray-300 dark:border-gray-600'}
|
||||
`}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
onClick={() => handleThemeColorSelect(color.hex)}
|
||||
disabled={disabled}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium theme-header">Custom Color</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCustomPicker(!showCustomPicker)}
|
||||
disabled={disabled}
|
||||
className="text-xs"
|
||||
>
|
||||
{showCustomPicker ? 'Hide' : 'Show'} Custom
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCustomPicker && (
|
||||
<div className="p-3 border theme-border rounded-lg space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={customColor}
|
||||
onChange={(e) => handleCustomColorChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-12 h-8 rounded border border-gray-300 dark:border-gray-600 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColor}
|
||||
onChange={(e) => {
|
||||
const color = e.target.value;
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||
setCustomColor(color);
|
||||
onChange(color);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-3 py-1 text-sm border theme-border rounded font-mono"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onChange(customColor)}
|
||||
disabled={disabled}
|
||||
className="text-xs"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs theme-text-muted">
|
||||
Enter a hex color code or use the color picker
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user