Savvy Gaming

Published on September 2nd, 2024 at 12:02 am

What is it?

The Savvy Gaming Network (or Savvy Gaming for short) is a gaming blog by Erik and Savvy Danielson. The site launched on June 8th, 2023, and almost one hundred articles have been written for the site since its launch. The articles are made up of several categories: Reviews, First Impressions, Lists and Rankings, Events, News, Recipes, and Podcasts. All of these were written by Savvy or her friend Elise.

The design of Savvy Gaming started as a Photoshop mockup that I re-created with Bootstrap Studio and then eventually as a fully featured WordPress theme. At the time of this writing, Savvy Gaming and ryanbj.com are the only two websites with a theme I built completely from scratch, but Savvy Gaming is arguably more advanced.

a screenshot of the savvygamingnetwork homepage
A screenshot of the Savvy Gaming homepage

What does it do?

Savvy Gaming is and will always be a non-profit gaming blog for someone interested in reading about news, reviews, and opinion pieces from a “savvy gamer.” Savvy wanted a place where she could play a game and express all of her likes, dislikes, and frustrations on the internet. Savvy Gaming may not be the next IGN, but we hope it will be a cozy place on the internet that gamers can stop by and enjoy.

As mentioned, Savvy Gaming’s content is divided into several categories. Reviews, for example, are articles that have a high-level interpretation of a game. You can navigate to the different categories using the navigation at the top of the page. After navigating to a category, a listing of all relevant articles will appear in the body of the page. Articles with a “FEATURED” banner are also mentioned in the “Featured Articles” section of the home page.

a screenshot of the reviews category in the Savvy Gaming Network
A screenshot of the Reviews category

By clicking on the title or featured image of an article, you’ll be able to navigate to the full content of the article. Most of the reviews will contain a high-level synopsis of the game and its story. Right now, the sidebar shows a few of the latest articles on the site, but the plan is to develop a way to show more relevant articles to the one you are reading. The author’s bio may also move over there.

a screenshot of an article from the savvy gaming network
A screen shot of an article

At the bottom of some of the articles is a Score Card. This was a fun addition to the theme because it was the first custom Gutenberg block that I have ever written for WordPress. It’s functional, too. When a writer for the blog wants to add a Score Card, they need only drag it from the blocks menu and add some numbers for each category, and the “Final Score” is calculated automatically. The “Final Score” is also color-coded depending on how good/bad the score is. Red = 0.0-3.3, Yellow = 3.4-6.6, Green = 6.7-10.0.

a screenshot of the score card and comments section on the savvy gaming network
A screenshot of a Score Card

How does it work?

The file architecture of the theme mirrors the layout of a “Classic Theme,” not a “Block Theme.” I am not attached to the Classic Theme layout, but it is what I was trained on. The eventual plan is to convert it to a block theme when I have more time to dedicate to this project. The majority of the code is made up of .php template files in the root of the theme directory. home.php, for example, makes up the template for the homepage, singular.php for a post or a page, archive.php for a category listing, etc.

Feel free to peruse the theme’s code at its GitHub Repository.

The functions.php file is made up of includes of other classes and files to serve as all of the functional aspects of the theme. An example of this is the primary-navigation-walker-class, which builds a dynamic navigation menu at the top of the site. The code below is a snapshot of that class, but please note that it is subject to change.

class-savvytheme-primary-walker-nav-menu.php

<?php

class Primary_Walker_Nav_Menu extends Walker_Nav_Menu
{

    // Check if list item is active
    function is_active_menu_item($item, $dropdown = false)
    {
        if ($dropdown == true) {
            if (array_search('current-menu-item', $item->classes) || array_search('current-page-parent', $item->classes)) {
                return ' active';
            }
        } elseif ($dropdown == false && array_search('current_menu_item', $item->classes)) {
            return ' active';
        } else {
            return '';
        }
    }

    // Container for dropdowns
    function start_lvl(&$output, $depth = 0, $args = null)
    {
        $output .= "<div class=\"dropdown-menu\">";
    }

