Schleicher Social RSS Feed
Categories:

Only because it has taken me a minute to remember how exactly I did this…learning caps on. Actually, mainly so if it breaks I can fix it. But, in short, this is how to generate and display a Mastodon feed on a homepage. No, I can’t claim credit for the coding completely; coding credit goes almost all to ChatGPT and Claude with some minor tweaks by myself to align CSS coloring and sizing.

Schleicher Social RSS Feed

System Requirements

Obligatory list of system requirements.

  • Working self-hosted site with dedicated domain name running on some flavor of Linux.
  • Working Mastodon instance (either on the same server or a different server). If on a different server make sure it is accessible from the web server.

That being said, if you’re with me to this point, you’re capable of building a LAMP stack and the basics, so I won’t get into the nitty gritty of those details. I do find that Webmin is an easier way of building and writing files here, though, just make sure the permissions are correct.

I also recommend a separate account on Mastodon for accessing and generating the feed with non-admin permissions. I use a bot account for most of the automated processing.

Feed Generation

We’re going to generate a full feed of public posts using a Python script. By default, Mastodon does not provide a full feed dump; there is an RSS feed generated for specific endpoints, but the full public feed is not available without some creativity. First up, we need to create a new file.

sudo nano /home/youruser/generate_rss.py

Replace youruser with your user directory; optionally place in the root directory. Next up, insert the following:

import requests
import xml.etree.ElementTree as ET
from xml.dom import minidom

def prettify_xml(elem):
    rough_string = ET.tostring(elem, 'utf-8')
    reparsed = minidom.parseString(rough_string)
    return reparsed.toprettyxml(indent="  ")

instance_url = "https://yourinstanceurl"
public_timeline_url = f"{instance_url}/api/v1/timelines/home"

# Replace these with your actual client credentials and access token
client_id = "yourclientid"
client_secret = "yourclientsecret"
access_token = "youraccesstoken"


# Add cache control headers
headers = {
    "Cache-Control": "no-cache, no-store, must-revalidate",
    "Pragma": "no-cache",
    "Expires": "0"
}

# Obtain data using authentication
response = requests.get(public_timeline_url, headers={"Authorization": f"Bearer {access_token}"})
data = response.json()

# Check for errors in the API response
if 'error' in data:
    print("Error:", data['error'])
    exit()

# Create the root element with the Atom namespace
rss = ET.Element("{http://www.w3.org/2005/Atom}rss")
rss.set("version", "2.0")

# Declare the media namespace
media_namespace = "http://search.yahoo.com/mrss/"
media_prefix = "media"
ET.register_namespace(media_prefix, media_namespace)

channel = ET.SubElement(rss, "channel")
title = ET.SubElement(channel, "title")
title.text = "Schleicher Social Timeline"
link = ET.SubElement(channel, "link")
link.text = public_timeline_url
description = ET.SubElement(channel, "description")
description.text = "Schleicher Social RSS Feed"

# Process the data and generate RSS feed
for item in data:
    rss_item = ET.SubElement(channel, "item")
    item_title = ET.SubElement(rss_item, "title")
    item_title.text = f"New post by {item['account']['display_name']}"
    item_link = ET.SubElement(rss_item, "link")
    item_link.text = item['url']
    item_description = ET.SubElement(rss_item, "description")
    item_description.text = item['content']
    item_author = ET.SubElement(rss_item, "author")
    item_author.text = item['account']['display_name']
    item_pub_date = ET.SubElement(rss_item, "pubDate")
    item_pub_date.text = item['created_at']
    # Add media attachments to the description and media content in <media:content> format
    if 'media_attachments' in item:
        for media in item['media_attachments']:
            if media['type'] == 'image':
                # Add image as a separate <media:content> tag
                media_content = ET.Element(f"{{{media_namespace}}}content")
                media_content.set("height", "151")
                media_content.set("medium", "image")
                media_content.set("url", media['url'])
                media_content.set("width", "151")
                # Append image tag to the description
                img_tag = f"<img src='{media['url']}' alt='Image'>"
                item_description.text += f"<br>{img_tag}"
                rss_item.append(media_content)

# Save the RSS feed to a file
with open('/path/to/end/directory/mastodon_rss.xml', 'w') as f:
    f.write(prettify_xml(rss))

You’ll need to replace the highlighted elements with your specific configuration. Importantly, leave the rss.xml file name the same (we’ll reference it in the plugin build coming up). I dropped the actual XML in the webroot of the site it is being displayed on (easiest if you need to verify structure and syntax of the generated file).

Set up a system cron to generate the XML

Next step is to tell the system to occasionally fetch the data, since a feed isn’t any good if it doesn’t update…Use whichever cron editor you would like; in this case we’re using crontab as root:

sudo crontab -e

Next we’ll schedule this to run every 15 minutes, replacing highlighted text with your specific directory config:

# Run every 15 minutes
*/15 * * * * /path/to/generate_rss.py

