phase 2 UX pass

This commit is contained in:
MattLeo 2025-12-08 15:35:34 -06:00
parent 1f1c6e0bbc
commit f90435399d
7 changed files with 290 additions and 15 deletions

View File

@ -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>
); );
} }

View File

@ -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)}

View 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;
}
}

View 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;

View 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]);
}

View 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
};
};

View File

@ -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;