Skip to content
This repository was archived by the owner on Apr 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/models/BlogTag.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ component extends="app.Models.Model" {
property(name="updatedAt", column="updatedat", dataType="datetime", defaultValue = "");
property(name="deletedAt", column="deletedat", dataType="datetime", defaultValue = "");

property(name="blogId", column="blog_id", dataType="integer");
property(name="blogId", column="blog_id", dataType="string");

// Associations
belongsTo(name="Blog", foreignKey="blogId");
belongsTo(name="Blog", foreignKey="blogId");
belongsTo(name="Tag", foreignKey="tagId");
}

Expand Down
113 changes: 112 additions & 1 deletion app/views/helpers.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,115 @@
var emailHash = lcase(hash(lcase(trim(arguments.email)), "MD5"));
return "https://www.gravatar.com/avatar/" & emailHash & "?s=" & arguments.size & "&d=404";
}
</cfscript>

/**
* 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 = '<iframe width="#arguments.width#" height="#arguments.height#" src="https://www.youtube.com/embed/#youtubeId#?rel=0" frameborder="0" allowfullscreen style="max-width: 100%; margin: 1rem 0; border-radius: 0.5rem;"></iframe>';
}
}
// Twitter/X
else if (findNoCase("twitter.com", trimmedUrl) || findNoCase("x.com", trimmedUrl)) {
embedHtml = '<blockquote class="twitter-tweet" style="margin: 1rem 0;"><a href="#trimmedUrl#"></a></blockquote><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>';
}

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 = '<a href="' & link & '" class="' & arguments.class & '" target="' & arguments.target & '">' & link & '</a>';
result = replace(result, link, linkHtml, "all");
}
}

return result;
}
</cfscript>
49 changes: 25 additions & 24 deletions app/views/web/BlogController/show.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
<cfif isLoggedInUser() AND (isUserAdmin() OR session.userID EQ blog.createdBy)>
<a href="/blog/edit/#blog.id#" class="btn bg--primary text-white rounded-3" id="editBlogBtn">
<i class="bi bi-pencil"></i> Edit
</a>
</a>
<cfif len(trim(blog.publishedAt)) AND blog.publishedAt LTE now()>
<button
<button
class="btn btn-danger rounded-3"
hx-post="/blog/unpublish"
hx-vals='{"id": "#blog.id#"}'
Expand Down Expand Up @@ -61,25 +61,25 @@
<!-- Post status + Categories -->
<cfif categories.recordCount GT 0>
<p class="fw-medium fs-12 text--lightGray mb-0">
#blog.PostStatus.name# in
#blog.PostStatus.name# in
<cfoutput query="categories">
<strong
<strong
class="text--primary"
style="cursor: pointer;"
hx-get="#urlFor(route="blogsFilter", filterType="category", filterValue="#REReplace(name, '\.', '-', 'all')#")#"
hx-target="body"
hx-swap="outerHTML"
hx-target="body"
hx-swap="outerHTML"
hx-push-url="true"
>#name#</strong><cfif currentrow LT recordcount>, </cfif>
</cfoutput>
</p>
</cfif>
<p class="fw-medium fs-12 text--lightGray mb-0">
Posted By:
<strong
Posted By:
<strong
class="text--primary"
style="cursor: pointer;"
hx-get="/blog/author/#blog.userusername#"
hx-get="/blog/author/#blog.userusername#"
hx-target="body"
hx-push-url="true"
hx-swap="outerHTML"
Expand All @@ -88,12 +88,12 @@
<!-- Tags -->
<cfif tags.recordCount GT 0>
<p class="fw-medium fs-12 text--lightGray mb-0">
Tags:
Tags:
<cfoutput query="tags">
<strong
<strong
class="text--primary"
style="cursor: pointer;"
hx-get="#urlFor(route="blogsFilter", filterType="tag", filterValue="#REReplace(name, '\.', '-', 'all')#")#"
hx-get="#urlFor(route="blogsFilter", filterType="tag", filterValue="#REReplace(name, '\.', '-', 'all')#")#"
hx-target="body"
hx-push-url="true"
hx-swap="outerHTML"
Expand All @@ -116,7 +116,7 @@
<cfoutput>#encodeForHTML(blog.content)#</cfoutput>
</div>
<cfelse>
#this.autoLink(blog.content,"text--primary")#
#embedAndAutoLink(blog.content,"text--primary")#
</cfif>
</div>
</div>
Expand All @@ -125,8 +125,8 @@
<div id="comment">
<cfoutput query="comments">
<div class="mt-4">
<div class="position-relative">
<cfif commentParentId eq '' or commentParentId eq 0>
<div class="position-relative">
<cfif commentParentId eq '' or commentParentId eq 0>
<div class="d-flex align-items-start gap-3">
<div>
<img src="#gravatarUrl(email, 96)#&d=404"
Expand All @@ -138,7 +138,7 @@
style="width:3rem;height:3rem;">
#ucase(left(listLast(fullName, " "), 1))#
</div>
</div>
</div>
<div class="p-3 rounded-4 flex-grow-1 bg-light">
<h6 class="fs-16 fw-bold">#fullName#</h6>
<cfif findNoCase("```", content) OR findNoCase("##", content) OR findNoCase("**", content) OR findNoCase("__", content) OR findNoCase(">", content)>
Expand All @@ -151,7 +151,7 @@
<div class="d-flex cursor-pointer align-items-center gap-2">
<a onclick="handleReply(#Id#)" class="fs-14 text--primary mb-0" data-commentid="#Id#" data-blogid="#blog.Id#">Reply</a>

<svg width="18" height="14" viewBox="0 0 18 14" fill="none"
<svg width="18" height="14" viewBox="0 0 18 14" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M1.42013 6.05185L5.66612 10.2979C5.76346 10.3952 5.81446 10.5099 5.81912 10.6419C5.82379 10.7745 5.77079 10.8959 5.66013 11.0059C5.55013 11.1125 5.43246 11.1665 5.30712 11.1679C5.18112 11.1699 5.06313 11.1159 4.95312 11.0059L0.565125 6.61685C0.477792 6.53019 0.416458 6.44119 0.381125 6.34985C0.345792 6.25919 0.328125 6.15985 0.328125 6.05185C0.328125 5.94385 0.345792 5.84452 0.381125 5.75385C0.416458 5.66319 0.477792 5.57419 0.565125 5.48685L4.95312 1.09785C5.04646 1.00452 5.16013 0.954521 5.29413 0.947854C5.42812 0.941187 5.55046 0.991187 5.66112 1.09785C5.77112 1.20785 5.82612 1.32685 5.82612 1.45485C5.82612 1.58285 5.77112 1.70185 5.66112 1.81185L1.42013 6.05185ZM6.03612 6.55185L9.78213 10.2979C9.87946 10.3952 9.93046 10.5099 9.93513 10.6419C9.93913 10.7745 9.88612 10.8959 9.77612 11.0059C9.66613 11.1125 9.54813 11.1665 9.42212 11.1679C9.29612 11.1692 9.17812 11.1152 9.06812 11.0059L4.68013 6.61685C4.59279 6.53019 4.53146 6.44119 4.49613 6.34985C4.46079 6.25919 4.44312 6.15985 4.44312 6.05185C4.44312 5.94385 4.46079 5.84452 4.49613 5.75385C4.53146 5.66319 4.59279 5.57419 4.68013 5.48685L9.06812 1.09785C9.16146 1.00452 9.27512 0.954521 9.40912 0.947854C9.54379 0.941187 9.66613 0.991187 9.77612 1.09785C9.88612 1.20785 9.94113 1.32685 9.94113 1.45485C9.94113 1.58285 9.88612 1.70185 9.77612 1.81185L6.03612 5.55185H13.4991C14.7418 5.55185 15.8025 5.99119 16.6811 6.86985C17.5598 7.74852 17.9991 8.80919 17.9991 10.0519V12.5519C17.9991 12.6945 17.9515 12.8135 17.8561 12.9089C17.7608 13.0042 17.6418 13.0519 17.4991 13.0519C17.3565 13.0519 17.2375 13.0042 17.1421 12.9089C17.0468 12.8135 16.9991 12.6945 16.9991 12.5519V10.0519C16.9991 9.09052 16.6561 8.26685 15.9701 7.58085C15.2841 6.89485 14.4605 6.55185 13.4991 6.55185H6.03612Z"
Expand Down Expand Up @@ -195,7 +195,7 @@
style="width:3rem;height:3rem;">
#ucase(left(listLast(session.username, " "), 1))#
</div>
</div>
</div>
<div class="p-3 rounded-4 flex-grow-1 bg-light">
<h6 class="fs-16 fw-bold">#session.username#</h6>

Expand All @@ -216,7 +216,7 @@
</div>
</cfoutput>
</div>

<cfif isLoggedInUser() AND canUserComment()>
<form hx-target="##comment" hx-on:htmx:after-request="handleClear()" hx-swap="beforeend" id="commentForm" hx-post="/blog/comment" class="pt-3 px-1 needs-validation" novalidate hx-validate="true">
<div class="d-flex gap-3 align-items-start">
Expand Down Expand Up @@ -262,7 +262,7 @@
</div>
</div>
</cfoutput>

<div class="pt-5 blog-main px-2">
<div class="d-flex align-items-center justify-content-between swiper-buttons position-relative">
<!-- Left Button -->
Expand All @@ -277,12 +277,12 @@

<div class="swiper py-5 blogSwiper h-max">
<div class="swiper-wrapper" id="blogs-container" hx-get="/home/loadBlogs" hx-trigger="load" hx-target="#blogs-container" hx-swap="innerHTML">

</div>
</div>
</div>
<!-- Scroll to Top Button -->
<button id="scrollToTopBtn" class="position-fixed bottom-0 end-0 m-4 btn bg--primary text-white rounded-circle"
<button id="scrollToTopBtn" class="position-fixed bottom-0 end-0 m-4 btn bg--primary text-white rounded-circle"
style="width: 50px; height: 50px; display: none; z-index: 99; border: none; padding: 0;">
<i class="bi bi-arrow-up fs-20"></i>
</button>
Expand Down Expand Up @@ -316,7 +316,7 @@
}
}
}

// Show/hide scroll to top button
if (window.scrollY > 300) {
scrollToTopBtn.style.display = 'block';
Expand All @@ -334,4 +334,5 @@
});

</script>
<script src="/js/showBlog.js"></script>
<script src="/js/embedHelper.js"></script>
<script src="/js/showBlog.js"></script>
1 change: 1 addition & 0 deletions public/javascripts/embedHelper.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading