diff --git a/.gitignore b/.gitignore index 82928df..2fdfa84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,12 @@ /server/node_modules/ /server/ka.db +/server/media +/server/temp_uploads + /client/node_modules/ - keys.txt - /fd-client/DS_Store /fd-client/fdk/ /fd-client/coverage/ diff --git a/client/kb-frontend/src/App.jsx b/client/kb-frontend/src/App.jsx index 6489c29..d9d3d80 100644 --- a/client/kb-frontend/src/App.jsx +++ b/client/kb-frontend/src/App.jsx @@ -8,6 +8,7 @@ import ArticleEditor from './components/ArticleEdit'; import Login from './components/Login'; import Registration from './components/Registration' import AdminPanel from './components/AdminPanel'; +import DraftsList from './components/DraftsList'; function App() { const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -123,9 +124,9 @@ function App() { if (isNew) { fetch('http://localhost:9000/api/articles', { method: 'POST', - headers: { + headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + 'Authorization': `Bearer ${token}` }, body: JSON.stringify(updatedArticle) }) @@ -139,9 +140,9 @@ function App() { } else { fetch(`http://localhost:9000/api/articles/${updatedArticle.ka_number}`, { method: 'PUT', - headers: { + headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + 'Authorization': `Bearer ${token}` }, body: JSON.stringify(updatedArticle) }) @@ -165,20 +166,40 @@ function App() { } }; - const handleCreateNew = () => { - setSelectedArticle({ - ka_number: 'New Article', - title: '', - content: '' - }); - setCurrentView('edit'); + const handleCreateNew = async () => { + try { + const response = await fetch('http://localhost:9000/api/articles', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: '', + content: '', + status: 'draft' + }) + }); + + if (response.ok) { + const draft = await response.json(); + setSelectedArticle(draft); + setCurrentView('edit'); + } else { + console.error('Failed to create draft'); + alert('Failed to create draft'); + } + } catch (error) { + console.error('Error creating draft:', error); + alert('Failed to create draft'); + } }; const handleDelete = () => { if (confirm('Are you sure you want to delete this article?')) { - fetch(`http://localhost:9000/api/articles/${selectedArticle.ka_number}`, { + fetch(`http://localhost:9000/api/articles/${selectedArticle.ka_number}`, { method: 'DELETE', - headers: {'Authorization': `Bearer ${token}`} + headers: { 'Authorization': `Bearer ${token}` } }) .then(response => { if (response.status === 401 || response.status === 403) { @@ -211,18 +232,32 @@ function App() { setCurrentView('list'); }; + const handleOpenMyDrafts = () => { + setCurrentView('drafts'); + }; + + const handleBackFromDrafts = () => { + setCurrentView('list'); + }; + + const handleSelectDraft = (draft) => { + setSelectedArticle(draft); + setCurrentView('edit'); + }; + return (
{/* Show Login/Registration if not logged in */} {!isLoggedIn ? ( authView === 'login' ? ( - @@ -245,17 +280,23 @@ function App() { ) : currentView === 'detail' ? ( - - ) : currentView === 'edit' ? ( - + ) : currentView === 'edit' ? ( + ) : currentView === 'admin' ? ( + ) : currentView === 'drafts' ? ( + ) : null} )} diff --git a/client/kb-frontend/src/components/ArticleEdit.jsx b/client/kb-frontend/src/components/ArticleEdit.jsx index e5d76d5..e7994a3 100644 --- a/client/kb-frontend/src/components/ArticleEdit.jsx +++ b/client/kb-frontend/src/components/ArticleEdit.jsx @@ -1,23 +1,68 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import ReactQuill from 'react-quill'; import 'react-quill/dist/quill.snow.css'; +import './ArticleEdit.css'; +import MediaGallery from './MediaGallery'; -function ArticleEditor ({ article, onSave, onCancel }) { +function ArticleEditor ({ article, onSave, onCancel, token }) { const [title, setTitle] = useState(article.title); const [content, setContent] = useState(article.content); + const [isGalleryOpen, setIsGalleryOpen] = useState(false); + const quillRef = useRef(null); - const handleSave = () => { + const handleSaveDraft = () => { onSave({ ka_number: article.ka_number, title: title, - content: content + content: content, + status: 'draft' }); }; + const handlePublish = () => { + if (!title.trim()) { + alert('Title is required to publish'); + return; + } + + onSave({ + ka_number: article.ka_number, + title: title, + content: content, + status: 'published' + }); + } + + const handleInsertMedia = (mediaItem) => { + const quill = quillRef.current?.getEditor(); + if (!quill) return; + + const range = quill.getSelection(true); + const mediaUrl = `http://localhost:9000${mediaItem.url}`; + + if (mediaItem.type === 'image') { + quill.insertEmbed(range.index, 'image', mediaUrl); + } else { + const videoHtml = ``; + quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml); + } + + quill.setSelection(range.index + 1); + }; + + const toggleGallery = () => { + setIsGalleryOpen(!isGalleryOpen); + }; + return (
-

Editing: {article.ka_number}

+

{ article.status === 'draft' ? 'Editing Draft: ' : 'Editing: '} + {article.ka_number} +

+ {article.status === 'draft' && ( + DRAFT + )}
@@ -34,13 +79,23 @@ function ArticleEditor ({ article, onSave, onCancel }) {
- - + + +
+ +
); } diff --git a/client/kb-frontend/src/components/ArticleList.css b/client/kb-frontend/src/components/ArticleList.css index f703b4b..ea4c676 100644 --- a/client/kb-frontend/src/components/ArticleList.css +++ b/client/kb-frontend/src/components/ArticleList.css @@ -17,4 +17,67 @@ .article-date { color: #666; font-size: 0.9rem; +} +.editor-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.draft-indicator { + background-color: #f39c12; + color: white; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: bold; +} + +.editor-buttons { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +.editor-buttons button { + padding: 0.75rem 2rem; + font-size: 1rem; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.save-draft-btn { + background-color: #f39c12; + color: white; +} + +.save-draft-btn:hover { + background-color: #e67e22; +} + +.publish-btn { + background-color: #27ae60; + color: white; +} + +.publish-btn:hover { + background-color: #229954; +} + +.cancel-btn { + background-color: #95a5a6; + color: white; +} + +.cancel-btn:hover { + background-color: #7f8c8d; +} + +.ql-editor { + color: #000000; +} + +.ql-editor.ql-blank::before { + color: #999999; } \ No newline at end of file diff --git a/client/kb-frontend/src/components/DraftsList.css b/client/kb-frontend/src/components/DraftsList.css new file mode 100644 index 0000000..e4870ce --- /dev/null +++ b/client/kb-frontend/src/components/DraftsList.css @@ -0,0 +1,129 @@ +.drafts-list { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.drafts-header { + display: flex; + align-items: center; + gap: 2rem; + margin-bottom: 2rem; + border-bottom: 2px solid #333; + padding-bottom: 1rem; +} + +.drafts-header h1 { + margin: 0; +} + +.no-drafts { + text-align: center; + padding: 4rem 2rem; + color: #666; +} + +.no-drafts p { + margin: 0.5rem 0; + font-size: 1.1rem; +} + +.drafts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.draft-card { + background: white; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.2s; +} + +.draft-card:hover { + border-color: #3498db; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.draft-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.draft-badge { + background-color: #f39c12; + color: white; + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: bold; +} + +.ka-number { + background-color: #3498db; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.85rem; +} + +.draft-title { + margin: 0 0 1rem 0; + font-size: 1.25rem; + color: #2c3e50; +} + +.draft-title:empty::before { + content: '(Untitled)'; + color: #999; + font-style: italic; +} + +.draft-preview { + color: #666; + font-size: 0.9rem; + line-height: 1.4; + margin-bottom: 1rem; + min-height: 3rem; +} + +.draft-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px solid #eee; +} + +.draft-date { + font-size: 0.85rem; + color: #999; +} + +.delete-draft-btn { + background-color: #e74c3c; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} + +.delete-draft-btn:hover { + background-color: #c0392b; +} + +.error-message { + background-color: #ffebee; + color: #c62828; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} \ No newline at end of file diff --git a/client/kb-frontend/src/components/DraftsList.jsx b/client/kb-frontend/src/components/DraftsList.jsx new file mode 100644 index 0000000..26ea2b7 --- /dev/null +++ b/client/kb-frontend/src/components/DraftsList.jsx @@ -0,0 +1,110 @@ +import { useState, useEffect } from 'react'; +import './DraftsList.css'; + +function DraftsList({ token, onBack, onSelectDraft }) { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + fetchDrafts(); + }, []); + + const fetchDrafts = async () => { + try { + const response = await fetch('http://localhost:9000/api/articles/drafts', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + setDrafts(data); + } else { + setError('Failed to fetch Drafts'); + } + } catch(err) { + console.error('Error fetching drafts:', err); + setError('Failed to connect to server'); + } finally { + setLoading(false); + } + }; + + const handleDeleteDraft = async (kaNumber, e) => { + e.stopPropagation(); + + if(!confirm('Are you sure you want to delete this draft?')) return; + + try { + const response = await fetch(`http://localhost:9000/api/articles/${kaNumber}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + setDrafts(drafts.filter(draft => draft.ka_number !== kaNumber)); + } else { + alert('Failed to delete draft'); + } + } catch(err) { + console.error('Error deleting draft:', err); + alert('Failed to delete draft'); + } + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+

My Drafts

+
+ + {error && (
{error}
)} + + {drafts.length === 0 ? ( +
+

You do not have any drafts yet.

+
+ ) : ( +
+ {drafts.map(draft => ( +
onSelectDraft(draft)} + > +
+ DRAFT + {draft.ka_number} +
+

{draft.title || '(Untitled)'}

+
+ {draft.content + ? draft.content.replace(/<[^>]*>/g, '').substring(0, 100) + '...' + : 'No content yet' + } +
+
+ Created: {new Date(draft.created_at).toLocaleDateString()} + +
+
+ ))} +
+ ) + } +
+ ); +} + +export default DraftsList; \ No newline at end of file diff --git a/client/kb-frontend/src/components/MediaGallery.css b/client/kb-frontend/src/components/MediaGallery.css new file mode 100644 index 0000000..c5fc014 --- /dev/null +++ b/client/kb-frontend/src/components/MediaGallery.css @@ -0,0 +1,242 @@ +.drawer-toggle { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + background-color: #3498db; + color: white; + border: none; + border-radius: 8px 8px 0 0; + padding: 0.75rem 2rem; + cursor: pointer; + z-index: 1001; + display: flex; + align-items: center; + gap: 0.5rem; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2); + transition: background-color 0.2s; +} + +.drawer-toggle:hover { + background-color: #2980b9; +} + +.drawer-toggle.open { + background-color: #e74c3c; +} + +.drawer-toggle.open:hover { + background-color: #c0392b; +} + +.toggle-icon { + font-size: 1.2rem; +} + +.toggle-text { + font-weight: bold; +} + +.media-count { + background-color: rgba(255, 255, 255, 0.3); + padding: 0.2rem 0.5rem; + border-radius: 12px; + font-size: 0.85rem; +} + +.media-drawer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 40vh; + background-color: white; + border-radius: 12px 12px 0 0; + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3); + z-index: 1000; + transform: translateY(100%); + transition: transform 0.3s ease-in-out; + display: flex; + flex-direction: column; +} + +.media-drawer.open { + transform: translateY(0); +} + +.drawer-handle { + padding: 0.75rem; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + background-color: #f5f5f5; + border-radius: 12px 12px 0 0; +} + +.drawer-handle:hover { + background-color: #e0e0e0; +} + +.handle-bar { + width: 50px; + height: 4px; + background-color: #bbb; + border-radius: 2px; +} + +.drawer-content { + flex: 1; + overflow-y: auto; + padding: 1rem 2rem; +} + +.gallery-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 2px solid #e0e0e0; +} + +.gallery-header h3 { + margin: 0; + color: #2c3e50; +} + +.upload-button { + background-color: #27ae60; + color: white; + padding: 0.75rem 1.5rem; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.2s; + font-weight: bold; +} + +.upload-button:hover { + background-color: #229954; +} + +.loading { + text-align: center; + padding: 2rem; + color: #666; +} + +.no-media { + text-align: center; + padding: 2rem; + color: #666; +} + +.no-media p { + margin: 0.5rem 0; +} + +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; + padding: 1rem 0; +} + +.media-item { + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 0.75rem; + background: white; + transition: all 0.2s; +} + +.media-item:hover { + border-color: #3498db; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.media-item img { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 6px; + margin-bottom: 0.75rem; + cursor: pointer; +} + +.video-thumbnail { + width: 100%; + height: 120px; + background-color: #34495e; + border-radius: 6px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-bottom: 0.75rem; + color: white; + cursor: pointer; +} + +.video-icon { + font-size: 2.5rem; +} + +.video-label { + margin-top: 0.5rem; + font-size: 0.85rem; + font-weight: bold; +} + +.media-actions { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.media-actions button { + flex: 1; + padding: 0.5rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; + font-weight: bold; + transition: all 0.2s; +} + +.insert-btn { + background-color: #3498db; + color: white; +} + +.insert-btn:hover { + background-color: #2980b9; +} + +.delete-btn { + background-color: #e74c3c; + color: white; +} + +.delete-btn:hover { + background-color: #c0392b; +} + +.media-filename { + font-size: 0.75rem; + color: #666; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.error-message { + background-color: #ffebee; + color: #c62828; + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; +} \ No newline at end of file diff --git a/client/kb-frontend/src/components/MediaGallery.jsx b/client/kb-frontend/src/components/MediaGallery.jsx new file mode 100644 index 0000000..099b0bb --- /dev/null +++ b/client/kb-frontend/src/components/MediaGallery.jsx @@ -0,0 +1,168 @@ +import { useState, useEffect } from 'react'; +import './MediaGallery.css'; + +function MediaGallery({ kaNumber, token, onInsertMedia, isOpen, onToggle }) { + const [media, setMedia] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + fetchMedia(); + }, [kaNumber, isOpen]); + + const fetchMedia = async () => { + try { + const response = await fetch(`http://localhost:9000/api/articles/${kaNumber}/media`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + setMedia(data); + } else { + setError('Failed to fetch media'); + } + } catch(err) { + console.error('Error fetching media:', err); + setError('Failed to load media'); + } finally { + setLoading(false); + } + }; + + const handleFileUpload = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + setUploading(true); + setError(''); + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch(`http://localhost:9000/api/articles/${kaNumber}/media`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }); + + if (response.ok) { + const newMedia = await response.json(); + setMedia([...media, newMedia]); + } else { + const data = await response.json(); + setError(data.error || 'Upload failed'); + } + } catch(err) { + console.error('Failed to uplaod file:', err); + setError('Failed to upload file'); + } finally { + setUploading(false); + e.target.value = ''; + } + } + + const handleDelete = async (filename) => { + if (!confirm('Are you sure you want to delete this file?')) return; + + try { + const response = await fetch(`http://localhost:9000/api/articles/${kaNumber}/media/${filename}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + setMedia(media.filter(m => m.filename !== filename)); + } else { + alert('Failed to delete file'); + } + } catch(err) { + console.error('Error deleting file:', err); + alert('Failed to delete file'); + } + }; + + return ( + <> + + +
+
+
+
+ +
+
+

Media Gallery - {kaNumber}

+ +
+ + {error &&
{error}
} + + {loading ? ( +
Loading media...
+ ) : media.length === 0 ? ( +
+

No media uploaded yet

+

Click "Upload Media" to add images or videos

+
+ ) : ( +
+ {media.map(item => ( +
+ {item.type === 'image' ? ( + {item.filename} onInsertMedia(item)} + /> + ) : ( +
onInsertMedia(item)}> + 🎥 + Video +
+ )} + +
+ + +
+
{item.filename}
+
+ ))} +
+ )} +
+
+ + ); +} + +export default MediaGallery; \ No newline at end of file diff --git a/client/kb-frontend/src/components/UserMenu.jsx b/client/kb-frontend/src/components/UserMenu.jsx index 1655d06..196de69 100644 --- a/client/kb-frontend/src/components/UserMenu.jsx +++ b/client/kb-frontend/src/components/UserMenu.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import './UserMenu.css'; -function UserMenu({ currentUser, onLogout, onOpenAdminPanel }) { +function UserMenu({ currentUser, onLogout, onOpenAdminPanel, onOpenMyDrafts }) { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); @@ -25,6 +25,8 @@ function UserMenu({ currentUser, onLogout, onOpenAdminPanel }) { action(); }; + const canEdit = currentUser?.role === 'Admin' || currentUser?.role === 'Editor'; + return (
)} + {canEdit && ( + + )}