added proper video embedding and added catagory and tag db foundation

This commit is contained in:
MattLeo 2025-12-05 16:36:22 -06:00
parent 05ce0c052d
commit f0ebd192a1
4 changed files with 361 additions and 42 deletions

View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@azure/msal-browser": "^4.26.2",
"quill-blot-formatter": "^1.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-quill": "^2.0.0"
@ -26,21 +27,21 @@
}
},
"node_modules/@azure/msal-browser": {
"version": "4.26.2",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.26.2.tgz",
"integrity": "sha512-F2U1mEAFsYGC5xzo1KuWc/Sy3CRglU9Ql46cDUx8x/Y3KnAIr1QAq96cIKCk/ZfnVxlvprXWRjNKoEpgLJXLhg==",
"version": "4.27.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.27.0.tgz",
"integrity": "sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "15.13.2"
"@azure/msal-common": "15.13.3"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "15.13.2",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.2.tgz",
"integrity": "sha512-cNwUoCk3FF8VQ7Ln/MdcJVIv3sF73/OT86cRH81ECsydh7F4CNfIo2OAx6Cegtg8Yv75x4506wN4q+Emo6erOA==",
"version": "15.13.3",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz",
"integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
@ -854,9 +855,9 @@
}
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -866,7 +867,7 @@
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
@ -1524,9 +1525,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz",
"integrity": "sha512-8QdH6czo+G7uBsNo0GiUfouPN1lRzKdJTGnKXwe12gkFbnnOUaUKGN55dMkfy+mnxmvjwl9zcI4VncczcVXDhA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -1545,9 +1546,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@ -1565,11 +1566,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
"electron-to-chromium": "^1.5.249",
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.1.4"
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@ -1636,9 +1637,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"version": "1.0.30001759",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz",
"integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==",
"dev": true,
"funding": [
{
@ -1783,6 +1784,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@ -1832,9 +1842,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.260",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz",
"integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==",
"version": "1.5.266",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz",
"integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==",
"dev": true,
"license": "ISC"
},
@ -2938,6 +2948,18 @@
"quill-delta": "^3.6.2"
}
},
"node_modules/quill-blot-formatter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/quill-blot-formatter/-/quill-blot-formatter-1.0.5.tgz",
"integrity": "sha512-iVmuEdmMIpvERBnnDfosWul6VAVN6tqQRruUzAEwA9ZbQ/Ef7DTHGZDUR4KklXpxM+z50opFp6m1NhNdN6HJhw==",
"license": "Apache-2.0",
"dependencies": {
"deepmerge": "^2.0.0"
},
"peerDependencies": {
"quill": "^1.3.4"
}
},
"node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
@ -3215,9 +3237,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
"integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==",
"dev": true,
"funding": [
{
@ -3256,9 +3278,9 @@
}
},
"node_modules/vite": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz",
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@azure/msal-browser": "^4.26.2",
"quill-blot-formatter": "^1.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-quill": "^2.0.0"

View File

