Added file uploads and drafts

This commit is contained in:
MattLeo 2025-12-05 13:42:09 -06:00
parent 31f1fbae94
commit 6d9b245f3c
13 changed files with 1211 additions and 49 deletions

5
.gitignore vendored
View File

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

View File

@ -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 (
<div className="app">
<Header
currentUser={isLoggedIn ? currentUser : null}
onLogout={handleLogout}
onOpenAdminPanel={handleOpenAdminPanel}
onOpenMyDrafts={handleOpenMyDrafts}
/>
{/* Show Login/Registration if not logged in */}
{!isLoggedIn ? (
authView === 'login' ? (
<Login
<Login
onLoginSuccess={handleLoginSuccess}
onSwitchToRegister={handleSwitchToRegister}
/>
@ -245,17 +280,23 @@ function App() {
<ArticleList articles={articles} onArticleClick={handleArticleClick} />
</>
) : currentView === 'detail' ? (
<ArticleDetail
article={selectedArticle}
onBack={handleBack}
onEdit={handleEdit}
<ArticleDetail
article={selectedArticle}
onBack={handleBack}
onEdit={handleEdit}
onDelete={handleDelete}
currentUser={currentUser}
currentUser={currentUser}
/>
) : currentView === 'edit' ? (
<ArticleEditor article={selectedArticle} onSave={handleSave} onCancel={handleCancelEdit} />
) : currentView === 'edit' ? (
<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}
</>
)}

View File

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

View File

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

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

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

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

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

View File

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

View File

@ -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
View File

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

View File

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

View File

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