Save and close (Ctrl+X, Y) to exit and enable the config and run. Once done, visiting that file in the browser should display your XML (e.g., https://yoursite/mastodon_rss.xml):

Plugin time

You have a couple of options from here. Option A is to use a third-party site to display the feed (e.g., Elfsight). That being said those often required paid subscriptions to overcome view or display limits. Option 2 is to adapt a plugin; I already did the legwork. Since the first option is fairly self explanatory (of note, the XML is already tailored to Elfsight), I’ll walk you through option 2, creating a plugin. This plugin will generate a carousel with basic config options. Again, I recommend Webmin for ease of editing (or Notepad++ before uploading in command shell).

Once created the plugin will pull up to 20 posts from the RSS XML; you’ll have an admin section in the WordPress dashboard that will allow you to define and select basic parameters (grid size, feed path, AJAX loading, etc.).

Create Directory

First up, create the directory for your plugin (e.g., /public/path/wp-content/plugins/family-mastodon-carousel). In that directory, let’s start with the plugin config. We’ll need to create a new file:

#switch to new directory
cd /path/to/family-mastodon-carousel

In this plugin we’ll create 3 new files. One to define the plugin behavior, one to run the java script for the carousel behavior, and the CSS to display it nicely. Using the text editor, create each of the 3 files.

family.mastodon.carousel.php

<?php
/**
 * Plugin Name: Family Mastodon Carousel
 * Description: Displays Mastodon RSS feed in a responsive carousel with mobile optimization
 * Version: 2.1.0
 * Author: Custom Plugin
 */

if (!defined('ABSPATH')) {
    exit;
}

class FamilyMastodonCarousel {
    
    private $plugin_name = 'family-mastodon-carousel';
    private $version = '2.1.0';
    
    public function __construct() {
        add_action('init', array($this, 'init'));
        add_action('admin_menu', array($this, 'admin_menu'));
        add_action('admin_init', array($this, 'admin_init'));
        add_action('wp_enqueue_scripts', array($this, 'enqueue_scripts'));
        add_shortcode('mastodon_carousel', array($this, 'shortcode_handler'));
        add_action('wp_ajax_test_mastodon_feed', array($this, 'ajax_test_feed'));
        add_action('wp_ajax_clear_mastodon_cache', array($this, 'ajax_clear_cache'));
        add_action('wp_ajax_load_mastodon_carousel', array($this, 'ajax_load_carousel'));
        add_action('wp_ajax_nopriv_load_mastodon_carousel', array($this, 'ajax_load_carousel'));
    }
    
    public function init() {
        // Plugin initialization
    }
    
    public function enqueue_scripts() {
        global $post;
        if (is_a($post, 'WP_Post') && has_shortcode($post->post_content, 'mastodon_carousel')) {
            wp_enqueue_style($this->plugin_name, plugin_dir_url(__FILE__) . 'carousel.css', array(), $this->version);
            wp_enqueue_script($this->plugin_name, plugin_dir_url(__FILE__) . 'carousel.js', array('jquery'), $this->version, true);
            wp_localize_script($this->plugin_name, 'mastodon_ajax', array(
                'ajax_url' => admin_url('admin-ajax.php'),
                'nonce' => wp_create_nonce('mastodon_carousel_nonce')
            ));
        }
    }
    
    private function is_mobile() {
        return wp_is_mobile() || (isset($_SERVER['HTTP_USER_AGENT']) && preg_match('/(iPhone|iPod|Android|BlackBerry|Mobile)/', $_SERVER['HTTP_USER_AGENT']));
    }
    
    private function prevent_page_caching() {
        if (!headers_sent()) {
            header('Cache-Control: no-cache, no-store, must-revalidate, max-age=0');
            header('Pragma: no-cache');
            header('Expires: 0');
            header('CF-Cache-Status: BYPASS');
            header('X-WP-Rocket-Cache: bypass');
            header('X-W3TC-Cache: bypass');
            header('X-LiteSpeed-Cache-Control: no-cache');
        }
        
        if (!defined('DONOTCACHEPAGE')) {
            define('DONOTCACHEPAGE', true);
        }
        if (!defined('DONOTCACHECONSTANT')) {
            define('DONOTCACHECONSTANT', true);
        }
    }
    
    public function admin_menu() {
        add_options_page(
            'Mastodon Carousel Settings',
            'Mastodon Carousel',
            'manage_options',
            $this->plugin_name,
            array($this, 'admin_page')
        );
    }
    
    public function admin_init() {
        register_setting($this->plugin_name, $this->plugin_name . '_options');
        
        add_settings_section('feed_settings', 'Feed Settings', array($this, 'section_callback'), $this->plugin_name);
        add_settings_field('rss_url', 'RSS Feed URL', array($this, 'rss_url_callback'), $this->plugin_name, 'feed_settings');
        add_settings_field('posts_limit', 'Number of Posts', array($this, 'posts_limit_callback'), $this->plugin_name, 'feed_settings');
        add_settings_field('cache_duration', 'Cache Duration (minutes)', array($this, 'cache_duration_callback'), $this->plugin_name, 'feed_settings');
        
        add_settings_section('appearance_settings', 'Appearance Settings', array($this, 'section_callback'), $this->plugin_name);
        add_settings_field('posts_per_slide', 'Posts Per Slide (Desktop)', array($this, 'posts_per_slide_callback'), $this->plugin_name, 'appearance_settings');
        add_settings_field('show_images', 'Show Post Images', array($this, 'show_images_callback'), $this->plugin_name, 'appearance_settings');
        add_settings_field('ajax_loading', 'Use AJAX Loading', array($this, 'ajax_loading_callback'), $this->plugin_name, 'appearance_settings');
    }
    
    public function section_callback() {
        // Section descriptions if needed
    }
    
    public function rss_url_callback() {
        $options = get_option($this->plugin_name . '_options');
        $value = isset($options['rss_url']) ? $options['rss_url'] : 'https://family.schleicher.social/mastodon_rss.xml';
        echo '<input type="url" name="' . $this->plugin_name . '_options[rss_url]" value="' . esc_attr($value) . '" class="regular-text" />';
        echo '<p class="description">Enter your Mastodon RSS feed URL</p>';
    }
    
    public function posts_limit_callback() {
        $options = get_option($this->plugin_name . '_options');
        $value = isset($options['posts_limit']) ? $options['posts_limit'] : 18;
        echo '<input type="number" name="' . $this->plugin_name . '_options[posts_limit]" value="' . esc_attr($value) . '" min="3" max="50" />';
        echo '<p class="description">Total posts to display (mobile shows 1 per slide automatically)</p>';
    }
    
    public function cache_duration_callback() {
        $options = get_option($this->plugin_name . '_options');
        $value = isset($options['cache_duration']) ? $options['cache_duration'] : 15;
        echo '<input type="number" name="' . $this->plugin_name . '_options[cache_duration]" value="' . esc_attr($value) . '" min="5" max="1440" />';
        echo '<p class="description">How long to cache the RSS feed (in minutes)</p>';
    }
    
    public function posts_per_slide_callback() {
        $options = get_option($this->plugin_name . '_options');
        $value = isset($options['posts_per_slide']) ? $options['posts_per_slide'] : 6;
        echo '<select name="' . $this->plugin_name . '_options[posts_per_slide]">';
        $opts = array(3 => '3 posts (1x3)', 4 => '4 posts (2x2)', 6 => '6 posts (2x3)', 8 => '8 posts (2x4)', 9 => '9 posts (3x3)');
        foreach ($opts as $num => $label) {
            echo '<option value="' . $num . '"' . selected($num, $value, false) . '>' . $label . '</option>';
        }
        echo '</select>';
        echo '<p class="description">Posts displayed per slide on desktop (mobile always shows 1 post per slide)</p>';
    }
    
    public function show_images_callback() {
        $options = get_option($this->plugin_name . '_options');
        $value = isset($options['show_images']) ? $options['show_images'] : 1;
        echo '<input type="checkbox" name="' . $this->plugin_name . '_options[show_images]" value="1" ' . checked(1, $value, false) . ' />';
        echo '<label> Display post images when available</label>';
    }
    
    public function ajax_loading_callback() {
        $options = get_option($this->plugin_name . '_options');
        $value = isset($options['ajax_loading']) ? $options['ajax_loading'] : 0;
        echo '<input type="checkbox" name="' . $this->plugin_name . '_options[ajax_loading]" value="1" ' . checked(1, $value, false) . ' />';
        echo '<label> Load feed content via AJAX (bypasses all caching)</label>';
        echo '<p class="description">Enable this if you have aggressive caching that prevents the feed from updating.</p>';
    }
    
    public function admin_page() {
        ?>
        <div class="wrap">
            <h1>Mastodon Carousel Settings</h1>
            <form method="post" action="options.php">
                <?php settings_fields($this->plugin_name); do_settings_sections($this->plugin_name); submit_button(); ?>
            </form>
            
            <div class="card">
                <h3>Test RSS Feed</h3>
                <button type="button" id="test-feed-btn" class="button button-secondary">Test Feed</button>
                <button type="button" id="clear-cache-btn" class="button button-secondary">Clear Cache</button>
                <div id="test-results" style="margin-top: 15px;"></div>
            </div>
            
            <div class="card">
                <h3>Shortcode Usage</h3>
                <p><strong>Basic:</strong> <code>[mastodon_carousel]</code></p>
                <p><strong>With params:</strong> <code>[mastodon_carousel limit="12" per_slide="3"]</code></p>
                <p><em>Note: Mobile devices show 1 post per slide automatically.</em></p>
            </div>
        </div>
        
        <script>
        jQuery(document).ready(function($) {
            $('#test-feed-btn').click(function() {
                var $btn = $(this), $results = $('#test-results');
                $btn.prop('disabled', true).text('Testing...');
                $results.html('<p>Connecting...</p>');
                
                $.ajax({
                    url: ajaxurl, type: 'POST',
                    data: { action: 'test_mastodon_feed', nonce: '<?php echo wp_create_nonce('test_mastodon_feed'); ?>' },
                    success: function(response) {
                        if (response.success) {
                            $results.html('<div style="color: green;"><strong>✓ Success!</strong> Found ' + response.data.count + ' posts.</div>');
                        } else {
                            $results.html('<div style="color: red;"><strong>✗ Error:</strong> ' + response.data + '</div>');
                        }
                    },
                    complete: function() { $btn.prop('disabled', false).text('Test Feed'); }
                });
            });
            
            $('#clear-cache-btn').click(function() {
                var $btn = $(this), $results = $('#test-results');
                $btn.prop('disabled', true).text('Clearing...');
                
                $.ajax({
                    url: ajaxurl, type: 'POST',
                    data: { action: 'clear_mastodon_cache', nonce: '<?php echo wp_create_nonce('clear_mastodon_cache'); ?>' },
                    success: function() { $results.html('<div style="color: green;"><strong>✓</strong> Cache cleared</div>'); },
                    complete: function() { $btn.prop('disabled', false).text('Clear Cache'); }
                });
            });
        });
        </script>
        <?php
    }
    
    public function ajax_test_feed() {
        check_ajax_referer('test_mastodon_feed', 'nonce');
        if (!current_user_can('manage_options')) wp_die('Unauthorized');
        
        $options = get_option($this->plugin_name . '_options');
        $rss_url = isset($options['rss_url']) ? $options['rss_url'] : '';
        
        if (empty($rss_url)) {
            wp_send_json_error('No RSS URL configured');
        }
        
        $feed = $this->fetch_rss_feed($rss_url, true);
        
        if (is_wp_error($feed)) {
            wp_send_json_error($feed->get_error_message());
        } else {
            wp_send_json_success(array('count' => count($feed)));
        }
    }
    
    public function ajax_clear_cache() {
        check_ajax_referer('clear_mastodon_cache', 'nonce');
        if (!current_user_can('manage_options')) wp_die('Unauthorized');
        
        global $wpdb;
        $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mastodon_feed_%'");
        $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_mastodon_feed_%'");
        wp_send_json_success();
    }
    
   public function ajax_load_carousel() {
    // Skip nonce verification entirely for this public endpoint
    // since the feed is public data anyway
    
    $limit = isset($_POST['limit']) ? intval($_POST['limit']) : 18;
    $per_slide = isset($_POST['per_slide']) ? intval($_POST['per_slide']) : 6;
    
    $options = get_option($this->plugin_name . '_options');
    $rss_url = isset($options['rss_url']) ? $options['rss_url'] : 'https://family.schleicher.social/mastodon_rss.xml';
    $show_images = isset($options['show_images']) ? $options['show_images'] : 1;
    
    $feed_items = $this->fetch_rss_feed($rss_url);
    
    if (is_wp_error($feed_items)) {
        wp_send_json_error('Unable to load feed: ' . $feed_items->get_error_message());
    }
    
    if (empty($feed_items)) {
        wp_send_json_error('No posts found in the feed.');
    }
    
    $content = $this->generate_carousel_content($feed_items, $limit, $per_slide, $show_images);
    wp_send_json_success($content);
}
    
    public function shortcode_handler($atts) {
        $options = get_option($this->plugin_name . '_options');
        $use_ajax = isset($options['ajax_loading']) ? $options['ajax_loading'] : 0;
        
        if ($use_ajax) {
            return $this->ajax_placeholder_content($atts);
        } else {
            $this->prevent_page_caching();
            return $this->generate_shortcode_content($atts);
        }
    }
    
    private function ajax_placeholder_content($atts) {
        $options = get_option($this->plugin_name . '_options');
        $atts = shortcode_atts(array(
            'limit' => isset($options['posts_limit']) ? $options['posts_limit'] : 18,
            'per_slide' => isset($options['posts_per_slide']) ? $options['posts_per_slide'] : 6,
        ), $atts, 'mastodon_carousel');
        
        $is_mobile = $this->is_mobile();
        
        ob_start();
        ?>
        <div class="schleicher-social-feed <?php echo $is_mobile ? 'mobile-layout' : 'desktop-layout'; ?>" 
             data-ajax-limit="<?php echo $atts['limit']; ?>" data-ajax-per-slide="<?php echo $atts['per_slide']; ?>">
            <div class="carousel-container" style="height: 400px; display: flex; align-items: center; justify-content: center;">
                <div class="loading-message">
                    <p>Loading social feed...</p>
                    <div class="loading-spinner" style="border: 3px solid #f3f3f3; border-top: 3px solid #d4a017; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 10px auto;"></div>
                </div>
            </div>
        </div>
        
        <style>@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }</style>
        
        <script>
        jQuery(document).ready(function($) {
            $('.schleicher-social-feed[data-ajax-limit]').each(function() {
                var $container = $(this);
                var limit = $container.data('ajax-limit');
                var perSlide = $container.data('ajax-per-slide');
                
                $.ajax({
                    url: mastodon_ajax.ajax_url, type: 'POST',
                    data: { action: 'load_mastodon_carousel', nonce: mastodon_ajax.nonce, limit: limit, per_slide: perSlide },
                    success: function(response) {
                        if (response.success) {
                            $container.html(response.data);
                            if (typeof SchleicharSocialCarousel !== 'undefined') {
                                new SchleicharSocialCarousel($container[0]);
                            }
                        } else {
                            $container.html('<div class="mastodon-carousel-error">Error: ' + response.data + '</div>');
                        }
                    },
                    error: function() {
                        $container.html('<div class="mastodon-carousel-error">Failed to load social feed.</div>');
                    }
                });
            });
        });
        </script>
        <?php
        return ob_get_clean();
    }
    
    private function generate_shortcode_content($atts) {
        $options = get_option($this->plugin_name . '_options');
        $atts = shortcode_atts(array(
            'limit' => isset($options['posts_limit']) ? $options['posts_limit'] : 18,
            'per_slide' => isset($options['posts_per_slide']) ? $options['posts_per_slide'] : 6,
        ), $atts, 'mastodon_carousel');
        
        $limit = intval($atts['limit']);
        $per_slide = intval($atts['per_slide']);
        $rss_url = isset($options['rss_url']) ? $options['rss_url'] : 'https://family.schleicher.social/mastodon_rss.xml';
        $show_images = isset($options['show_images']) ? $options['show_images'] : 1;
        
        $feed_items = $this->fetch_rss_feed($rss_url);
        
        if (is_wp_error($feed_items)) {
            return '<div class="mastodon-carousel-error">Unable to load feed: ' . esc_html($feed_items->get_error_message()) . '</div>';
        }
        
        if (empty($feed_items)) {
            return '<div class="mastodon-carousel-error">No posts found in the feed.</div>';
        }
        
        $carousel_content = $this->generate_carousel_content($feed_items, $limit, $per_slide, $show_images);
        $is_mobile = $this->is_mobile();
        
        ob_start();
        ?>
        <div class="schleicher-social-feed <?php echo $is_mobile ? 'mobile-layout' : 'desktop-layout'; ?>" 
             data-posts-per-slide="<?php echo $per_slide; ?>" data-is-mobile="<?php echo $is_mobile ? 'true' : 'false'; ?>">
            <?php echo $carousel_content; ?>
        </div>
        <?php
        return ob_get_clean();
    }
    
    private function generate_carousel_content($feed_items, $limit, $per_slide, $show_images) {
        $feed_items = array_slice($feed_items, 0, $limit);
        $is_mobile = $this->is_mobile();
        
        if ($is_mobile) {
            $slides = array();
            foreach ($feed_items as $item) {
                $slides[] = array($item);
            }
            $container_height = 'auto';
            $grid_cols = 1; $grid_rows = 1;
        } else {
            $slides = array_chunk($feed_items, $per_slide);
            switch ($per_slide) {
                case 3: $grid_cols = 3; $grid_rows = 1; $container_height = 480; break;
                case 4: $grid_cols = 2; $grid_rows = 2; $container_height = 960; break;
                case 6: $grid_cols = 3; $grid_rows = 2; $container_height = 960; break;
                case 8: $grid_cols = 4; $grid_rows = 2; $container_height = 960; break;
                case 9: $grid_cols = 3; $grid_rows = 3; $container_height = 1440; break;
                default: $grid_cols = 3; $grid_rows = 2; $container_height = 960; break;
            }
        }
        
        $total_slides = count($slides);
        
        ob_start();
        ?>
        <div class="carousel-container" style="height: <?php echo $container_height; ?>px;">
            <div class="carousel-track" data-total-slides="<?php echo $total_slides; ?>">
                <?php foreach ($slides as $slide_index => $slide_items): ?>
                    <div class="carousel-slide <?php echo $slide_index === 0 ? 'active' : ''; ?>">
                        <div class="posts-grid" <?php if (!$is_mobile): ?>style="grid-template-columns: repeat(<?php echo $grid_cols; ?>, 1fr); grid-template-rows: repeat(<?php echo $grid_rows; ?>, 1fr);"<?php endif; ?>>
                            <?php foreach ($slide_items as $item): ?>
                                <div class="post-card" data-post-id="<?php echo esc_attr($item['id']); ?>">
                                    <?php if ($show_images && !empty($item['image'])): ?>
                                        <div class="post-image">
                                            <img src="<?php echo esc_url($item['image']); ?>" alt="Post image" loading="lazy" />
                                        </div>
                                    <?php else: ?>
                                        <div class="post-image no-image">
                                            <img src="https://family.schleicher.social/wp-content/uploads/2024/01/pulse-image-16793114010641.png" alt="Schleicher Social" loading="lazy" />
                                        </div>
                                    <?php endif; ?>
                                    
                                    <div class="post-content">
                                        <div class="post-header">
                                            <span class="post-author"><?php echo esc_html($item['author']); ?></span>
                                            <span class="post-date"><?php echo esc_html($item['date']); ?></span>
                                        </div>
                                        
                                        <div class="post-text">
                                            <?php echo wp_kses_post($item['content']); ?>
                                        </div>
                                        
                                        <div class="post-actions">
                                            <?php if (!empty($item['link'])): ?>
                                                <a href="<?php echo esc_url($item['link']); ?>" target="_blank" rel="noopener noreferrer" class="read-more-btn">Read more</a>
                                            <?php endif; ?>
                                        </div>
                                        
                                        <div class="post-footer">
                                            <span class="post-source">
                                                <svg class="source-icon" width="16" height="16" viewBox="0 0 24 24">
                                                    <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
                                                </svg>
                                                Schleicher Social
                                            </span>
                                            <?php if (!empty($item['link'])): ?>
                                                <a href="<?php echo esc_url($item['link']); ?>" target="_blank" rel="noopener noreferrer" class="share-btn">Share</a>
                                            <?php endif; ?>
                                        </div>
                                    </div>
                                </div>
                            <?php endforeach; ?>
                        </div>
                    </div>
                <?php endforeach; ?>
            </div>
            
            <?php if ($total_slides > 1): ?>
                <button class="carousel-prev" aria-label="Previous slide">‹</button>
                <button class="carousel-next" aria-label="Next slide">›</button>
            <?php endif; ?>
        </div>
        
        <?php if ($total_slides > 1): ?>
            <div class="carousel-dots">
                <?php for ($i = 0; $i < $total_slides; $i++): ?>
                    <button class="carousel-dot <?php echo $i === 0 ? 'active' : ''; ?>" data-slide="<?php echo $i; ?>" aria-label="Go to slide <?php echo ($i + 1); ?>"></button>
                <?php endfor; ?>
            </div>
        <?php endif; ?>
        <?php
        return ob_get_clean();
    }
    
    private function fetch_rss_feed($url, $force_refresh = false) {
        if (empty($url)) {
            return new WP_Error('empty_url', 'RSS URL is empty');
        }
        
        $options = get_option($this->plugin_name . '_options');
        $cache_duration = isset($options['cache_duration']) ? $options['cache_duration'] : 15;
        $cache_key = 'mastodon_feed_' . md5($url);
        
        if (!$force_refresh) {
            $cached_data = get_transient($cache_key);
            if ($cached_data !== false) {
                return $cached_data;
            }
        }
        
        $response = wp_remote_get($url, array(
            'timeout' => 30,
            'headers' => array('User-Agent' => 'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url'))
        ));
        
        if (is_wp_error($response)) {
            return $response;
        }
        
        $body = wp_remote_retrieve_body($response);
        if (empty($body)) {
            return new WP_Error('empty_feed', 'RSS feed returned empty response');
        }
        
        libxml_use_internal_errors(true);
        $xml = simplexml_load_string($body);
        
        if ($xml === false) {
            $errors = libxml_get_errors();
            $error_msg = !empty($errors) ? $errors[0]->message : 'Invalid XML format';
            return new WP_Error('invalid_xml', 'Feed parsing error: ' . $error_msg);
        }
        
        $items = array();
        
        if (isset($xml->channel->item)) {
            foreach ($xml->channel->item as $item) {
                $title = (string) $item->title;
                $link = (string) $item->link;
                $pub_date = (string) $item->pubDate;
                $description = (string) $item->description;
                
                $content = $this->clean_mastodon_content($description);
                if (empty($content) || strlen($content) < 20) {
                    $content = $this->clean_mastodon_content($title);
                }
                
                $image = '';
                $item->registerXPathNamespace('media', 'http://search.yahoo.com/mrss/');
                $media_content = $item->xpath('media:content[@type="image/jpeg" or @type="image/png" or @type="image/gif" or @type="image/webp"]');
                if (!empty($media_content)) {
                    $image = (string) $media_content[0]['url'];
                }
                
                if (empty($image)) {
                    $media_content = $item->xpath('media:content[@url]');
                    if (!empty($media_content)) {
                        $url_check = (string) $media_content[0]['url'];
                        if (preg_match('/\.(jpg|jpeg|png|gif|webp)$/i', $url_check) || strpos($url_check, '/media_attachments/') !== false) {
                            $image = $url_check;
                        }
                    }
                }
                
                if (empty($image) && isset($item->enclosure)) {
                    foreach ($item->enclosure as $enclosure) {
                        $enc_type = (string) $enclosure['type'];
                        if (strpos($enc_type, 'image/') === 0) {
                            $image = (string) $enclosure['url'];
                            break;
                        }
                    }
                }
                
                $author = 'The Schleicher Bot';
                if (isset($item->author)) {
                    $author = (string) $item->author;
                } elseif (isset($item->children('dc', true)->creator)) {
                    $author = (string) $item->children('dc', true)->creator;
                }
                
                $date = '';
                if (!empty($pub_date)) {
                    $timestamp = strtotime($pub_date);
                    if ($timestamp !== false) {
                        $date = date('F j', $timestamp);
                    }
                }
                
                $post_id = md5($link . $pub_date);
                
                $items[] = array(
                    'id' => $post_id,
                    'title' => $title,
                    'content' => $content,
                    'link' => $link,
                    'date' => $date,
                    'author' => $author,
                    'image' => $image
                );
            }
        }
        
        if (!empty($items)) {
            set_transient($cache_key, $items, $cache_duration * MINUTE_IN_SECONDS);
        }
        
        return $items;
    }
    
    private function clean_mastodon_content($html) {
        $html = preg_replace('/<span class="invisible"[^>]*>.*?<\/span>/is', '', $html);
        $html = preg_replace('/<span class="ellipsis"[^>]*>(.*?)<\/span>/is', '$1...', $html);
        
        $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $html = str_replace(array('<br>', '<br/>', '<br />', '</p>'), "\n", $html);
        $html = strip_tags($html);
        
        $html = preg_replace('/\n\s*\n/', "\n\n", $html);
        $html = preg_replace('/[ \t]+/', ' ', $html);
        $html = trim($html);
        $html = nl2br($html);
        
        if (strlen($html) > 200) {
            $html = substr($html, 0, 197);
            $last_space = strrpos($html, ' ');
            if ($last_space !== false && $last_space > 150) {
                $html = substr($html, 0, $last_space);
            }
            $html .= '...';
        }
        
        if (strlen($html) < 10) {
            $html = 'View full post for details.';
        }
        
        return $html;
    }
}

