diff --git a/app/controllers/Controller.cfc b/app/controllers/Controller.cfc index 9194233..e55fb02 100644 --- a/app/controllers/Controller.cfc +++ b/app/controllers/Controller.cfc @@ -751,4 +751,113 @@ component extends="wheels.Controller" { rethrow; } } + + /** + * Extracts YouTube video ID from various YouTube URL formats + * Supports: https://youtube.com/watch?v=xyz, https://youtu.be/xyz, https://www.youtube.com/embed/xyz + */ + string function extractYouTubeId(required string url) { + var id = ""; + // youtu.be format + if (findNoCase("youtu.be/", arguments.url)) { + id = listLast(arguments.url, "/"); + // Remove any query parameters + if (findNoCase("?", id)) { + id = listFirst(id, "?"); + } + } + // youtube.com/watch?v= format + else if (findNoCase("youtube.com", arguments.url) && findNoCase("v=", arguments.url)) { + var params = listLast(arguments.url, "?"); + var paramList = listToArray(params, "&"); + for (var param in paramList) { + if (findNoCase("v=", param)) { + id = listLast(param, "="); + break; + } + } + } + // Already embed format + else if (findNoCase("youtube.com/embed/", arguments.url)) { + id = listLast(listFirst(arguments.url, "?"), "/"); + } + return trim(id); + } + + /** + * Detects if a URL is embeddable and returns embed HTML + * Supports: YouTube, Twitter + */ + string function getEmbedHtml(required string url, string width="100%", string height="400") { + var embedHtml = ""; + var youtubeId = ""; + var vimeoId = ""; + var trimmedUrl = trim(arguments.url); + + // YouTube + if (findNoCase("youtube.com", trimmedUrl) || findNoCase("youtu.be", trimmedUrl)) { + youtubeId = extractYouTubeId(trimmedUrl); + if (len(youtubeId)) { + embedHtml = ''; + } + } + // Twitter/X + else if (findNoCase("twitter.com", trimmedUrl) || findNoCase("x.com", trimmedUrl)) { + embedHtml = '
'; + } + + return embedHtml; + } + + /** + * Checks if a URL is embeddable + */ + boolean function isEmbeddableUrl(required string url) { + var embeddableDomains = ["youtube.com", "youtu.be", "twitter.com", "x.com"]; + var trimmedUrl = lcase(trim(arguments.url)); + + for (var domain in embeddableDomains) { + if (findNoCase(domain, trimmedUrl)) { + return true; + } + } + return false; + } + + /** + * Converts plain text URLs and embeddable links into HTML + * For embeddable URLs (YouTube, Vimeo, etc.), creates embed iframes + * For other URLs, creates anchor tags + */ + string function embedAndAutoLink(required string content, string class="text--primary", string target="_blank") { + var result = arguments.content; + var urlPattern = "(https?://[^\s<""'`]+)"; + var matches = reMatch(urlPattern, result); + + // Remove duplicates + var uniqueUrls = {}; + for (var match in matches) { + var cleanUrl = trim(match); + // Skip if it's already part of an href or src + if (!findNoCase("href='#cleanUrl#", result) && !findNoCase('href="' & cleanUrl & '"', result) && !findNoCase("src='#cleanUrl#", result) && !findNoCase('src="' & cleanUrl & '"', result)) { + uniqueUrls[cleanUrl] = cleanUrl; + } + } + + // Replace each unique URL + for (var link in uniqueUrls) { + if (isEmbeddableUrl(link)) { + var embedCode = getEmbedHtml(link); + if (len(embedCode)) { + result = replace(result, link, embedCode, "all"); + } + } else { + // Regular link + var linkHtml = '' & link & ''; + result = replace(result, link, linkHtml, "all"); + } + } + + return result; + } } diff --git a/app/controllers/web/BlogController.cfc b/app/controllers/web/BlogController.cfc index 056c03a..d171461 100644 --- a/app/controllers/web/BlogController.cfc +++ b/app/controllers/web/BlogController.cfc @@ -546,6 +546,9 @@ component extends="app.Controllers.Controller" { Throw("Blog not found"); } + // Process embeds in content + blog.content = embedAndAutoLink(blog.content); + // Set blog post data for layout meta tags (avoids DB query in view) request.blogPostForMeta = blog; diff --git a/public/javascripts/createBlog.js b/public/javascripts/createBlog.js index ad517de..dfaae61 100644 --- a/public/javascripts/createBlog.js +++ b/public/javascripts/createBlog.js @@ -1 +1 @@ -document.addEventListener("DOMContentLoaded",(function(){const e=new EasyMDE({element:document.getElementById("editor"),spellChecker:!1,status:!1,autofocus:!0,toolbar:["bold","italic","heading","|","quote","unordered-list","ordered-list","|","link","image","code","|","preview","side-by-side","fullscreen","|","guide"],uploadImage:!0,imageUploadEndpoint:"/blog/upload",imageMaxSize:3145728,imageAccept:"image/png, image/jpeg, image/gif, image/webp",imageTexts:{sbInit:"Upload an image",sbOnDragEnter:"Drop image here",sbOnDrop:"Uploading...",sbProgress:"Uploading...",sbOnUploaded:"Uploaded!",sizeUnits:"b,Kb,Mb"},previewRender:function(e){return marked.parse(e)},theme:"github-light",sideBySideFullscreen:!1,maxHeight:"500px",minHeight:"500px",placeholder:"Write your blog post here...",shortcuts:{toggleSideBySide:"Ctrl-Alt-P",toggleFullScreen:"Ctrl-Alt-F",togglePreview:"Ctrl-Alt-V",toggleBold:"Ctrl-B",toggleItalic:"Ctrl-I",drawLink:"Ctrl-K",drawImage:"Ctrl-G",drawTable:"Ctrl-T",toggleHeadingSmaller:"Ctrl-H",toggleHeadingBigger:"Ctrl-Shift-H",toggleUnorderedList:"Ctrl-L",toggleOrderedList:"Ctrl-Alt-L",toggleCodeBlock:"Ctrl-Alt-C",toggleBlockquote:"Ctrl-Q",togglePreview:"Ctrl-P"}});function t(){const t=e.value();document.getElementById("content").value=t}t(),e.codemirror.on("change",(function(){const t=e.value();document.getElementById("content").value=t})),document.addEventListener("htmx:configRequest",(function(e){"blogForm"===e.detail.elt.id&&(t(),e.detail.parameters.content=document.getElementById("content").value)})),document.getElementById("blogForm").addEventListener("htmx:beforeRequest",(function(e){t()})),document.getElementById("saveDraftBtn").addEventListener("click",(function(){e&&e.codemirror.save(),t(),document.getElementById("isDraft").value="1",document.getElementById("blogForm").requestSubmit()})),document.getElementById("blogForm").addEventListener("submit",(function(n){t();let i=document.getElementById("content").value;var o=!0;const l=document.getElementById("title"),r=document.getElementById("categoryId"),d=document.getElementById("posttypeId"),a=document.getElementById("postTags"),s=document.getElementById("editor"),c=document.getElementById("tagInput"),u="1"===document.getElementById("isDraft").value;[l,r,d,c].forEach((e=>e.classList.remove("is-invalid"))),s.classList.remove("border-danger");const m=document.getElementById("titleExists");if(m&&"1"!==m.value&&""!==l.value.trim()){n.preventDefault();const t=document.getElementById("id").value||"",i=new FormData;i.append("title",l.value.trim()),t&&i.append("id",t);var g=document.querySelector('input[name="authenticityToken"]');return g&&i.append("authenticityToken",g.value),fetch("/blog/check-title",{method:"POST",body:i}).then((e=>e.text())).then((t=>{document.getElementById("title-message").innerHTML=t;const n=document.getElementById("titleExists");if(n&&"1"===n.value)return l.classList.add("is-invalid"),notifier.show("Error!","A blog post already exist with this title.","","/img/high_priority-48.png",4e3),!1;t(),e.submit()})),!1}return m&&"1"===m.value&&(o=!1,l.classList.add("is-invalid")),o?(u||(""===l.value.trim()&&(o=!1,l.classList.add("is-invalid")),""===r.value.trim()&&(o=!1,r.classList.add("is-invalid")),""===d.value.trim()&&(o=!1,d.classList.add("is-invalid")),""===a.value.trim()&&(o=!1,c.classList.add("is-invalid"),c.classList.remove("border-0")),""===i.trim()&&(o=!1,s.classList.add("border-danger"))),o?void t():(n.preventDefault(),notifier.show("Error!","Please fill out all required fields.","","/img/high_priority-48.png",4e3),!1)):(n.preventDefault(),n.stopPropagation(),notifier.show("Error!","A blog post already exist with this title.","","/img/high_priority-48.png",4e3),!1)})),document.addEventListener("htmx:afterSwap",(function(e){"categoryId"===e.target.id&&$("#categoryId").select2({placeholder:"Select Categories",theme:"bootstrap-5",width:$(this).data("width")?$(this).data("width"):($(this).hasClass("w-100"),"100%"),maximumSelectionLength:5})})),$("#categoryId").select2({placeholder:"Select Categories",theme:"bootstrap-5",width:$(this).data("width")?$(this).data("width"):($(this).hasClass("w-100"),"100%"),maximumSelectionLength:5});const n=document.getElementById("tagContainer"),i=document.getElementById("tagInput"),o=document.getElementById("postTags");let l=[];function r(e){const t=document.createElement("span");t.classList.add("tag","cursor-pointer"),t.innerHTML=`${e} `,t.querySelector(".remove-tag").addEventListener("click",(function(){!function(e){l=l.filter((t=>t!==e)),a(),d()}(e)})),n.insertBefore(t,i)}function d(){n.querySelectorAll(".tag").forEach((e=>e.remove())),l.forEach((e=>r(e)))}function a(){o.value=l.join(",")}o.value&&(l=o.value.split(","),d()),i.addEventListener("keydown",(function(e){if((","===e.key||"Enter"===e.key)&&l.length<5){e.preventDefault();let t=i.value.trim().replace(/,/g,"");""===t||l.includes(t)||(l.push(t),r(t),a(),i.value="")}else l.length>=5&&e.preventDefault()}));const s=document.getElementById("title");s&&s.addEventListener("blur",(function(){const e=this.value.trim();if(e){const n=document.getElementById("id").value||"",i=new FormData;i.append("title",e),n&&i.append("id",n);var t=document.querySelector('input[name="authenticityToken"]');t&&i.append("authenticityToken",t.value),fetch("/blog/check-title",{method:"POST",body:i}).then((e=>{if(!e.ok)throw new Error("Server error: "+e.status);return e.text()})).then((e=>{document.getElementById("title-message").innerHTML=e;const t=document.getElementById("titleExists");t&&"1"===t.value?s.classList.add("is-invalid"):s.classList.remove("is-invalid")})).catch((e=>{console.error("Error checking title:",e),document.getElementById("title-message").innerHTML='Error checking title availability'}))}else document.getElementById("title-message").innerHTML=""})),document.body.addEventListener("htmx:afterRequest",(function(e){const t=e.detail.xhr;500===t.status&&t.responseURL.includes("/blog/store")&¬ifier.show("Error","Something went wrong!","danger","",5e3),200===t.status&&t.responseURL.includes("/blog/store")&&(notifier.show("Success",t.responseText,"success","",5e3),setTimeout((function(){}),4e3))}))})),document.addEventListener("DOMContentLoaded",(function(){let e=document.getElementById("blogForm"),t=document.getElementById("editLoader");e.addEventListener("htmx:configRequest",(function(){t&&(t.style.display="block")})),document.body.addEventListener("htmx:afterRequest",(function(e){let n=e.detail.xhr;if(t&&(t.style.display="none"),200===n.status&&n.responseText.trim()){let e;try{e=JSON.parse(n.responseText)}catch(t){e={}}notifier.show("Success!","Blog Updated successfully.","success","",2e3),e.REDIRECTURL&&setTimeout((()=>{window.location.href=e.REDIRECTURL}),2e3)}else notifier.show("Error!","Something went wrong.","danger","",5e3)}))})); \ No newline at end of file +document.addEventListener("DOMContentLoaded",(function(){const e=new EasyMDE({element:document.getElementById("editor"),spellChecker:!1,status:!1,autofocus:!0,toolbar:["bold","italic","heading","|","quote","unordered-list","ordered-list","|","link","image","code","|","preview","side-by-side","fullscreen","|","guide"],uploadImage:!0,imageUploadEndpoint:"/blog/upload",imageMaxSize:3145728,imageAccept:"image/png, image/jpeg, image/gif, image/webp",imageTexts:{sbInit:"Upload an image",sbOnDragEnter:"Drop image here",sbOnDrop:"Uploading...",sbProgress:"Uploading...",sbOnUploaded:"Uploaded!",sizeUnits:"b,Kb,Mb"},previewRender:function(e){return marked.parse(e)},theme:"github-light",sideBySideFullscreen:!1,maxHeight:"500px",minHeight:"500px",placeholder:"Write your blog post here...",shortcuts:{toggleSideBySide:"Ctrl-Alt-P",toggleFullScreen:"Ctrl-Alt-F",togglePreview:"Ctrl-Alt-V",toggleBold:"Ctrl-B",toggleItalic:"Ctrl-I",drawLink:"Ctrl-K",drawImage:"Ctrl-G",drawTable:"Ctrl-T",toggleHeadingSmaller:"Ctrl-H",toggleHeadingBigger:"Ctrl-Shift-H",toggleUnorderedList:"Ctrl-L",toggleOrderedList:"Ctrl-Alt-L",toggleCodeBlock:"Ctrl-Alt-C",toggleBlockquote:"Ctrl-Q",togglePreview:"Ctrl-P"}});function t(){const t=e.value();document.getElementById("content").value=t}t(),e.codemirror.on("change",(function(){const t=e.value();document.getElementById("content").value=t})),document.addEventListener("htmx:configRequest",(function(e){"blogForm"===e.detail.elt.id&&(t(),e.detail.parameters.content=document.getElementById("content").value)})),document.getElementById("blogForm").addEventListener("htmx:beforeRequest",(function(e){t()})),document.getElementById("saveDraftBtn").addEventListener("click",(function(){e&&e.codemirror.save(),t(),document.getElementById("isDraft").value="1",document.getElementById("blogForm").requestSubmit()})),document.getElementById("blogForm").addEventListener("submit",(function(n){t();let i=document.getElementById("content").value;var o=!0;const l=document.getElementById("title"),r=document.getElementById("categoryId"),d=document.getElementById("posttypeId"),a=document.getElementById("postTags"),s=document.getElementById("editor"),c=document.getElementById("tagInput"),u="1"===document.getElementById("isDraft").value;[l,r,d,c].forEach((e=>e.classList.remove("is-invalid"))),s.classList.remove("border-danger");const m=document.getElementById("titleExists");if(m&&"1"!==m.value&&""!==l.value.trim()){n.preventDefault();const t=document.getElementById("id").value||"",i=new FormData;i.append("title",l.value.trim()),t&&i.append("id",t);var g=document.querySelector('input[name="authenticityToken"]');return g&&i.append("authenticityToken",g.value),fetch("/blog/check-title",{method:"POST",body:i}).then((e=>e.text())).then((t=>{document.getElementById("title-message").innerHTML=t;const n=document.getElementById("titleExists");if(n&&"1"===n.value)return l.classList.add("is-invalid"),notifier.show("Error!","A blog post already exist with this title.","","/img/high_priority-48.png",4e3),!1;t(),e.submit()})),!1}return m&&"1"===m.value&&(o=!1,l.classList.add("is-invalid")),o?(u||(""===l.value.trim()&&(o=!1,l.classList.add("is-invalid")),""===r.value.trim()&&(o=!1,r.classList.add("is-invalid")),""===d.value.trim()&&(o=!1,d.classList.add("is-invalid")),""===a.value.trim()&&(o=!1,c.classList.add("is-invalid"),c.classList.remove("border-0")),""===i.trim()&&(o=!1,s.classList.add("border-danger"))),o?void t():(n.preventDefault(),notifier.show("Error!","Please fill out all required fields.","","/img/high_priority-48.png",4e3),!1)):(n.preventDefault(),n.stopPropagation(),notifier.show("Error!","A blog post already exist with this title.","","/img/high_priority-48.png",4e3),!1)})),document.addEventListener("htmx:afterSwap",(function(e){"categoryId"===e.target.id&&$("#categoryId").select2({placeholder:"Select Categories",theme:"bootstrap-5",width:$(this).data("width")?$(this).data("width"):($(this).hasClass("w-100"),"100%"),maximumSelectionLength:5})})),$("#categoryId").select2({placeholder:"Select Categories",theme:"bootstrap-5",width:$(this).data("width")?$(this).data("width"):($(this).hasClass("w-100"),"100%"),maximumSelectionLength:5});const n=document.getElementById("tagContainer"),i=document.getElementById("tagInput"),o=document.getElementById("postTags");let l=[];function r(e){const t=document.createElement("span");t.classList.add("tag","cursor-pointer"),t.innerHTML=`${e} `,t.querySelector(".remove-tag").addEventListener("click",(function(){!function(e){l=l.filter((t=>t!==e)),a(),d()}(e)})),n.insertBefore(t,i)}function d(){n.querySelectorAll(".tag").forEach((e=>e.remove())),l.forEach((e=>r(e)))}function a(){o.value=l.join(",")}o.value&&(l=o.value.split(","),d()),i.addEventListener("keydown",(function(e){if((","===e.key||"Enter"===e.key)&&l.length<5){e.preventDefault();let t=i.value.trim().replace(/,/g,"");""===t||l.includes(t)||(l.push(t),r(t),a(),i.value="")}else l.length>=5&&e.preventDefault()}));const s=document.getElementById("title");s&&s.addEventListener("blur",(function(){const e=this.value.trim();if(e){const n=document.getElementById("id").value||"",i=new FormData;i.append("title",e),n&&i.append("id",n);var t=document.querySelector('input[name="authenticityToken"]');t&&i.append("authenticityToken",t.value),fetch("/blog/check-title",{method:"POST",body:i}).then((e=>{if(!e.ok)throw new Error("Server error: "+e.status);return e.text()})).then((e=>{document.getElementById("title-message").innerHTML=e;const t=document.getElementById("titleExists");t&&"1"===t.value?s.classList.add("is-invalid"):s.classList.remove("is-invalid")})).catch((e=>{console.error("Error checking title:",e),document.getElementById("title-message").innerHTML='Error checking title availability'}))}else document.getElementById("title-message").innerHTML=""})),document.body.addEventListener("htmx:afterRequest",(function(e){const t=e.detail.xhr;500===t.status&&t.responseURL.includes("/blog/store")&¬ifier.show("Error","Something went wrong!","danger","",5e3),200===t.status&&t.responseURL.includes("/blog/store")&&(notifier.show("Success",t.responseText,"success","",5e3),setTimeout((function(){window.location.href="/blog"}),4e3))}))})),document.addEventListener("DOMContentLoaded",(function(){let e=document.getElementById("blogForm"),t=document.getElementById("editLoader");e.addEventListener("htmx:configRequest",(function(){t&&(t.style.display="block")})),document.body.addEventListener("htmx:afterRequest",(function(e){let n=e.detail.xhr;if(t&&(t.style.display="none"),200===n.status&&n.responseText.trim()){let e;try{e=JSON.parse(n.responseText)}catch(t){e={}}notifier.show("Success!","Blog Updated successfully.","success","",2e3),e.REDIRECTURL&&setTimeout((()=>{window.location.href=e.REDIRECTURL}),2e3)}else notifier.show("Error!","Something went wrong.","danger","",5e3)}))})); \ No newline at end of file