Add custom CSS skins feature

This commit is contained in:
Tetrakern 2024-10-25 12:07:49 +02:00
parent c7d645ac28
commit d169331627
21 changed files with 940 additions and 35 deletions

View File

@ -317,7 +317,7 @@ Fictioneer customizes WordPress by using as many standard action and filter hook
| `wp_default_scripts` | `fictioneer_remove_jquery_migrate` (10)
| `wp_enqueue_scripts` | `fictioneer_add_custom_scripts` (10), `fictioneer_style_queue` (10), `fictioneer_output_customize_css` (9999), `fictioneer_output_customize_preview_css` (9999), `fictioneer_elementor_override_styles` (9999)
| `wp_footer` | `fictioneer_render_category_submenu` (10), `fictioneer_render_tag_submenu` (10), `fictioneer_render_genre_submenu` (10), `fictioneer_render_fandom_submenu` (10), `fictioneer_render_character_submenu` (10), `fictioneer_render_warning_submenu` (10)
| `wp_head` | `fictioneer_output_head_seo` (5), `fictioneer_output_rss` (10), `fictioneer_output_schemas` (10), `fictioneer_add_fiction_css` (10), `fictioneer_output_head_fonts` (5), `fictioneer_output_head_translations` (10), `fictioneer_remove_mu_registration_styles` (1), `fictioneer_output_mu_registration_style` (10), `fictioneer_output_head_meta` (1), `fictioneer_output_head_critical_scripts` (9999). `fictioneer_output_head_anti_flicker` (10), `fictioneer_cleanup_discord_meta` (10)
| `wp_head` | `fictioneer_output_head_seo` (5), `fictioneer_output_rss` (10), `fictioneer_output_schemas` (10), `fictioneer_add_fiction_css` (10), `fictioneer_output_head_fonts` (5), `fictioneer_output_head_translations` (10), `fictioneer_remove_mu_registration_styles` (1), `fictioneer_output_mu_registration_style` (10), `fictioneer_output_head_meta` (1), `fictioneer_output_head_critical_scripts` (9999). `fictioneer_output_head_anti_flicker` (10), `fictioneer_cleanup_discord_meta` (10), `fictioneer_output_critical_skin_scripts` (9999)
| `wp_insert_comment` | `fictioneer_delete_cached_story_card_by_comment` (10), `fictioneer_increment_story_comment_count` (10)
| `wp_logout` | `fictioneer_remove_logged_in_cookie` (10)
| `wp_update_nav_menu` | `fictioneer_purge_nav_menu_transients` (10)

View File

