phase 2 UX pass
This commit is contained in:
parent
1f1c6e0bbc
commit
f90435399d
@ -5,6 +5,9 @@ import 'react-quill/dist/quill.snow.css';
|
||||
import './ArticleEdit.css';
|
||||
import MediaGallery from './MediaGallery';
|
||||
import BlotFormatter from 'quill-blot-formatter';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
import { useToastHelpers } from '../hooks/useToast';
|
||||
import Toast from './Toast';
|
||||
|
||||
Quill.register('modules/blotFormatter', BlotFormatter);
|
||||
const BlockEmbed = Quill.import('blots/block/embed');
|
||||
@ -69,6 +72,19 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
||||
const [newCategoryInput, setNewCategoryInput] = useState('');
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
||||
const quillRef = useRef(null);
|
||||
const categoryDropdownRef = useRef(null);
|
||||
const tagDropdownRef = useRef(null);
|
||||
const { toast, hideToast, showSuccess, showError } = useToastHelpers();
|
||||
|
||||
useClickOutside(categoryDropdownRef, () => {
|
||||
setShowCategoryDropdown(false);
|
||||
setShowCreateCategory(false);
|
||||
setNewCategoryInput('');
|
||||
}, showCategoryDropdown);
|
||||
|
||||
useClickOutside(tagDropdownRef, () => {
|
||||
setShowTagSuggestions(false);
|
||||
}, showTagSuggestions);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
@ -84,27 +100,22 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
||||
}
|
||||
}, [article]);
|
||||
|
||||
// Add padding and scroll when gallery opens
|
||||
useEffect(() => {
|
||||
const editorElement = document.querySelector('.article-editor');
|
||||
|
||||
if (isGalleryOpen) {
|
||||
// Add padding equal to drawer height (40vh + some extra space)
|
||||
editorElement.style.paddingBottom = 'calc(40vh + 2rem)';
|
||||
|
||||
// Scroll down smoothly so editor is visible above drawer
|
||||
|
||||
setTimeout(() => {
|
||||
window.scrollTo({
|
||||
top: document.body.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 100); // Small delay to let padding apply first
|
||||
}, 100);
|
||||
} else {
|
||||
// Remove padding when closed
|
||||
editorElement.style.paddingBottom = '';
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (editorElement) {
|
||||
editorElement.style.paddingBottom = '';
|
||||
@ -184,16 +195,26 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
await saveArticle('draft');
|
||||
try {
|
||||
await saveArticle('draft');
|
||||
showSuccess('Draft saved successfully!');
|
||||
} catch (err) {
|
||||
showError('Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!title.trim()) {
|
||||
alert('Title is required to publish');
|
||||
showError('Title is required to publish');
|
||||
return;
|
||||
}
|
||||
|
||||
await saveArticle('published');
|
||||
try {
|
||||
await saveArticle('published');
|
||||
showSuccess('Article published successfully!');
|
||||
} catch (err) {
|
||||
showError('Failed to publish article');
|
||||
}
|
||||
}
|
||||
|
||||
const handleInsertMedia = (mediaItem) => {
|
||||
@ -340,7 +361,7 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
||||
|
||||
<div className='form-field'>
|
||||
<label>Categories:</label>
|
||||
<div className='category-selector'>
|
||||
<div className='category-selector' ref={categoryDropdownRef}>
|
||||
<button
|
||||
type='button'
|
||||
className='dropdown-button'
|
||||
@ -436,7 +457,7 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
||||
|
||||
<div className='form-field'>
|
||||
<label>Tags:</label>
|
||||
<div className='tag-selector'>
|
||||
<div className='tag-selector' ref={tagDropdownRef}>
|
||||
<div className='tag-input-wrapper'>
|
||||
<input
|
||||
type='text'
|
||||
@ -512,6 +533,15 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
||||
isOpen={isGalleryOpen}
|
||||
onToggle={toggleGallery}
|
||||
/>
|
||||
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={hideToast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './FilterBar.css';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
|
||||
function FilterBar({ token, onFilterChange }) {
|
||||
const [availableCategories, setAvailableCategories] = useState([]);
|
||||
@ -8,6 +9,12 @@ function FilterBar({ token, onFilterChange }) {
|
||||
const [selectedTags, setSelectedTags] = useState([]);
|
||||
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
||||
const [showTagDropdown, setShowTagDropdown] = useState(false);
|
||||
|
||||
const categoryDropdownRef = useRef(null);
|
||||
const tagDropdownRef = useRef(null);
|
||||
|
||||
useClickOutside(categoryDropdownRef, () => setShowCategoryDropdown(false), showCategoryDropdown);
|
||||
useClickOutside(tagDropdownRef, () => setShowTagDropdown(false), showTagDropdown);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
@ -61,6 +68,7 @@ function FilterBar({ token, onFilterChange }) {
|
||||
} else {
|
||||
setSelectedCategories([...selectedCategories, category]);
|
||||
}
|
||||
setShowCategoryDropdown(false);
|
||||
};
|
||||
|
||||
const toggleTag = (tag) => {
|
||||
@ -69,6 +77,8 @@ function FilterBar({ token, onFilterChange }) {
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, tag]);
|
||||
}
|
||||
|
||||
setShowTagDropdown(false);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
@ -83,7 +93,7 @@ function FilterBar({ token, onFilterChange }) {
|
||||
<div className="filter-controls">
|
||||
<div className="filter-group">
|
||||
<label>Filter by Category:</label>
|
||||
<div className="filter-dropdown-container">
|
||||
<div className="filter-dropdown-container" ref={categoryDropdownRef}>
|
||||
<button
|
||||
className="dropdown-button"
|
||||
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
|
||||
@ -119,7 +129,7 @@ function FilterBar({ token, onFilterChange }) {
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Filter by Tag:</label>
|
||||
<div className="filter-dropdown-container">
|
||||
<div className="filter-dropdown-container" ref={tagDropdownRef}>
|
||||
<button
|
||||
className="dropdown-button"
|
||||
onClick={() => setShowTagDropdown(!showTagDropdown)}
|
||||
|
||||
104
client/kb-frontend/src/components/Toast.css
Normal file
104
client/kb-frontend/src/components/Toast.css
Normal file
@ -0,0 +1,104 @@
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: var(--spacing-2xl);
|
||||
right: var(--spacing-2xl);
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
z-index: var(--z-toast);
|
||||
animation: slideInRight 0.3s ease-out, fadeOut 0.3s ease-in 2.7s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background-color: var(--color-success-light);
|
||||
border-left: 4px solid var(--color-success);
|
||||
color: var(--color-success-dark);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background-color: var(--color-danger-light);
|
||||
border-left: 4px solid var(--color-danger);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background-color: var(--color-warning-light);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
color: var(--color-warning-dark);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background-color: var(--color-primary-light);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: var(--font-size-2xl);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.toast {
|
||||
top: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
left: var(--spacing-lg);
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
46
client/kb-frontend/src/components/Toast.jsx
Normal file
46
client/kb-frontend/src/components/Toast.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
import './Toast.css';
|
||||
|
||||
/**
|
||||
* Toast notification component for success/error messages
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - Type of toast: 'success', 'error', 'warning', 'info'
|
||||
* @param {number} duration - How long to show toast in ms (default: 3000)
|
||||
* @param {function} onClose - Callback when toast closes
|
||||
*/
|
||||
function Toast({ message, type = 'success', duration = 3000, onClose }) {
|
||||
useEffect(() => {
|
||||
if (duration && duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [duration, onClose]);
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓';
|
||||
case 'error':
|
||||
return '✕';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
case 'info':
|
||||
return 'ⓘ';
|
||||
default:
|
||||
return '✓';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`toast toast-${type}`}>
|
||||
<div className="toast-icon">{getIcon()}</div>
|
||||
<div className="toast-message">{message}</div>
|
||||
<button className="toast-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Toast;
|
||||
35
client/kb-frontend/src/hooks/useClickOutside.js
Normal file
35
client/kb-frontend/src/hooks/useClickOutside.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook to detect clicks outside of a referenced element
|
||||
* @param {React.RefObject} ref - Reference to the element to monitor
|
||||
* @param {Function} handler - Callback function to execute when clicking outside
|
||||
* @param {boolean} enabled - Whether the hook is active (default: true)
|
||||
*/
|
||||
export function useClickOutside(ref, handler, enabled = true) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (ref.current && !ref.current.contains(event.target)) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [ref, handler, enabled]);
|
||||
}
|
||||
49
client/kb-frontend/src/hooks/useToast.js
Normal file
49
client/kb-frontend/src/hooks/useToast.js
Normal file
@ -0,0 +1,49 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook for managing toast notifications
|
||||
* Returns: [toast state, showToast function, hideToast function]
|
||||
*/
|
||||
export function useToast() {
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = useCallback((message, type = 'success', duration = 3000) => {
|
||||
setToast({ message, type, duration });
|
||||
}, []);
|
||||
|
||||
const hideToast = useCallback(() => {
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
return [toast, showToast, hideToast];
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
export const useToastHelpers = () => {
|
||||
const [toast, showToast, hideToast] = useToast();
|
||||
|
||||
const showSuccess = useCallback((message, duration) => {
|
||||
showToast(message, 'success', duration);
|
||||
}, [showToast]);
|
||||
|
||||
const showError = useCallback((message, duration) => {
|
||||
showToast(message, 'error', duration);
|
||||
}, [showToast]);
|
||||
|
||||
const showWarning = useCallback((message, duration) => {
|
||||
showToast(message, 'warning', duration);
|
||||
}, [showToast]);
|
||||
|
||||
const showInfo = useCallback((message, duration) => {
|
||||
showToast(message, 'info', duration);
|
||||
}, [showToast]);
|
||||
|
||||
return {
|
||||
toast,
|
||||
hideToast,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo
|
||||
};
|
||||
};
|
||||
@ -115,6 +115,7 @@
|
||||
--z-modal: 1050;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1070;
|
||||
--z-toast: 1080;
|
||||
|
||||
/* ===== LAYOUT ===== */
|
||||
--max-width-xs: 480px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user