feat: tag management
This commit is contained in:
@@ -22,6 +22,12 @@ const SettingsIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TagsIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
|
||||
@@ -35,12 +41,20 @@ export const ActivityBar: React.FC = () => {
|
||||
|
||||
// Check if settings tab is currently active
|
||||
const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId);
|
||||
|
||||
// Check if tags tab is currently active
|
||||
const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
// Open settings as a dedicated (non-transient) tab
|
||||
openTab({ type: 'settings', id: 'settings', isTransient: false });
|
||||
};
|
||||
|
||||
const handleTagsClick = () => {
|
||||
// Open tags as a dedicated (non-transient) tab
|
||||
openTab({ type: 'tags', id: 'tags', isTransient: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="activity-bar">
|
||||
<div className="activity-bar-top">
|
||||
@@ -58,6 +72,13 @@ export const ActivityBar: React.FC = () => {
|
||||
>
|
||||
<MediaIcon />
|
||||
</button>
|
||||
<button
|
||||
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
|
||||
onClick={handleTagsClick}
|
||||
title="Tags"
|
||||
>
|
||||
<TagsIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="activity-bar-bottom">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||||
import { PostLinks } from '../PostLinks';
|
||||
import { ErrorModal } from '../ErrorModal';
|
||||
import { SettingsView } from '../SettingsView';
|
||||
import { TagsView } from '../TagsView';
|
||||
import { AutoSaveManager } from '../../utils';
|
||||
import './Editor.css';
|
||||
|
||||
@@ -1029,6 +1030,7 @@ export const Editor: React.FC = () => {
|
||||
const showPost = activeTab?.type === 'post';
|
||||
const showMedia = activeTab?.type === 'media';
|
||||
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
|
||||
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
|
||||
|
||||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||||
useEffect(() => {
|
||||
@@ -1082,6 +1084,16 @@ export const Editor: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show tags if tags tab is active
|
||||
if (showTags) {
|
||||
return (
|
||||
<>
|
||||
<TagsView />
|
||||
{renderErrorModal()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Show post editor if a post tab is active
|
||||
if (showPost && activeTabId) {
|
||||
const post = posts.find(p => p.id === activeTabId);
|
||||
|
||||
@@ -636,6 +636,50 @@ const MediaList: React.FC = () => {
|
||||
};
|
||||
|
||||
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
|
||||
import { scrollToTagsSection, TagsCategory } from '../TagsView';
|
||||
|
||||
const TagsNav: React.FC = () => {
|
||||
const [activeSection, setActiveSection] = useState<TagsCategory | null>(null);
|
||||
|
||||
const handleNavClick = (category: TagsCategory) => {
|
||||
setActiveSection(category);
|
||||
scrollToTagsSection(category);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-content settings-panel">
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-section-header">
|
||||
<span>TAGS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-nav-list">
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'cloud' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('cloud')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">☁️</span>
|
||||
<span>Tag Cloud</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'manage' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('manage')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">✏️</span>
|
||||
<span>Create & Edit</span>
|
||||
</button>
|
||||
<button
|
||||
className={`settings-nav-entry ${activeSection === 'merge' ? 'active' : ''}`}
|
||||
onClick={() => handleNavClick('merge')}
|
||||
>
|
||||
<span className="settings-nav-entry-icon">🔀</span>
|
||||
<span>Merge Tags</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsNav: React.FC = () => {
|
||||
const { syncConfigured } = useAppStore();
|
||||
@@ -715,6 +759,7 @@ export const Sidebar: React.FC = () => {
|
||||
{activeView === 'posts' && <PostsList />}
|
||||
{activeView === 'media' && <MediaList />}
|
||||
{activeView === 'settings' && <SettingsNav />}
|
||||
{activeView === 'tags' && <TagsNav />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,10 @@ const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: {
|
||||
return 'Settings';
|
||||
}
|
||||
|
||||
if (tab.type === 'tags') {
|
||||
return 'Tags';
|
||||
}
|
||||
|
||||
if (tab.type === 'post') {
|
||||
const post = posts.find(p => p.id === tab.id);
|
||||
return post?.title || 'Untitled';
|
||||
@@ -40,6 +44,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'tags':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14.28 7.72l-6-6A1 1 0 007.57 1.5H2.5A1 1 0 001.5 2.5v5.07a1 1 0 00.22.56l6 6a1 1 0 001.41 0l5.15-5a1 1 0 000-1.41zM4 5a1 1 0 110-2 1 1 0 010 2z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
|
||||
366
src/renderer/components/TagsView/TagsView.css
Normal file
366
src/renderer/components/TagsView/TagsView.css
Normal file
@@ -0,0 +1,366 @@
|
||||
.tags-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
background-color: var(--background-primary);
|
||||
}
|
||||
|
||||
.tags-view-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.tags-view-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tags-view-content {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
/* Text utilities */
|
||||
.text-muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.tags-section {
|
||||
margin-bottom: 40px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tags-section-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tags-section-header h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tags-section-description {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tags-section-content {
|
||||
padding: 16px;
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Loading and Empty States */
|
||||
.tags-loading,
|
||||
.tags-empty {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tags-empty button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Tag Cloud */
|
||||
.tag-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tag-cloud-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tag-cloud-item:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--background-hover);
|
||||
}
|
||||
|
||||
.tag-cloud-item.selected {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--accent-color-transparent);
|
||||
box-shadow: 0 0 0 2px var(--accent-color-transparent);
|
||||
}
|
||||
|
||||
.tag-cloud-item.has-color {
|
||||
border: none;
|
||||
padding: 7px 13px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.tag-cloud-item.has-color:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tag-cloud-item.has-color.selected {
|
||||
box-shadow: 0 0 0 3px var(--accent-color);
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tag-cloud-item:not(.has-color) .tag-count {
|
||||
background: var(--background-tertiary);
|
||||
}
|
||||
|
||||
/* Selection Info */
|
||||
.tag-selection-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--accent-color-transparent);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tag-selection-info button {
|
||||
font-size: 0.85rem;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.tag-create-form,
|
||||
.tag-edit-form,
|
||||
.merge-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tag-create-form h4,
|
||||
.tag-edit-form h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tag-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tag-form-row input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tag-form-row input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.tag-form-row select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tag-form-row select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Color picker group */
|
||||
.color-picker-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.color-picker-group input[type="color"] {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
.color-picker-group input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.color-picker-group input[type="color"]::-webkit-color-swatch {
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.clear-color {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 0.75rem !important;
|
||||
min-width: auto !important;
|
||||
background: var(--background-tertiary) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.clear-color:hover {
|
||||
background: var(--background-hover) !important;
|
||||
}
|
||||
|
||||
/* Color presets */
|
||||
.color-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.color-preset {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.color-preset:hover {
|
||||
transform: scale(1.15);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Tag preview */
|
||||
.tag-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.tags-view button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tags-view button:hover {
|
||||
background: var(--background-hover);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tags-view button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tags-view button.primary {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tags-view button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.tags-view button.danger {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tags-view button.danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Confirm Dialog */
|
||||
.confirm-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 450px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.confirm-dialog h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
615
src/renderer/components/TagsView/TagsView.tsx
Normal file
615
src/renderer/components/TagsView/TagsView.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import './TagsView.css';
|
||||
|
||||
// Types
|
||||
interface TagWithCount {
|
||||
name: string;
|
||||
color: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface TagData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Export category IDs for sidebar navigation
|
||||
export type TagsCategory = 'cloud' | 'manage' | 'merge';
|
||||
|
||||
// Scroll to a tags section by category ID
|
||||
export const scrollToTagsSection = (category: TagsCategory) => {
|
||||
const element = document.getElementById(`tags-section-${category}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
// Get contrasting text color for background
|
||||
const getContrastColor = (hex: string): string => {
|
||||
// Remove # if present
|
||||
const color = hex.replace('#', '');
|
||||
|
||||
// Parse hex to RGB
|
||||
let r: number, g: number, b: number;
|
||||
if (color.length === 3) {
|
||||
r = parseInt(color[0] + color[0], 16);
|
||||
g = parseInt(color[1] + color[1], 16);
|
||||
b = parseInt(color[2] + color[2], 16);
|
||||
} else {
|
||||
r = parseInt(color.substring(0, 2), 16);
|
||||
g = parseInt(color.substring(2, 4), 16);
|
||||
b = parseInt(color.substring(4, 6), 16);
|
||||
}
|
||||
|
||||
// Calculate relative luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
};
|
||||
|
||||
// Color picker presets
|
||||
const COLOR_PRESETS = [
|
||||
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
|
||||
'#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9',
|
||||
'#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
|
||||
'#ec4899', '#f43f5e',
|
||||
];
|
||||
|
||||
// Tag Cloud Item
|
||||
const TagCloudItem: React.FC<{
|
||||
tag: TagWithCount;
|
||||
isSelected: boolean;
|
||||
onSelect: (name: string) => void;
|
||||
maxCount: number;
|
||||
}> = ({ tag, isSelected, onSelect, maxCount }) => {
|
||||
// Calculate font size based on count (range: 0.8rem to 2rem)
|
||||
const minSize = 0.85;
|
||||
const maxSize = 1.8;
|
||||
const ratio = maxCount > 1 ? (tag.count - 1) / (maxCount - 1) : 0;
|
||||
const fontSize = minSize + (maxSize - minSize) * ratio;
|
||||
|
||||
const hasColor = !!tag.color;
|
||||
const style: React.CSSProperties = hasColor
|
||||
? {
|
||||
backgroundColor: tag.color!,
|
||||
color: getContrastColor(tag.color!),
|
||||
fontSize: `${fontSize}rem`,
|
||||
}
|
||||
: {
|
||||
fontSize: `${fontSize}rem`,
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`tag-cloud-item ${isSelected ? 'selected' : ''} ${hasColor ? 'has-color' : ''}`}
|
||||
style={style}
|
||||
onClick={() => onSelect(tag.name)}
|
||||
title={`${tag.count} post${tag.count !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{tag.name}
|
||||
<span className="tag-count">{tag.count}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Confirm Dialog for destructive actions
|
||||
const ConfirmDialog: React.FC<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
cancelText?: string;
|
||||
isDestructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ isOpen, title, message, confirmText, cancelText = 'Cancel', isDestructive, onConfirm, onCancel }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay">
|
||||
<div className="confirm-dialog">
|
||||
<h3>{title}</h3>
|
||||
<p>{message}</p>
|
||||
<div className="confirm-dialog-actions">
|
||||
<button onClick={onCancel}>{cancelText}</button>
|
||||
<button
|
||||
className={isDestructive ? 'danger' : 'primary'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Section Header
|
||||
const SectionHeader: React.FC<{
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, title, description, children }) => (
|
||||
<div className="tags-section" id={id}>
|
||||
<div className="tags-section-header">
|
||||
<h3>{title}</h3>
|
||||
{description && <p className="tags-section-description">{description}</p>}
|
||||
</div>
|
||||
<div className="tags-section-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TagsView: React.FC = () => {
|
||||
const { showErrorModal } = useAppStore();
|
||||
|
||||
// State
|
||||
const [tagsWithCounts, setTagsWithCounts] = useState<TagWithCount[]>([]);
|
||||
const [allTags, setAllTags] = useState<TagData[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Create tag form
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [newTagColor, setNewTagColor] = useState('');
|
||||
|
||||
// Edit tag state
|
||||
const [editingTagId, setEditingTagId] = useState<string | null>(null);
|
||||
const [editTagColor, setEditTagColor] = useState<string>('');
|
||||
const [editTagName, setEditTagName] = useState('');
|
||||
|
||||
// Merge tags state
|
||||
const [mergeTargetName, setMergeTargetName] = useState('');
|
||||
|
||||
// Confirm dialogs
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ tagId: string; tagName: string } | null>(null);
|
||||
const [mergeConfirm, setMergeConfirm] = useState<{ sourceNames: string[]; targetName: string } | null>(null);
|
||||
|
||||
// Load tags
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [tagsWithCountsResult, allTagsResult] = await Promise.all([
|
||||
window.electronAPI?.tags.getWithCounts(),
|
||||
window.electronAPI?.tags.getAll(),
|
||||
]);
|
||||
|
||||
if (tagsWithCountsResult) {
|
||||
setTagsWithCounts(tagsWithCountsResult as TagWithCount[]);
|
||||
}
|
||||
if (allTagsResult) {
|
||||
setAllTags(allTagsResult as TagData[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTags();
|
||||
}, [loadTags]);
|
||||
|
||||
// Listen for tag events
|
||||
useEffect(() => {
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('tag:created', () => loadTags()) || (() => {})
|
||||
);
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('tag:updated', () => loadTags()) || (() => {})
|
||||
);
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('tag:deleted', () => loadTags()) || (() => {})
|
||||
);
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('tag:renamed', () => loadTags()) || (() => {})
|
||||
);
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('tags:merged', () => loadTags()) || (() => {})
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribers.forEach(unsub => unsub());
|
||||
};
|
||||
}, [loadTags]);
|
||||
|
||||
// Handle tag selection
|
||||
const handleTagSelect = (name: string) => {
|
||||
setSelectedTags(prev => {
|
||||
if (prev.includes(name)) {
|
||||
return prev.filter(n => n !== name);
|
||||
}
|
||||
return [...prev, name];
|
||||
});
|
||||
};
|
||||
|
||||
// Create tag
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) {
|
||||
showToast.error('Tag name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI?.tags.create({
|
||||
name: newTagName.trim(),
|
||||
color: newTagColor || undefined,
|
||||
});
|
||||
setNewTagName('');
|
||||
setNewTagColor('');
|
||||
showToast.success('Tag created');
|
||||
loadTags();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
showToast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete tag (with confirmation)
|
||||
const handleDeleteTag = async () => {
|
||||
if (!deleteConfirm) return;
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.tags.delete(deleteConfirm.tagId);
|
||||
if (result?.success) {
|
||||
showToast.success(`Tag deleted. ${result.postsUpdated} post(s) updated.`);
|
||||
setSelectedTags(prev => prev.filter(n => n !== deleteConfirm.tagName));
|
||||
loadTags();
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: 'Delete Failed',
|
||||
message: err.message,
|
||||
});
|
||||
} finally {
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Start editing tag
|
||||
const handleStartEdit = (tag: TagData) => {
|
||||
setEditingTagId(tag.id);
|
||||
setEditTagColor(tag.color || '');
|
||||
setEditTagName(tag.name);
|
||||
};
|
||||
|
||||
// Save tag edit
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingTagId) return;
|
||||
|
||||
try {
|
||||
// Update color
|
||||
await window.electronAPI?.tags.update(editingTagId, {
|
||||
color: editTagColor || null,
|
||||
});
|
||||
|
||||
// If name changed, rename the tag
|
||||
const originalTag = allTags.find(t => t.id === editingTagId);
|
||||
if (originalTag && originalTag.name !== editTagName.trim().toLowerCase()) {
|
||||
await window.electronAPI?.tags.rename(editingTagId, editTagName.trim());
|
||||
}
|
||||
|
||||
showToast.success('Tag updated');
|
||||
setEditingTagId(null);
|
||||
loadTags();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
showToast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Merge tags (with confirmation)
|
||||
const handleMergeTags = async () => {
|
||||
if (!mergeConfirm) return;
|
||||
|
||||
try {
|
||||
// Find target tag
|
||||
const targetTag = allTags.find(t => t.name === mergeConfirm.targetName);
|
||||
if (!targetTag) {
|
||||
showToast.error('Target tag not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find source tag IDs
|
||||
const sourceTags = allTags.filter(t =>
|
||||
mergeConfirm.sourceNames.includes(t.name) && t.id !== targetTag.id
|
||||
);
|
||||
|
||||
if (sourceTags.length === 0) {
|
||||
showToast.error('No source tags to merge');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI?.tags.merge(
|
||||
sourceTags.map(t => t.id),
|
||||
targetTag.id
|
||||
);
|
||||
|
||||
if (result?.success) {
|
||||
showToast.success(
|
||||
`Merged ${result.tagsDeleted} tag(s) into "${result.targetTag}". ${result.postsUpdated} post(s) updated.`
|
||||
);
|
||||
setSelectedTags([]);
|
||||
setMergeTargetName('');
|
||||
loadTags();
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: 'Merge Failed',
|
||||
message: err.message,
|
||||
});
|
||||
} finally {
|
||||
setMergeConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Sync tags from posts
|
||||
const handleSyncFromPosts = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.tags.syncFromPosts();
|
||||
if (result) {
|
||||
if (result.added.length > 0) {
|
||||
showToast.success(`Discovered ${result.added.length} new tag(s)`);
|
||||
} else {
|
||||
showToast.info('All tags are already synced');
|
||||
}
|
||||
loadTags();
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
showToast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selection
|
||||
const handleClearSelection = () => {
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
// Get max count for sizing
|
||||
const maxCount = Math.max(...tagsWithCounts.map(t => t.count), 1);
|
||||
|
||||
// Selected tag objects
|
||||
const selectedTagObjects = allTags.filter(t => selectedTags.includes(t.name));
|
||||
|
||||
return (
|
||||
<div className="tags-view">
|
||||
<div className="tags-view-header">
|
||||
<h2>Tag Management</h2>
|
||||
<p className="text-muted">Manage your blog's tags, assign colors, and perform bulk operations.</p>
|
||||
</div>
|
||||
|
||||
<div className="tags-view-content">
|
||||
{/* Tag Cloud Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-cloud"
|
||||
title="Tag Cloud"
|
||||
description="Click tags to select them for bulk operations. Hover to see post counts."
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="tags-loading">Loading tags...</div>
|
||||
) : tagsWithCounts.length === 0 ? (
|
||||
<div className="tags-empty">
|
||||
<p>No tags found</p>
|
||||
<button onClick={handleSyncFromPosts}>Discover tags from posts</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tag-cloud">
|
||||
{tagsWithCounts.map(tag => (
|
||||
<TagCloudItem
|
||||
key={tag.name}
|
||||
tag={tag}
|
||||
isSelected={selectedTags.includes(tag.name)}
|
||||
onSelect={handleTagSelect}
|
||||
maxCount={maxCount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="tag-selection-info">
|
||||
<span>{selectedTags.length} tag(s) selected</span>
|
||||
<button onClick={handleClearSelection}>Clear selection</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
{/* Tag Management Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-manage"
|
||||
title="Create & Edit Tags"
|
||||
description="Create new tags or edit existing ones. Assign colors to make tags visually distinct."
|
||||
>
|
||||
{/* Create new tag */}
|
||||
<div className="tag-create-form">
|
||||
<h4>Create New Tag</h4>
|
||||
<div className="tag-form-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tag name"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()}
|
||||
/>
|
||||
<div className="color-picker-group">
|
||||
<input
|
||||
type="color"
|
||||
value={newTagColor || '#808080'}
|
||||
onChange={(e) => setNewTagColor(e.target.value)}
|
||||
title="Choose color"
|
||||
/>
|
||||
{newTagColor && (
|
||||
<button
|
||||
className="clear-color"
|
||||
onClick={() => setNewTagColor('')}
|
||||
title="Remove color"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleCreateTag} className="primary">Create</button>
|
||||
</div>
|
||||
<div className="color-presets">
|
||||
{COLOR_PRESETS.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
className="color-preset"
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => setNewTagColor(color)}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected tag editor */}
|
||||
{selectedTagObjects.length === 1 && (
|
||||
<div className="tag-edit-form">
|
||||
<h4>Edit Tag: {selectedTagObjects[0].name}</h4>
|
||||
{editingTagId === selectedTagObjects[0].id ? (
|
||||
<div className="tag-form-row">
|
||||
<input
|
||||
type="text"
|
||||
value={editTagName}
|
||||
onChange={(e) => setEditTagName(e.target.value)}
|
||||
placeholder="Tag name"
|
||||
/>
|
||||
<div className="color-picker-group">
|
||||
<input
|
||||
type="color"
|
||||
value={editTagColor || '#808080'}
|
||||
onChange={(e) => setEditTagColor(e.target.value)}
|
||||
/>
|
||||
{editTagColor && (
|
||||
<button
|
||||
className="clear-color"
|
||||
onClick={() => setEditTagColor('')}
|
||||
title="Remove color"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleSaveEdit} className="primary">Save</button>
|
||||
<button onClick={() => setEditingTagId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tag-form-row">
|
||||
<span className="tag-preview" style={
|
||||
selectedTagObjects[0].color
|
||||
? { backgroundColor: selectedTagObjects[0].color, color: getContrastColor(selectedTagObjects[0].color) }
|
||||
: {}
|
||||
}>
|
||||
{selectedTagObjects[0].name}
|
||||
</span>
|
||||
<button onClick={() => handleStartEdit(selectedTagObjects[0])}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() => setDeleteConfirm({
|
||||
tagId: selectedTagObjects[0].id,
|
||||
tagName: selectedTagObjects[0].name
|
||||
})}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
{/* Merge Tags Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-merge"
|
||||
title="Merge Tags"
|
||||
description="Select multiple tags above, then merge them into a single tag. All posts will be updated."
|
||||
>
|
||||
{selectedTags.length < 2 ? (
|
||||
<p className="text-muted">Select 2 or more tags from the cloud above to merge them.</p>
|
||||
) : (
|
||||
<div className="merge-form">
|
||||
<p>Merge <strong>{selectedTags.length}</strong> tags into:</p>
|
||||
<div className="tag-form-row">
|
||||
<select
|
||||
value={mergeTargetName}
|
||||
onChange={(e) => setMergeTargetName(e.target.value)}
|
||||
>
|
||||
<option value="">Select target tag...</option>
|
||||
{selectedTags.map(name => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={!mergeTargetName}
|
||||
onClick={() => {
|
||||
if (mergeTargetName) {
|
||||
setMergeConfirm({
|
||||
sourceNames: selectedTags.filter(n => n !== mergeTargetName),
|
||||
targetName: mergeTargetName,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Merge Tags
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-muted text-small">
|
||||
Tags to be deleted: {selectedTags.filter(n => n !== mergeTargetName).join(', ') || '(none)'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionHeader>
|
||||
|
||||
{/* Sync Section */}
|
||||
<SectionHeader
|
||||
id="tags-section-sync"
|
||||
title="Sync Tags"
|
||||
description="Discover tags that exist in posts but not in the tag database."
|
||||
>
|
||||
<button onClick={handleSyncFromPosts}>
|
||||
Sync Tags from Posts
|
||||
</button>
|
||||
</SectionHeader>
|
||||
</div>
|
||||
|
||||
{/* Confirm Dialogs */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirm}
|
||||
title="Delete Tag"
|
||||
message={`Are you sure you want to delete the tag "${deleteConfirm?.tagName}"? This will remove it from all posts. This action runs as a background task.`}
|
||||
confirmText="Delete Tag"
|
||||
isDestructive
|
||||
onConfirm={handleDeleteTag}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!mergeConfirm}
|
||||
title="Merge Tags"
|
||||
message={`Are you sure you want to merge ${mergeConfirm?.sourceNames.length} tag(s) into "${mergeConfirm?.targetName}"? The source tags will be deleted and all posts will be updated. This runs as a background task.`}
|
||||
confirmText="Merge Tags"
|
||||
onConfirm={handleMergeTags}
|
||||
onCancel={() => setMergeConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
src/renderer/components/TagsView/index.ts
Normal file
2
src/renderer/components/TagsView/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TagsView, scrollToTagsSection } from './TagsView';
|
||||
export type { TagsCategory } from './TagsView';
|
||||
@@ -12,5 +12,6 @@ export { TaskPopup } from './TaskPopup';
|
||||
export { ResizablePanel } from './ResizablePanel';
|
||||
export { CredentialsPanel } from './CredentialsPanel';
|
||||
export { SettingsView } from './SettingsView';
|
||||
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
||||
export { PostLinks } from './PostLinks';
|
||||
export { ErrorModal, type ErrorDetails } from './ErrorModal';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { persist } from 'zustand/middleware';
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Tab types
|
||||
export type TabType = 'post' | 'media' | 'settings';
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'tags';
|
||||
|
||||
export interface Tab {
|
||||
type: TabType;
|
||||
@@ -88,7 +88,7 @@ interface AppState {
|
||||
activeTabId: string | null;
|
||||
|
||||
// UI State
|
||||
activeView: 'posts' | 'media' | 'settings';
|
||||
activeView: 'posts' | 'media' | 'settings' | 'tags';
|
||||
sidebarVisible: boolean;
|
||||
panelVisible: boolean;
|
||||
selectedPostId: string | null;
|
||||
@@ -136,7 +136,7 @@ interface AppState {
|
||||
restoreTabState: (state: TabState) => void;
|
||||
|
||||
// Actions
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
|
||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags') => void;
|
||||
toggleSidebar: () => void;
|
||||
togglePanel: () => void;
|
||||
setSelectedPost: (id: string | null) => void;
|
||||
|
||||
52
src/renderer/types/electron.d.ts
vendored
52
src/renderer/types/electron.d.ts
vendored
@@ -131,6 +131,45 @@ export interface CategoryCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TagData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TagWithCount {
|
||||
name: string;
|
||||
color: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DeleteTagResult {
|
||||
success: boolean;
|
||||
postsUpdated: number;
|
||||
}
|
||||
|
||||
export interface MergeTagsResult {
|
||||
success: boolean;
|
||||
postsUpdated: number;
|
||||
tagsDeleted: number;
|
||||
targetTag: string;
|
||||
}
|
||||
|
||||
export interface RenameTagResult {
|
||||
success: boolean;
|
||||
postsUpdated: number;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
export interface SyncTagsResult {
|
||||
discovered: number;
|
||||
added: string[];
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
projects: {
|
||||
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
||||
@@ -223,6 +262,19 @@ export interface ElectronAPI {
|
||||
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||
updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||
};
|
||||
tags: {
|
||||
getAll: () => Promise<TagData[]>;
|
||||
getWithCounts: () => Promise<TagWithCount[]>;
|
||||
get: (id: string) => Promise<TagData | null>;
|
||||
getByName: (name: string) => Promise<TagData | null>;
|
||||
create: (data: { name: string; color?: string }) => Promise<TagData>;
|
||||
update: (id: string, data: { name?: string; color?: string | null }) => Promise<TagData | null>;
|
||||
delete: (id: string) => Promise<DeleteTagResult>;
|
||||
merge: (sourceTagIds: string[], targetTagId: string) => Promise<MergeTagsResult>;
|
||||
rename: (id: string, newName: string) => Promise<RenameTagResult>;
|
||||
getPostsWithTag: (tagId: string) => Promise<string[]>;
|
||||
syncFromPosts: () => Promise<SyncTagsResult>;
|
||||
};
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user