Added file uploads and drafts
This commit is contained in:
parent
31f1fbae94
commit
6d9b245f3c
5
.gitignore
vendored
5
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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);
|
||||
@ -165,20 +166,40 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setSelectedArticle({
|
||||
ka_number: 'New Article',
|
||||
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: ''
|
||||
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}`, {
|
||||
method: 'DELETE',
|
||||
headers: {'Authorization': `Bearer ${token}`}
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
@ -211,12 +232,26 @@ function App() {
|
||||
setCurrentView('list');
|
||||
};
|
||||
|
||||
const handleOpenMyDrafts = () => {
|
||||
setCurrentView('drafts');
|
||||
};
|
||||
|
||||
const handleBackFromDrafts = () => {
|
||||
setCurrentView('list');
|
||||
};
|
||||
|
||||
const handleSelectDraft = (draft) => {
|
||||
setSelectedArticle(draft);
|
||||
setCurrentView('edit');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Header
|
||||
currentUser={isLoggedIn ? currentUser : null}
|
||||
onLogout={handleLogout}
|
||||
onOpenAdminPanel={handleOpenAdminPanel}
|
||||
onOpenMyDrafts={handleOpenMyDrafts}
|
||||
/>
|
||||
|
||||
{/* Show Login/Registration if not logged in */}
|
||||
@ -253,9 +288,15 @@ function App() {
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
) : currentView === 'edit' ? (
|
||||
<ArticleEditor article={selectedArticle} onSave={handleSave} onCancel={handleCancelEdit} />
|
||||
<ArticleEditor article={selectedArticle} onSave={handleSave} onCancel={handleCancelEdit} token={token} />
|
||||
) : currentView === 'admin' ? (
|
||||
<AdminPanel token={token} onBack={handleBackFromAdmin} />
|
||||
) : currentView === 'drafts' ? (
|
||||
<DraftsList
|
||||
token={token}
|
||||
onBack={handleBackFromDrafts}
|
||||
onSelectDraft={handleSelectDraft}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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 = `<video controls style="max-width: 100%; height: auto;"><source src="${mediaUrl}" type="video/mp4">Your browser does not support video playback.</video>`;
|
||||
quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml);
|
||||
}
|
||||
|
||||
quill.setSelection(range.index + 1);
|
||||
};
|
||||
|
||||
const toggleGallery = () => {
|
||||
setIsGalleryOpen(!isGalleryOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='article-editor'>
|
||||
<div className='editor-header'>
|
||||
<h2>Editing: {article.ka_number}</h2>
|
||||
<h2>{ article.status === 'draft' ? 'Editing Draft: ' : 'Editing: '}
|
||||
{article.ka_number}
|
||||
</h2>
|
||||
{article.status === 'draft' && (
|
||||
<span className='draft-indicator'>DRAFT</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='editor-form'>
|
||||
@ -34,13 +79,23 @@ function ArticleEditor ({ article, onSave, onCancel }) {
|
||||
<ReactQuill
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
ref={quillRef}
|
||||
/>
|
||||
|
||||
<div className='editor-buttons'>
|
||||
<button onClick={handleSave}>Save</button>
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button className='save-draft-btn' onClick={handleSaveDraft}>Save Draft</button>
|
||||
<button className='publish-btn' onClick={handlePublish}>Publish</button>
|
||||
<button className='cancel-btn' onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaGallery
|
||||
kaNumber={article.ka_number}
|
||||
token={token}
|
||||
onInsertMedia={handleInsertMedia}
|
||||
isOpen={isGalleryOpen}
|
||||
onToggle={toggleGallery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,3 +18,66 @@
|
||||
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;
|
||||
}
|
||||
129
client/kb-frontend/src/components/DraftsList.css
Normal file
129
client/kb-frontend/src/components/DraftsList.css
Normal file
@ -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;
|
||||
}
|
||||
110
client/kb-frontend/src/components/DraftsList.jsx
Normal file
110
client/kb-frontend/src/components/DraftsList.jsx
Normal file
@ -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 <div className='drafts-list'>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='drafts-list'>
|
||||
<div className='drafts-header'>
|
||||
<h1>My Drafts</h1>
|
||||
</div>
|
||||
|
||||
{error && (<div className='error-message'>{error}</div>)}
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className='no-drafts'>
|
||||
<p>You do not have any drafts yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='drafts-grid'>
|
||||
{drafts.map(draft => (
|
||||
<div
|
||||
key={draft.id}
|
||||
className='draft-card'
|
||||
onClick={() => onSelectDraft(draft)}
|
||||
>
|
||||
<div className='draft-header'>
|
||||
<span className='draft-badge'>DRAFT</span>
|
||||
<span className='ka-number'>{draft.ka_number}</span>
|
||||
</div>
|
||||
<h3 className='draft-title'>{draft.title || '(Untitled)'}</h3>
|
||||
<div className='draft-preview'>
|
||||
{draft.content
|
||||
? draft.content.replace(/<[^>]*>/g, '').substring(0, 100) + '...'
|
||||
: 'No content yet'
|
||||
}
|
||||
</div>
|
||||
<div className='draft-footer'>
|
||||
<span className='draft-date'>Created: {new Date(draft.created_at).toLocaleDateString()}</span>
|
||||
<button
|
||||
className='delete-draft-btn'
|
||||
onClick={(e) => handleDeleteDraft(draft.ka_number, e)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DraftsList;
|
||||
242
client/kb-frontend/src/components/MediaGallery.css
Normal file
242
client/kb-frontend/src/components/MediaGallery.css
Normal file
@ -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;
|
||||
}
|
||||
168
client/kb-frontend/src/components/MediaGallery.jsx
Normal file
168
client/kb-frontend/src/components/MediaGallery.jsx
Normal file
@ -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 (
|
||||
<>
|
||||
<button className={`drawer-toggle ${isOpen ? 'open' : ''}`} onClick={onToggle}>
|
||||
<span className='toggle-icon'>{isOpen ? '▼' : '▲'}</span>
|
||||
<span className='toggle-text'>Media Gallery</span>
|
||||
{media.length > 0 && <span className='media-count'>{media.length}</span>}
|
||||
</button>
|
||||
|
||||
<div className={`media-drawer ${isOpen ? 'open' : ''}`}>
|
||||
<div className='drawer-handle' onClick={onToggle}>
|
||||
<div className='handle-bar'></div>
|
||||
</div>
|
||||
|
||||
<div className='drawer-content'>
|
||||
<div className='gallery-header'>
|
||||
<h3>Media Gallery - {kaNumber}</h3>
|
||||
<label className='upload-button'>
|
||||
<input
|
||||
type='file'
|
||||
onChange={handleFileUpload}
|
||||
accept='image/*, video/*'
|
||||
disabled={uploading}
|
||||
style={{display: 'none'}}
|
||||
/>
|
||||
{uploading ? 'Uploading...' : '+ Upload Media'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <div className='error-message'>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading media...</div>
|
||||
) : media.length === 0 ? (
|
||||
<div className="no-media">
|
||||
<p>No media uploaded yet</p>
|
||||
<p>Click "Upload Media" to add images or videos</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='media-grid'>
|
||||
{media.map(item => (
|
||||
<div key={item.filename} className='media-item'>
|
||||
{item.type === 'image' ? (
|
||||
<img
|
||||
src={`http://localhost:9000${item.url}`}
|
||||
alt={item.filename}
|
||||
onClick={() => onInsertMedia(item)}
|
||||
/>
|
||||
) : (
|
||||
<div className='video-thumbnail' onClick={() => onInsertMedia(item)}>
|
||||
<span className='video-icon'>🎥</span>
|
||||
<span className='video-label'>Video</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='media-actions'>
|
||||
<button
|
||||
className='insert-btn'
|
||||
onClick={() => onInsertMedia(item)}
|
||||
>Insert</button>
|
||||
<button
|
||||
className='delete-btn'
|
||||
onClick={() => handleDelete(item.filename)}
|
||||
>Delete</button>
|
||||
</div>
|
||||
<div className='media-filename'>{item.filename}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MediaGallery;
|
||||
@ -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 (
|
||||
<div className='user-menu' ref={menuRef}>
|
||||
<button className='user-menu-button' onClick={toggleMenu}>
|
||||
@ -41,6 +43,12 @@ function UserMenu({ currentUser, onLogout, onOpenAdminPanel }) {
|
||||
onClick={() => handleMenuClick(onOpenAdminPanel)}
|
||||
>Admin Panel</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
className='menu-item'
|
||||
onClick={() => handleMenuClick(onOpenMyDrafts)}
|
||||
>My Drafts</button>
|
||||
)}
|
||||
<button
|
||||
className='menu-item'
|
||||
onClick={() => handleMenuClick(onLogout)}
|
||||
|
||||
49
server/db.js
49
server/db.js
@ -1,6 +1,6 @@
|
||||
const initSqlJs = require('sql.js');
|
||||
const fs = require('fs');
|
||||
const { resourceUsage } = require('process');
|
||||
const path = require('path');
|
||||
|
||||
let db = null;
|
||||
const DB_PATH = './ka.db';
|
||||
@ -42,6 +42,7 @@ async function initDb() {
|
||||
ka_number TEXT UNIQUE,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
status TEXT DEFAULT 'published',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT,
|
||||
updated_at DATETIME,
|
||||
@ -50,6 +51,7 @@ async function initDb() {
|
||||
`);
|
||||
|
||||
db.run("CREATE INDEX idx_ka_number ON articles(ka_number)");
|
||||
db.run("CREATE INDEX idx_status ON articles(status)");
|
||||
|
||||
// Creating Users table
|
||||
db.run(`
|
||||
@ -80,11 +82,11 @@ async function initDb() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all articles from the database
|
||||
* Gets all published articles from the database
|
||||
* @returns {[Object]} - returns an array of all stored articles
|
||||
*/
|
||||
function getAllArticles() {
|
||||
const result = db.exec("SELECT * FROM articles ORDER BY created_at DESC");
|
||||
const result = db.exec("SELECT * FROM articles WHERE status = 'published' ORDER BY created_at DESC");
|
||||
|
||||
if (result.length === 0) {
|
||||
return [];
|
||||
@ -130,14 +132,15 @@ function getArticle(ka_num) {
|
||||
* @param {string} title - The title of the article
|
||||
* @param {string} content - The content of the article
|
||||
* @param {string} author - The username of the article creator
|
||||
* @param {string} status - the status of the created article ('draft' or 'published')
|
||||
* @returns {Object} - The newly created article from the database
|
||||
*/
|
||||
function createArticle(title, content, author) {
|
||||
function createArticle(title, content, author, status = 'published') {
|
||||
const ka_num = getNextKANumber();
|
||||
|
||||
db.run(
|
||||
"INSERT INTO articles (ka_number, title, content, created_by) VALUES (?, ?, ?, ?)",
|
||||
[ka_num, title, content, author]
|
||||
"INSERT INTO articles (ka_number, title, content, created_by, status) VALUES (?, ?, ?, ?, ?)",
|
||||
[ka_num, title, content, author, status]
|
||||
);
|
||||
|
||||
// Saving updated DB to file
|
||||
@ -153,12 +156,13 @@ function createArticle(title, content, author) {
|
||||
* @param {string} title - The title of the article
|
||||
* @param {string} content - The content of the article
|
||||
* @param {string} author - The username that updated the article
|
||||
* @param {string} status - The status of the article ('published' or 'draft')
|
||||
* @returns {Object} - The newly updatd article from the database
|
||||
*/
|
||||
function updateArticle(ka_num, title, content, author) {
|
||||
function updateArticle(ka_num, title, content, author, status = 'published') {
|
||||
db.run(
|
||||
"UPDATE articles SET title = ?, content = ?, updated_at = CURRENT_TIMESTAMP, updated_by = ? WHERE ka_number = ?",
|
||||
[title, content, author, ka_num]
|
||||
"UPDATE articles SET title = ?, content = ?, updated_at = CURRENT_TIMESTAMP, updated_by = ?, status = ? WHERE ka_number = ?",
|
||||
[title, content, author, status, ka_num]
|
||||
);
|
||||
|
||||
// Saving updated DB to file
|
||||
@ -181,6 +185,13 @@ function deleteArticle(ka_num) {
|
||||
// Saving updated DB to file
|
||||
const data = db.export();
|
||||
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||
|
||||
// Deleting media folder if it exists
|
||||
const mediaDir = path.join('./media', ka_num);
|
||||
|
||||
if (fs.existsSync(mediaDir)) {
|
||||
fs.rmSync(mediaDir, {recursive: true, force: true});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -337,6 +348,23 @@ function getAllUsers() {
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all draft articles owned by a user
|
||||
* @param {string} author - The author of the articles
|
||||
* @returns {[Object]} - An array of article objects
|
||||
*/
|
||||
function getOwnedDrafts(author) {
|
||||
const stmt = db.prepare("SELECT * FROM articles WHERE status = 'draft' AND created_by = ? ORDER BY created_at DESC")
|
||||
stmt.bind([author]);
|
||||
|
||||
const results = [];
|
||||
while (stmt.step()) {
|
||||
results.push(stmt.getAsObject());
|
||||
}
|
||||
stmt.free()
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initDb,
|
||||
getAllArticles,
|
||||
@ -351,5 +379,6 @@ module.exports = {
|
||||
getUserById,
|
||||
updateUserRole,
|
||||
deleteUser,
|
||||
getAllUsers
|
||||
getAllUsers,
|
||||
getOwnedDrafts
|
||||
};
|
||||
173
server/package-lock.json
generated
173
server/package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"sql.js": "^1.13.0"
|
||||
}
|
||||
},
|
||||
@ -68,6 +69,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argon2": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
|
||||
@ -114,6 +121,23 @@
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@ -152,6 +176,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@ -707,12 +746,94 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@ -864,6 +985,20 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@ -1069,6 +1204,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@ -1092,6 +1244,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@ -1101,6 +1259,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
@ -1139,6 +1303,15 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"sql.js": "^1.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
158
server/server.js
158
server/server.js
@ -14,7 +14,8 @@ const {
|
||||
getUserById,
|
||||
getAllUsers,
|
||||
updateUserRole,
|
||||
deleteUser
|
||||
deleteUser,
|
||||
getOwnedDrafts
|
||||
} = require('./db');
|
||||
const { generateToken, authenticateToken, authorizeRoles } = require('./auth');
|
||||
const app = express();
|
||||
@ -22,12 +23,28 @@ const cors = require('cors');
|
||||
const msal = require('@azure/msal-node');
|
||||
const entraConfig = require('./entraConfig');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const PORT = 9000;
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json());
|
||||
|
||||
const msalClient = new msal.ConfidentialClientApplication(entraConfig);
|
||||
const upload = multer({
|
||||
dest: './temp_uploads/',
|
||||
limits: {
|
||||
filesize: 100 * 1024 * 1024
|
||||
}
|
||||
});
|
||||
|
||||
if (!fs.existsSync('./temp_uploads')) {
|
||||
fs.mkdirSync('./temp_uploads');
|
||||
}
|
||||
if (!fs.existsSync('./media')) {
|
||||
fs.mkdirSync('./media');
|
||||
}
|
||||
|
||||
initDb().then(() => {
|
||||
|
||||
@ -62,13 +79,13 @@ initDb().then(() => {
|
||||
|
||||
app.post('/api/articles', authenticateToken, authorizeRoles('Admin', 'Editor'), (req, res) => {
|
||||
try {
|
||||
const { title, content } = req.body;
|
||||
const { title, content, status } = req.body;
|
||||
|
||||
if (!title) {
|
||||
if (!title && status === 'published') {
|
||||
return res.status(400).json({error: 'Title is required' });
|
||||
}
|
||||
|
||||
const article = createArticle(title, content, req.user.display_name);
|
||||
const article = createArticle(title, content, req.user.display_name, status);
|
||||
res.status(201).json(article);
|
||||
|
||||
} catch (error) {
|
||||
@ -79,13 +96,23 @@ initDb().then(() => {
|
||||
|
||||
app.put('/api/articles/:ka_number', authenticateToken, authorizeRoles('Admin', 'Editor'), (req, res) => {
|
||||
try {
|
||||
const { title, content } = req.body;
|
||||
const { title, content, status } = req.body;
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({error: 'Title is required' });
|
||||
if (status && !['draft', 'published'].includes(status)) {
|
||||
return res.status(400).json({error: 'Invalid publishing status'});
|
||||
}
|
||||
|
||||
const article = updateArticle(req.params.ka_number, title, content, req.user.display_name);
|
||||
if ( status === 'published' && !title) {
|
||||
return res.status(400).json({error: 'Title is required to publish article'});
|
||||
}
|
||||
|
||||
const article = updateArticle(
|
||||
req.params.ka_number,
|
||||
title,
|
||||
content,
|
||||
req.user.display_name,
|
||||
status || 'published'
|
||||
);
|
||||
|
||||
if (!article) {
|
||||
return res.status(404).json({error: 'Article not found'});
|
||||
@ -331,6 +358,121 @@ initDb().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/articles/drafts', authenticateToken, authorizeRoles('Admin', 'Editor'), (req, res) => {
|
||||
try {
|
||||
const drafts = getOwnedDrafts(req.user.display_name);
|
||||
res.json(drafts);
|
||||
} catch(error) {
|
||||
console.error('Error fetching owned drafts', error);
|
||||
res.status(500).json({error: 'Failed to fetch owned drafts', details: String(error)});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/articles/:ka_number/media',
|
||||
authenticateToken,
|
||||
authorizeRoles('Admin', 'Editor'),
|
||||
upload.single('file'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const kaNumber = req.params.ka_number;
|
||||
const file = req.file;
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({error: 'No file uploaded'});
|
||||
}
|
||||
|
||||
const article = getArticle(kaNumber);
|
||||
if (!article) {
|
||||
fs.unlinkSync(file.path);
|
||||
return res.status(404).json({error: 'Article not found'});
|
||||
}
|
||||
|
||||
const mediaDir = path.join('./media', kaNumber);
|
||||
if (!fs.existsSync(mediaDir)) {
|
||||
fs.mkdirSync(mediaDir, {recursive: true});
|
||||
}
|
||||
|
||||
const fileExt = path.extname(file.originalname).toLowerCase();
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(fileExt);
|
||||
const isVideo = ['.mp4', '.mov', '.avi', '.mkv', '.webm'].includes(fileExt);
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
fs.unlinkSync(file.path);
|
||||
return res.status(400).json({error: 'Invalid file type. Only images and videos are allowed.'})
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const outputFilename = isVideo
|
||||
? `video_${timestamp}${fileExt}`
|
||||
: `image_${timestamp}${fileExt}`
|
||||
const outputPath = path.join(mediaDir, outputFilename);
|
||||
|
||||
fs.renameSync(file.path, outputPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
filename: outputFilename,
|
||||
url: `/media/${kaNumber}/${outputFilename}`,
|
||||
type: isVideo ? 'video' : 'image'
|
||||
});
|
||||
} catch(error) {
|
||||
console.error('Error uploading media:', error);
|
||||
res.status(500).json({error: 'Failed to upload media', details: String(error)});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get('/api/articles/:ka_number/media', authenticateToken, authorizeRoles('Admin', 'Editor'), (req, res) => {
|
||||
try {
|
||||
const kaNumber = req.params.ka_number;
|
||||
const mediaDir = path.join('./media', kaNumber);
|
||||
|
||||
if (!fs.existsSync(mediaDir)) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(mediaDir);
|
||||
const mediaList = files.map(filename => {
|
||||
const fileExt = path.extname(filename).toLocaleLowerCase();
|
||||
const isVideo = ['.mp4', '.mov', '.avi', '.mkv', '.webm'].includes(fileExt);
|
||||
|
||||
return {
|
||||
filename: filename,
|
||||
url: `/media/${kaNumber}/${filename}`,
|
||||
type: isVideo ? 'video' : 'image'
|
||||
};
|
||||
});
|
||||
|
||||
res.json(mediaList);
|
||||
} catch (error) {
|
||||
console.error('Error fetching media:', error);
|
||||
res.status(500).json({error: 'Failed to fetch media', details: String(error)});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/articles/:ka_number/media/:filename',
|
||||
authenticateToken,
|
||||
authorizeRoles('Admin', 'Editor'),
|
||||
(req, res) => {
|
||||
try {
|
||||
const {ka_number, filename } = req.params;
|
||||
const filePath = path.join('./media', ka_number, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({error: 'File not found'});
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
res.json({message: 'File deleted successfully'});
|
||||
} catch (error) {
|
||||
console.error('Error when deleting file:', error);
|
||||
res.status(500).json({error: 'Failed to delete file', details: String(error)});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.use('/media', express.static('./media'));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user