// Initialize the plugin
new FamilyMastodonCarousel();

// Activation hook
register_activation_hook(__FILE__, function() {
    $default_options = array(
        'rss_url' => 'https://yoursite.com/mastodon_rss.xml',
        'posts_limit' => 18,
        'cache_duration' => 15,
        'posts_per_slide' => 6,
        'show_images' => 1,
        'ajax_loading' => 0
    );
    add_option('family-mastodon-carousel_options', $default_options);
});

// Deactivation hook  
register_deactivation_hook(__FILE__, function() {
    global $wpdb;
    $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mastodon_feed_%'");
    $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_mastodon_feed_%'");
});

?>

carousel.js

/**
 * Schleicher Social Feed JavaScript - Mobile Optimized Carousel
 * Version 2.1.0 - Single post per slide on mobile
 */

(function($) {
    'use strict';
    
    class SchleicharSocialCarousel {
        constructor(container) {
            this.container = $(container);
            this.track = this.container.find('.carousel-track');
            this.slides = this.container.find('.carousel-slide');
            this.dots = this.container.find('.carousel-dot');
            this.prevBtn = this.container.find('.carousel-prev');
            this.nextBtn = this.container.find('.carousel-next');
            
            this.currentSlide = 0;
            this.originalSlides = this.slides.length;
            this.totalSlides = this.originalSlides;
            this.autoPlayInterval = null;
            this.isAutoPlay = true;
            this.autoPlayDelay = 6000;
            this.isMobile = window.innerWidth <= 480;
            this.mobileSlides = [];
            this.originalPosts = [];
            
            // Store original posts data
            this.storeOriginalData();
            
            if (this.totalSlides > 1 || this.isMobile) {
                this.init();
            } else {
                // Hide navigation if only one slide
                this.prevBtn.hide();
                this.nextBtn.hide();
                this.dots.parent().hide();
            }
        }
        
        storeOriginalData() {
            // Store all post cards from all slides
            this.slides.each((slideIndex, slide) => {
                const $slide = $(slide);
                const posts = $slide.find('.post-card').toArray();
                this.originalPosts.push(...posts);
            });
        }
        
        init() {
            this.handleResize(); // Set up initial layout
            this.bindEvents();
            this.updateNavigation();
            
            if (this.totalSlides > 1) {
                this.startAutoPlay();
            }
        }
        
        bindEvents() {
            // Previous button
            this.prevBtn.on('click', (e) => {
                e.preventDefault();
                this.prevSlide();
            });
            
            // Next button
            this.nextBtn.on('click', (e) => {
                e.preventDefault();
                this.nextSlide();
            });
            
            // Dot navigation
            this.dots.on('click', (e) => {
                e.preventDefault();
                const slideIndex = parseInt($(e.target).data('slide'));
                if (!isNaN(slideIndex)) {
                    this.goToSlide(slideIndex);
                }
            });
            
            // Keyboard navigation
            this.container.on('keydown', (e) => {
                if (!this.container.is(':focus-within')) return;
                
                switch(e.which) {
                    case 37: // Left arrow
                        e.preventDefault();
                        this.prevSlide();
                        break;
                    case 39: // Right arrow
                        e.preventDefault();
                        this.nextSlide();
                        break;
                    case 32: // Space bar
                        e.preventDefault();
                        this.toggleAutoPlay();
                        break;
                }
            });
            
            // Pause autoplay on hover
            this.container.on('mouseenter', () => {
                this.pauseAutoPlay();
            });
            
            this.container.on('mouseleave', () => {
                if (this.isAutoPlay) {
                    this.startAutoPlay();
                }
            });
            
            // Touch/swipe support
            this.addTouchSupport();
            
            // Pause autoplay when page is not visible
            $(document).on('visibilitychange', () => {
                if (document.hidden) {
                    this.pauseAutoPlay();
                } else if (this.isAutoPlay) {
                    this.startAutoPlay();
                }
            });
            
            // Handle window resize
            $(window).on('resize', () => {
                clearTimeout(this.resizeTimeout);
                this.resizeTimeout = setTimeout(() => {
                    this.handleResize();
                }, 250);
            });
            
            // Handle post card interactions
            this.container.find('.post-card').on('mouseenter', function() {
                $(this).addClass('hovered');
            }).on('mouseleave', function() {
                $(this).removeClass('hovered');
            });
        }
        
        handleResize() {
            const wasMobile = this.isMobile;
            this.isMobile = window.innerWidth <= 480;
            
            // If mobile state changed, rebuild carousel
            if (wasMobile !== this.isMobile) {
                this.rebuildCarousel();
            }
        }
        
        rebuildCarousel() {
            this.pauseAutoPlay();
            
            if (this.isMobile) {
                this.createMobileLayout();
            } else {
                this.restoreDesktopLayout();
            }
            
            this.updateDots();
            this.currentSlide = 0;
            this.goToSlide(0);
            
            if (this.totalSlides > 1) {
                this.startAutoPlay();
            }
        }
        
        createMobileLayout() {
            // Clear existing slides
            this.track.empty();
            
            // Create individual slides for each post
            this.mobileSlides = [];
            this.originalPosts.forEach((post, index) => {
                const $slide = $('<div class="carousel-slide"></div>');
                if (index === 0) {
                    $slide.addClass('active');
                }
                
                const $grid = $('<div class="posts-grid"></div>');
                $grid.append($(post).clone());
                $slide.append($grid);
                
                this.track.append($slide);
                this.mobileSlides.push($slide);
            });
            
            this.slides = this.container.find('.carousel-slide');
            this.totalSlides = this.originalPosts.length;
            
            // Show navigation even for single slide on mobile
            this.prevBtn.show();
            this.nextBtn.show();
            this.dots.parent().show();
        }
        
        restoreDesktopLayout() {
            // Clear mobile slides
            this.track.empty();
            
            // Get posts per slide from container data attribute
            const postsPerSlide = parseInt(this.container.data('posts-per-slide')) || 6;
            
            // Recreate original multi-post slides
            const slideChunks = [];
            for (let i = 0; i < this.originalPosts.length; i += postsPerSlide) {
                slideChunks.push(this.originalPosts.slice(i, i + postsPerSlide));
            }
            
            slideChunks.forEach((posts, slideIndex) => {
                const $slide = $('<div class="carousel-slide"></div>');
                if (slideIndex === 0) {
                    $slide.addClass('active');
                }
                
                const $grid = $('<div class="posts-grid"></div>');
                posts.forEach(post => {
                    $grid.append($(post).clone());
                });
                $slide.append($grid);
                
                this.track.append($slide);
            });
            
            this.slides = this.container.find('.carousel-slide');
            this.totalSlides = slideChunks.length;
            
            // Hide navigation if only one slide
            if (this.totalSlides <= 1) {
                this.prevBtn.hide();
                this.nextBtn.hide();
                this.dots.parent().hide();
            } else {
                this.prevBtn.show();
                this.nextBtn.show();
                this.dots.parent().show();
            }
        }
        
        updateDots() {
            // Remove existing dots
            this.dots.parent().empty();
            
            // Create new dots
            const $dotsContainer = this.dots.parent();
            for (let i = 0; i < this.totalSlides; i++) {
                const $dot = $('<button class="carousel-dot"></button>');
                $dot.attr('data-slide', i);
                $dot.attr('aria-label', `Go to slide ${i + 1}`);
                
                if (i === 0) {
                    $dot.addClass('active');
                }
                
                $dotsContainer.append($dot);
            }
            
            // Update dots reference
            this.dots = this.container.find('.carousel-dot');
            
            // Rebind dot events
            this.dots.off('click').on('click', (e) => {
                e.preventDefault();
                const slideIndex = parseInt($(e.target).data('slide'));
                if (!isNaN(slideIndex)) {
                    this.goToSlide(slideIndex);
                }
            });
        }
        
        addTouchSupport() {
            let startX = 0;
            let startY = 0;
            let endX = 0;
            let endY = 0;
            const minSwipeDistance = 60;
            let isScrolling = false;
            
            this.container.on('touchstart', (e) => {
                const touch = e.originalEvent.touches[0];
                startX = touch.clientX;
                startY = touch.clientY;
                isScrolling = false;
            });
            
            this.container.on('touchmove', (e) => {
                if (isScrolling) return;
                
                const touch = e.originalEvent.touches[0];
                const deltaX = Math.abs(touch.clientX - startX);
                const deltaY = Math.abs(touch.clientY - startY);
                
                // Determine if user is scrolling vertically or swiping horizontally
                if (deltaY > deltaX && deltaY > 10) {
                    isScrolling = true;
                    return;
                }
                
                if (deltaX > 10) {
                    e.preventDefault(); // Prevent scrolling when swiping
                }
            });
            
            this.container.on('touchend', (e) => {
                if (isScrolling) return;
                
                const touch = e.originalEvent.changedTouches[0];
                endX = touch.clientX;
                endY = touch.clientY;
                
                const deltaX = endX - startX;
                const deltaY = endY - startY;
                
                // Check if horizontal swipe is longer than vertical
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    if (Math.abs(deltaX) > minSwipeDistance) {
                        if (deltaX > 0) {
                            this.prevSlide(); // Swipe right = previous
                        } else {
                            this.nextSlide(); // Swipe left = next
                        }
                    }
                }
            });
        }
        
        goToSlide(index) {
            if (index < 0 || index >= this.totalSlides || index === this.currentSlide) {
                return;
            }
            
            // Remove active class from current slide and dot
            this.slides.eq(this.currentSlide).removeClass('active');
            this.dots.eq(this.currentSlide).removeClass('active');
            
            // Update current slide index
            this.currentSlide = index;
            
            // Add active class to new slide and dot
            this.slides.eq(this.currentSlide).addClass('active');
            this.dots.eq(this.currentSlide).addClass('active');
            
            this.updateNavigation();
            this.resetAutoPlay();
            
            // Announce slide change for screen readers
            this.announceSlideChange();
        }
        
        nextSlide() {
            const nextIndex = (this.currentSlide + 1) % this.totalSlides;
            this.goToSlide(nextIndex);
        }
        
        prevSlide() {
            const prevIndex = (this.currentSlide - 1 + this.totalSlides) % this.totalSlides;
            this.goToSlide(prevIndex);
        }
        
        updateNavigation() {
            // Update button states for accessibility
            this.prevBtn.attr('aria-disabled', this.totalSlides <= 1);
            this.nextBtn.attr('aria-disabled', this.totalSlides <= 1);
            
            // Update dots
            this.dots.attr('aria-pressed', 'false');
            this.dots.eq(this.currentSlide).attr('aria-pressed', 'true');
        }
        
        startAutoPlay() {
            if (this.totalSlides <= 1) return;
            
            this.pauseAutoPlay(); // Clear any existing interval
            this.autoPlayInterval = setInterval(() => {
                this.nextSlide();
            }, this.autoPlayDelay);
        }
        
        pauseAutoPlay() {
            if (this.autoPlayInterval) {
                clearInterval(this.autoPlayInterval);
                this.autoPlayInterval = null;
            }
        }
        
        resetAutoPlay() {
            if (this.isAutoPlay) {
                this.startAutoPlay();
            }
        }
        
        toggleAutoPlay() {
            this.isAutoPlay = !this.isAutoPlay;
            if (this.isAutoPlay) {
                this.startAutoPlay();
            } else {
                this.pauseAutoPlay();
            }
            
            // Provide feedback to user
            const message = this.isAutoPlay ? 'Auto-play enabled' : 'Auto-play disabled';
            this.announceToScreenReader(message);
        }
        
        announceSlideChange() {
            const slideNum = this.currentSlide + 1;
            const message = `Showing slide ${slideNum} of ${this.totalSlides}`;
            this.announceToScreenReader(message);
        }
        
        announceToScreenReader(message) {
            // Create or update live region for screen reader announcements
            let liveRegion = this.container.find('.sr-live-region');
            if (liveRegion.length === 0) {
                liveRegion = $('<div class="sr-live-region" aria-live="polite" style="position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden;"></div>');
                this.container.append(liveRegion);
            }
            liveRegion.text(message);
        }
        
        // Method to manually refresh carousel (useful if content changes)
        refresh() {
            this.storeOriginalData();
            this.rebuildCarousel();
        }
        
        // Method to go to a specific post (useful for deep linking)
        goToPost(postId) {
            // Find the slide containing the post with this ID
            if (this.isMobile) {
                // In mobile mode, each post is its own slide
                this.originalPosts.forEach((post, index) => {
                    if ($(post).attr('data-post-id') === postId) {
                        this.goToSlide(index);
                        return false;
                    }
                });
            } else {
                // In desktop mode, search within slides
                this.slides.each((index, slide) => {
                    if ($(slide).find(`[data-post-id="${postId}"]`).length > 0) {
                        this.goToSlide(index);
                        return false; // Break the loop
                    }
                });
            }
        }
    }
    
    // Initialize carousels when document is ready
    $(document).ready(function() {
        $('.schleicher-social-feed').each(function() {
            new SchleicharSocialCarousel(this);
        });
    });
    
    // Handle dynamic content loading (for AJAX-loaded content)
    $(document).on('DOMNodeInserted', function(e) {
        const $target = $(e.target);
        if ($target.hasClass('schleicher-social-feed') || $target.find('.schleicher-social-feed').length) {
            $target.find('.schleicher-social-feed').each(function() {
                if (!$(this).data('carousel-initialized')) {
                    new SchleicharSocialCarousel(this);
                    $(this).data('carousel-initialized', true);
                }
            });
        }
    });
    
    // Expose the class globally for manual initialization
    window.SchleicharSocialCarousel = SchleicharSocialCarousel;
    
})(jQuery);

carousel.css

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Carousel CSS - Version 2.1.0</title>
<style>
/* Schleicher Social Feed - Carousel Styles */
/* Version 2.1.0 - Mobile Optimized with Bottom Padding Fix */

/* Main container */
.schleicher-social-feed {
    width: 100%;
    max-width: 100%;
    margin: 20px auto;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    position: relative;
}

/* Carousel wrapper */
.carousel-container {
    position: relative;
    width: 100%;
    overflow: visible;
    background: transparent;
    border-radius: 12px;
    padding: 20px;
    box-sizing: border-box;
}

/* Track for slides */
.carousel-track {
    position: relative;
    width: 100%;
    height: 100%;
    overflow: hidden;
}

/* Individual slides */
.carousel-slide {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.5s ease, visibility 0.5s ease;
}

.carousel-slide.active {
    opacity: 1;
    visibility: visible;
    position: relative;
}

/* Grid layout */
.posts-grid {
    display: grid;
    gap: 20px;
    height: 100%;
    width: 100%;
    box-sizing: border-box;
}

/* Post card */
.post-card {
    background: #ffffff;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
    overflow: hidden;
    transition: transform 0.3s ease, box-shadow 0.3s ease;
    display: flex;
    flex-direction: column;
    height: 400px;
    min-height: 400px;
    max-height: 400px;
    position: relative;
}

.post-card:hover {
    transform: translateY(-5px);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}

/* Post image */
.post-image {
    width: 100%;
    height: 160px;
    overflow: hidden;
    position: relative;
    flex-shrink: 0;
}

.post-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    transition: transform 0.3s ease;
}

.post-card:hover .post-image img {
    transform: scale(1.05);
}

/* Placeholder image styling */
.post-image.no-image {
    background: linear-gradient(135deg, #1372B7 0%, #0a3d62 100%);
    display: flex;
    align-items: center;
    justify-content: center;
}

.post-image.no-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0.95;
}

/* Post content area */
.post-content {
    padding: 16px;
    display: flex;
    flex-direction: column;
    flex-grow: 1;
    position: relative;
}

/* Post header */
.post-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 10px;
    padding-bottom: 10px;
    border-bottom: 1px solid #f0f0f0;
}

.post-author {
    font-weight: 600;
    color: #1a1a1a;
    font-size: 14px;
}

.post-date {
    color: #666;
    font-size: 12px;
}

/* Post text content */
.post-text {
    color: #333;
    line-height: 1.6;
    font-size: 14px;
    margin-bottom: 15px;
    flex-grow: 1;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    word-wrap: break-word;
}

/* Post actions */
.post-actions {
    margin-top: auto;
    padding-bottom: 10px;
}

.read-more-btn {
    display: inline-block;
    padding: 8px 20px;
    background: #8e0303;
    color: white !important;
    text-decoration: none !important;
    border-radius: 6px;
    font-size: 14px;
    font-weight: 500;
    transition: background 0.3s ease, transform 0.2s ease;
    cursor: pointer;
}

.read-more-btn:hover {
    background: #666666;
    transform: translateY(-1px);
    text-decoration: none !important;
}

/* Post footer */
.post-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-top: 12px;
    border-top: 1px solid #f0f0f0;
    margin-top: auto;
}

.post-source {
    display: flex;
    align-items: center;
    color: #666;
    font-size: 12px;
}

.source-icon {
    width: 16px;
    height: 16px;
    margin-right: 6px;
    fill: #0a3d62;
}

.share-btn {
    display: inline-flex;
    align-items: center;
    padding: 5px 12px;
    border: 1px solid #666666;
    border-radius: 4px;
    color: #ffffff !important;
    text-decoration: none !important;
    font-size: 12px;
    transition: all 0.3s ease;
    background: #666666;
    cursor: pointer;
}

.share-btn:hover {
    border-color: #d4a017;
    color: #666666 !important;
    background: #d4a017;
    text-decoration: none !important;
}

/* Navigation buttons */
.carousel-prev,
.carousel-next {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    background: #d4a017;
    color: white;
    border: 2px solid #d4a017;
    width: 44px;
    height: 44px;
    border-radius: 50%;
    cursor: pointer;
    font-size: 24px;
    font-weight: normal;
    transition: all 0.3s ease;
    z-index: 100;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    line-height: 1;
}

.carousel-prev:hover,
.carousel-next:hover {
    background: #0a3d62;
    color: white;
    border-color: #0a3d62;
    box-shadow: 0 4px 12px rgba(29, 161, 242, 0.3);
}

.carousel-prev {
    left: -60px;
}

.carousel-next {
    right: -60px;
}

/* Dots navigation */
.carousel-dots {
    display: flex;
    justify-content: center;
    gap: 8px;
    margin-top: 20px;
    padding: 10px;
}

.carousel-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    border: none;
    background: #d0d0d0;
    cursor: pointer;
    transition: all 0.3s ease;
    padding: 0;
}

.carousel-dot:hover {
    background: #0a3d62;
    transform: scale(1.2);
}

.carousel-dot.active {
    width: 24px;
    border-radius: 4px;
    background: #8e0303;
}

/* Layout variations based on posts per slide */
/* 3 posts (1x3) - Single row */
.schleicher-social-feed[data-posts-per-slide="3"] .carousel-container {
    height: 480px !important;
}

.schleicher-social-feed[data-posts-per-slide="3"] .posts-grid {
    grid-template-columns: repeat(3, 1fr) !important;
    grid-template-rows: 1fr !important;
}

/* 4 posts (2x2) */
.schleicher-social-feed[data-posts-per-slide="4"] .carousel-container {
    height: 960px !important;
}

.schleicher-social-feed[data-posts-per-slide="4"] .posts-grid {
    grid-template-columns: repeat(2, 1fr);
    grid-template-rows: repeat(2, 1fr);
}

/* 6 posts (2x3) - Default */
.schleicher-social-feed[data-posts-per-slide="6"] .carousel-container {
    height: 960px !important;
}

.schleicher-social-feed[data-posts-per-slide="6"] .posts-grid {
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: repeat(2, 1fr);
}

/* 8 posts (2x4) */
.schleicher-social-feed[data-posts-per-slide="8"] .carousel-container {
    height: 960px !important;
}

.schleicher-social-feed[data-posts-per-slide="8"] .posts-grid {
    grid-template-columns: repeat(4, 1fr);
    grid-template-rows: repeat(2, 1fr);
}

/* 9 posts (3x3) */
.schleicher-social-feed[data-posts-per-slide="9"] .carousel-container {
    height: 1440px !important;
}

.schleicher-social-feed[data-posts-per-slide="9"] .posts-grid {
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: repeat(3, 1fr);
}

/* Error message */
.mastodon-carousel-error {
    padding: 20px;
    text-align: center;
    color: #e74c3c;
    background: #fdf2f2;
    border: 1px solid #f5c6cb;
    border-radius: 8px;
    margin: 20px auto;
    max-width: 600px;
}

/* Loading state */
.schleicher-social-feed.loading {
    min-height: 440px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #666;
}

.schleicher-social-feed.loading::after {
    content: 'Loading posts...';
    font-size: 16px;
}

/* Loading spinner animation */
@keyframes spin {
    0% { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
}

/* Responsive design - Tablet */
@media (max-width: 1024px) {
    .carousel-container {
        padding: 15px;
    }
    
    .posts-grid {
        gap: 15px;
    }
    
    /* Maintain 3 columns for 1x3 layout on tablets */
    .schleicher-social-feed[data-posts-per-slide="3"] .posts-grid {
        grid-template-columns: repeat(3, 1fr) !important;
    }
    
    /* Adjust other layouts */
    .schleicher-social-feed[data-posts-per-slide="6"] .posts-grid,
    .schleicher-social-feed[data-posts-per-slide="8"] .posts-grid,
    .schleicher-social-feed[data-posts-per-slide="9"] .posts-grid {
        grid-template-columns: repeat(2, 1fr);
        grid-template-rows: auto;
    }
}

/* Responsive design - Small Tablet */
@media (max-width: 768px) {
    .carousel-container {
        padding: 12px;
        height: auto !important;
    }
    
    .posts-grid {
        grid-template-columns: repeat(2, 1fr) !important;
        grid-template-rows: auto !important;
        gap: 12px;
    }
    
    .post-image {
        height: 140px;
    }
    
    .post-card {
        min-height: auto;
    }
    
    .carousel-prev,
    .carousel-next {
        width: 38px;
        height: 38px;
        font-size: 20px;
        left: -50px;
        right: -50px;
    }
}

/* Mobile Optimization - 1 POST PER SLIDE */
@media (max-width: 480px) {
    /* Mobile layout gets proper structure from PHP */
    .schleicher-social-feed.mobile-layout .carousel-container {
        padding: 15px;
        height: auto !important;
        min-height: 520px;
    }
    
    /* Mobile grid styling */
    .schleicher-social-feed.mobile-layout .posts-grid {
        display: flex !important;
        align-items: center;
        justify-content: center;
        width: 100%;
        height: 100%;
    }
    
    /* Make the single card larger and centered on mobile */
    .schleicher-social-feed.mobile-layout .post-card {
        height: auto !important;
        min-height: 480px;
        max-height: none;
        width: 100%;
        max-width: 360px;
        margin: 0 auto;
        display: flex;
        flex-direction: column;
    }
    
    .schleicher-social-feed.mobile-layout .post-image {
        height: 220px;
        flex-shrink: 0;
    }
    
    .schleicher-social-feed.mobile-layout .post-content {
        padding: 18px;
        display: flex;
        flex-direction: column;
        flex-grow: 1;
        min-height: 0;
    }
    
    .schleicher-social-feed.mobile-layout .post-text {
        font-size: 15px;
        -webkit-line-clamp: 4;
        margin-bottom: 18px;
        line-height: 1.5;
    }
    
    .schleicher-social-feed.mobile-layout .post-author {
        font-size: 15px;
    }
    
    .schleicher-social-feed.mobile-layout .post-date {
        font-size: 13px;
    }
    
    .schleicher-social-feed.mobile-layout .read-more-btn {
        padding: 10px 24px;
        font-size: 15px;
    }
    
    .schleicher-social-feed.mobile-layout .post-footer {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding-top: 12px;
        border-top: 1px solid #f0f0f0;
        margin-top: auto;
        flex-shrink: 0;
    }
    
    /* Show navigation arrows on mobile */
    .schleicher-social-feed.mobile-layout .carousel-prev,
    .schleicher-social-feed.mobile-layout .carousel-next {
        display: flex !important;
        width: 40px;
        height: 40px;
        font-size: 22px;
    }
    
    .schleicher-social-feed.mobile-layout .carousel-prev {
        left: -55px;
    }
    
    .schleicher-social-feed.mobile-layout .carousel-next {
        right: -55px;
    }
    
    .schleicher-social-feed.mobile-layout .carousel-dots {
        padding: 15px 8px;
        gap: 8px;
        margin-top: 15px;
    }
    
    .schleicher-social-feed.mobile-layout .carousel-dot {
        width: 8px;
        height: 8px;
    }
    
    .schleicher-social-feed.mobile-layout .carousel-dot.active {
        width: 20px;
    }
    
    /* Fallback for any non-mobile-layout on mobile screens */
    .schleicher-social-feed:not(.mobile-layout) .carousel-container {
        height: auto !important;
        padding: 12px;
    }
    
    .schleicher-social-feed:not(.mobile-layout) .posts-grid {
        grid-template-columns: 1fr !important;
        grid-template-rows: auto !important;
        gap: 12px;
    }
    
    .schleicher-social-feed:not(.mobile-layout) .post-card {
        height: auto !important;
        min-height: 300px;
    }
}

/* Extra small screens */
@media (max-width: 360px) {
    .carousel-container {
        padding: 12px;
    }
    
    .schleicher-social-feed.mobile-layout .post-card {
        max-width: 100%;
    }
    
    .carousel-prev,
    .carousel-next {
        left: -45px !important;
        right: -45px !important;
    }
}

/* Accessibility */
.carousel-prev:focus,
.carousel-next:focus,
.carousel-dot:focus,
.read-more-btn:focus,
.share-btn:focus {
    outline: 2px solid #1da1f2;
    outline-offset: 2px;
}

/* Remove unwanted link styles */
.post-card a {
    box-shadow: none !important;
}

/* Screen reader only content */
.sr-live-region {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
}

/* Print styles */
@media print {
    .carousel-prev,
    .carousel-next,
    .carousel-dots {
        display: none;
    }
    
    .carousel-slide {
        position: static;
        opacity: 1;
        visibility: visible;
    }
    
    .posts-grid {
        grid-template-columns: repeat(2, 1fr);
    }
}

/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
    .carousel-slide,
    .post-card,
    .post-image img,
    .carousel-prev,
    .carousel-next,
    .carousel-dot,
    .read-more-btn,
    .share-btn {
        transition: none;
    }
}
</style>

Once complete you’ll have a new plugin in the WordPress plugin page. Note that if you want to edit the color and styles, this is a good time to edit carousel.css to change any associated coloring. The plugin does include a basic admin menu, but it does not currently allow for CSS changes/editing.

In the plugin page, enable the plugin. To generate the feed, head to the admin settings of the plugin (Settings→Mastodon Carousel) and enter the path to your XML file. Determine if you want to display images with posts as well as the cache time. Optionally, use AJAX if you want to bypass caching. Finally, test the RSS feed to see if everything is set up correctly.

Display on the page

Lastly drop the shortcode (mastodon_carousel) wherever you would like to display the feed. Optionally, you can define specific parameters by passing those in the shortcode (e.g., limiting number of posts or posts per slide).

Schleicher Social RSS Feed

At the end, you’ll have a functional carousel with basic config options, auto-play (changes every 5 seconds), and a nice, clean look with the full public feed from your Mastodon instance.

Zack
Author: Zack

Pharmacist, tech guy, gamer, fixer. Good at a little bit of everything.

Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments

More results...

Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
post
calendar
Filter by Categories
Boar Report
Family
Help

Recent Comments

Archives

Categories

0
Got a comment?x
()
x