    // Create list items and links for the menu
    function start_el(&$output, $item, $depth = 0, $args = array(), $id = 0)
    {

        // If the list item is a dropdown
        if (array_search('menu-item-has-children', $item->classes)) {
            $output .= '<li class="nav-item dropdown' . $this->is_active_menu_item($item, true) . '">'
                . '<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" href="' . $item->url . '">'
                . $item->title . '</a>';
        } // If the list item is a dropdown child
        elseif ($item->menu_item_parent != 0) {
            $output .= '<a class="dropdown-item" href="' . $item->url . '">'
                . $item->title . '</a>';
        } // If the list item is not a dropdown
        else {
            $output .= '<li class="nav-item' . $this->is_active_menu_item($item, false) . '">'
                . '<a class="nav-link" href="' . $item->url . '">'
                . $item->title . '</a>';
        }
    }

    function end_el(&$output, $item, $depth = 0, $args = null)
    {
        if ($item->menu_item_parent == 0) {
            $output .= "</li>";
        }
    }

    function end_lvl(&$output, $depth = 0, $args = null)
    {
        $output .= "</div>";
    }
}

Arguably, the most complex part of the theme is its Comments section. It is still imperfect, and there are reports of problems with using the comments section on the phone, but I am still amazed at how far I have come. The entire comment structure was rebuilt to match the theme, and I even attempted to use JavaScript to re-design part of the structure for mobile devices. The first file to take note of is the `comments.php` file, which makes up the template for the comments section. There are four states in the comments section:

  1. The article has at least one comment, and the user is logged in
  2. The article has at least one comment, but the user is not logged in
  3. The article does not have any comments, but the user is logged in
  4. The article does not have any comments, and the user is not logged in

comment.php

<?php
/*
Template Name: Comments Template
*/
if ( post_password_required() ) {
	return;
} ?>


<?php if (have_comments()) : ?>

    <h1 class="display-2">Comments</h1>
    <hr>
    <ol class="list-unstyled comment-list">
        <?php wp_list_comments(array(
            'walker' => new SavvyTheme_Walker_Comment(),
            'short_ping' => true,
            'style' => 'ol'
        )); ?>

        <?php
        if ( comments_open() && 'asc' === strtolower( get_option( 'comment_order', 'asc' ) ) ) {
            comment_form(array('title_reply' => 'Join the discussion!', 'comment_notes_before' => ''));
        }

        if ( !comments_open() ) : ?>
            <div class="comment-respond comment-closed">
                <p><?php _e('Comments are closed.', 'savvytheme') ?></p>
            </div>
        <?php endif; ?>
    </ol>

<?php else : ?>
    <?php
	if ( comments_open() && 'asc' === strtolower( get_option( 'comment_order', 'asc' ) ) ) {
		comment_form(array('title_reply' => 'Leave a reply!', 'comment_notes_before' => ''));
	}
    ?>
<?php endif; ?>

If there is at least one comment in the comments section, then the comments structure is built with the SavvyTheme_Walker_Comment object, which is derived from the Walker_Comment class.

class-savvytheme-comment-walker.php

<?php
/**
 * Custom comment walker for this theme
 *
 * @package WordPress
 * @subpackage Savvy_Theme
**/
class SavvyTheme_Walker_Comment extends Walker_Comment
{
    /**
     * Starts the list before the elements are added.
     *
     * @global int $comment_depth
     *
     * @param string $output Used to append additional content (passed by reference).
     * @param int    $depth  Optional. Depth of the current comment. Default 0.
     * @param array  $args   Optional. Uses 'style' argument for type of HTML list. Default empty array.
     */
    public function start_lvl( &$output, $depth = 0, $args = array() ) {
        $GLOBALS['comment_depth'] = $depth + 1;
        $output .= '<ol class="list-unstyled children">' . "\n";
    }

