Files
Projects-Portfolio/projects-portfolio.php
Keith Solomon 2bbd2dc7c8 Update bundled plugin-update-checker to v5.7 and remove broken setBranch() call
The bundled PUC was missing the setBranch() method on the main UpdateChecker
class (it's only on Vcs\PluginUpdateChecker via the VcsCheckerMethods trait).
When called with a non-VCS URL like the Gitea update URL, this caused a fatal
error at plugin activation.

Updating to PUC v5.7 (May 2026) which is the latest upstream release. The
setBranch() call is removed because PUC doesn't recognize Gitea as a VCS host;
the plugin falls back to PUC's plain JSON metadata mode, which is fine for a
plugin hosted on a self-managed repo.
2026-08-11 16:08:20 -05:00

394 lines
15 KiB
PHP

<?php
/**
* The plugin bootstrap file
*
* @link https://keithsolomon.net
* @since 1.0.0
* @package Projects_Portfolio
*
* @wordpress-plugin
*
* Plugin Name: Projects Portfolio
* Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.
* Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
* Version: 1.1.1
* Author: Keith Solomon
* Author URI: https://keithsolomon.net
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: projects-wp
* Domain Path: /languages
* Update URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
*/
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
require 'includes/plugin-update-checker/plugin-update-checker.php';
use YahnisElsts\PluginUpdateChecker\v5\PucFactory;
$myUpdateChecker = PucFactory::buildUpdateChecker(
'https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio',
__FILE__,
'projects-portfolio'
);
// Note: PUC's setBranch() is only available when the host is recognized as a
// VCS provider (GitHub, GitLab, Bitbucket). The Gitea URL here falls back to
// PUC's plain JSON metadata mode, so we skip the call rather than throw.
// Current plugin version.
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.1' );
// Add the required files.
require 'admin/admin-settings.php';
require 'admin/cpt-taxonomy.php';
require 'admin/metabox.php';
require 'includes/helper-functions.php';
require 'includes/providers/interface-repository-provider.php';
require 'includes/providers/class-github-provider.php';
require 'includes/providers/class-gitea-provider.php';
require 'includes/providers/class-provider-factory.php';
/**
* Flush rewrite rules on activation.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_flush_rewrite_rules() {
projects_portfolio_register_cpt_and_taxonomy();
projects_portfolio_add_rewrite_rules();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'projects_portfolio_flush_rewrite_rules' );
/**
* Add rewrite rules for download endpoint.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_add_rewrite_rules() {
add_rewrite_rule(
'^download/([0-9]+)/?$',
'index.php?project_download_id=$matches[1]',
'top'
);
}
add_action( 'init', 'projects_portfolio_add_rewrite_rules' );
/**
* WP_Query Vars
*
* @param array $vars Query variables array.
*
* @since 1.0.0
* @return array
*/
function projects_portfolio_query_vars( $vars ) {
$vars[] = 'project_download_id';
return $vars;
}
add_filter( 'query_vars', 'projects_portfolio_query_vars' );
/**
* Handle Download Redirect and Increment Download Count
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_handle_download_redirect() {
$project_id = get_query_var( 'project_download_id' );
if ( $project_id ) {
$download_url = projects_portfolio_get_release_url( (int) $project_id );
if ( $download_url ) {
// Increment download count.
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
++$download_count;
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
// Redirect to the release ZIP on the project's host.
wp_safe_redirect( esc_url_raw( $download_url ) );
exit;
} else {
wp_die(
esc_html__( 'Invalid download URL. Please check the repository URL.', 'projects-wp' ),
esc_html__( 'Download Error', 'projects-wp' ),
array( 'response' => 404 )
);
}
}
}
add_action( 'template_redirect', 'projects_portfolio_handle_download_redirect' );
/**
* Add Download Count Column to Projects Admin Table
*
* @since 1.0.0
* @param array $columns The columns array.
* @return mixed
*/
function projects_portfolio_add_download_count_column( $columns ) {
$columns['download_count'] = esc_html__( 'Download Count', 'projects-wp' );
return $columns;
}
add_filter( 'manage_projects_posts_columns', 'projects_portfolio_add_download_count_column' );
/**
* Display Download Count in Admin Column
*
* @since 1.0.0
* @param string $column The column name.
* @param int $post_id The post ID.
* @return void
*/
function projects_portfolio_display_download_count_column( $column, $post_id ) {
if ( 'download_count' === $column ) {
$download_count = (int) get_post_meta( $post_id, '_projects_portfolio_download_count', true );
echo $download_count ? esc_html( $download_count ) : esc_html__( '0', 'projects-wp' );
}
}
add_action( 'manage_projects_posts_custom_column', 'projects_portfolio_display_download_count_column', 10, 2 );
/**
* Make the Download Count Column Sortable
*
* @since 1.0.0
* @param array $columns The columns array.
* @return mixed
*/
function projects_portfolio_sortable_columns( $columns ) {
$columns['download_count'] = 'download_count';
return $columns;
}
add_filter( 'manage_edit-projects_sortable_columns', 'projects_portfolio_sortable_columns' );
/**
* Handle Sorting for Download Count
*
* @since 1.0.0
* @param WP_Query $query The query object.
* @return void
*/
function projects_portfolio_sort_download_count_column( $query ) {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( 'download_count' === $query->get( 'orderby' ) ) {
$query->set( 'meta_key', '_projects_portfolio_download_count' );
$query->set( 'orderby', 'meta_value_num' );
}
}
add_action( 'pre_get_posts', 'projects_portfolio_sort_download_count_column' );
/**
* Register custom REST API endpoint for projects.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_register_custom_endpoint() {
register_rest_route(
'projects/v1',
'/projects',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'projects_portfolio_get_projects_data',
'permission_callback' => '__return_true',
)
);
}
add_action( 'rest_api_init', 'projects_portfolio_register_custom_endpoint' );
/**
* Load custom templates for single and archive project views.
*
* @since 1.0.0
* @param string $template The template file path.
* @return mixed
*/
function projects_portfolio_template_loader( $template ) {
if ( is_singular( 'projects' ) ) {
// Check for a single-projects.php template in the theme
$theme_template = locate_template( 'single-projects.php' );
return $theme_template ? $theme_template : plugin_dir_path( __FILE__ ) . 'templates/single-projects.php';
}
if ( is_post_type_archive( 'projects' ) ) {
// Check for an archive-projects.php template in the theme
$theme_template = locate_template( 'archive-projects.php' );
return $theme_template ? $theme_template : plugin_dir_path( __FILE__ ) . 'templates/archive-projects.php';
}
if ( is_tax( 'project-type' ) ) {
// Check for a taxonomy-project-type.php template in the theme
$theme_template = locate_template( 'archive-project-type.php' );
return $theme_template ? $theme_template : plugin_dir_path( __FILE__ ) . 'templates/taxonomy-project-type.php';
}
return $template;
}
add_filter( 'template_include', 'projects_portfolio_template_loader' );
/**
* Enqueue styles for project single and archive templates.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_enqueue_project_styles() {
if ( is_singular( 'projects' ) || is_post_type_archive( 'projects' ) || is_tax( 'project-type' ) ) {
$is_fse_theme = wp_theme_has_theme_json() && function_exists( 'wp_is_block_theme' ) && wp_is_block_theme();
// Register stylesheet for both theme types
wp_register_style(
'projects-wp-styles',
plugin_dir_url( __FILE__ ) . 'assets/css/style.css',
array(),
PROJECTS_PORTFOLIO_VERSION
);
if ( $is_fse_theme ) {
// Inline block style enqueue (needed for FSE themes)
add_action(
'wp_footer',
function () {
echo '<style id="projects-wp-styles">';
echo file_get_contents( plugin_dir_path( __FILE__ ) . 'assets/css/style.css' ); // phpcs:ignore
echo '</style>';
}
);
} else {
// Classic theme enqueue
wp_enqueue_style( 'projects-wp-styles' );
}
}
if ( is_singular( 'projects' ) ) {
wp_enqueue_script(
'projects-wp-buttons',
plugin_dir_url( __FILE__ ) . 'assets/js/buttons.js',
array(),
PROJECTS_PORTFOLIO_VERSION,
true
);
}
}
add_action( 'wp_enqueue_scripts', 'projects_portfolio_enqueue_project_styles' );
/**
* Enqueue admin styles.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_admin_styles() {
wp_enqueue_style( 'projects-wp-admin-css', plugin_dir_url( __FILE__ ) . 'assets/css/admin-styles.css' );
}
add_action( 'admin_enqueue_scripts', 'projects_portfolio_admin_styles' );
/**
* Modify the number of posts per page for the 'projects' post type archive.
*
* Ensures only the 'projects' archive page is affected without changing global settings.
*
* @param WP_Query $query The WordPress query object.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_custom_posts_per_page( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
$projects_per_page = apply_filters( 'projects_portfolio_archives_projects_per_page', 12 );
if ( is_post_type_archive( 'projects' ) ) {
$query->set( 'posts_per_page', $projects_per_page );
}
}
add_action( 'pre_get_posts', 'projects_portfolio_custom_posts_per_page', 1 );
/**
* Modify the posts per page for the 'projects' post type archive in Full Site Editing (FSE) themes.
*
* This function ensures that the 'projects' post type archive displays 12 posts per page,
* even when using an FSE theme with a Query Loop block.
*
* @param array $query The existing query variables for the Query Loop block.
* @param object $block The current block object (not used but available if needed).
*
* @since 1.0.0
* @return array Modified query variables with 'posts_per_page' set for the projects archive.
*/
function modify_projects_posts_per_page_fse( $query, $block ) { // phpcs:ignore
// Ensure we are modifying the archive query for the 'projects' post type.
if ( isset( $query['post_type'] ) && $query['post_type'] === 'projects' ) {
$query['posts_per_page'] = 12;
}
return $query;
}
add_filter( 'query_loop_block_query_vars', 'modify_projects_posts_per_page_fse', 10, 2 );
/**
* Add social sharing buttons with Tabler icons.
*
* @param int $project_id The project post ID.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_social_sharing_buttons( $project_id ) {
$url = rawurlencode( get_permalink( $project_id ) );
$title = rawurlencode( get_the_title( $project_id ) );
$html = '<div class="project-social-sharing">';
// Facebook.
$html .= '<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-facebook" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="0" y="0" width="24" height="24" fill="none"/><path d="M7 10v-3a1 1 0 0 1 1 -1h3v-4h4v4h3a1 1 0 0 1 1 1v3h-4v10h-4v-10h-3" /></svg>';
$html .= '</a>';
// X (Twitter).
$html .= '<a href="https://twitter.com/intent/tweet?text=' . $title . '&url=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-x" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 4l10 16m0 -16l-10 16" /></svg>';
$html .= '</a>';
// LinkedIn.
$html .= '<a href="https://www.linkedin.com/sharing/share-offsite/?url=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-linkedin" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="4" y="4" width="16" height="16" rx="2" /><line x1="8" y1="11" x2="8" y2="16" /><line x1="8" y1="8" x2="8" y2="8.01" /><line x1="12" y1="16" x2="12" y2="11" /><path d="M16 16v-3a2 2 0 0 0 -4 0" /></svg>';
$html .= '</a>';
// Reddit.
$html .= '<a href="https://www.reddit.com/submit?url=' . $url . '&title=' . $title . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-reddit" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><circle cx="12" cy="12" r="9" /><path d="M12 12m-6 0a6 6 0 1 0 12 0a6 6 0 1 0 -12 0" /><path d="M12 12h0" /><path d="M8.5 13c.667 .667 1.333 1 2 1s1.333 -.333 2 -1" /></svg>';
$html .= '</a>';
// WhatsApp.
$html .= '<a href="https://wa.me/?text=' . $title . '%20' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-whatsapp" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 21l1.65 -4.75a9 9 0 1 1 3.85 3.85z" /><path d="M9 10c1 2 3 3 5 4" /><path d="M9 13c1 1 2 2 4 3" /></svg>';
$html .= '</a>';
// Pinterest.
$html .= '<a href="https://pinterest.com/pin/create/button/?url=' . $url . '&description=' . $title . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-brand-pinterest" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><circle cx="12" cy="12" r="9" /><path d="M8 17c1 -2 1.5 -4 .5 -6s-.5 -4 .5 -6" /><path d="M15 11c.667 -1 1.667 -2.333 3 -4" /></svg>';
$html .= '</a>';
// Email.
$html .= '<a href="mailto:?subject=' . $title . '&body=' . $url . '" target="_blank" rel="noopener noreferrer">';
$html .= '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-mail" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="#0073aa" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7l9 6l9 -6" /><rect x="3" y="5" width="18" height="14" rx="2" /></svg>';
$html .= '</a>';
$html .= '</div>';
echo wp_kses_post( $html );
}
add_action( 'projects_after_download_button', 'projects_portfolio_social_sharing_buttons', 999 );