implemented following users to receive (opt-in) email notifications when they publish an article.

This commit is contained in:
2025-04-14 22:32:32 -06:00
parent fdcc1a3349
commit 420022dbf8
11 changed files with 248 additions and 82 deletions
+46 -37
View File
@@ -5,6 +5,7 @@ require_once 'functions/functions.php';
include_once 'components/head.php';
include_once 'functions/article_functions.php';
include_once 'functions/account_functions.php';
// UI Components
foreach (glob('components/article/*.php') as $file)
@@ -22,49 +23,57 @@ if (isset($_POST['form_id'])) {
header('Location: articles.php?article_id=' . $_POST['article_id']);
} else {
$id = create_article($_SESSION['user_id'], $_POST['title'], $_POST['excerpt'], (isset($_POST['tags']) ? $_POST['tags'] : []), $_POST['markdown']);
$emails = exec_stmt('SELECT email FROM user INNER JOIN follows ON user.user_id = follows.follower_user_id WHERE follows.following_user_id = ? AND follows.notify_user = 1', 'i', $_SESSION['user_id'])->fetch_assoc();
notify_users($emails, $_SESSION['username'], $id, $_POST['title'], $_POST['excerpt']);
header('Location: articles.php?article_id=' . $id);
}
break;
case 'follow':
follow_user($_SESSION['user_id'], $_POST['author_id'], ($_POST['follow'] == 'email_notify' ? 1 : 0));
break;
case 'unfollow':
unfollow_user($_SESSION['user_id'], $_POST['author_id']);
break;
}
} else {
if (isset($_GET['view']))
$view = $_GET['view'];
else
$view = 'list';
}
switch ($view) {
case 'list':
echo article_list();
break;
case 'filter':
if (isset($_GET['tag']))
echo article_list(article_ids_by_tag($_GET['tag']));
else if (isset($_GET['author_id']))
echo article_list(articles_ids_by_author($_GET['author_id']));
break;
case 'sort':
if (isset($_GET['sort']) && $_GET['sort'] == 'oldest')
echo article_list(article_ids_by_oldest());
else if (isset($_GET['sort']) && $_GET['sort'] == 'newest')
echo article_list(article_ids_by_newest());
break;
case 'read':
if (isset($_SESSION['user_id']) && isset($_GET['article_id'])) {
increment_read_counter($_GET['article_id']);
echo article_page_from_markdown($_GET['article_id']);
} else {
$redirect = (isset($_GET['article_id']) ? 'articles.php?view=read&article_id=' . $_GET['article_id'] : '');
if ($redirect)
header('Location: user.php?view=login&redirect=' . urlencode($redirect));
else
header('Location: articles.php');
}
break;
case 'compose':
echo compose_view((isset($_GET['article_id']) ? $_GET['article_id'] : ''));
break;
}
if (isset($_GET['view']))
$view = $_GET['view'];
else
$view = 'list';
switch ($view) {
case 'list':
echo article_list();
break;
case 'filter':
if (isset($_GET['tag']))
echo article_list(article_ids_by_tag($_GET['tag']));
else if (isset($_GET['author_id']))
echo article_list(articles_ids_by_author($_GET['author_id']));
break;
case 'sort':
if (isset($_GET['sort']) && $_GET['sort'] == 'oldest')
echo article_list(article_ids_by_oldest());
else if (isset($_GET['sort']) && $_GET['sort'] == 'newest')
echo article_list(article_ids_by_newest());
break;
case 'read':
if (isset($_SESSION['user_id']) && isset($_GET['article_id'])) {
increment_read_counter($_GET['article_id']);
echo article_page_from_markdown($_GET['article_id']);
} else {
$redirect = (isset($_GET['article_id']) ? 'articles.php?view=read&article_id=' . $_GET['article_id'] : '');
if ($redirect)
header('Location: user.php?view=login&redirect=' . urlencode($redirect));
else
header('Location: articles.php');
}
break;
case 'compose':
echo compose_view((isset($_GET['article_id']) ? $_GET['article_id'] : ''));
break;
}
if (($view == 'list' || $view == 'sort') && (isset($_SESSION['role_id']) && $_SESSION['role_id'] <= 3)) {
+36 -4
View File
@@ -8,6 +8,7 @@ function article_page_from_markdown($article_id)
$article = exec_stmt('SELECT * FROM article WHERE article_id = ?', 'i', $article_id)->fetch_assoc();
$author = exec_stmt('SELECT user_id, username, profile_picture FROM user WHERE user_id = ?', 'i', $article['author_id'])->fetch_assoc();
$markdown = read_file_one_string($_ENV['ARTICLES_FQ_PATH'] . $article_id . '/article.md');
$following = exec_stmt('SELECT follower_user_id, following_user_id FROM follows WHERE follower_user_id = ? AND following_user_id = ?', 'ii', $_SESSION['user_id'], $author['user_id'])->fetch_assoc();
$parsedown = new Parsedown();
$parsedown->setSafeMode(true);
@@ -21,10 +22,41 @@ function article_page_from_markdown($article_id)
' . (isset($_SESSION['user_id']) && $_SESSION['user_id'] == $article['author_id'] ? '<a href="articles.php?view=compose&article_id=' . $article_id . '"><i class="nf nf-fa-edit"></i></a>' : '') . '
</div>
<div class="row">
<div class="col_right">
<h5>Written by <a href="user.php?action=view&user_id=' . $author['user_id'] . '">' . $author['username'] . '</a></h5>
<p>Published on ' . $article['published_at'] . '<br>
Lasted edited on ' . $article['updated_at'] . '</p>
<div class="row">
<div class="col_right">
<h5>Written by <a href="user.php?view=display&user_id=' . $author['user_id'] . '">' . $author['username'] . '</a></h5>
<p>Published on ' . $article['published_at'] . '<br>
Lasted edited on ' . $article['updated_at'] . '</p>
</div>
';
if ($_SESSION['user_id'] != $author['user_id'] && !$following) {
$html .= '
<div class="dropdown">
<span>Follow</span>
<div class="dropdown_content">
<form method="post">
<input type="hidden" name="form_id" value="follow">
<input type="hidden" name="author_id" value="' . $author['user_id'] . '">
<button name="follow" value="no_notify" class="button"><i class="nf nf-md-bell_cancel"></i></button>
<button name="follow" value="email_notify" class="button"><i class="nf nf-cod-mail"></i></button>
</form>
</div>
</div>
';
} else if ($following) {
$html .= '
<div class="margin_right">
<form method="post">
<input type="hidden" name="form_id" value="unfollow">
<input type="hidden" name="author_id" value="' . $author['user_id'] . '">
<input type="submit" value="Unfollow">
</form>
</div>
';
}
$html .= '
</div>
<div class="card_pic_box">
<img src="' . $_ENV['PROFILE_IMAGES_PQ_PATH'] . $author['profile_picture'] . '" class="card_profile_picture" />
+1 -1
View File
@@ -27,7 +27,7 @@ function article_card($article_id)
<h4>' . $article['title'] . '</h4>
<div class="card_info_box">
<div class="col-right">
<a href="user.php?action=view&user_id=' . $author['user_id'] . '">Written by: ' . $author['username'] . '</a>
<a href="user.php?view=display&user_id=' . $author['user_id'] . '">Written by: ' . $author['username'] . '</a>
<small>Published: ' . $article['published_at'] . '</small>
<small>Updated: ' . $article['updated_at'] . '</small>
</div>
+38 -2
View File
@@ -1,14 +1,50 @@
<?php
include_once 'components/article/card.php';
function user_view($user_id)
{
if ($_SESSION['user_id'] != $user_id)
$following = exec_stmt('SELECT follower_user_id, following_user_id FROM follows WHERE follower_user_id = ? AND following_user_id = ?', 'ii', $_SESSION['user_id'], $user_id)->fetch_assoc();
$user = exec_stmt('SELECT user_id, username, email, role_id, created_at, last_active, is_active, profile_picture FROM user WHERE user_id = ?', 'i', $user_id)->fetch_assoc();
if ($user == null)
header('Location: articles.php');
header('Location: articles.php?view=sort&sort=newest');
$view = '
<div class="user_view">
<div class="row">
<h1>' . $user['username'] . '</h1>
<div class="row">
<h1>' . $user['username'] . '</h1>
';
if ($_SESSION['user_id'] != $user_id && !$following) {
$view .= '
<div class="dropdown">
<span>Follow</span>
<div class="dropdown_content">
<form method="post">
<input type="hidden" name="form_id" value="follow">
<input type="hidden" name="author_id" value="' . $user_id . '">
<button name="follow" value="no_notify" class="button"><i class="nf nf-md-bell_cancel"></i></button>
<button name="follow" value="email_notify" class="button"><i class="nf nf-cod-mail"></i></button>
</form>
</div>
</div>
';
} else if ($following) {
$view .= '
<div class="margin_right">
<form method="post">
<input type="hidden" name="form_id" value="unfollow">
<input type="hidden" name="author_id" value="' . $user_id . '">
<input type="submit" value="Unfollow">
</form>
</div>
';
}
$view .= '
</div>
<div class="card_pic_box">
<img src="' . $_ENV['PROFILE_IMAGES_PQ_PATH'] . $user['profile_picture'] . '" class="profile_picture" />
</div>
+37 -12
View File
@@ -31,7 +31,8 @@ a,
.modal_button,
th,
td,
li {
li,
span {
font-family: 'Hack Nerd Font Mono', Hack, sans-serif;
padding: 0;
margin: 0;
@@ -65,7 +66,8 @@ a,
label,
li,
.modal_button,
.button {
.button,
span {
font-size: 1vw;
}
@@ -285,10 +287,6 @@ table tr td label {
border: 3px solid orange;
}
.button:hover {
box-shadow: 0 2px 4px 0 rgba(255, 165, 0, 0.3), 0 3px 8px 0 rgba(255, 165, 0, 0.7);
}
.narrow {
width: 20%;
}
@@ -301,10 +299,6 @@ table tr td label {
text-decoration: none;
}
.tag:hover {
box-shadow: 0 2px 4px 0 rgba(255, 165, 0, 0.3), 0 3px 8px 0 rgba(255, 165, 0, 0.7);
}
.tag_row {}
.tag_box {
@@ -383,7 +377,6 @@ textarea:focus,
input:hover,
textarea:hover {
border: 3px solid orange;
box-shadow: 0 2px 4px 0 rgba(255, 165, 0, 0.3), 0 3px 8px 0 rgba(255, 165, 0, 0.7);
outline: none;
}
@@ -734,6 +727,37 @@ hr {
border-radius: 9px;
}
.dropdown {
position: relative;
display: block;
margin-right: 15px;
padding: 15px;
border-radius: 9px;
}
.dropdown_content {
display: none;
position: absolute;
min-width: 50px;
padding: 15px;
z-index: 2;
}
.dropdown:hover .dropdown_content {
display: block;
margin-left: -15px;
margin-top: 15px;
border-radius: 12px;
}
.dropdown_content form * {
margin-top: 5px;
margin-bottom: 5px;
}
.margin_right {
margin-right: 15px;
}
@media screen and (max-width: 992px) {
body {
@@ -772,7 +796,8 @@ hr {
ul li a,
ol li a,
.modal_button,
.button {
.button,
span {
font-size: 2vw;
}
+14 -4
View File
@@ -13,7 +13,8 @@ h4,
h5,
p,
small,
label {
label,
span {
color: white;
}
@@ -54,9 +55,9 @@ table tr {
color: white;
}
.button:hover {
color: black;
background-color: white;
.button:hover,
.button:hover i {
color: orange;
}
.tag {
@@ -139,6 +140,15 @@ select:-webkit-autofill {
border: 2px solid orange;
}
.dropdown {
border: 2px solid white;
}
.dropdown_content {
background-color: black;
border: 2px solid white;
}
@media screen and (max-width: 992px) {
nav {
border-bottom: none;
+14 -4
View File
@@ -13,7 +13,8 @@ h4,
h5,
p,
small,
label {
label,
span {
color: black;
}
@@ -54,9 +55,9 @@ table tr {
color: black;
}
.button:hover {
color: white;
background-color: black;
.button:hover,
.button:hover * {
color: orange;
}
.tag {
@@ -139,6 +140,15 @@ select:-webkit-autofill {
border: 2px solid orange;
}
.dropdown {
border: 2px solid black;
}
.dropdown_content {
background-color: white;
border: 2px solid black;
}
@media screen and (max-width: 992px) {
nav {
border-bottom: none;
+10
View File
@@ -4,6 +4,16 @@ require_once 'article_functions.php';
use Postmark\PostmarkClient;
function follow_user($follower_user_id, $following_user_id, $notify)
{
exec_stmt('INSERT INTO follows (follower_user_id, following_user_id, notify_user) VALUES (?, ?, ?)', 'iii', $follower_user_id, $following_user_id, $notify);
}
function unfollow_user($follower_user_id, $following_user_id)
{
exec_stmt('DELETE FROM follows WHERE follower_user_id = ? AND following_user_id = ?', 'ii', $follower_user_id, $following_user_id);
}
function user_id_exists($user_id)
{
$result = exec_stmt('SELECT user_id FROM user WHERE user_id = ?', 'i', $user_id)->fetch_assoc();
+36 -18
View File
@@ -1,17 +1,36 @@
<?php
include_once 'functions/init.php';
if (!empty($_FILES['upload'])) {
require_once '../vendor/autoload.php';
use Postmark\PostmarkClient;
// Load environment variables.
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->safeLoad();
if (!empty($_FILES['upload']))
upload($_FILES['upload'], $_ENV['UPLOAD_TMP_FQ_PATH'], isset($_POST['filename']) ? $_POST['filename'] : null);
function notify_users($emails, $auther_username, $article_id, $article_title, $excerpt)
{
foreach ($emails as $email) {
$email_html = '
<h1>' . $article_title . '</h1>
<p>An author you follow just published a new article! Read it <a href="https://vintagecoding.net/articles.php?view=read&article_id=' . $article_id . '" target="_blank">here</a></p>
<p><strong>Here is a snippet from the article!</strong></p>
<p>' . $excerpt . '</p>
';
$client = new PostmarkClient($_ENV['POSTMARK_API_TOKEN']);
$client->sendEmail(
'mailer@joshashton.dev',
$email,
$auther_username . ' just published a new article! - vintagecoding.net',
$email_html
);
}
}
function delete_dir($dir)
{
if (!is_dir($dir)) return false;
if (!is_dir($dir))
return false;
if (substr($dir, strlen($dir) - 1, 1) != '/') {
$dir .= '/';
@@ -28,7 +47,8 @@ function delete_dir($dir)
function delete_file($file)
{
if (!file_exists($file)) return false;
if (!file_exists($file))
return false;
unlink($file);
}
@@ -54,7 +74,7 @@ function upload($input, $dest_dir, $filename = null)
break;
default:
# code...
// code...
break;
}
@@ -66,7 +86,7 @@ function upload($input, $dest_dir, $filename = null)
function crop_image($img_path, $x, $y, $height, $width)
{
//$dest = $_ENV['UPLOAD_TMP_FQ_PATH'];
// $dest = $_ENV['UPLOAD_TMP_FQ_PATH'];
$dest = '/tmp/';
// Get the image type
$imageType = exif_imagetype($img_path);
@@ -80,11 +100,11 @@ function crop_image($img_path, $x, $y, $height, $width)
$sourceImage = imagecreatefrompng($img_path);
break;
default:
return false; // Unsupported image type
return false; // Unsupported image type
}
if (!$sourceImage)
return false; // Failed to create image resource
return false; // Failed to create image resource
// Create a new image resource for the cropped image
$croppedImage = imagecreatetruecolor($width, $height);
@@ -109,7 +129,7 @@ function crop_image($img_path, $x, $y, $height, $width)
imagedestroy($sourceImage);
imagedestroy($croppedImage);
return true; // Cropping successful
return true; // Cropping successful
}
/*
@@ -132,7 +152,7 @@ function extract_content_from_tag($string, $start, $end)
{
$ini = strpos($string, $start);
if ($ini != 0)
return "nope";
return 'nope';
$ini += strlen($start);
$len = strpos($string, $end, $ini) - $ini;
@@ -151,9 +171,7 @@ function check_csv_upload($target)
function pretty_dump($arr)
{
echo '
<pre>
' . print_r($arr) . '
</pre>
';
echo '<pre>';
print_r($arr);
echo '</pre>';
}
+10
View File
@@ -60,6 +60,16 @@ CREATE TABLE article_tags (
FOREIGN KEY (tag_id) REFERENCES tags(tag_id)
);
CREATE TABLE follows (
follower_user_id INT NOT NULL,
following_user_id INT NOT NULL,
notify_user BOOLEAN DEFAULT FALSE,
followed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (follower_user_id, following_user_id),
FOREIGN KEY (follower_user_id) REFERENCES user(user_id) ON DELETE CASCADE,
FOREIGN KEY (following_user_id) REFERENCES user(user_id) ON DELETE CASCADE
);
INSERT INTO roles (role)
VALUES ('owner'), ('admin'), ('contributor'), ('reader');
+6
View File
@@ -82,6 +82,12 @@ if (isset($_POST['form_id'])) {
$_POST['delete_verify_password']
);
break;
case 'follow':
follow_user($_SESSION['user_id'], $_POST['author_id'], ($_POST['follow'] == 'email_notify' ? 1 : 0));
break;
case 'unfollow':
unfollow_user($_SESSION['user_id'], $_POST['author_id']);
break;
}
echo $msg;