    /**
     * Outputs a comment.
     *
     * @see wp_list_comments()
     *
     * @param WP_Comment $comment Comment to display.
     * @param int        $depth   Depth of the current comment.
     * @param array      $args    An array of arguments.
     */
    protected function html5_comment($comment, $depth, $args) {

        // Author Info
        $comment_author = get_comment_author( $comment );

        // Comment Timestamp
        $comment_timestamp = sprintf( __( '%1$s at %2$s', 'savvytheme' ), get_comment_date( '', $comment ), get_comment_time() );

        ?>

        <li>
            <article id="comment-<?php comment_ID(); ?>" class="post-comment">
                <h5 class="comment-title"><?php echo $comment_author; ?>
                    <span class="comment-date text-muted"> - <?php echo $comment_timestamp ?></span>
                </h5>
                <?php
                // Comment Text
                if ( '0' == $comment->comment_approved ) {
                    echo '<p>'
                        . get_comment_text()
                        . '<span class="text-muted"> - '
                        . __( 'Your comment is awaiting moderation.', 'savvytheme' )
                        . '</span></p>';
                } else {
                    comment_text();
                }

                // Reply Link
                comment_reply_link(
                    array_merge(
                        $args,
                        array(
                            'add_below' => 'comment-body',
                            'depth'     => $depth,
                            'max_depth' => $args['max_depth'],
                        )
                    )
                );
                if (current_user_can( 'edit_comments' )) {
                    echo '| ';
                    edit_comment_link( __( 'Edit' ), ' <span class="edit-link">', '</span>' );
                }
                ?>

            </article>
        </li>

    <?php
    }
}

WordPress has a lot of pre-built functionality when it comes to the comment form. To overwrite a lot of this, I needed to add filters to the default comment form fields as shown below:

comment-section.php

<?php

/**
 * Customize WordPress Comment Form by removing and redesigning fields
 *
 * @param Array $fields Fields.
 * @return Array        Fields modified.
 */
add_filter('comment_form_fields', function (array $fields): array
{
    // Unset fields from displaying by default
    unset($fields['comment']);
    unset($fields['cookies']);
    unset($fields['url']);

    // Restyle fields to match theme
    $fields['author'] = '<input id="author" name="author" type="text" class="form-control form-control-orange" placeholder="Full Name" aria-label=".form-control" required>';
    $fields['email'] = '<input id="email" name="email" type="text" class="form-control form-control-orange" placeholder="Email" aria-label=".form-control" required>';
    $fields['comment'] = '<textarea id="comment" name="comment" class="form-control form-control-orange" placeholder="Comment" required></textarea>';
    $fields['cookies'] = '<div class="form-check"><input id="wp-comment-cookies-consent" name="wp-comment-cookies-consent" type="checkbox" class="form-check-input form-check-orange" value="yes">'
        . '<label for="wp-comment-cookies-consent" class="form-check-label">Save my name and email to my browser for the next time I comment here.</label></div>';

    return $fields;
});

/**
 * Redesign the checkbox in the comments form to match the theme
 *
 * @param String $field  Field.
 * @return String        Field modified.
 */
add_filter( 'comment_form_field_comment', function ( String $field ): string {
    if (str_contains($field, 'wp-comment-cookies-consent')) {
        // Wrap the checkbox and label in a div with class 'form-check-inline'
        $field = '<div class="form-check-inline">' . $field . '</div>';
    }
    return $field;
});

/**
 * Redesign the Comment form fields to match the theme
 *
 * @param Array $defaults Defaults.
 * @return Array          Defaults modified.
 */
add_filter( 'comment_form_defaults', function (array $defaults ): array
{
    // If user is logged in, remove logged_in_as text
    if ( is_user_logged_in() ) {
        $defaults['logged_in_as'] = '';
    }

    // Change comment form fields to match theme
    $defaults['submit_field'] = '<p class="form-submit">%1$s %2$s</p>';
    $defaults['submit_button'] = '<input name="%1$s" type="submit" id="%2$s" class="btn btn-orange" value="%4$s" />';
    $defaults['fields']['cookies'] = '<p class="comment-form-cookies-consent">%s</p>';
    $defaults['hidden_fields']['comment_post_ID'] = '<input id="comment_post_ID" name="comment_post_ID" type="hidden" value="' . get_the_ID() . '" />';
    $defaults['hidden_fields']['comment_parent'] = '<input id="comment_parent" name="comment_parent" type="hidden" value="0" />';
    return $defaults;
});

Finally, the most complex part of the comment’s template is the JavaScript. Although WordPress helped a lot in the back-end processing of submitting and approving comments, its out-of-the-box implementation for WordPress themes could be more satisfactory for mobile devices. Using JavaScript, I tried to redesign the structure of the comments section for mobile devices so that you can add a comment to a post with a large and easy-to-tap button, and the comment form would slide up from the bottom of your screen and float in place. I also added a little close button at the top right to remove the sticky form. At the time of this writing, this feature mostly works. There are still some issues adding a comment to a post that does not already have a comment, but I plan to fix these issues in the future.

comment-form.js

// Helper constants
const commentForm = document.getElementById('respond');
const replyTitle = document.getElementById('reply-title');
const commentList = document.querySelector('.comment-list');
const commentParentField = commentForm.querySelector('#comment_parent');
const replyButtons = document.querySelectorAll('.comment-reply-link');
const footer = document.querySelector('footer');
const rem = parseFloat(getComputedStyle(document.body).fontSize);

// State variables
let animationBlock = false;
let mobileView = false;

/*************************************************
 * Custom Dom Objects
 *************************************************/

// Cancel button
const cancelButton = document.createElement('button');
cancelButton.setAttribute('rel', 'nofollow');
cancelButton.setAttribute('id', 'cancel-comment-reply-link');
cancelButton.setAttribute('class', 'btn-orange')

// Join Discussion Button
const joinButtonContainer = document.createElement('div');
const joinButton = document.createElement('button');
joinButtonContainer.classList.add('d-grid', 'gap-2');
joinButton.setAttribute('id', 'join-discussion-link');
joinButton.setAttribute('class', 'btn btn-orange btn-lg btn-block');
joinButtonContainer.appendChild(joinButton);
joinButton.textContent = 'Join the Discussion!';

// Developer text
const devText = document.createElement('span');
devText.style = "color: white; position: fixed; top: 0px; right: 0px; " +
    "background-color: rgba(100, 100, 100, 0.8); z-index: 2;"
document.body.appendChild(devText);
devLog = text => { devText.textContent = text; }

/*************************************************
 * On Document Load
 *************************************************/
if (window.innerWidth <= 576) {
    mobileView = true;
    commentForm.style.visibility = 'hidden';
    resetCancelButtonStyles();
    commentList.appendChild(joinButtonContainer);
}

// Initialize Cancel Button Styles
resetCancelButtonStyles();

/*************************************************
 * Functions
 *************************************************/
function resetCancelButtonStyles() {
    if (mobileView) {
        cancelButton.classList.add('mobile-cancel');
        cancelButton.classList.remove('float-end', 'btn');
        cancelButton.textContent = 'X';
    } else {
        cancelButton.classList.add('btn', 'float-end');
        cancelButton.classList.remove('mobile-cancel');
        cancelButton.textContent = 'Cancel';
    }
}

// Show comment form
function showCommentForm(commentId) {

    // If not currently in an animation
    if (!animationBlock) {
        let comment = null;

        // If form is for a reply
        if (commentId !== "0") {
            comment = document.getElementById(`comment-${commentId}`);

            // Update the #reply-title heading with the author name
            const commentTitle = comment.querySelector('.comment-title');
            const authorName = commentTitle.childNodes[0].nodeValue.trim();
            replyTitle.innerHTML = `Leave a reply to <strong>${authorName}</strong>`;

            // Move the comment form element to be a child of the comment being replied to
            comment.parentNode.appendChild(commentForm);
        }

        // Update the comment_parent hidden field to the correct comment id
        commentParentField.value = commentId;

        // Animate if on mobile and adjust cancel button
        if (mobileView) {
            resetCancelButtonStyles();
            if (commentForm.style.visibility === 'hidden') {
                slideUp(commentForm, 1000);
                fadeOut(joinButtonContainer, 1000);
                setTimeout(() => {
                    commentList.removeChild(joinButtonContainer);
                }, 950);
                setTimeout( () => {
                    fadeIn(commentForm.parentNode.insertBefore(cancelButton, commentForm.nextSibling), 250);
                    cancelButton.style.bottom = commentForm.offsetHeight + 'px';
                }, 1000);
                commentList.style.marginBottom = (commentForm.offsetHeight - footer.offsetHeight) + 'px';
            } else {
                commentForm.parentNode.insertBefore(cancelButton, commentForm.nextSibling);
                cancelButton.style.bottom = commentForm.offsetHeight + 'px';
            }
        } else {
            replyTitle.appendChild(cancelButton);
            commentForm.style.visibility = 'visible';
        }
    }
}

