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 './ArticleEdit.css';
|
||||||
import MediaGallery from './MediaGallery';
|
import MediaGallery from './MediaGallery';
|
||||||
import BlotFormatter from 'quill-blot-formatter';
|
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);
|
Quill.register('modules/blotFormatter', BlotFormatter);
|
||||||
const BlockEmbed = Quill.import('blots/block/embed');
|
const BlockEmbed = Quill.import('blots/block/embed');
|
||||||
@ -69,6 +72,19 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
|||||||
const [newCategoryInput, setNewCategoryInput] = useState('');
|
const [newCategoryInput, setNewCategoryInput] = useState('');
|
||||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
||||||
const quillRef = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
@ -84,27 +100,22 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
|||||||
}
|
}
|
||||||
}, [article]);
|
}, [article]);
|
||||||
|
|
||||||
// Add padding and scroll when gallery opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editorElement = document.querySelector('.article-editor');
|
const editorElement = document.querySelector('.article-editor');
|
||||||
|
|
||||||
if (isGalleryOpen) {
|
if (isGalleryOpen) {
|
||||||
// Add padding equal to drawer height (40vh + some extra space)
|
|
||||||
editorElement.style.paddingBottom = 'calc(40vh + 2rem)';
|
editorElement.style.paddingBottom = 'calc(40vh + 2rem)';
|
||||||
|
|
||||||
// Scroll down smoothly so editor is visible above drawer
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: document.body.scrollHeight,
|
top: document.body.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
}, 100); // Small delay to let padding apply first
|
}, 100);
|
||||||
} else {
|
} else {
|
||||||
// Remove padding when closed
|
|
||||||
editorElement.style.paddingBottom = '';
|
editorElement.style.paddingBottom = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
if (editorElement) {
|
if (editorElement) {
|
||||||
editorElement.style.paddingBottom = '';
|
editorElement.style.paddingBottom = '';
|
||||||
@ -184,16 +195,26 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = async () => {
|
const handleSaveDraft = async () => {
|
||||||
|
try {
|
||||||
await saveArticle('draft');
|
await saveArticle('draft');
|
||||||
|
showSuccess('Draft saved successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to save draft');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
alert('Title is required to publish');
|
showError('Title is required to publish');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await saveArticle('published');
|
await saveArticle('published');
|
||||||
|
showSuccess('Article published successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
showError('Failed to publish article');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInsertMedia = (mediaItem) => {
|
const handleInsertMedia = (mediaItem) => {
|
||||||
@ -340,7 +361,7 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
|||||||
|
|
||||||
<div className='form-field'>
|
<div className='form-field'>
|
||||||
<label>Categories:</label>
|
<label>Categories:</label>
|
||||||
<div className='category-selector'>
|
<div className='category-selector' ref={categoryDropdownRef}>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='dropdown-button'
|
className='dropdown-button'
|
||||||
@ -436,7 +457,7 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
|||||||
|
|
||||||
<div className='form-field'>
|
<div className='form-field'>
|
||||||
<label>Tags:</label>
|
<label>Tags:</label>
|
||||||
<div className='tag-selector'>
|
<div className='tag-selector' ref={tagDropdownRef}>
|
||||||
<div className='tag-input-wrapper'>
|
<div className='tag-input-wrapper'>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
@ -512,6 +533,15 @@ function ArticleEditor({ article, onSave, onCancel, token }) {
|
|||||||
isOpen={isGalleryOpen}
|
isOpen={isGalleryOpen}
|
||||||
onToggle={toggleGallery}
|
onToggle={toggleGallery}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
duration={toast.duration}
|
||||||
|
onClose={hideToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import './FilterBar.css';
|
import './FilterBar.css';
|
||||||
|
import { useClickOutside } from '../hooks/useClickOutside';
|
||||||
|
|
||||||
function FilterBar({ token, onFilterChange }) {
|
function FilterBar({ token, onFilterChange }) {
|
||||||
const [availableCategories, setAvailableCategories] = useState([]);
|
const [availableCategories, setAvailableCategories] = useState([]);
|
||||||
@ -9,6 +10,12 @@ function FilterBar({ token, onFilterChange }) {
|
|||||||
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
||||||
const [showTagDropdown, setShowTagDropdown] = 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(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
fetchTags();
|
fetchTags();
|
||||||
@ -61,6 +68,7 @@ function FilterBar({ token, onFilterChange }) {
|
|||||||
} else {
|
} else {
|
||||||
setSelectedCategories([...selectedCategories, category]);
|
setSelectedCategories([...selectedCategories, category]);
|
||||||
}
|
}
|
||||||
|
setShowCategoryDropdown(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTag = (tag) => {
|
const toggleTag = (tag) => {
|
||||||
@ -69,6 +77,8 @@ function FilterBar({ token, onFilterChange }) {
|
|||||||
} else {
|
} else {
|
||||||
setSelectedTags([...selectedTags, tag]);
|
setSelectedTags([...selectedTags, tag]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowTagDropdown(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
@ -83,7 +93,7 @@ function FilterBar({ token, onFilterChange }) {
|
|||||||
<div className="filter-controls">
|
<div className="filter-controls">
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label>Filter by Category:</label>
|
<label>Filter by Category:</label>
|
||||||
<div className="filter-dropdown-container">
|
<div className="filter-dropdown-container" ref={categoryDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className="dropdown-button"
|
className="dropdown-button"
|
||||||
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
|
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
|
||||||
@ -119,7 +129,7 @@ function FilterBar({ token, onFilterChange }) {
|
|||||||
|
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label>Filter by Tag:</label>
|
<label>Filter by Tag:</label>
|
||||||
<div className="filter-dropdown-container">
|
<div className="filter-dropdown-container" ref={tagDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className="dropdown-button"
|
className="dropdown-button"
|
||||||
onClick={() => setShowTagDropdown(!showTagDropdown)}
|
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-modal: 1050;
|
||||||
--z-popover: 1060;
|
--z-popover: 1060;
|
||||||
--z-tooltip: 1070;
|
--z-tooltip: 1070;
|
||||||
|
--z-toast: 1080;
|
||||||
|
|
||||||
/* ===== LAYOUT ===== */
|
/* ===== LAYOUT ===== */
|
||||||
--max-width-xs: 480px;
|
--max-width-xs: 480px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user