@ -640,6 +640,18 @@
"oF" : 1,
"pg" : 0
},
"\/css\/skin-template.css" : {
"aP" : 1,
"bl" : 0,
"ci" : 0,
"co" : 0,
"ft" : 16,
"ma" : 0,
"oA" : 1,
"oAP" : "\/css\/skin-template-min.css",
"oF" : 1,
"pg" : 0
},
"\/css\/splide.css" : {
"aP" : 1,
"bl" : 0,
@ -2145,14 +2157,6 @@
"oAP" : "\/singular-canvas-site.php",
"oF" : 1
},
"\/singular-dashboard.php" : {
"cB" : 0,
"ft" : 8192,
"hM" : 0,
"oA" : 1,
"oAP" : "\/singular-dashboard.php",
"oF" : 1
},
"\/singular-index-advanced.php" : {
"cB" : 0,
"ft" : 8192,
@ -2319,6 +2323,17 @@
"sC" : 3,
"tS" : 0
},
"\/src\/js\/critical-skin-script.js" : {
"bF" : 0,
"ft" : 64,
"ma" : 0,
"mi" : 1,
"oA" : 0,
"oAP" : "\/js\/critical-skin-script.min.js",
"oF" : 2,
"sC" : 3,
"tS" : 0
},
"\/src\/js\/customizer.js" : {
"bF" : 0,
"ft" : 64,
@ -3819,21 +3834,6 @@
"pg" : 0,
"sct" : 0
},
"\/src\/scss\/dashboard.scss" : {
"aP" : 0,
"bl" : 0,
"co" : 0,
"dP" : 10,
"ec" : 0,
"ft" : 4,
"ma" : 0,
"oA" : 0,
"oAP" : "\/css\/dashboard.css",
"oF" : 2,
"oS" : 3,
"pg" : 0,
"sct" : 0
},
"\/src\/scss\/editor.scss" : {
"aP" : 0,
"bl" : 0,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

8
css/skin-template.css Normal file
View File

@ -0,0 +1,8 @@
/**
* Name: SKIN NAME
* Author: YOUR NAME
* Version: 1.0.0
*/
/* === Add your CSS below these lines (and remove them to save bytes) =================== */
/* === https://github.com/Tetrakern/fictioneer/blob/main/INSTALLATION.md#css-snippets === */

View File

@ -634,6 +634,15 @@ if ( get_option( 'fictioneer_enable_bookmarks' ) && is_admin() ) {
require_once __DIR__ . '/includes/functions/users/_bookmarks.php';
}
/**
* Add the skins feature.
*/
if ( get_option( 'fictioneer_enable_css_skins' ) && is_admin() ) {
// Only used for AJAX
require_once __DIR__ . '/includes/functions/users/_skins.php';
}
/**
* Add content helper functions.
*/

View File

@ -1810,7 +1810,7 @@ if ( get_option( 'fictioneer_enable_anti_flicker' ) ) {
}
// =============================================================================
// OUTPUT HEAD CRITICAL SCRIPTS
// OUTPUT CRITICAL SCRIPTS
// =============================================================================
/**
@ -1833,6 +1833,22 @@ function fictioneer_output_head_critical_scripts() {
}
add_action( 'wp_head', 'fictioneer_output_head_critical_scripts', 9999 );
/**
* Outputs critical skin scripts in the <head>
*
* @since 5.26.0
*/
function fictioneer_output_critical_skin_scripts() {
// Start HTML ---> ?>
<script id="fictioneer-skin-script" type="text/javascript" data-jetpack-boost="ignore" data-no-optimize="1" data-no-defer="1" data-no-minify="1">!function(){const e="fcnLoggedIn=",t=document.cookie.split(";");let n=null;for(var c=0;c<t.length;c++){const i=t[c].trim();if(0==i.indexOf(e)){n=decodeURIComponent(i.substring(12,i.length));break}}if(!n)return;const i=JSON.parse(localStorage.getItem("fcnSkins"))??{data:{},active:null,fingerprint:n};if(i?.data?.[i.active]?.css&&n===i?.fingerprint){const e=document.createElement("style");e.textContent=i.data[i.active].css,e.id="fictioneer-active-custom-skin",document.querySelector("head").appendChild(e)}}();</script>
<?php // <--- End HTML
}
if ( get_option( 'fictioneer_enable_css_skins' ) ) {
add_action( 'wp_head', 'fictioneer_output_critical_skin_scripts', 9999 );
}
// =============================================================================
// ADD EXCERPTS TO PAGES
// =============================================================================

View File

@ -1468,6 +1468,8 @@ function fictioneer_fast_ajax() {
'fictioneer_ajax_get_auth',
'fictioneer_ajax_get_user_data',
'fictioneer_ajax_get_avatar',
'fictioneer_ajax_save_skins',
'fictioneer_ajax_get_skins',
// Admin
'fictioneer_ajax_query_relationship_posts',
'fictioneer_ajax_search_posts_to_unlock'

View File

@ -86,7 +86,6 @@ function fictioneer_account_password( $args ) {
if ( current_user_can( 'fcn_admin_panel_access' ) && get_option( 'fictioneer_show_wp_login_link' ) ) {
add_action( 'fictioneer_account_content', 'fictioneer_account_password', 11 );
}
// =============================================================================
// ACCOUNT OAUTH BINDINGS SECTION
@ -108,6 +107,31 @@ function fictioneer_account_oauth( $args ) {
get_template_part( 'partials/account/_oauth', null, $args );
}
add_action( 'fictioneer_account_content', 'fictioneer_account_oauth', 20 );
}
// =============================================================================
// SITE SKINS
// =============================================================================
/**
* Outputs the HTML for the site skins section
*
* @since 5.26.0
*
* @param WP_User $args['user'] Current user.
* @param boolean $args['is_admin'] True if the user is an administrator.
* @param boolean $args['is_author'] True if the user is an author (by capabilities).
* @param boolean $args['is_editor'] True if the user is an editor.
* @param boolean $args['is_moderator'] True if the user is a moderator (by capabilities).
*/
function fictioneer_account_site_skins( $args ) {
get_template_part( 'partials/account/_skins', null, $args );
}
if ( get_option( 'fictioneer_enable_css_skins' ) ) {
add_action( 'fictioneer_account_content', 'fictioneer_account_site_skins', 25 );
}
// =============================================================================
// ACCOUNT DATA SECTION

View File

@ -731,6 +731,12 @@ define( 'FICTIONEER_OPTIONS', array(
'group' => 'fictioneer-settings-general-group',
'sanitize_callback' => 'fictioneer_sanitize_checkbox',
'default' => 0
),
'fictioneer_enable_css_skins' => array(
'name' => 'fictioneer_enable_css_skins',
'group' => 'fictioneer-settings-general-group',
'sanitize_callback' => 'fictioneer_sanitize_checkbox',
'default' => 0
)
),
'integers' => array(
@ -1204,6 +1210,7 @@ function fictioneer_get_option_label( $option ) {
'fictioneer_show_story_modified_date' => __( 'Show the modified date on story pages', 'fictioneer' ),
'fictioneer_disable_chapter_list_transients' => __( 'Disable chapter list Transient caching', 'fictioneer' ),
'fictioneer_disable_shortcode_transients' => __( 'Disable shortcode Transient caching', 'fictioneer' ),
'fictioneer_enable_css_skins' => __( 'Enable CSS skins (requires account)', 'fictioneer' ),
);
}

View File

@ -742,6 +742,16 @@ $images = get_template_directory_uri() . '/img/documentation/';
?>
</div>
<div class="fictioneer-card__row">
<?php
fictioneer_settings_label_checkbox(
'fictioneer_enable_css_skins',
__( 'Enable CSS skins (requires account)', 'fictioneer' ),
__( 'Allows logged-in users to apply custom styles.', 'fictioneer' )
);
?>
</div>
<div class="fictioneer-card__row">
<?php
fictioneer_settings_label_checkbox(

View File

@ -0,0 +1,136 @@
<?php
// =============================================================================
// AJAX: SAVE SKINS FOR USERS
// =============================================================================
/**
* AJAX: Saves skins JSON
*
* Note: Skins are not evaluated server-side, only stored as JSON string.
* Everything else happens client-side.
*
* @since 5.26.0
*/
function fictioneer_ajax_save_skins() {
// Enabled?
if ( ! get_option( 'fictioneer_enable_css_skins' ) ) {
wp_send_json_error( null, 403 );
}
// Rate limit
fictioneer_check_rate_limit( 'fictioneer_ajax_save_skins', 5 );
// Setup and validations
$user = fictioneer_get_validated_ajax_user();
if ( ! $user ) {
wp_send_json_error( array( 'error' => 'Request did not pass validation.' ) );
}
if ( empty( $_POST['skins'] ?? 0 ) ) {
wp_send_json_error( array( 'error' => 'Missing arguments.' ) );
}
// Sanitize
$skins = sanitize_text_field( $_POST['skins'] );
if ( $skins && fictioneer_is_valid_json( wp_unslash( $skins ) ) ) {
// Inspect
$decoded = json_decode( wp_unslash( $skins ), true );
$fingerprint = fictioneer_get_user_fingerprint( $user->ID );
if ( ! $decoded || ! isset( $decoded['data'] ) ) {
wp_send_json_error( array( 'error' => 'Invalid JSON (SKIN-001).' ) );
}
if ( ( $decoded['fingerprint'] ?? 0 ) !== $fingerprint ) {
wp_send_json_error( array( 'error' => 'Invalid JSON (SKIN-002).' ) );
}
if ( count( $decoded ) > 3 ) {
wp_send_json_error( array( 'error' => 'Invalid JSON (SKIN-003).' ) );
}
foreach( $decoded['data'] as $sub_array ) {
if ( ! is_array( $sub_array ) ) {
wp_send_json_error( array( 'error' => 'Invalid JSON (SKIN-004).' ) );
}
if ( count( $sub_array ) > 4 ) {
wp_send_json_error( array( 'error' => 'Invalid JSON (SKIN-005).' ) );
}
$allowed_keys = ['name', 'author', 'version', 'css'];
foreach ( $sub_array as $sub_key => $value ) {
if ( ! in_array( $sub_key, $allowed_keys ) ) {
wp_send_json_error( array( 'error' => 'Invalid JSON (SKIN-006).' ) );
}
if ( $sub_key === 'css' && fictioneer_sanitize_css( $value ) === '' ) {
wp_send_json_error( array( 'error' => 'Invalid CSS (SKIN-007).' ) );
}
}
}
// Easier than checking whether the skins have changed
delete_user_meta( $user->ID, 'fictioneer_skins' );
if ( update_user_meta( $user->ID, 'fictioneer_skins', $skins ) ) {
wp_send_json_success( array( 'message' => __( 'Skins uploaded successfully.', 'fictioneer' ) ) );
} else {
wp_send_json_error( array( 'error' => 'Skins could not be uploaded.' ) );
}
}
// Something went wrong if we end up here...
wp_send_json_error( array( 'error' => 'An unknown error occurred.' ) );
}
if ( get_option( 'fictioneer_enable_css_skins' ) ) {
add_action( 'wp_ajax_fictioneer_ajax_save_skins', 'fictioneer_ajax_save_skins' );
}
/**
* AJAX: Sends skins JSON
*
* @since 5.26.0
*/
function fictioneer_ajax_get_skins() {
// Enabled?
if ( ! get_option( 'fictioneer_enable_css_skins' ) ) {
wp_send_json_error( null, 403 );
}
// Rate limit
fictioneer_check_rate_limit( 'fictioneer_ajax_get_skins', 5 );
// Setup and validations
$user = fictioneer_get_validated_ajax_user();
if ( ! $user ) {
wp_send_json_error( array( 'error' => 'Request did not pass validation.' ) );
}
// Look for saved skins on user...
$skins = get_user_meta( $user->ID, 'fictioneer_skins', true );
// Response
if ( $skins ) {
wp_send_json_success(
array(
'skins' => $skins,
'message' => __( 'Skins downloaded and applied successfully.', 'fictioneer' )
)
);
}
wp_send_json_error();
}
if ( get_option( 'fictioneer_enable_css_skins' ) ) {
add_action( 'wp_ajax_fictioneer_ajax_get_skins', 'fictioneer_ajax_get_skins' );
}

10
js/complete.min.js vendored

File diff suppressed because one or more lines are too long

1
js/critical-skin-script.min.js vendored Normal file
View File

@ -0,0 +1 @@
!function(){const e="fcnLoggedIn=",t=document.cookie.split(";");let n=null;for(var c=0;c<t.length;c++){const i=t[c].trim();if(0==i.indexOf(e)){n=decodeURIComponent(i.substring(12,i.length));break}}if(!n)return;const i=JSON.parse(localStorage.getItem("fcnSkins"))??{data:{},active:null,fingerprint:n};if(i?.data?.[i.active]?.css&&n===i?.fingerprint){const e=document.createElement("style");e.textContent=i.data[i.active].css,e.id="fictioneer-active-custom-skin",document.querySelector("head").appendChild(e)}}();

File diff suppressed because one or more lines are too long

2
js/utility.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,89 @@
<?php
/**
* Partial: Account - Skins
*
* @package WordPress
* @subpackage Fictioneer
* @since 5.26.0
*
* @internal $args['user'] Current user.
* @internal $args['is_admin'] True if the user is an administrator.
* @internal $args['is_author'] True if the user is an author (by capabilities).
* @internal $args['is_editor'] True if the user is an editor.
* @internal $args['is_moderator'] True if the user is a moderator (by capabilities).
*/
// No direct access!
defined( 'ABSPATH' ) OR exit;
// Cookie set?
if ( ! isset( $_COOKIE['fcnLoggedIn'] ) ) {
return;
}
// Setup
$current_user = $args['user'];
$template_download = esc_url( get_template_directory_uri() . '/css/skin-template.css' );
$translations = array(
'wrongFileType' => _x( 'Wrong file type. Please upload a valid CSS file.', 'Custom CSS skin.', 'fictioneer' ),
'invalidCss' => _x( 'Invalid file contents. Please check for missing braces ("{}") or forbidden characters ("<").', 'Custom CSS skin.', 'fictioneer' ),
'missingMetaData' => _x( 'File is missing the "Name" meta data.', 'Custom CSS skin.', 'fictioneer' ),
'tooManySkins' => _x( 'You cannot upload more than 3 skins.', 'Custom CSS skin.', 'fictioneer' ),
'wrongFingerprint' => _x( 'Wrong or missing fingerprint hash.', 'Custom CSS skin.', 'fictioneer' ),
'fileTooLarge' => _x( 'Maximum file size of 200 KB exceeded.', 'Custom CSS skin.', 'fictioneer' ),
'invalidJson' => _x( 'Invalid JSON.', 'Custom CSS skin.', 'fictioneer' ),
'error' => _x( 'Error.', 'Custom CSS skin.', 'fictioneer' )
);
?>
<h3 class="profile__skins-headline"><?php _e( 'Skins', 'fictioneer' ); ?></h3>
<p class="profile__description"><?php
_e( 'You can add up to three custom CSS skins (max. 200 KB each), with one active at a time. These skins apply only to your current device and browser (and may vanish at any time), but you can manually sync them up and down with your account. Be cautious about which skin you trust, as they can mess up the site.', 'fictioneer' );
?></p>
<p class="profile__description"><?php
printf(
__( 'To create your own skin, <a href="%s" download>download this template</a>.', 'fictioneer' ),
$template_download
);
?></p>
<template id="template-custom-skin">
<div class="custom-skin">
<button type="button" class="custom-skin__toggle" data-click-action="skin-toggle">
<i class="fa-regular fa-circle off"></i>
<i class="fa-solid fa-circle-dot on"></i>
</button>
<div class="custom-skin__info">
<span class="custom-skin__name" data-finder="skin-name">&mdash;</span>
<span class="custom-skin__spacer"></span>
<span class="custom-skin__version" data-finder="skin-version">&mdash;</span>
<span class="custom-skin__spacer"></span>
<span class="custom-skin__author" data-finder="skin-author">&mdash;</span>
</div>
<button type="button" class="custom-skin__delete" data-click-action="skin-delete"><i class="fa-solid fa-trash-can"></i></button>
</div>
</template>
<div class="profile__segment">
<div class="custom-skin-list"></div>
<form id="css-upload-form" class="custom-skin _upload">
<script type="text/javascript" data-jetpack-boost="ignore" data-no-optimize="1" data-no-defer="1" data-no-minify="1">
var fcn_skinTranslations = <?php echo json_encode( $translations ); ?>;
</script>
<input type="file" id="css-file" name="css-file" accept=".css">
<i class="fa-solid fa-plus"></i>
</form>
<div class="profile__actions">
<button type="button" class="button" data-click-action="skins-sync-up" data-disable-with="<?php esc_attr_e( 'Uploading…', 'fictioneer' ); ?>"><?php _e( 'Sync Up', 'fictioneer' ); ?></button>
<button type="button" class="button" data-click-action="skins-sync-down" data-disable-with="<?php esc_attr_e( 'Downloading…', 'fictioneer' ); ?>"><?php _e( 'Sync Down', 'fictioneer' ); ?></button>
</div>
</div>

View File

@ -0,0 +1,32 @@
// To be minified and added to the head
(function() {
const name = 'fcnLoggedIn=';
const cookies = document.cookie.split(';');
let fingerprint = null;
for (var i = 0; i < cookies.length; i++) {
const c = cookies[i].trim();
if (c.indexOf(name) == 0) {
fingerprint = decodeURIComponent(c.substring(name.length, c.length));
break;
}
}
if (!fingerprint) {
return;
}
const skins = JSON.parse(localStorage.getItem('fcnSkins')) ?? { data: {}, active: null, fingerprint: fingerprint };
if (skins?.data?.[skins.active]?.css && fingerprint === skins?.fingerprint) {
const styleTag = document.createElement('style');
styleTag.textContent = skins.data[skins.active].css;
styleTag.id = 'fictioneer-active-custom-skin';
document.querySelector('head').appendChild(styleTag);
}
})();

View File

@ -375,3 +375,417 @@ _$('.button-clear-bookmarks')?.addEventListener(
fcn_setBookmarks(fcn_bookmarks);
}
);
// =============================================================================
// CSS SKINS
// =============================================================================
/**
* Returns the skins JSON from local storage or a default.
*
* @since 5.26.0
* @return {Object} The skins JSON.
*/
function fcn_getSkins() {
const fingerprint = fcn_getCookie('fcnLoggedIn');
if (!fingerprint) {
return null;
}
const _default = { 'data': {}, 'active': null, 'fingerprint': fingerprint };
const skins = fcn_parseJSON(localStorage.getItem('fcnSkins')) ?? _default;
if (!skins?.fingerprint || fingerprint !== skins.fingerprint) {
return _default;
}
if (typeof skins.data !== 'object' || Array.isArray(skins.data)) {
skins.data = {};
}
return skins;
}
/**
* Saves the skins JSON to local storage.
*
* @since 5.26.0
* @param {Object} skins - The skins to store.
*/
function fcn_setSkins(skins) {
if (
typeof skins !== 'object' || skins === null ||
typeof skins.data !== 'object' || Array.isArray(skins.data)
) {
fcn_showNotification(fcn_skinTranslations.invalidJson, 3, 'warning');
return;
}
if (!skins?.fingerprint || fcn_getCookie('fcnLoggedIn') !== skins.fingerprint) {
fcn_showNotification(fcn_skinTranslations.wrongFingerprint, 3, 'warning');
return;
}
localStorage.setItem('fcnSkins', JSON.stringify(skins));
}
/**
* Returns info object about a CSS skin.
*
* @since 5.26.0
* @param {String} css - The CSS to analyze.
* @return {Object} CSS info with name, author, and version.
*/
function fcn_getSkinInfo(css) {
const nameMatch = css.match(/Name:\s*(.+)/);
const authorMatch = css.match(/Author:\s*(.+)/);
const versionMatch = css.match(/Version:\s*(.+)/);
return {
name: nameMatch ? fcn_sanitizeHTML(nameMatch[1].trim()) : null,
author: authorMatch ? fcn_sanitizeHTML(authorMatch[1].trim()) : null,
version: versionMatch ? fcn_sanitizeHTML(versionMatch[1].trim()) : null
};
}
/**
* Toggles the currently selected skin (custom CSS).
*
* @since 5.26.0
* @param {HTMLElement} target - The clicked element.
*/
function fcn_toggleSkin(target) {
const item = target.closest('.custom-skin');
const skins = fcn_getSkins();
if (item.classList.contains('active')) {
_$$('.custom-skin').forEach(element => element.classList.remove('active'));
skins.active = null;
} else {
_$$('.custom-skin').forEach(element => element.classList.remove('active'));
item.classList.add('active');
skins.active = target.dataset.skinId;
}
fcn_setSkins(skins);
fcn_applySkin();
}
/**
* Delete the skin (custom CSS).
*
* @since 5.26.0
* @param {HTMLElement} target - The clicked element.
*/
function fcn_deleteSkin(target) {
const item = target.closest('.custom-skin');
const skins = fcn_getSkins();
if (item.classList.contains('active')) {
skins.active = null;
}
delete skins.data[target.dataset.skinId];
_$('#css-upload-form > input').value = '';
fcn_setSkins(skins);
fcn_renderSkinList();
fcn_applySkin();
}
/**
* Add skin (custom CSS) to document <head>.
*
* @since 5.26.0
*/
function fcn_applySkin() {
const fingerprint = fcn_getCookie('fcnLoggedIn');
// Ensure the theme login check is passed
if (!fingerprint) {
return;
}
// Get skins from local storage
const skins = fcn_getSkins();
// Cleanup old style tag (if any)
_$$$('fictioneer-active-custom-skin')?.remove();
// Check fingerprint
if (skins?.fingerprint !== fingerprint) {
return;
}
// Check if skins data is valid and an active skin is set
if (skins?.data?.[skins.active]?.css) {
const styleTag = document.createElement('style');
styleTag.textContent = skins.data[skins.active].css;
styleTag.id = 'fictioneer-active-custom-skin';
_$('head').appendChild(styleTag);
}
}
/**
* Renders list of selectable skins.
*
* @since 5.26.0
*/
function fcn_renderSkinList() {
const container = _$('.custom-skin-list');
const fingerprint = fcn_getCookie('fcnLoggedIn');
// Ensure the theme login check is passed
if (!fingerprint || !container) {
return;
}
// Get skins from local storage
const skins = fcn_getSkins();
// Clear previous content
container.innerHTML = '';
// Check fingerprint
if (skins?.fingerprint !== fingerprint) {
_$$$('css-upload-form').style.display = '';
return;
}
// Ensure skins data exists and has entries
if (skins?.data && Object.keys(skins.data).length > 0) {
// Loop through the skins and render them
Object.entries(skins.data).forEach(([key, skin]) => {
const template = _$$$('template-custom-skin').content.cloneNode(true);
// Active skin?
if (skins.active === key) {
template.querySelector('.custom-skin').classList.add('active');
}
// Fill template with skin data
template.querySelector('[data-click-action="skin-toggle"]').dataset.skinId = key;
template.querySelector('[data-click-action="skin-delete"]').dataset.skinId = key;
template.querySelector('[data-finder="skin-name"]').innerText = skin.name;
template.querySelector('[data-finder="skin-version"]').innerText = skin.version;
template.querySelector('[data-finder="skin-author"]').innerText = skin.author;
// Append the template to the container
container.appendChild(template);
});
// Add click events
_$$('[data-click-action="skin-toggle"]').forEach(button => {
button.addEventListener('click', event => fcn_toggleSkin(event.currentTarget));
});
_$$('[data-click-action="skin-delete"]').forEach(button => {
button.addEventListener('click', event => fcn_deleteSkin(event.currentTarget));
});
}
if (Object.keys(skins.data).length > 2) {
_$$$('css-upload-form').style.display = 'none';
} else {
_$$$('css-upload-form').style.display = '';
}
}
// Initialize
fcn_renderSkinList();
// Upload
_$('#css-upload-form > input')?.addEventListener('input', event => {
event.preventDefault();
const input = _$$$('css-file');
const file = input.files[0];
const skins = fcn_getSkins();
const fingerprint = fcn_getCookie('fcnLoggedIn');
if (Object.keys(skins.data).length > 2) {
fcn_showNotification(fcn_skinTranslations.tooManySkins, 3, 'warning');
return;
}
if (skins?.fingerprint !== fingerprint) {
fcn_showNotification(fcn_skinTranslations.wrongFingerprint, 3, 'warning');
return;
}
if (!file) {
return;
}
if (file.size > 200000) {
fcn_showNotification(fcn_skinTranslations.fileTooLarge, 3, 'warning');
return;
}
if (file.type !== 'text/css') {
fcn_showNotification(fcn_skinTranslations.wrongFileType, 3, 'warning');
return;
}
const reader = new FileReader();
reader.onload = event => {
const css = event.target.result;
const info = fcn_getSkinInfo(css);
const skins = fcn_getSkins();
if (!fcn_validateCss(css)) {
fcn_showNotification(fcn_skinTranslations.invalidCss, 5, 'warning');
return;
}
if (!info.name) {
fcn_showNotification(fcn_skinTranslations.missingMetaData, 3, 'warning');
return;
}
const key = btoa(info.name);
skins.data[key] = {
name: info.name,
version: info.version,
author: info.author,
css: css
};
fcn_setSkins(skins);
fcn_renderSkinList();
}
reader.onerror = () => {
console.error(reader.error);
fcn_showNotification(reader.error, 3, 'warning');
return;
}
reader.readAsText(file);
});
/**
* AJAX: Uploads the skins to the database.
*
* @since 5.26.0
* @param {HTMLElement} trigger - The event trigger element.
*/
function fcn_uploadSkins(trigger) {
// Ensure the theme login check is passed
if (!fcn_isUserLoggedIn() || trigger.classList.contains('disabled')) {
return;
}
// Get skins from local storage
const skins = fcn_getSkins();
// Toggle button progress
fcn_toggleInProgress(trigger);
// Request
fcn_ajaxPost({
'action': 'fictioneer_ajax_save_skins',
'fcn_fast_ajax': 1,
'skins': JSON.stringify(skins)
})
.then(response => {
if (response.success) {
fcn_showNotification(response.data.message, 3, 'success');
} else {
fcn_showNotification(
response.data.failure ?? response.data.error ?? fictioneer_tl.notification.error,
3,
'warning'
);
// Make sure the actual error (if any) is printed to the console too
if (response.data.error || response.data.failure) {
console.error('Error:', response.data.error ?? response.data.failure);
}
}
})
.catch(error => {
if (error.status && error.statusText) {
fcn_showNotification(`${error.status}: ${error.statusText}`, 3, 'warning');
}
console.error(error);
})
.then(() => {
fcn_toggleInProgress(trigger);
});
}
_$('[data-click-action="skins-sync-up"]')?.addEventListener('click', event => {
fcn_uploadSkins(event.currentTarget);
});
/**
* AJAX: Downloads the skins from the database.
*
* @since 5.26.0
* @param {HTMLElement} trigger - The event trigger element.
*/
function fcn_downloadSkins(trigger) {
// Ensure the theme login check is passed
if (!fcn_isUserLoggedIn() || trigger.classList.contains('disabled')) {
return;
}
// Toggle button progress
fcn_toggleInProgress(trigger);
// Request
fcn_ajaxPost({
'action': 'fictioneer_ajax_get_skins',
'fcn_fast_ajax': 1
})
.then(response => {
if (response.success) {
fcn_showNotification(response.data.message, 3, 'success');
fcn_setSkins(JSON.parse(response.data.skins));
fcn_renderSkinList();
fcn_applySkin()
} else {
fcn_showNotification(
response.data.failure ?? response.data.error ?? fictioneer_tl.notification.error,
3,
'warning'
);
// Make sure the actual error (if any) is printed to the console too
if (response.data.error || response.data.failure) {
console.error('Error:', response.data.error ?? response.data.failure);
}
}
})
.catch(error => {
if (error.status && error.statusText) {
fcn_showNotification(`${error.status}: ${error.statusText}`, 3, 'warning');
}
console.error(error);
})
.then(() => {
_$('#css-upload-form > input').value = '';
fcn_toggleInProgress(trigger);
});
}
_$('[data-click-action="skins-sync-down"]')?.addEventListener('click', event => {
fcn_downloadSkins(event.currentTarget);
});

View File

@ -807,6 +807,33 @@ function fcn_sanitizeHTML(html) {
return temp.innerHTML;
}
// =============================================================================
// SANITIZE CSS
// =============================================================================
/**
* Validates a CSS string.
*
* @since 5.26.0
* @param {String} css - The CSS to validate.
* @return {Boolean} True or false.
*/
function fcn_validateCss(css) {
const openBraces = (css.match(/{/g) || []).length;
const closeBraces = (css.match(/}/g) || []).length;
if (openBraces !== closeBraces) {
return false;
}
if (css.includes('<')) {
return false;
}
return true;
}
// =============================================================================
// SCREEN COLLISION DETECTION
// =============================================================================
@ -973,3 +1000,31 @@ function fcn_isUserLoggedIn() {
return false;
}
// =============================================================================
// PROGRESSIVE ELEMENT STATUS
// =============================================================================
/**
* Toggles progression state of an element.
*
* @since 5.26.0
* @param {HTMLElement} element - The element.
* @param {Boolean|null} force - Whether to disable or enable. Defaults to
* the opposite of the current state.
*/
function fcn_toggleInProgress(element, force = null) {
force = force !== null ? force : !element.disabled;
if (force) {
element.dataset.enableWith = element.innerHTML;
element.innerHTML = element.dataset.disableWith ?? 'Processing';
element.disabled = true;
element.classList.add('disabled');
} else {
element.innerHTML = element.dataset.enableWith;
element.disabled = false;
element.classList.remove('disabled');
}
}

View File

@ -209,3 +209,105 @@
}
}
}
.custom-skin-list {
display: grid;
gap: 1rem;
&:not(:empty) {
margin-bottom: 1rem;
}
}
.custom-skin {
flex: 1 1 100%;
display: flex;
align-items: center;
color: var(--button-secondary-color);
background: var(--button-secondary-background);
font-size: var(--fs-xs);
font-weight: var(--button-font-weight);
line-height: 18px;
padding: .5rem;
border: var(--button-secondary-border);
border-radius: var(--layout-border-radius-small);
width: 100%;
&._upload {
position: relative;
border-style: dashed;
padding: 0;
height: 2.75rem;
&:not(:hover) .fa-plus {
opacity: .4;
}
input {
cursor: pointer;
height: 100%;
width: 100%;
opacity: 0;
}
.fa-plus {
position: absolute;
top: 50%;
left: 50%;
pointer-events: none;
text-align: center;
transition: opacity var(--transition-duration);
transform: translate(-50%, -50%);
}
}
&.active {
color: var(--button-color-active);
background: var(--button-background-active);
border: var(--button-border-active);
input[type=checkbox]:checked {
background: transparent;
}
.custom-skin__toggle .off {
display: none;
}
}
&:not(.active) {
.custom-skin__toggle .on {
display: none;
}
}
&__info {
flex: 1 1 auto;
}
&__toggle {
padding: .25rem 1rem .25rem .25rem;
}
&__spacer {
&::after {
content: '|';
margin: 0 .25rem;
opacity: .6;
}
}
&__version,
&__author {
opacity: .6;
}
&__delete {
padding: .25rem .25rem .25rem 1rem;
transition: opacity var(--transition-duration);
&:not(:hover) {
opacity: .3;
}
}
}