// Hide comment form
function hideCommentForm() {

    // If not currently in an animation
    if (!animationBlock) {

        // Hide the form for smaller displays
        if (mobileView) {
            commentForm.parentNode.removeChild(cancelButton);
            slideDown(commentForm, 1000);
            fadeIn(commentList.appendChild(joinButtonContainer), 1000);
            commentList.style.marginBottom = rem + 'px';
        } else {
            replyTitle.removeChild(cancelButton);
        }

        // Reset the comment_parent hidden field
        commentParentField.value = "0";

        // Move the comment form back to the bottom
        commentList.appendChild(commentForm);

        // Reset the reply title
        replyTitle.innerText = 'Join the discussion!';
    }

    return false;
}

// Animations
function slideUp(obj, dur) {
    obj.style.visibility = 'visible';
    animationBlock = true;
    obj.animate(
        [{
            transform: `translateY(${screen.height - obj.offsetHeight}px)`,
            opacity: 0,
            easing: "ease"
        },
            {
                transform: `translateY(0px)`,
                opacity: 1
            }],
        dur
    ).onfinish = function () {
        animationBlock = false;
    };
}

function slideDown(obj, dur) {
    animationBlock = true;
    obj.animate(
        [{
            transform: `translateY(0px)`,
            opacity: 1,
            easing: "ease"
        },
            {
                transform: `translateY(${screen.height - obj.offsetHeight}px)`,
                opacity: 0,
            }],
        dur
    ).onfinish = function () {
        obj.style.visibility = 'hidden';
        animationBlock = false;
    };
}

function fadeIn(obj, dur) {
    animationBlock = true;
    obj.animate({
        opacity: [0,1]
    }, dur).onfinish = () => {
        animationBlock = false;
    };
}

function fadeOut(obj, dur) {
    animationBlock = true;
    obj.animate({
        opacity: [1,0]
    }, dur).onfinish = () => {
        animationBlock = false;
    };
}

/*************************************************
 * Listeners
 *************************************************/

// Loop through each reply button and add an event listener to it
replyButtons.forEach(link => {
    const commentId = link.getAttribute('data-commentid');
    link.addEventListener('click', event => {
        event.preventDefault();
        showCommentForm(commentId);
    });
});

// Reset the comment form when cancelled
cancelButton.addEventListener('click', () => {
    hideCommentForm();
});

// Open default comment form if button is clicked
joinButton.addEventListener('click', () => {
    showCommentForm("0");
});

// Switch to mobile view or desktop view if screen is resized
window.addEventListener('resize', () => {

    // Desktop switching to mobile
    if (window.innerWidth <= 576 && mobileView === false) {
        mobileView = true;
        resetCancelButtonStyles();

        // If currently replying
        if (commentParentField.value !== "0") {
            replyTitle.removeChild(cancelButton);
            showCommentForm(commentParentField.value);
        } else {
            hideCommentForm();
            fadeIn(commentList.appendChild(joinButtonContainer), 1000);
        }
    }

    // Mobile switching to desktop
    else if (window.innerWidth > 576 && mobileView === true) {
        mobileView = false;
        resetCancelButtonStyles();

        // If modal is open
        if (commentForm.style.visibility === 'visible') {
            // If currently replying
            if (commentParentField.value !== "0") {
                commentForm.parentNode.removeChild(cancelButton);
                showCommentForm(commentParentField.value);
            } else {
                showCommentForm("0");
            }
            commentList.style.marginBottom = rem + 'px';
        } else {
            showCommentForm("0");
            fadeOut(joinButtonContainer);
            setTimeout(() => {
                commentList.removeChild(joinButtonContainer);
            }, 950);
        }
    }

    return true;
});