diff --git a/backend/core/core.js b/backend/core/core.js index 501d116..f4a84a0 100644 --- a/backend/core/core.js +++ b/backend/core/core.js @@ -4,6 +4,7 @@ const sharp = require("sharp"); const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsCommand, DeleteObjectsCommand } = require("@aws-sdk/client-s3"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); let s3; +const crypto = require("crypto"); const md = require("markdown-it")() .use(require("markdown-it-underline")) .use(require("markdown-it-footnote")) @@ -195,27 +196,6 @@ async function postBlog(blog_post, owner_id) { // Save to database const database_blog = await prisma.blogPost.create({ data: { ...blog_post_formatted, owner: { connect: { id: owner_id } } } }); - // Init image vars - let uploaded_images = []; - let uploaded_thumbnail = "DEFAULT"; - - // For Each image, upload to S3 - if (blog_post.images) { - for (let i = 0; blog_post.images.length > i; i++) { - const image = blog_post.images[i]; - const image_data = Buffer.from(image.data_blob.split(",")[1], "base64"); - const name = await _uploadImage(database_blog.id, "blog", false, image_data, image.id); - if (name) uploaded_images.push(name); - } - } - - // Upload thumbnail to S3 - if (blog_post.thumbnail) { - const image_data = Buffer.from(blog_post.thumbnail.data_blob.split(",")[1], "base64"); - const name = await _uploadImage(database_blog.id, "blog", true, image_data, blog_post.thumbnail.id); - if (name) uploaded_thumbnail = name; - } - // Update the blog post to include references to our images await prisma.blogPost.update({ where: { id: database_blog.id }, data: { images: uploaded_images, thumbnail: uploaded_thumbnail } }); return { success: true, blog_id: database_blog.id }; @@ -256,7 +236,6 @@ async function updateBlog(blog_post, requester_id) { const [hour, minute] = blog_post.time.split(":"); publish_date = new Date(year, month - 1, day, hour, minute); } - let blog_post_formatted = { title: blog_post.title, description: blog_post.description, @@ -264,37 +243,11 @@ async function updateBlog(blog_post, requester_id) { visibility: blog_post.unlisted ? "UNLISTED" : "PUBLISHED", publish_date: publish_date || blog_post.publish_date, tags: blog_post.tags, + images: [...post.data.raw_images, ...blog_post.images], }; await prisma.blogPost.update({ where: { id: post.data.id }, data: blog_post_formatted }); - let uploaded_images = []; - let uploaded_thumbnail = "DEFAULT"; - - // For Each image, upload to S3 - if (blog_post.images) { - for (let i = 0; blog_post.images.length > i; i++) { - const image = blog_post.images[i]; - const image_data = Buffer.from(image.data_blob.split(",")[1], "base64"); - const name = await _uploadImage(post.id, "blog", false, image_data, image.id); - if (name) uploaded_images.push(name); - } - } - - let data_to_update = { - images: [...post.data.raw_images, ...uploaded_images], - }; - - if (blog_post.thumbnail) { - const image_data = Buffer.from(blog_post.thumbnail.data_blob.split(",")[1], "base64"); - const name = await _uploadImage(post.data.id, "blog", true, image_data, blog_post.thumbnail.id); - if (name) uploaded_thumbnail = name; - - data_to_update.thumbnail = uploaded_thumbnail; - } - - await prisma.blogPost.update({ where: { id: post.data.id }, data: data_to_update }); - return { success: true }; } async function deleteImage(image, requester_id) { @@ -323,19 +276,18 @@ async function deleteImage(image, requester_id) { return { success: true }; } -async function _uploadImage(parent_id, parent_type, is_thumbnail, buffer, name) { +async function postImage(post_id, buffer) { if (!use_s3_storage) return null; let size = { width: 1920, height: 1080 }; - if (is_thumbnail) size = { width: 300, height: 300 }; + const image_name = crypto.randomUUID(); - const compressed_image = await sharp(buffer, { animated: true }) + const compressed_image = await sharp(Buffer.from(buffer.split(",")[1], "base64"), { animated: true }) .resize({ ...size, withoutEnlargement: true, fit: "inside" }) .webp({ quality: 90, animated: true }) .toBuffer(); - const params = { Bucket: process.env.S3_BUCKET_NAME, - Key: `${process.env.ENVIRONMENT}/${parent_type}/${parent_id}/${name}.webp`, + Key: `${process.env.ENVIRONMENT}/posts/${post_id}/${image_name}.webp`, Body: compressed_image, ContentType: "image/webp", }; @@ -343,7 +295,7 @@ async function _uploadImage(parent_id, parent_type, is_thumbnail, buffer, name) const command = new PutObjectCommand(params); await s3.send(command); - return name; + return image_name; } async function _getImage(parent_id, parent_type, name) { if (!use_s3_storage) return null; @@ -383,7 +335,7 @@ async function _deleteS3Directory(id, type) { // If there are more objects to delete (truncated result), recursively call the function again // if (listed_objects.IsTruncated) await emptyS3Directory(bucket, dir); } -async function _renderPost(blog_post, raw, { post_type = "blog" } = {}) { +async function _renderPost(blog_post, raw) { if (raw) { // Had to do this, only God knows why. blog_post.raw_images = []; @@ -392,17 +344,13 @@ async function _renderPost(blog_post, raw, { post_type = "blog" } = {}) { blog_post.raw_thumbnail = blog_post.thumbnail; blog_post.raw_content = blog_post.content; } - if (blog_post.images) { // Get the image urls for the post for (i = 0; blog_post.images.length > i; i++) { - blog_post.images[i] = await _getImage(blog_post.id, post_type, blog_post.images[i]); + blog_post.images[i] = await _getImage(blog_post.id, "posts", blog_post.images[i]); } } - // get thumbnail URL - blog_post.thumbnail = await _getImage(blog_post.id, post_type, blog_post.thumbnail); - if (blog_post.content) { // Render the markdown contents of the post blog_post.content = md.render(blog_post.content); @@ -487,6 +435,12 @@ async function _getSettings() { return (settings[key] = value); }); } +// Create a new empty "post". +// Used so uploaded images know where to go +async function newPost(owner_id) { + const post = await prisma.blogPost.create({ data: { owner: { connect: { id: owner_id } } } }); + return post.id; +} async function getSetting(key, { parse = true }) { if (!settings[key]) return null; @@ -513,4 +467,4 @@ async function postSetting(key, value) { } } -module.exports = { settings, registerUser, getUser, getAuthorPage, postBlog, updateBlog, getBlog, deleteBlog, deleteImage, postSetting, getSetting }; +module.exports = { settings, newPost, registerUser, getUser, getAuthorPage, postBlog, updateBlog, getBlog, deleteBlog, postImage, deleteImage, postSetting, getSetting }; diff --git a/backend/core/external_api.js b/backend/core/external_api.js index 457c1ee..83f61c2 100644 --- a/backend/core/external_api.js +++ b/backend/core/external_api.js @@ -1,7 +1,6 @@ const feed_lib = require("feed").Feed; const core = require("./core"); -// TODO: Expose ATOM Feed items function getBaseFeed() { return new feed_lib({ title: core.settings.WEBSITE_NAME, diff --git a/backend/core/internal_api.js b/backend/core/internal_api.js index cf1f052..597991e 100644 --- a/backend/core/internal_api.js +++ b/backend/core/internal_api.js @@ -43,6 +43,11 @@ async function postSetting(request, response) { response.json(await core.postSetting(request.body.setting_name, request.body.value)); } +async function postImage(request, response) { + // TODO: Permissions for uploading images + // TODO: Verification for image uploading + return response.json(await core.postImage(request.body.post_id, request.body.buffer)); +} async function deleteImage(req, res) { // TODO: Permissions for deleting image return res.json(await core.deleteImage(req.body, req.session.user.id)); @@ -79,4 +84,4 @@ async function patchBlog(req, res) { return res.json(await core.updateBlog({ ...valid.data, id: req.body.id }, req.session.user.id)); } -module.exports = { postRegister, postLogin, postSetting, deleteImage, postBlog, deleteBlog, patchBlog }; +module.exports = { postRegister, postLogin, postSetting, postImage, deleteImage, postBlog, deleteBlog, patchBlog }; diff --git a/backend/page_scripts.js b/backend/page_scripts.js index b453a08..43068af 100644 --- a/backend/page_scripts.js +++ b/backend/page_scripts.js @@ -4,12 +4,10 @@ const core = require("./core/core"); function getThemePage(page_name) { return `themes/${core.settings.theme}/ejs/${page_name}.ejs`; } - function getDefaults(req) { // TODO: Fix reference to website_name return { logged_in_user: req.session.user, website_name: core.settings.WEBSITE_NAME || "Yet-Another-Blog", settings: core.settings }; } - async function index(request, response) { // Check if the master admin has been created // const is_setup_complete = core.settings["SETUP_COMPLETE"]; @@ -53,18 +51,9 @@ async function blogSingle(req, res) { if (blog.success === false) return res.redirect("/"); res.render(getThemePage("post"), { ...getDefaults(req), blog_post: blog.data }); } -function blogNew(request, response) { - // TODO: Turn date formatting into function - let existing_blog = {}; - let published_date_parts = new Date().toLocaleDateString().split("/"); - const formatted_date = `${published_date_parts[2]}-${published_date_parts[0].padStart(2, "0")}-${published_date_parts[1].padStart(2, "0")}`; - existing_blog.publish_date = formatted_date; - - let published_time_parts = new Date().toLocaleTimeString([], { timeStyle: "short" }).slice(0, 4).split(":"); - const formatted_time = `${published_time_parts[0].padStart(2, "0")}:${published_time_parts[1].padStart(2, "0")}`; - existing_blog.publish_time = formatted_time; - - response.render("blogNew.ejs", { ...getDefaults(request), existing_blog: existing_blog }); +async function blogNew(request, response) { + const new_post = await core.newPost(request.session.user.id); + return response.redirect(`/post/${new_post}/edit`); } async function blogEdit(req, res) { let existing_blog = await core.getBlog({ id: req.params.blog_id, raw: true }); @@ -78,7 +67,7 @@ async function blogEdit(req, res) { const formatted_date = `${published_date_parts[2]}-${published_date_parts[0].padStart(2, "0")}-${published_date_parts[1].padStart(2, "0")}`; existing_blog.publish_date = formatted_date; - res.render("blogNew.ejs", { ...getDefaults(req), existing_blog: existing_blog }); + res.render(getThemePage("postNew"), { ...getDefaults(req), existing_blog: existing_blog }); } async function admin(request, response) { response.render(getThemePage("admin-settings"), { ...getDefaults(request) }); diff --git a/frontend/public/js/newBlog.js b/frontend/public/js/newBlog.js index 0a1245a..d7b1f72 100644 --- a/frontend/public/js/newBlog.js +++ b/frontend/public/js/newBlog.js @@ -6,7 +6,7 @@ let pending_thumbnail = {}; const thumbnail_area = qs(".e-thumbnail"); const image_area = qs(".e-image-area"); -const text_area = qs(".e-content textarea"); +const post_content_area = qs(".e-content textarea"); // Style function stylizeDropArea(element) { @@ -27,13 +27,13 @@ function stylizeDropArea(element) { } // Auto resize on page load -text_area.style.height = text_area.scrollHeight + "px"; -text_area.style.minHeight = text_area.scrollHeight + "px"; +post_content_area.style.height = post_content_area.scrollHeight + "px"; +post_content_area.style.minHeight = post_content_area.scrollHeight + "px"; // Auto expand blog area -text_area.addEventListener("input", (e) => { - text_area.style.height = text_area.scrollHeight + "px"; - text_area.style.minHeight = e.target.scrollHeight + "px"; +post_content_area.addEventListener("input", (e) => { + post_content_area.style.height = post_content_area.scrollHeight + "px"; + post_content_area.style.minHeight = e.target.scrollHeight + "px"; }); stylizeDropArea(thumbnail_area); @@ -204,12 +204,12 @@ qs("#insert-sup").addEventListener("click", () => textareaAction("^", undefined, function textareaAction(insert, cursor_position, dual_side) { // Insert the custom string at the cursor position - const selectionStart = text_area.selectionStart; - const selectionEnd = text_area.selectionEnd; + const selectionStart = post_content_area.selectionStart; + const selectionEnd = post_content_area.selectionEnd; - const textBefore = text_area.value.substring(0, selectionStart); - const textAfter = text_area.value.substring(selectionEnd); - const selectedText = text_area.value.substring(selectionStart, selectionEnd); + const textBefore = post_content_area.value.substring(0, selectionStart); + const textAfter = post_content_area.value.substring(selectionEnd); + const selectedText = post_content_area.value.substring(selectionStart, selectionEnd); let updatedText; @@ -219,34 +219,34 @@ function textareaAction(insert, cursor_position, dual_side) { updatedText = `${textBefore}${insert}${selectedText}${textAfter}`; } - text_area.value = updatedText; + post_content_area.value = updatedText; // Set the cursor position after the custom string qs(".e-content textarea").focus(); const newPosition = selectionStart + (cursor_position || insert.length); - text_area.setSelectionRange(newPosition, newPosition); + post_content_area.setSelectionRange(newPosition, newPosition); } -text_area.addEventListener("drop", (event) => { +post_content_area.addEventListener("drop", (event) => { event.preventDefault(); // Get the custom string from the drag data const customString = `\{image:${event.dataTransfer.getData("text/plain")}\}\n`; // Insert the custom string at the cursor position - const selectionStart = text_area.selectionStart; - const selectionEnd = text_area.selectionEnd; + const selectionStart = post_content_area.selectionStart; + const selectionEnd = post_content_area.selectionEnd; - const textBefore = text_area.value.substring(0, selectionStart); - const textAfter = text_area.value.substring(selectionEnd); + const textBefore = post_content_area.value.substring(0, selectionStart); + const textAfter = post_content_area.value.substring(selectionEnd); const updatedText = textBefore + customString + textAfter; - text_area.value = updatedText; + post_content_area.value = updatedText; // Set the cursor position after the custom string const newPosition = selectionStart + customString.length; - text_area.setSelectionRange(newPosition, newPosition); + post_content_area.setSelectionRange(newPosition, newPosition); }); // Load the existing images into our existing_images variable diff --git a/frontend/views/themes/default/css/generic.css b/frontend/views/themes/default/css/generic.css index 74d5564..c478a5a 100644 --- a/frontend/views/themes/default/css/generic.css +++ b/frontend/views/themes/default/css/generic.css @@ -85,6 +85,10 @@ body { background-color: #e70404; } +.button.caution { + background-color: #6d6d6c; +} + .button.disabled { filter: contrast(50%); filter: brightness(50%); @@ -139,6 +143,11 @@ body { margin: auto 5px auto auto; } +.separator { + border-bottom: 2px solid #b3b3b3; + margin-bottom: 1rem; +} + @media screen and (max-width: 1280px) { .page-center { width: 95%; diff --git a/frontend/views/themes/default/css/generic.scss b/frontend/views/themes/default/css/generic.scss index b0cafab..2430c7a 100644 --- a/frontend/views/themes/default/css/generic.scss +++ b/frontend/views/themes/default/css/generic.scss @@ -89,6 +89,9 @@ body { .button.bad { background-color: #e70404; } +.button.caution { + background-color: #6d6d6c; +} .button.disabled { filter: contrast(50%); filter: brightness(50%); @@ -145,6 +148,11 @@ body { margin: auto 5px auto auto; } +.separator { + border-bottom: 2px solid #b3b3b3; + margin-bottom: 1rem; +} + @media screen and (max-width: 1280px) { .page-center { width: 95%; diff --git a/frontend/views/themes/default/css/newPost.css b/frontend/views/themes/default/css/newPost.css new file mode 100644 index 0000000..23c673f --- /dev/null +++ b/frontend/views/themes/default/css/newPost.css @@ -0,0 +1,99 @@ +.page .page-center { + background-color: white; + min-height: 100px; + box-shadow: rgba(0, 0, 0, 0.1098039216) 0 0px 5px; + margin-top: 2rem; + padding: 1rem; + box-sizing: border-box; + width: 1080px; + max-width: 1080px; + flex-direction: column; + border-radius: 5px; +} +.page .page-center .page-title { + text-align: center; + font-size: 1.5rem; +} +.page .page-center .info-container { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} +.page .page-center .info-container input { + width: 100%; + padding: 0.5rem; + box-sizing: border-box; + background-color: rgb(245, 245, 245); + border: 1px solid gray; + border-radius: 5px; +} +.page .page-center .info-container textarea { + padding: 0.5rem; + box-sizing: border-box; + max-width: 100%; + min-width: 100%; + min-height: 5rem; + background-color: rgb(245, 245, 245); + border: 1px solid gray; +} +.page .page-center .side-by-side-info { + display: flex; + flex-direction: row; +} +.page .page-center .side-by-side-info input { + margin: 0 0.25rem; +} +.page .page-center .side-by-side-info .button { + padding: 0.5rem; + box-sizing: border-box; + margin: auto; +} + +.rich-text-editor .controls { + width: 100%; + min-height: 10px; + background-color: #dbd8d8; + display: flex; + flex-direction: row; +} +.rich-text-editor .controls a { + box-sizing: border-box; + margin: 0.1rem; + cursor: pointer; + background-color: #dbd8d8; + display: flex; + flex-direction: row; + width: 2rem; + height: 2rem; +} +.rich-text-editor .controls a span { + margin: auto; +} +.rich-text-editor .controls a:hover, +.rich-text-editor .controls a:focus { + filter: brightness(80%); +} +.rich-text-editor .controls .left { + margin: 0 auto 0 0; + height: 100%; + display: flex; + flex-direction: row; +} +.rich-text-editor .controls .right { + margin: 0 0 0 auto; + display: flex; + flex-direction: row; +} +.rich-text-editor .controls .right a { + padding: 2px; + box-sizing: border-box; + display: flex; + flex-direction: row; +} +.rich-text-editor .controls .right a img { + height: 20px; + margin: auto; +} +.rich-text-editor textarea { + border-radius: 0 0 5px 5px; +} \ No newline at end of file diff --git a/frontend/views/themes/default/css/newPost.scss b/frontend/views/themes/default/css/newPost.scss new file mode 100644 index 0000000..683a54c --- /dev/null +++ b/frontend/views/themes/default/css/newPost.scss @@ -0,0 +1,119 @@ +.page .page-center { + background-color: white; + min-height: 100px; + box-shadow: #0000001c 0 0px 5px; + margin-top: 2rem; + padding: 1rem; + box-sizing: border-box; + width: 1080px; + max-width: 1080px; + flex-direction: column; + border-radius: 5px; + + .page-title { + text-align: center; + font-size: 1.5rem; + } + + .info-container { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + + .title { + } + + input { + width: 100%; + padding: 0.5rem; + box-sizing: border-box; + background-color: rgb(245, 245, 245); + border: 1px solid gray; + border-radius: 5px; + } + textarea { + padding: 0.5rem; + box-sizing: border-box; + max-width: 100%; + min-width: 100%; + min-height: 5rem; + background-color: rgb(245, 245, 245); + border: 1px solid gray; + // border-radius: 5px; + } + } + + .side-by-side-info { + display: flex; + flex-direction: row; + + input { + margin: 0 0.25rem; + } + + .button { + padding: 0.5rem; + box-sizing: border-box; + margin: auto; + } + } +} + +.rich-text-editor { + .controls { + width: 100%; + min-height: 10px; + background-color: #dbd8d8; + display: flex; + flex-direction: row; + + a { + box-sizing: border-box; + margin: 0.1rem; + cursor: pointer; + background-color: #dbd8d8; + display: flex; + flex-direction: row; + width: 2rem; + height: 2rem; + + span { + margin: auto; + } + } + + a:hover, + a:focus { + filter: brightness(80%); + } + + .left { + margin: 0 auto 0 0; + height: 100%; + display: flex; + flex-direction: row; + } + + .right { + margin: 0 0 0 auto; + display: flex; + flex-direction: row; + + a { + padding: 2px; + box-sizing: border-box; + display: flex; + flex-direction: row; + + img { + height: 20px; + margin: auto; + } + } + } + } + + textarea { + border-radius: 0 0 5px 5px; + } +} diff --git a/frontend/views/themes/default/css/post.css b/frontend/views/themes/default/css/post.css index 9399d19..8a63dd4 100644 --- a/frontend/views/themes/default/css/post.css +++ b/frontend/views/themes/default/css/post.css @@ -1,6 +1,5 @@ .page .page-center { background-color: white; - min-height: 100px; box-shadow: rgba(0, 0, 0, 0.1098039216) 0 0px 5px; margin-top: 2rem; padding: 1rem; @@ -19,4 +18,10 @@ } .page .page-center .image-container img { max-width: 100%; +} +.page .page-center .horizontal-button-container { + width: 100%; +} +.page .page-center .horizontal-button-container button { + height: 2rem; } \ No newline at end of file diff --git a/frontend/views/themes/default/css/post.scss b/frontend/views/themes/default/css/post.scss index a7f1557..49e54f2 100644 --- a/frontend/views/themes/default/css/post.scss +++ b/frontend/views/themes/default/css/post.scss @@ -1,6 +1,6 @@ .page .page-center { background-color: white; - min-height: 100px; + // min-height: 100px; box-shadow: #0000001c 0 0px 5px; margin-top: 2rem; padding: 1rem; @@ -22,4 +22,12 @@ max-width: 100%; } } + + .horizontal-button-container { + width: 100%; + + button { + height: 2rem; + } + } } diff --git a/frontend/views/themes/default/ejs/partials/post.ejs b/frontend/views/themes/default/ejs/partials/post.ejs index f114bdd..10f4695 100644 --- a/frontend/views/themes/default/ejs/partials/post.ejs +++ b/frontend/views/themes/default/ejs/partials/post.ejs @@ -1,12 +1,11 @@