@ -1,10 +1,61 @@
import { useState, useRef } from 'react';
import Quill from 'quill';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import './ArticleEdit.css';
import MediaGallery from './MediaGallery';
import BlotFormatter from 'quill-blot-formatter';
function ArticleEditor ({ article, onSave, onCancel, token }) {
Quill.register('modules/blotFormatter', BlotFormatter);
const BlockEmbed = Quill.import('blots/block/embed');
class VideoBlot extends BlockEmbed {
static create(value) {
const node = super.create();
node.setAttribute('controls', '');
node.setAttribute('style', 'max-width: 100%; height: auto;');
node.setAttribute('width', '100%');
const source = document.createElement('source');
source.setAttribute('src', value);
source.setAttribute('type', 'video/mp4');
node.appendChild(source);
return node;
}
static value(node) {
const source = node.querySelector('source');
return source ? source.getAttribute('src') : '';
}
static formats(node) {
return {
width: node.getAttribute('width'),
height: node.getAttribute('height')
};
}
format(name, value) {
if (name === 'width' || name === 'height') {
if (value) {
this.domNode.setAttribute(name, value);
} else {
this.domNode.removeAttribute(name);
}
} else {
super.format(name, value);
}
}
}
VideoBlot.blotName = 'video';
VideoBlot.tagName = 'video';
Quill.register(VideoBlot);
function ArticleEditor({ article, onSave, onCancel, token }) {
const [title, setTitle] = useState(article.title);
const [content, setContent] = useState(article.content);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
@ -43,7 +94,7 @@ function ArticleEditor ({ article, onSave, onCancel, token }) {
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.insertEmbed(range.index, 'video', mediaUrl);
quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml);
}
@ -54,10 +105,42 @@ function ArticleEditor ({ article, onSave, onCancel, token }) {
setIsGalleryOpen(!isGalleryOpen);
};
const modules = {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'indent': '-1' }, { 'indent': '+1' }],
['link'],
['clean']
],
blotFormatter: {
specs: [
BlotFormatter.DefaultSpec
],
overlay: {
style: {
border: '2px solid #3498db'
}
}
},
clipboard: {
matchVisual: false
}
};
const formats = [
'header',
'bold', 'italic', 'underline', 'strike',
'list', 'bullet', 'indent',
'link', 'image', 'video',
'width', 'height'
];
return (
<div className='article-editor'>
<div className='editor-header'>
<h2>{ article.status === 'draft' ? 'Editing Draft: ' : 'Editing: '}
<h2>{article.status === 'draft' ? 'Editing Draft: ' : 'Editing: '}
{article.ka_number}
</h2>
{article.status === 'draft' && (
@ -80,6 +163,8 @@ function ArticleEditor ({ article, onSave, onCancel, token }) {
value={content}
onChange={setContent}
ref={quillRef}
modules={modules}
formats={formats}
/>
<div className='editor-buttons'>

View File

@ -72,6 +72,48 @@ async function initDb() {
db.run("CREATE INDEX idx_entra_id ON users(entra_id)");
db.run("CREATE INDEX idx_username ON users(username)");
// Creating tags table and its junction table
db.run(`
CREATE TABLE tags (
id INTEGER PRIMAY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.run('CREATE INDEX idx_tag_name ON tags(name)');
db.run(`
CREATE TABLE article_tags (
article_id INTEGER,
tag_id INTEGER,
PRIMARY KEY (article_id, tag_id),
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
)
`);
// Creating categories table and its junction table
db.run(`
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.run('CREATE INDEX idx_category_name ON categories(name');
db.run(`
CREATE TABLE article_categories (
article_id INTEGER,
category_id INTEGER,
PRIMARY KEY (article_id, category_id),
FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
)
`);
// Saving the created db to file
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
@ -83,7 +125,7 @@ async function initDb() {
/**
* Gets all published articles from the database
* @returns {[Object]} - returns an array of all stored articles
* @returns {Array<Object>} - returns an array of all stored articles
*/
function getAllArticles() {
const result = db.exec("SELECT * FROM articles WHERE status = 'published' ORDER BY created_at DESC");
@ -197,7 +239,7 @@ function deleteArticle(ka_num) {
/**
* Searches the article table for any words matching in the title, content, or ka number and returns an array of them
* @param {string} query - The search query that will be used for looking up the assocaited articles
* @returns {[Object]} - An arry of articles that match the query content
* @returns {Array<Object>} - An arry of articles that match the query content
*/
function searchArticles(query) {
const searchTerm = `%${query}%`;
@ -326,7 +368,7 @@ function deleteUser(userId) {
/**
* Gets all users currently stored in the database
* @returns {[Object]} - an array of user objects
* @returns {Array<Object>} - an array of user objects
*/
function getAllUsers() {
const result = db.exec(
@ -351,7 +393,7 @@ function getAllUsers() {
/**
* Return all draft articles owned by a user
* @param {string} author - The author of the articles
* @returns {[Object]} - An array of article objects
* @returns {Array<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")
@ -365,6 +407,167 @@ function getOwnedDrafts(author) {
return results;
}
/**
* Gets all categories
* @returns {Array<Object>} - Array of category objects
*/
function getAllCategories() {
const result = db.exec("SELECT * FROM categories ORDER BY name ASC");
if (result.length === 0) return [];
const columns = result[0].columns;
const rows = result[0].values;
return rows.map(row => {
const category = {};
columns.forEach((col, index) => {
category[col] = row[index];
});
return category;
});
}
/**
* Get the existing category object, or create it if it doesn't.
* @param {String} name - the name of the category to obtain
* @returns {Object} - The category object from the database
*/
function getOrCreateCategory(name) {
const stmt = db.prepare("SELECT * FROM categories WHERE name = ?");
stmt.bind([name]);
if (stmt.step()) {
const category = stmt.getAsObject();
stmt.free();
return category;
}
stmt.free();
db.run("INSERT INTO categories (name) VALUES (?)", [name]);
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
return getOrCreateCategory(name);
}
/**
* Gets all the tags in the database
* @returns {Array<Object>} - an array of tag objects
*/
function getAllTags() {
const result = db.exec("SELECT * FROM tags ORDER BY name ASC");
if (result.length === 0) return [];
const columns = result[0].columns;
const rows = result[0].values;
return rows.map(row => {
const tag = {};
columns.forEach((col, index) => {
tag[col] = row[index];
});
return tag;
});
}
/**
* Get the tag object from the database if it exists, and create it if it doesn't
* @param {string} name - the name of the tag
* @returns {Object} - the tag object from the database
*/
function getOrCreateTag(name) {
const stmt = db.prepare("SELECT * FROM tags WHERE name = ?");
stmt.bind([name]);
if(stmt.step()) {
const tag = stmt.getAsObject();
stmt.free()
return tag;
}
stmt.free();
db.run("INSERT INTO tags (name) VALUES (?)",[name]);
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
return getOrCreateTag(name);
}
/**
* Set categories for an article
* @param {Int} articleId - Artilce ID
* @param {Array<string>} categoryNames - An array of category names
*/
function setArticleCategories(articleId, categoryNames) {
db.run("DELETE FROM article_categories WHERE article_id = ?", [articleId]);
categoryNames.forEach(name => {
const category = getOrCreateCategory(name);
db.run("INSERT INTO article_categories (article_id, category_id) VALUES (?, ?)",
[articleId, category.id]
);
});
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
}
/**
* Set the tags for an article
* @param {Int} articleId - Article ID
* @param {Array<string>} tagNames - An array of tag names
*/
function setArticleTags(articleId, tagNames) {
db.run("DELETE FROM article_tags WHERE article_id = ?", [articleId]);
tagNames.forEach(name => {
const tag = getOrCreateTag(name);
db.run("INSERT INTO article_tags (article_id, tag_id) VALUES (?, ?)", [articleId, tag.id]);
});
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
}
/**
* Get the categories of an article
* @param {int} articleId - Article ID
* @returns {Array<string>} - An array of category names
*/
function getArticleCategories(articleId) {
const result = db.exec(`
SELECT c.name FROM categories c
JOIN article_categories ac ON c.id = ac.category_id
WHERE ac.article_id = ?
ORDER BY c.name ASC
`, [articleId]);
if (result.length === 0) return [];
return result[0].values.map(row => row[0]);
}
/**
* Get the tags on an article
* @param {Int} articleId - Article ID
* @returns {Array<string>} - An array of tag names
*/
function getArticleTags(articleId) {
const result = db.exec(`
SELECT t.name FROM tags t
JOIN article_tags at ON t.id = at.tag_id
WHERE ta.article_id = ?
ORDER BY t.name ASC
`, [articleId]);
if(result.length === 0) return [];
return result[0].values.map(row => row[0]);
}
module.exports = {
initDb,
getAllArticles,
@ -380,5 +583,13 @@ module.exports = {
updateUserRole,
deleteUser,
getAllUsers,
getOwnedDrafts
getOwnedDrafts,
getAllCategories,
getAllTags,
getOrCreateCategory,
getOrCreateTag,
getArticleCategories,
getArticleTags,
setArticleCategories,
setArticleTags
};