Plugin Directory

Changeset 3477599


Ignore:
Timestamp:
03/08/2026 08:38:54 PM (3 weeks ago)
Author:
ehtmlu
Message:

Version 1.2.0

Location:
peak-publisher
Files:
30 added
13 edited
20 copied

Legend:

Unmodified
Added
Removed
  • peak-publisher/tags/1.2.0/assets/css/admin.css

    r3444907 r3477599  
    6969    0% { transform: rotate(0deg); }
    7070    100% { transform: rotate(360deg); }
     71}
     72
     73/* Permalink Notice */
     74.pblsh--permalink-notice {
     75    display: flex;
     76    flex-direction: column;
     77    align-items: center;
     78    text-align: center;
     79    padding: 4rem var(--content-padding-inline);
     80    max-width: 32rem;
     81    margin: 0 auto;
     82}
     83
     84.pblsh--permalink-notice__icon svg {
     85    color: var(--color-blue);
     86}
     87
     88.pblsh--permalink-notice__title {
     89    font-size: 1.5rem;
     90    margin: 1rem 0 0.5rem;
     91}
     92
     93.pblsh--permalink-notice__text {
     94    color: #50575e;
     95    margin: 0.25rem 0;
     96    line-height: 1.5;
     97    font-size: 1rem;
     98    text-wrap: pretty;
     99}
     100
     101.pblsh--permalink-notice__button {
     102    margin-top: 1.5rem;
     103    text-decoration: none;
    71104}
    72105
     
    851884}
    852885
     886.pblsh--table__icon-header,
     887.pblsh--table__icon-cell {
     888    width: 3rem;
     889    text-align: right;
     890    padding: 0 !important;
     891}
     892.pblsh--table__icon {
     893    /* It is intentional that non-square images are distorted, as WordPress itself behaves in this way as well. */
     894    width: 3rem;
     895    height: 3rem;
     896    display: inline-block;
     897    vertical-align: middle;
     898}
     899.pblsh--table__name-header,
     900.pblsh--table__name-cell {
     901    padding-left: 1rem !important;
     902}
     903
    853904.pblsh--table__name-cell {
    854905    min-width: 12.5rem; /* 200px */
     
    903954
    904955/* Status Column */
    905 .pblsh--table__status-header {
    906     width: 6.25rem; /* 100px */
    907 }
    908 
    909956.pblsh--table__status-cell {
    910957    width: 6.25rem; /* 100px */
     
    9591006    border-bottom: 1px solid var(--border-color-medium);
    9601007    margin-bottom: var(--content-padding-block);
     1008}
     1009.pblsh--plugin-header__main {
     1010    display: flex;
     1011    align-items: center;
     1012    gap: 0.75rem;
     1013    min-width: 0;
     1014}
     1015.pblsh--plugin-header__actions {
     1016    display: flex;
     1017    align-items: center;
    9611018}
    9621019.pblsh--plugin-header code {
     
    10791136    max-width: 50rem; /* 800px */
    10801137}
     1138/* Tab panel (Releases / Assets) */
     1139.pblsh--tab-panel {
     1140    margin-top: 1.25rem;
     1141}
     1142
     1143.pblsh--tab-nav {
     1144    display: flex;
     1145    gap: 0;
     1146    padding: 0;
     1147    margin-bottom: 0;
     1148}
     1149
     1150.pblsh--tab-nav__tab {
     1151    background: #f3f4f6;
     1152    border: 1px solid var(--border-color-light);
     1153    border-bottom-color: var(--border-color-light);
     1154    border-radius: 0.4rem 0.4rem 0 0;
     1155    padding: 0.5rem 1.375rem;
     1156    font-size: 0.875rem;
     1157    font-weight: 500;
     1158    color: #666;
     1159    cursor: pointer;
     1160    position: relative;
     1161    z-index: 1;
     1162    margin-bottom: -1px;
     1163    margin-right: -1px;
     1164    transition: background 0.15s, color 0.15s;
     1165}
     1166
     1167.pblsh--tab-nav__tab:last-child {
     1168    margin-right: 0;
     1169}
     1170
     1171.pblsh--tab-nav__tab:hover:not(.pblsh--tab-nav__tab--active) {
     1172    background: #e9edf2;
     1173    color: #1d2327;
     1174}
     1175
     1176.pblsh--tab-nav__tab--active {
     1177    background: #fff;
     1178    color: #1d2327;
     1179    font-weight: 600;
     1180    z-index: 2;
     1181    border-bottom-color: #fff;
     1182}
     1183
     1184.pblsh--tab-panel__body {
     1185    position: relative;
     1186    z-index: 0;
     1187    background: #fff;
     1188    border: 1px solid var(--border-color-light);
     1189    border-radius: 0 0.5rem 0.5rem 0.5rem;
     1190    box-shadow: 0 0.0625rem 0.1875rem rgba(0, 0, 0, 0.1);
     1191    overflow: hidden;
     1192}
     1193
     1194/* Strip box styling from inner elements rendered directly inside the panel */
     1195.pblsh--tab-panel__body > .pblsh--table-container,
     1196.pblsh--tab-panel__body > .pblsh--card {
     1197    border: none;
     1198    box-shadow: none;
     1199    border-radius: 0;
     1200    margin-top: 0;
     1201}
     1202.pblsh--plugin-header__icon {
     1203    /* It is intentional that non-square images are distorted, as WordPress itself behaves in this way as well. */
     1204    width: 5rem;
     1205    height: 5rem;
     1206    flex-shrink: 0;
     1207}
    10811208
    10821209.pblsh--editor {
     
    14431570    margin-bottom: 1rem;
    14441571}
     1572
     1573
     1574/* ============================================================
     1575   Plugin Assets Section
     1576   ============================================================ */
     1577
     1578.pblsh--assets-card {
     1579    padding: 1.5rem;
     1580}
     1581
     1582.pblsh--assets-group {
     1583    margin-bottom: 5rem;
     1584}
     1585.pblsh--assets-group:last-child {
     1586    margin-bottom: 0;
     1587    border-bottom: 0;
     1588    padding-bottom: 0;
     1589}
     1590
     1591.pblsh--assets-group__label {
     1592    display: flex;
     1593    align-items: center;
     1594    gap: 0.5rem;
     1595    font-weight: 600;
     1596    letter-spacing: 0.05em;
     1597    text-transform: uppercase;
     1598    margin-bottom: 1rem;
     1599}
     1600
     1601.pblsh--assets-slots {
     1602    display: flex;
     1603    flex-wrap: wrap;
     1604    gap: 0.875rem;
     1605}
     1606
     1607/* Individual asset slot */
     1608.pblsh--asset-slot {
     1609    display: flex;
     1610    flex-direction: column;
     1611    border: 1.5px solid var(--border-color-medium);
     1612    border-radius: 8px;
     1613    background: #fff;
     1614    transition: border-color 0.15s, box-shadow 0.15s;
     1615    position: relative;
     1616}
     1617
     1618
     1619.pblsh--asset-slot--uploading {
     1620    opacity: 0.7;
     1621    pointer-events: none;
     1622}
     1623
     1624/* Upload progress */
     1625.pblsh--asset-slot__progress {
     1626    position: absolute;
     1627    inset: 0;
     1628    display: flex;
     1629    flex-direction: column;
     1630    align-items: center;
     1631    justify-content: center;
     1632    gap: 0.375rem;
     1633    background: rgba(255, 255, 255, 0.88);
     1634    padding: 0.5rem;
     1635}
     1636
     1637.pblsh--asset-slot__progress-bar {
     1638    width: 80%;
     1639    height: 4px;
     1640    background: #e0e4e8;
     1641    border-radius: 2px;
     1642    overflow: hidden;
     1643}
     1644.pblsh--asset-slot__progress-bar::after {
     1645    content: '';
     1646    display: block;
     1647    height: 100%;
     1648    width: var(--pct, 0%);
     1649    background: var(--color-blue);
     1650    transition: width 0.1s linear;
     1651    border-radius: 2px;
     1652}
     1653
     1654.pblsh--asset-slot__progress-label {
     1655    font-size: 0.6875rem;
     1656    color: #555d66;
     1657}
     1658
     1659/* Warnings */
     1660.pblsh--asset-slot__warnings {
     1661    margin-top: 0.25rem;
     1662}
     1663
     1664.pblsh--asset-slot__warning {
     1665    display: flex;
     1666    align-items: flex-start;
     1667    gap: 0.25rem;
     1668    font-size: 0.75rem; /* 12px */
     1669    color: #c07600;
     1670    line-height: 1.3;
     1671}
     1672.pblsh--asset-slot__warning svg {
     1673    flex-shrink: 0;
     1674    margin-top: 2px;
     1675}
     1676.pblsh--asset-slot__warning span {
     1677    white-space: pre-line;
     1678}
     1679
     1680/* Actions row */
     1681.pblsh--asset-slot__actions {
     1682    display: flex;
     1683    gap: 0;
     1684    align-items: center;
     1685    flex-shrink: 0;
     1686}
     1687
     1688.pblsh--asset-slot__actions .components-button.has-icon {
     1689    padding: 0 5px;
     1690    min-width: 32px;
     1691    height: 32px;
     1692    min-height: 32px;
     1693}
     1694
     1695/* Small loading indicator for assets section */
     1696.pblsh--loading--small {
     1697    min-height: 4rem;
     1698    padding: 1rem;
     1699}
     1700
     1701/* "Add new screenshot" slot styling */
     1702.pblsh--asset-slot--new {
     1703    border-style: dashed;
     1704    border-color: #c4d0db;
     1705    background: #fafbfc;
     1706    box-shadow: none;
     1707}
     1708
     1709/* ---- Assets: Box-Grid-Layout ---- */
     1710.pblsh--assets-slots--boxes {
     1711    gap: 1rem;
     1712    display: grid;
     1713    grid-template-columns: repeat(auto-fill, minmax(min(100%, 421px), 421px));
     1714}
     1715
     1716.pblsh--asset-slot--box {
     1717    display: flex;
     1718    flex-direction: column;
     1719    overflow: hidden;
     1720    position: relative;
     1721}
     1722
     1723.pblsh--asset-slot--box > .pblsh--asset-slot__actions {
     1724    position: absolute;
     1725    bottom: 0.25rem;
     1726    right: 0.25rem;
     1727    display: flex;
     1728    align-items: center;
     1729    gap: 0;
     1730}
     1731
     1732.pblsh--asset-slot__box-label {
     1733    font-weight: 600;
     1734    font-size: 0.9375rem;
     1735    color: #1e1e1e;
     1736}
     1737
     1738.pblsh--asset-slot__box-body {
     1739    display: flex;
     1740    flex-direction: row;
     1741    gap: 0.75rem;
     1742    padding: 0.75rem;
     1743    flex: 1;
     1744    align-items: flex-start;
     1745    flex-wrap: wrap;
     1746}
     1747
     1748.pblsh--asset-slot__box-image {
     1749    max-width: 100%;
     1750    border: 1px solid transparent;
     1751    transition: border-color 0.15s;
     1752}
     1753
     1754.pblsh--asset-slot__box-image-inner {
     1755    background-color: #fff;
     1756    background-image:
     1757        linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
     1758        linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
     1759        linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
     1760        linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
     1761    background-size: 16px 16px;
     1762    background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
     1763    display: flex;
     1764    align-items: center;
     1765    justify-content: center;
     1766    overflow: hidden;
     1767    transition: box-shadow 0.15s;
     1768    position: relative;
     1769    max-width: 100%;
     1770    height: 0px;
     1771    aspect-ratio: var(--x, 1) / var(--y, 1);
     1772    padding-top: calc(var(--y, 1) / var(--x, 1) * 100%);
     1773}
     1774.pblsh--asset-slot__box-image:hover {
     1775    border-color: rgba(0, 0, 0, 0.2);
     1776}
     1777.pblsh--asset-slot__box-image-inner:hover {
     1778    box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
     1779}
     1780.pblsh--asset-slot__box-image--icon .pblsh--asset-slot__box-image-inner {
     1781    --x: 1;
     1782    --y: 1;
     1783    width: 128px;
     1784}
     1785.pblsh--asset-slot__box-image--banner .pblsh--asset-slot__box-image-inner {
     1786    --x: 772;
     1787    --y: 250;
     1788    width: 395px;
     1789}
     1790.pblsh--asset-slot__box-image--screenshot .pblsh--asset-slot__box-image-inner {
     1791    --x: 16;
     1792    --y: 9;
     1793    width: 395px;
     1794}
     1795.pblsh--asset-slot__box-img {
     1796    width: 100%;
     1797    height: 100%;
     1798    object-fit: contain;
     1799    display: block;
     1800    position: absolute;
     1801    inset: 0;
     1802}
     1803
     1804.pblsh--asset-slot__box-info {
     1805    flex: 1;
     1806    min-width: 10rem;
     1807    display: flex;
     1808    flex-direction: column;
     1809}
     1810
     1811.pblsh--asset-slot__box-filename {
     1812    word-break: break-word;
     1813}
     1814
     1815.pblsh--asset-slot__box-meta {
     1816    color: #8c96a0;
     1817}
     1818
     1819.pblsh--asset-slot__box-body--empty {
     1820    justify-content: center;
     1821    align-items: center;
     1822    position: relative;
     1823    min-height: 5rem;
     1824    flex: 1;
     1825}
     1826
     1827.pblsh--asset-slot__box-empty {
     1828    display: flex;
     1829    flex-direction: column;
     1830    align-items: center;
     1831    text-align: center;
     1832    color: #6c7781;
     1833}
     1834.pblsh--asset-slot__box-empty-title {
     1835    font-size: 0.9375rem;
     1836    font-weight: 600;
     1837    color: #1e1e1e;
     1838}
     1839.pblsh--asset-slot__box-empty-expected {
     1840    color: #8c96a0;
     1841}
     1842.pblsh--asset-slot__box-upload-btn {
     1843    margin-top: 0.5rem;
     1844}
     1845
     1846.pblsh--asset-slot__box-footer {
     1847    display: flex;
     1848    justify-content: flex-end;
     1849    padding: 0.25rem 0.375rem;
     1850    border-top: 1px solid var(--border-color-light);
     1851    background: #f9fafb;
     1852}
     1853
     1854/* Screenshot drag: cursor on the image area (the draggable element) */
     1855.pblsh--asset-slot__box-image[draggable="true"] {
     1856    cursor: grab;
     1857}
     1858.pblsh--asset-slot__box-image[draggable="true"]:active {
     1859    cursor: grabbing;
     1860}
     1861
     1862/* Dragging state — only the image fades, not the whole slot */
     1863.pblsh--asset-slot--dragging .pblsh--asset-slot__box-image {
     1864    opacity: 0.4;
     1865}
     1866
     1867/* Drop target highlight */
     1868.pblsh--asset-slot--drag-target {
     1869    outline: 2px dashed var(--wp-admin-theme-color, #3858e9);
     1870    outline-offset: -2px;
     1871}
     1872
     1873/* Caption text — shown right after the slot label */
     1874.pblsh--asset-slot__caption {
     1875    font-size: 0.9375rem;
     1876    color: #1e1e1e;
     1877    line-height: 1.4;
     1878}
  • peak-publisher/tags/1.2.0/assets/js/admin.js

    r3444907 r3477599  
    33    'use strict';
    44   
    5     const { __ } = wp.i18n;
     5    const { __, sprintf } = wp.i18n;
    66    const { useState, useEffect, useRef, createElement, render } = wp.element;
    77    const { useSelect } = wp.data;
     
    1010    const { showAlert, getDefaultConfig } = Pblsh.Utils;
    1111
     12    // Permalink check — shown instead of the app when permalinks are set to "Plain"
     13    const PermalinkNotice = () => {
     14        const { permalinkPlain, permalinkDayAndName } = PblshData.i18n;
     15        return createElement('div', { className: 'pblsh-app' },
     16            createElement('div', { className: 'pblsh--header' },
     17                createElement('h2', { className: 'pblsh--header__title' }, __('Peak Publisher', 'peak-publisher'))
     18            ),
     19            createElement('div', { className: 'pblsh--permalink-notice' },
     20                createElement('div', { className: 'pblsh--permalink-notice__icon' },
     21                    Pblsh.Utils.getSvgIcon('chat_alert', { size: 48 })
     22                ),
     23                createElement('h3', { className: 'pblsh--permalink-notice__title' },
     24                    __('Pretty Permalinks Required', 'peak-publisher')
     25                ),
     26                createElement('p', { className: 'pblsh--permalink-notice__text' },
     27                    sprintf(
     28                        /* translators: %s: name of the "Plain" permalink option (translated by WordPress) */
     29                        __('Peak Publisher uses the WordPress REST API, which requires pretty permalinks. Your permalink structure is currently set to "%s", which does not support REST API routes.', 'peak-publisher'),
     30                        permalinkPlain
     31                    )
     32                ),
     33                createElement('p', { className: 'pblsh--permalink-notice__text' },
     34                    createElement('strong', null,
     35                        sprintf(
     36                            /* translators: %1$s: "Plain" option name, %2$s: "Day and name" option name (both translated by WordPress) */
     37                            __('To fix this, go to the permalink settings and select any structure other than "%1$s" (e.g. "%2$s").', 'peak-publisher'),
     38                            permalinkPlain,
     39                            permalinkDayAndName
     40                        )
     41                    ),
     42                ),
     43                createElement('a', {
     44                    className: 'components-button is-primary pblsh--permalink-notice__button',
     45                    href: PblshData.permalinkSettingsUrl,
     46                }, __('Go to Permalink Settings', 'peak-publisher'))
     47            )
     48        );
     49    };
     50
    1251    // Main App Component
    1352    const PeakPublisherApp = () => {
     53        // Block the entire app when permalinks are set to "Plain"
     54        if (PblshData.hasPlainPermalinks) {
     55            return createElement(PermalinkNotice);
     56        }
     57
    1458        const [view, setView] = useState('list'); // 'list' | 'editor' | 'addition-process'
    1559        const [currentPluginId, setCurrentPluginId] = useState(null);
     60        const [initialTab, setInitialTab] = useState(null);
    1661        const isLoading = useSelect((select) => select('pblsh/plugins').isLoadingList(), []);
    1762        const hasLoadedList = useSelect((select) => {
     
    3479                    plugin: params.get('plugin'),
    3580                    view: params.get('view'),
     81                    tab: params.get('tab'),
    3682                };
    3783            } catch (e) {
     
    4793                if ('view' in next) {
    4894                    if (next.view) { params.set('view', String(next.view)); } else { params.delete('view'); }
     95                }
     96                if ('tab' in next) {
     97                    if (next.tab) { params.set('tab', String(next.tab)); } else { params.delete('tab'); }
    4998                }
    5099                const newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
     
    62111                    const idNum = Number(q.plugin);
    63112                    if (!isNaN(idNum)) {
     113                        if (q.tab) setInitialTab(q.tab);
    64114                        await handleEdit(idNum);
    65115                        return;
     
    135185            setCurrentPluginId(null);
    136186            setIsNew(false);
    137             setQuery({ plugin: null, view: null });
     187            setInitialTab(null);
     188            setQuery({ plugin: null, view: null, tab: null });
    138189        };
    139190
     
    254305                    isLoadingReleases: isLoadingReleases,
    255306                    onBack: handleCancel,
     307                    initialTab: initialTab,
     308                    onTabChange: (tab) => setQuery({ tab: tab === 'releases' ? null : tab }),
    256309                });
    257310            } else {
  • peak-publisher/tags/1.2.0/assets/js/api.js

    r3444907 r3477599  
    116116        });
    117117    },
     118    // Get all assets for a plugin
     119    getPluginAssets: async (pluginId) => {
     120        return await window.Pblsh.API.request('plugins/' + pluginId + '/assets');
     121    },
     122    // Upload an asset file (slot: icon_128 | icon_256 | icon_svg | banner_sd | banner_hd | banner_svg | screenshot)
     123    // screenshotN: null = append new screenshot, number = replace specific screenshot
     124    uploadPluginAsset: async (pluginId, slot, screenshotN, file, onProgress) => {
     125        return new Promise((resolve, reject) => {
     126            const xhr = new XMLHttpRequest();
     127            xhr.open('POST', window.wpApiSettings.root + 'pblsh-admin/v1/plugins/' + pluginId + '/assets');
     128            xhr.setRequestHeader('X-WP-Nonce', window.wpApiSettings.nonce);
     129            xhr.responseType = 'json';
     130            xhr.upload.onprogress = (e) => {
     131                if (!e.lengthComputable) return;
     132                const percent = e.loaded * 100 / e.total;
     133                if (typeof onProgress === 'function') onProgress(percent);
     134            };
     135            xhr.onload = () => {
     136                if (xhr.status >= 200 && xhr.status < 300) {
     137                    resolve(xhr.response || {});
     138                } else {
     139                    const msg = xhr.response && xhr.response.message ? xhr.response.message : 'Upload failed (status ' + xhr.status + ')';
     140                    reject(new Error(msg));
     141                }
     142            };
     143            xhr.onerror = () => reject(new Error('Network error during asset upload.'));
     144            const form = new FormData();
     145            form.append('file', file, file.name);
     146            form.append('slot', slot);
     147            if (screenshotN !== null && screenshotN !== undefined) {
     148                form.append('screenshot_n', String(screenshotN));
     149            }
     150            xhr.send(form);
     151        });
     152    },
     153    // Delete an asset from a plugin slot
     154    deletePluginAsset: async (pluginId, slot, screenshotN) => {
     155        return await window.Pblsh.API.request('plugins/' + pluginId + '/assets', {
     156            method: 'DELETE',
     157            body: { slot, screenshot_n: screenshotN !== undefined ? screenshotN : null },
     158        });
     159    },
     160    // Move a screenshot from one position to another
     161    moveScreenshot: async (pluginId, fromN, toN) => {
     162        return await window.Pblsh.API.request('plugins/' + pluginId + '/assets/move', {
     163            method: 'POST',
     164            body: { slot: 'screenshot', from: fromN, to: toN },
     165        });
     166    },
    118167});
  • peak-publisher/tags/1.2.0/assets/js/components/GlobalDropOverlay.js

    r3460404 r3477599  
    5353
    5454    useEffect(() => {
     55        // Check if a drag event carries external files (not an internal page drag like screenshot reordering)
     56        const isExternalFileDrag = (e) => e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.indexOf('Files') !== -1;
     57
    5558        const onDragEnter = (e) => {
     59            if (!isExternalFileDrag(e)) return;
    5660            e.preventDefault();
    5761            setDragCounter((c) => c + 1);
     
    5963        };
    6064        const onDragOver = (e) => {
     65            if (!isExternalFileDrag(e)) return;
    6166            e.preventDefault();
    6267        };
     
    6974        };
    7075        const onDrop = (e) => {
     76            if (!isExternalFileDrag(e)) return;
    7177            e.preventDefault();
    7278            setDragCounter(0);
  • peak-publisher/tags/1.2.0/assets/js/components/PluginEditor.js

    r3444907 r3477599  
    11// PluginEditor Component (simplified overview + releases list)
    2 lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack }) => {
     2lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack, initialTab, onTabChange }) => {
    33    const { __ } = wp.i18n;
    4     const { createElement } = wp.element;
     4    const { createElement, useState, useEffect, useRef } = wp.element;
    55    const { useSelect } = wp.data;
    6     const { Tooltip, Button } = wp.components;
     6    const { Tooltip, Button, DropdownMenu, MenuItem } = wp.components;
    77    const { getSvgIcon } = Pblsh.Utils;
    88
    99    const safe = (val) => (val === undefined || val === null) ? '' : val;
     10    const formatFilesize = (bytes) => {
     11        if (!bytes || bytes <= 0) return null;
     12        if (bytes < 1024) return bytes + ' B';
     13        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
     14        return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
     15    };
    1016    const serverSettings = useSelect((select) => select('pblsh/settings').getServer(), []);
    1117    const showInstallations = !!(serverSettings && serverSettings.count_plugin_installations);
     18
     19    // ---- Asset state ----
     20    const [assets, setAssets]               = useState(null);   // null = not yet loaded
     21    const [assetsLoading, setAssetsLoading] = useState(false);
     22    const [uploadingSlot, setUploadingSlot] = useState(null);   // 'icon_128' | 'screenshot-3' | null
     23    const [uploadProgress, setUploadProgress] = useState(0);
     24    const fileInputRefs = useRef({});  // { [slotKey]: HTMLInputElement }
     25    const validTabs = ['releases', 'assets'];
     26    const [activeTab, setActiveTab] = useState(initialTab && validTabs.includes(initialTab) ? initialTab : 'releases');
     27    const [draggingN, setDraggingN] = useState(null);     // screenshot_n being dragged
     28    const [dragOverN, setDragOverN] = useState(null);     // slot N being hovered during drag
     29    const dragOverTimeout = useRef(null);                   // auto-clear drag-over when cursor leaves
     30
     31    // Auto-fetch assets when the tab is pre-selected via deep link
     32    useEffect(() => {
     33        if (activeTab === 'assets' && assets === null) fetchAssets();
     34    }, [pluginData && pluginData.id]);
     35
     36    const fetchAssets = async () => {
     37        if (!pluginData || !pluginData.id) return;
     38        setAssetsLoading(true);
     39        try {
     40            const data = await Pblsh.API.getPluginAssets(pluginData.id);
     41            setAssets(data);
     42        } catch (e) {
     43            // Non-fatal: just show empty asset state
     44            setAssets({});
     45        } finally {
     46            setAssetsLoading(false);
     47        }
     48    };
     49
     50    const switchToTab = (tab) => {
     51        setActiveTab(tab);
     52        if (typeof onTabChange === 'function') onTabChange(tab);
     53        if (tab === 'assets' && assets === null) fetchAssets();
     54    };
     55
     56    const handleAssetUpload = async (slot, screenshotN, file) => {
     57        if (!file || !pluginData || !pluginData.id) return;
     58        const slotKey = slot === 'screenshot' ? 'screenshot-' + screenshotN : slot;
     59        setUploadingSlot(slotKey);
     60        setUploadProgress(0);
     61        try {
     62            const result = await Pblsh.API.uploadPluginAsset(
     63                pluginData.id, slot, screenshotN, file,
     64                (pct) => setUploadProgress(Math.floor(pct))
     65            );
     66            if (result && result.status === 'ok') {
     67                await fetchAssets();
     68                if (typeof refreshPlugin === 'function') refreshPlugin();
     69                if (result.warnings && result.warnings.length > 0) {
     70                    Pblsh.Utils.showAlert(result.warnings.map(w => w.message).join('\n'), 'warning');
     71                }
     72            } else {
     73                Pblsh.Utils.showAlert((result && result.message) || __('Upload failed.', 'peak-publisher'), 'error');
     74            }
     75        } catch (e) {
     76            Pblsh.Utils.showAlert(e.message || __('Upload failed.', 'peak-publisher'), 'error');
     77        } finally {
     78            setUploadingSlot(null);
     79            setUploadProgress(0);
     80        }
     81    };
     82
     83    const handleAssetDelete = async (slot, screenshotN) => {
     84        if (!pluginData || !pluginData.id) return;
     85        if (!confirm(__('Delete this asset?', 'peak-publisher'))) return;
     86        try {
     87            const result = await Pblsh.API.deletePluginAsset(pluginData.id, slot, screenshotN);
     88            if (result && result.assets) {
     89                setAssets(result.assets);
     90            } else {
     91                await fetchAssets();
     92            }
     93            if (typeof refreshPlugin === 'function') refreshPlugin();
     94        } catch (e) {
     95            Pblsh.Utils.showAlert(e.message || __('Delete failed.', 'peak-publisher'), 'error');
     96        }
     97    };
     98
     99    const handleScreenshotMove = async (fromN, toN) => {
     100        if (!pluginData || !pluginData.id || fromN === toN) return;
     101        try {
     102            const result = await Pblsh.API.moveScreenshot(pluginData.id, fromN, toN);
     103            if (result && result.assets) {
     104                setAssets(result.assets);
     105            } else {
     106                await fetchAssets();
     107            }
     108        } catch (e) {
     109            Pblsh.Utils.showAlert(e.message || __('Move failed.', 'peak-publisher'), 'error');
     110        }
     111    };
     112
     113    const openFilePicker = (slot, screenshotN) => {
     114        const key = slot === 'screenshot' ? 'screenshot-' + (screenshotN !== null && screenshotN !== undefined ? screenshotN : 'new') : slot;
     115        if (fileInputRefs.current[key]) {
     116            fileInputRefs.current[key].value = '';
     117            fileInputRefs.current[key].click();
     118        }
     119    };
     120
     121    // Slot configurations from server (single source of truth: AssetManager::get_slots())
     122    const ASSET_SLOTS = window.PblshData.assetSlots || {};
     123    // Helper: build accept string from exts array, e.g. ['png','jpg','gif'] → '.png,.jpg,.jpeg,.gif'
     124    const slotAccept = (s) => (s.exts || []).flatMap(e => e === 'jpg' ? ['.jpg', '.jpeg'] : ['.' + e]).join(',');
     125    const slotHint   = (s) => s.prefix + '.{' + (s.exts || []).join('|') + '}';
     126
     127    const renderAssetBox = (slot, assetData, screenshotN = null, caption = null) => {
     128        const stripTags = (html) => { const el = document.createElement('div'); el.innerHTML = html; return el.textContent || ''; };
     129        const screenshotLabel = caption
     130            ? screenshotN + '. ' + stripTags(caption)
     131            : __('Screenshot', 'peak-publisher') + ' ' + screenshotN;
     132        const raw = ASSET_SLOTS[slot];
     133        const def = slot === 'screenshot'
     134            ? { label: screenshotLabel, accept: slotAccept(raw), hint: raw.prefix + '-' + screenshotN + '.{' + raw.exts.join('|') + '}', group: raw.group, expectedW: raw.expectedW, expectedH: raw.expectedH }
     135            : raw ? { label: raw.label, accept: slotAccept(raw), hint: slotHint(raw), group: raw.group, expectedW: raw.expectedW, expectedH: raw.expectedH } : null;
     136        if (!def) return null;
     137        const slotKey = slot === 'screenshot' ? 'screenshot-' + screenshotN : slot;
     138        const isUploading = uploadingSlot === slotKey;
     139        const hasAsset = !!(assetData && assetData.filename);
     140        const warnings = (assetData && assetData.warnings) || [];
     141        const isScreenshot = slot === 'screenshot';
     142        const isDragging = isScreenshot && draggingN === screenshotN;
     143        const isDragOver = isScreenshot && dragOverN === screenshotN && draggingN !== screenshotN;
     144
     145        const parts = [];
     146        if (hasAsset && assetData.width && assetData.height) parts.push(assetData.width + '\u00d7' + assetData.height + '\u00a0px');
     147        const fs = hasAsset ? formatFilesize(assetData.filesize) : null;
     148        if (fs) parts.push(fs);
     149
     150        const acceptLabel = (raw.exts || []).map(e => e.toUpperCase()).join(' · ');
     151        const sizeLabel = def.expectedW && def.expectedH ? def.expectedW + '\u00d7' + def.expectedH + '\u00a0px' : null;
     152        const imageModClass = def.group === 'banners' ? 'pblsh--asset-slot__box-image--banner'
     153            : def.group === 'screenshots' ? 'pblsh--asset-slot__box-image--screenshot'
     154            : 'pblsh--asset-slot__box-image--icon';
     155
     156        // Drag-and-drop handlers for screenshot slots
     157        const dragProps = isScreenshot ? {
     158            onDragOver: (e) => {
     159                e.preventDefault();
     160                e.dataTransfer.dropEffect = 'move';
     161                setDragOverN(screenshotN);
     162                clearTimeout(dragOverTimeout.current);
     163                dragOverTimeout.current = setTimeout(() => setDragOverN(null), 150);
     164            },
     165            onDrop: (e) => {
     166                e.preventDefault();
     167                clearTimeout(dragOverTimeout.current);
     168                setDragOverN(null);
     169                setDraggingN(null);
     170                const fromN = parseInt(e.dataTransfer.getData('text/plain'), 10);
     171                if (!fromN || fromN === screenshotN) return;
     172                if (hasAsset) {
     173                    if (!confirm(__('Replace the existing screenshot at this position?', 'peak-publisher'))) return;
     174                }
     175                handleScreenshotMove(fromN, screenshotN);
     176            },
     177        } : {};
     178
     179        // Drag source props (only on the image area of filled screenshot slots)
     180        const dragSourceProps = (isScreenshot && hasAsset) ? {
     181            draggable: true,
     182            onDragStart: (e) => {
     183                e.dataTransfer.setData('text/plain', String(screenshotN));
     184                e.dataTransfer.effectAllowed = 'move';
     185                setDraggingN(screenshotN);
     186            },
     187            onDragEnd: () => { setDraggingN(null); setDragOverN(null); clearTimeout(dragOverTimeout.current); },
     188        } : {};
     189
     190        const classNames = [
     191            'pblsh--asset-slot',
     192            'pblsh--asset-slot--box',
     193            isUploading ? 'pblsh--asset-slot--uploading' : '',
     194            isDragging ? 'pblsh--asset-slot--dragging' : '',
     195            isDragOver ? 'pblsh--asset-slot--drag-target' : '',
     196        ].filter(Boolean).join(' ');
     197
     198        return createElement('div', {
     199            key: slotKey,
     200            className: classNames,
     201            ...dragProps,
     202        },
     203            createElement('input', {
     204                ref: (el) => { fileInputRefs.current[slotKey] = el; },
     205                type: 'file',
     206                accept: def.accept,
     207                className: 'pblsh--hidden-file-input',
     208                onChange: (e) => { const file = e.target.files && e.target.files[0]; if (file) handleAssetUpload(slot, screenshotN, file); },
     209            }),
     210            hasAsset
     211                ? createElement('div', { className: 'pblsh--asset-slot__box-body' },
     212                    createElement('div', {
     213                        className: 'pblsh--asset-slot__box-image ' + imageModClass,
     214                        ...dragSourceProps,
     215                    },
     216                        createElement('div', { className: 'pblsh--asset-slot__box-image-inner' },
     217                            isUploading && createElement('div', { className: 'pblsh--asset-slot__progress' },
     218                                createElement('div', { className: 'pblsh--asset-slot__progress-bar', style: { '--pct': uploadProgress + '%' } }),
     219                                createElement('div', { className: 'pblsh--asset-slot__progress-label' }, uploadProgress + '%'),
     220                            ),
     221                            createElement('img', {
     222                                src: assetData.url,
     223                                alt: assetData.filename,
     224                                className: 'pblsh--asset-slot__box-img',
     225                                draggable: false,
     226                            }),
     227                        ),
     228                    ),
     229                    createElement('div', { className: 'pblsh--asset-slot__box-info' },
     230                        createElement('div', { className: 'pblsh--asset-slot__box-label' }, def.label),
     231                        createElement('div', { className: 'pblsh--asset-slot__box-filename' }, assetData.filename),
     232                        parts.length > 0 && createElement('div', { className: 'pblsh--asset-slot__box-meta' },
     233                            parts.join('\u2002\u2022\u2002'),
     234                        ),
     235                        warnings.length > 0 && createElement('div', { className: 'pblsh--asset-slot__warnings' },
     236                            warnings.map((w, i) => createElement('div', { key: i, className: 'pblsh--asset-slot__warning', title: w.message },
     237                                getSvgIcon('information_outline', { size: 14 }),
     238                                createElement('span', null, w.message),
     239                            ))
     240                        ),
     241                    ),
     242                )
     243                : createElement('div', {
     244                    className: 'pblsh--asset-slot__box-body pblsh--asset-slot__box-body--empty',
     245                },
     246                    isUploading
     247                        ? createElement('div', { className: 'pblsh--asset-slot__progress' },
     248                            createElement('div', { className: 'pblsh--asset-slot__progress-bar', style: { '--pct': uploadProgress + '%' } }),
     249                            createElement('div', { className: 'pblsh--asset-slot__progress-label' }, uploadProgress + '%'),
     250                        )
     251                        : createElement('div', { className: 'pblsh--asset-slot__box-empty' },
     252                            createElement('div', { className: 'pblsh--asset-slot__box-empty-title' }, def.label),
     253                            createElement('div', { className: 'pblsh--asset-slot__box-empty-expected' },
     254                                __('Expected:', 'peak-publisher') + ' ' + [acceptLabel, sizeLabel].filter(Boolean).join(' · '),
     255                            ),
     256                            createElement(Button, {
     257                                isPrimary: true,
     258                                className: 'pblsh--asset-slot__box-upload-btn',
     259                                onClick: () => openFilePicker(slot, screenshotN),
     260                                disabled: isUploading,
     261                            }, __('Select File', 'peak-publisher')),
     262                        ),
     263                ),
     264            hasAsset && createElement('div', { className: 'pblsh--asset-slot__actions' },
     265                createElement(Button, {
     266                    isTertiary: true,
     267                    className: 'has-icon',
     268                    label: __('Replace', 'peak-publisher'),
     269                    icon: getSvgIcon('pencil', { size: 18 }),
     270                    onClick: () => openFilePicker(slot, screenshotN),
     271                    disabled: isUploading,
     272                }),
     273                createElement(DropdownMenu, {
     274                    icon: getSvgIcon('dots_horizontal', { size: 24 }),
     275                    label: __('More options', 'peak-publisher'),
     276                    children: ({ onClose }) => createElement(MenuItem, {
     277                        isDestructive: true,
     278                        onClick: () => { handleAssetDelete(slot, screenshotN); onClose(); },
     279                    },
     280                        getSvgIcon('delete_forever', { size: 24 }),
     281                        __('Delete', 'peak-publisher'),
     282                    ),
     283                }),
     284            ),
     285        );
     286    };
     287
     288    const renderAssetsSection = () => {
     289        if (assetsLoading && !assets) {
     290            return createElement('div', { className: 'pblsh--card pblsh--assets-card' },
     291                createElement('div', { className: 'pblsh--loading pblsh--loading--small' },
     292                    createElement('div', { className: 'pblsh--loading__spinner' }),
     293                ),
     294            );
     295        }
     296
     297        // Build screenshot slot map: { N: assetData } for quick lookup
     298        const screenshots = (assets && assets.screenshots) || [];
     299        const captions = (assets && assets.screenshot_captions) || {};
     300        const screenshotMap = {};
     301        screenshots.forEach(s => { screenshotMap[s.screenshot_n] = s; });
     302
     303        // Determine visible slot range
     304        const captionKeys = Object.keys(captions).map(Number).filter(n => n > 0);
     305        const screenshotKeys = screenshots.map(s => s.screenshot_n);
     306        const maxCaption = captionKeys.length > 0 ? Math.max(...captionKeys) : 0;
     307        const maxScreenshot = screenshotKeys.length > 0 ? Math.max(...screenshotKeys) : 0;
     308        const maxN = Math.max(maxCaption, maxScreenshot);
     309
     310        // Trim trailing empty slots that have no caption
     311        let visibleMaxN = maxN;
     312        while (visibleMaxN > 0 && !screenshotMap[visibleMaxN] && !captions[visibleMaxN]) {
     313            visibleMaxN--;
     314        }
     315
     316        // Build slot list: 1..visibleMaxN + "+new" at the end
     317        const nextN = visibleMaxN + 1;
     318        const slots = [];
     319        for (let i = 1; i <= visibleMaxN; i++) {
     320            slots.push({ n: i, screenshot: screenshotMap[i] || null, caption: captions[i] || null });
     321        }
     322
     323        // "+New" slot
     324        const newSlotKey = 'screenshot-new';
     325        const isUploadingNew = uploadingSlot === newSlotKey;
     326        const isDragOverNew = dragOverN === nextN && draggingN !== nextN;
     327
     328        const newScreenshotBox = createElement('div', {
     329            key: newSlotKey,
     330            className: ['pblsh--asset-slot', 'pblsh--asset-slot--box', 'pblsh--asset-slot--new', isUploadingNew ? 'pblsh--asset-slot--uploading' : '', isDragOverNew ? 'pblsh--asset-slot--drag-target' : ''].filter(Boolean).join(' '),
     331            onDragOver: (e) => {
     332                e.preventDefault();
     333                e.dataTransfer.dropEffect = 'move';
     334                setDragOverN(nextN);
     335                clearTimeout(dragOverTimeout.current);
     336                dragOverTimeout.current = setTimeout(() => setDragOverN(null), 150);
     337            },
     338            onDrop: (e) => {
     339                e.preventDefault();
     340                clearTimeout(dragOverTimeout.current);
     341                setDragOverN(null);
     342                setDraggingN(null);
     343                const fromN = parseInt(e.dataTransfer.getData('text/plain'), 10);
     344                if (!fromN || fromN === nextN) return;
     345                handleScreenshotMove(fromN, nextN);
     346            },
     347        },
     348            createElement('input', {
     349                ref: (el) => { fileInputRefs.current[newSlotKey] = el; },
     350                type: 'file',
     351                accept: slotAccept(ASSET_SLOTS.screenshot),
     352                className: 'pblsh--hidden-file-input',
     353                onChange: (e) => {
     354                    const file = e.target.files && e.target.files[0];
     355                    if (file) {
     356                        setUploadingSlot(newSlotKey);
     357                        handleAssetUpload('screenshot', nextN, file).finally(() => setUploadingSlot(null));
     358                    }
     359                },
     360            }),
     361            createElement('div', {
     362                className: 'pblsh--asset-slot__box-body pblsh--asset-slot__box-body--empty',
     363            },
     364                isUploadingNew
     365                    ? createElement('div', { className: 'pblsh--asset-slot__progress' },
     366                        createElement('div', { className: 'pblsh--asset-slot__progress-bar', style: { '--pct': uploadProgress + '%' } }),
     367                        createElement('div', { className: 'pblsh--asset-slot__progress-label' }, uploadProgress + '%'),
     368                    )
     369                    : createElement('div', { className: 'pblsh--asset-slot__box-empty' },
     370                        createElement('div', { className: 'pblsh--asset-slot__box-empty-title' },
     371                            __('Screenshot', 'peak-publisher') + ' ' + nextN + ' — ' + __('New', 'peak-publisher'),
     372                        ),
     373                        createElement('div', { className: 'pblsh--asset-slot__box-empty-expected' }, (ASSET_SLOTS.screenshot.exts || []).map(function(e) { return e.toUpperCase(); }).join(' · ')),
     374                        createElement(Button, {
     375                            isPrimary: true,
     376                            className: 'pblsh--asset-slot__box-upload-btn',
     377                            onClick: () => { fileInputRefs.current[newSlotKey] && (fileInputRefs.current[newSlotKey].value = '', fileInputRefs.current[newSlotKey].click()); },
     378                            disabled: isUploadingNew,
     379                        }, __('Select File', 'peak-publisher')),
     380                    ),
     381            ),
     382        );
     383
     384        return createElement('div', { className: 'pblsh--card pblsh--assets-card' },
     385            // Icons group
     386            createElement('div', { className: 'pblsh--assets-group' },
     387                createElement('div', { className: 'pblsh--assets-group__label' }, __('Icons', 'peak-publisher')),
     388                createElement('div', { className: 'pblsh--assets-slots pblsh--assets-slots--boxes' },
     389                    renderAssetBox('icon_svg', assets && assets.icon_svg),
     390                    renderAssetBox('icon_256', assets && assets.icon_256),
     391                    renderAssetBox('icon_128', assets && assets.icon_128),
     392                ),
     393            ),
     394            // Banners group
     395            createElement('div', { className: 'pblsh--assets-group' },
     396                createElement('div', { className: 'pblsh--assets-group__label' }, __('Banners', 'peak-publisher')),
     397                createElement('div', { className: 'pblsh--assets-slots pblsh--assets-slots--boxes' },
     398                    renderAssetBox('banner_svg', assets && assets.banner_svg),
     399                    renderAssetBox('banner_hd', assets && assets.banner_hd),
     400                    renderAssetBox('banner_sd', assets && assets.banner_sd),
     401                ),
     402            ),
     403            // Screenshots group (slot-based)
     404            createElement('div', { className: 'pblsh--assets-group' },
     405                createElement('div', { className: 'pblsh--assets-group__label' }, __('Screenshots', 'peak-publisher')),
     406                createElement('div', { className: 'pblsh--assets-slots pblsh--assets-slots--boxes' },
     407                    slots.map(({ n, screenshot, caption }) =>
     408                        renderAssetBox('screenshot', screenshot, n, caption)
     409                    ),
     410                    newScreenshotBox,
     411                ),
     412            ),
     413        );
     414    };
    12415
    13416    // Prefer releases from store (keeps UI in sync on toggles), fallback to pluginData.releases
     
    37440                        createElement('div', { className: 'pblsh--plugin-header' },
    38441                            createElement('div', { className: 'pblsh--plugin-header__main' },
    39                                 createElement('h3', { className: 'pblsh--plugin-title' }, pluginData?.name),
    40                                 createElement('div', { className: 'pblsh--plugin-meta' },
    41                                     createElement('strong', null, __('Slug', 'peak-publisher')),
    42                                     createElement('code', null, safe(pluginData?.slug) || '—'),
     442                                pluginData?.icon_url ? createElement('img', {
     443                                    className: 'pblsh--plugin-header__icon',
     444                                    src: pluginData.icon_url,
     445                                    alt: '',
     446                                    width: 80,
     447                                    height: 80,
     448                                }) : null,
     449                                createElement('div', null,
     450                                    createElement('h3', { className: 'pblsh--plugin-title' }, pluginData?.name),
     451                                    createElement('div', { className: 'pblsh--plugin-meta' },
     452                                        createElement('strong', null, __('Slug', 'peak-publisher')),
     453                                        createElement('code', null, safe(pluginData?.slug) || '—'),
     454                                    ),
    43455                                ),
    44456                            ),
     
    180592    };
    181593
     594    const renderTabNav = () => createElement('div', { className: 'pblsh--tab-nav' },
     595        createElement('button', {
     596            type: 'button',
     597            className: 'pblsh--tab-nav__tab' + (activeTab === 'releases' ? ' pblsh--tab-nav__tab--active' : ''),
     598            onClick: () => switchToTab('releases'),
     599        }, __('Releases', 'peak-publisher')),
     600        createElement('button', {
     601            type: 'button',
     602            className: 'pblsh--tab-nav__tab' + (activeTab === 'assets' ? ' pblsh--tab-nav__tab--active' : ''),
     603            onClick: () => switchToTab('assets'),
     604        }, __('Assets', 'peak-publisher')),
     605    );
     606
    182607    return createElement('div', { className: 'pblsh--editor' },
    183608        createElement('div', { className: 'pblsh--editor__content' },
     
    186611                    createElement('div', { className: 'pblsh--main__content' },
    187612                        renderInfoBox(),
    188                         renderReleasesTable(),
     613                        createElement('div', { className: 'pblsh--tab-panel', 'data-active-tab': activeTab },
     614                            renderTabNav(),
     615                            createElement('div', { className: 'pblsh--tab-panel__body' },
     616                                activeTab === 'releases' && renderReleasesTable(),
     617                                activeTab === 'assets'   && renderAssetsSection(),
     618                            ),
     619                        ),
    189620                    ),
    190621                ),
  • peak-publisher/tags/1.2.0/assets/js/components/PluginList.js

    r3444907 r3477599  
    4343                        createElement('tr', null,
    4444                            createElement('th', { className: 'pblsh--table__status-header' }, __('Status', 'peak-publisher')),
    45                             //createElement('th', { className: 'pblsh--table__icon-header' }, __('Icon', 'peak-publisher')),
     45                            createElement('th', { className: 'pblsh--table__icon-header' }),
    4646                            createElement('th', { className: 'pblsh--table__name-header' }, __('Plugin Name', 'peak-publisher')),
    4747                            createElement('th', { className: 'pblsh--table__slug-header' }, __('Slug', 'peak-publisher')),
     
    6868                                    })
    6969                                ),
    70                                 /* createElement('td', { className: 'pblsh--table__icon-cell' },
    71                                     plugin.icon
    72                                         ? createElement('img', {
    73                                             src: plugin.icon,
    74                                             alt: plugin.name,
    75                                             className: 'pblsh--table__icon-thumbnail',
    76                                             width: 80,
    77                                             height: 60
    78                                         })
    79                                         : createElement('div', { className: 'pblsh--table__no-icon' },
    80                                             getSvgIcon('image')
    81                                         )
    82                                 ), */
     70                                createElement('td', { className: 'pblsh--table__icon-cell' },
     71                                    plugin.icon_url && createElement('img', {
     72                                        src: plugin.icon_url,
     73                                        alt: '',
     74                                        className: 'pblsh--table__icon',
     75                                        width: 48,
     76                                        height: 48,
     77                                    }),
     78                                ),
    8379                                createElement('td', { className: 'pblsh--table__name-cell' },
    8480                                    createElement('strong', null, plugin.name)
  • peak-publisher/tags/1.2.0/assets/js/utils.js

    r3444907 r3477599  
    22lodash.set(window, 'Pblsh.Utils', {
    33
    4     // Show alert message (only for errors)
     4    // Show alert message (for errors and warnings)
    55    showAlert: (message, type = 'error') => {
    6         if (type === 'error') {
     6        if (type === 'error' || type === 'warning') {
    77            alert(message);
    88        }
     
    7474            chart_line: 'M16,11.78L20.24,4.45L21.97,5.45L16.74,14.5L10.23,10.75L5.46,19H22V21H2V3H4V17.54L9.5,8L16,11.78Z',
    7575            information_outline: 'M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z',
     76            chat_alert: 'M12,3C17.5,3 22,6.58 22,11C22,15.42 17.5,19 12,19C10.76,19 9.57,18.82 8.47,18.5C5.55,21 2,21 2,21C4.33,18.67 4.7,17.1 4.75,16.5C3.05,15.07 2,13.13 2,11C2,6.58 6.5,3 12,3M11,14V16H13V14H11M11,12H13V6H11V12Z',
    7677        };
    7778
  • peak-publisher/tags/1.2.0/classes/AdminAPI.php

    r3444907 r3477599  
    1111    const NAMESPACE = 'pblsh-admin/v1';
    1212
     13    private ?AssetManager $asset_manager = null;
     14
    1315    /**
    1416     * Constructor.
     
    1618    private function __construct() {
    1719        $this->register_routes();
     20    }
     21
     22    /**
     23     * Lazy-load the AssetManager singleton.
     24     */
     25    private function assets(): AssetManager {
     26        if ($this->asset_manager === null) {
     27            require_once __DIR__ . '/AssetManager.php';
     28            $this->asset_manager = AssetManager::init();
     29        }
     30        return $this->asset_manager;
    1831    }
    1932
     
    113126            'methods' => 'POST',
    114127            'callback' => [$this, 'save_peak_publisher_settings_rest'],
     128            'permission_callback' => [$this, 'check_permission'],
     129        ]);
     130
     131        // Plugin assets
     132        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets', [
     133            'methods' => 'GET',
     134            'callback' => [$this, 'handle_get_assets'],
     135            'permission_callback' => [$this, 'check_permission'],
     136        ]);
     137        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets', [
     138            'methods' => 'POST',
     139            'callback' => [$this, 'handle_upload_asset'],
     140            'permission_callback' => [$this, 'check_permission'],
     141        ]);
     142        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets', [
     143            'methods' => 'DELETE',
     144            'callback' => [$this, 'handle_delete_asset'],
     145            'permission_callback' => [$this, 'check_permission'],
     146        ]);
     147        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets/move', [
     148            'methods' => 'POST',
     149            'callback' => [$this, 'handle_move_asset'],
    115150            'permission_callback' => [$this, 'check_permission'],
    116151        ]);
     
    166201                'name' => $plugin_post->post_title,
    167202                'slug' => $plugin_post->post_name,
    168                 'icon' => get_post_meta($plugin_post->ID, 'pblsh_icon', true),
     203                'icon_url' => $this->assets()->get_best_icon_url($plugin_post->post_name),
    169204                'version' => $latest_version,
    170205                'status' => $plugin_post->post_status,
     
    214249            'name' => $post->post_title,
    215250            'slug' => $post->post_name,
    216             'icon' => get_post_meta($post->ID, 'pblsh_icon', true),
     251            'icon_url' => $this->assets()->get_best_icon_url($post->post_name),
    217252            'version' => $latest_version,
    218253            'status' => $post->post_status,
     
    391426        }
    392427
     428        // Delete the plugin's assets directory.
     429        $assets_dir = get_plugin_assets_basedir($plugin->post_name);
     430        if (is_dir($assets_dir)) {
     431            get_wp_filesystem()->delete(trailingslashit($assets_dir), true);
     432        }
     433
    393434        // Remove all empty folders from the upload directory
    394435        remove_empty_folders(peak_publisher_upload_basedir());
     
    449490    }
    450491
     492    /**
     493     * Get all assets for a plugin.
     494     */
     495    public function handle_get_assets(\WP_REST_Request $request): array {
     496        $id   = (int) $request->get_param('id');
     497        $post = get_post($id);
     498        if (!$post || $post->post_type !== 'pblsh_plugin') {
     499            return ['status' => 'error', 'message' => 'Plugin not found.'];
     500        }
     501        $result  = $this->assets()->get_all($post->post_name);
     502        $result['screenshot_captions'] = $this->get_screenshot_captions($id);
     503        return $result;
     504    }
     505
     506    /**
     507     * Get screenshot captions from the latest published release's readme.txt.
     508     *
     509     * @return object Screenshot captions keyed by number, e.g. {1: "Caption", 2: "Caption"}.
     510     */
     511    private function get_screenshot_captions(int $plugin_id): object {
     512        $latest = get_posts([
     513            'post_type'      => 'pblsh_release',
     514            'post_status'    => 'publish',
     515            'post_parent'    => $plugin_id,
     516            'posts_per_page' => 1,
     517            'orderby'        => 'date',
     518            'order'          => 'DESC',
     519        ]);
     520        if (empty($latest)) {
     521            return (object) [];
     522        }
     523        $content = json_decode((string) $latest[0]->post_content, true);
     524        $screenshots = $content['plugin_readme_txt']['content']['screenshots'] ?? [];
     525        if (empty($screenshots) || !is_array($screenshots)) {
     526            return (object) [];
     527        }
     528        // Ensure keys are integers and values are strings.
     529        $captions = [];
     530        foreach ($screenshots as $n => $caption) {
     531            $captions[(int) $n] = (string) $caption;
     532        }
     533        return (object) $captions;
     534    }
     535
     536    /**
     537     * Upload an asset file to a plugin slot.
     538     * Expects multipart/form-data with: file (binary), slot (string), screenshot_n (int, optional).
     539     */
     540    public function handle_upload_asset(\WP_REST_Request $request): array {
     541        $id   = (int) $request->get_param('id');
     542        $post = get_post($id);
     543        if (!$post || $post->post_type !== 'pblsh_plugin') {
     544            return ['status' => 'error', 'message' => 'Plugin not found.'];
     545        }
     546
     547        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
     548        if (empty($_FILES['file']) || (int) ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
     549            $err_code = (int) ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE);
     550            return ['status' => 'error', 'message' => 'No file uploaded (error code ' . $err_code . ').'];
     551        }
     552
     553        $slot         = sanitize_key((string) ($request->get_param('slot') ?? ''));
     554        $screenshot_n_raw = $request->get_param('screenshot_n');
     555        $screenshot_n = $screenshot_n_raw !== null && $screenshot_n_raw !== '' ? (int) $screenshot_n_raw : null;
     556
     557        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passed to AssetManager which validates it.
     558        $file_data = $_FILES['file'];
     559        $result = $this->assets()->upload($id, $post->post_name, $slot, $screenshot_n, $file_data);
     560
     561        // Calculate banner average color for geopattern fallback icons.
     562        // Based on WordPress.org Plugin Directory.
     563        if ( in_array( $slot, [ 'banner_sd', 'banner_hd' ], true ) && ( $result['status'] ?? '' ) !== 'error' ) {
     564            $this->update_banner_color( $id, $post->post_name );
     565        }
     566
     567        return $result;
     568    }
     569
     570    /**
     571     * Delete an asset from a plugin slot.
     572     * Expects JSON body: { slot: string, screenshot_n?: int }.
     573     */
     574    public function handle_delete_asset(\WP_REST_Request $request): array {
     575        $id   = (int) $request->get_param('id');
     576        $post = get_post($id);
     577        if (!$post || $post->post_type !== 'pblsh_plugin') {
     578            return ['status' => 'error', 'message' => 'Plugin not found.'];
     579        }
     580
     581        $params       = $request->get_json_params();
     582        $slot         = sanitize_key((string) ($params['slot'] ?? ''));
     583        $screenshot_n_raw = $params['screenshot_n'] ?? null;
     584        $screenshot_n = $screenshot_n_raw !== null ? (int) $screenshot_n_raw : null;
     585
     586        $deleted = $this->assets()->delete($id, $post->post_name, $slot, $screenshot_n);
     587
     588        // Recalculate banner average color for geopattern fallback icons.
     589        // Based on WordPress.org Plugin Directory.
     590        if ( in_array( $slot, [ 'banner_sd', 'banner_hd' ], true ) ) {
     591            $this->update_banner_color( $id, $post->post_name );
     592        }
     593
     594        $assets  = $this->assets()->get_all($post->post_name);
     595        $assets['screenshot_captions'] = $this->get_screenshot_captions($id);
     596        return ['status' => 'ok', 'deleted' => $deleted, 'assets' => $assets];
     597    }
     598
     599    /**
     600     * Move a screenshot from one position to another.
     601     * Expects JSON body: { slot: "screenshot", from: int, to: int }.
     602     */
     603    public function handle_move_asset(\WP_REST_Request $request): array {
     604        $id   = (int) $request->get_param('id');
     605        $post = get_post($id);
     606        if (!$post || $post->post_type !== 'pblsh_plugin') {
     607            return ['status' => 'error', 'message' => 'Plugin not found.'];
     608        }
     609
     610        $params = $request->get_json_params();
     611        $from   = isset($params['from']) ? (int) $params['from'] : 0;
     612        $to     = isset($params['to'])   ? (int) $params['to']   : 0;
     613
     614        $result = $this->assets()->move_screenshot($id, $post->post_name, $from, $to);
     615        if ($result['status'] === 'error') {
     616            return $result;
     617        }
     618
     619        $assets = $this->assets()->get_all($post->post_name);
     620        $assets['screenshot_captions'] = $this->get_screenshot_captions($id);
     621        return ['status' => 'ok', 'assets' => $assets];
     622    }
     623
     624    /**
     625     * Recalculate and store the banner average color for geopattern fallback icons.
     626     *
     627     * Based on WordPress.org Plugin Directory.
     628     * @see https://github.com/WordPress/wordpress.org — class-tools.php
     629     */
     630    private function update_banner_color( int $plugin_id, string $plugin_slug ): void {
     631        $banner_average_color = '';
     632
     633        // Find the first available banner file (prefer HD, then SD) via asset meta.
     634        foreach ( [ 'banner_hd', 'banner_sd' ] as $slot ) {
     635            $info = $this->assets()->find_file_in_slot( $plugin_slug, $slot );
     636            if ( $info !== null ) {
     637                $filepath = trailingslashit( get_plugin_assets_basedir( $plugin_slug ) ) . $info['filename'];
     638                if ( file_exists( $filepath ) ) {
     639                    $banner_average_color = get_image_average_color( $filepath );
     640                    if ( ! is_string( $banner_average_color ) ) {
     641                        $banner_average_color = '';
     642                    }
     643                }
     644                break;
     645            }
     646        }
     647
     648        if ( $banner_average_color !== '' ) {
     649            update_post_meta( $plugin_id, 'assets_banners_color', wp_slash( $banner_average_color ) );
     650        } else {
     651            delete_post_meta( $plugin_id, 'assets_banners_color' );
     652        }
     653    }
     654
    451655    public function get_peak_publisher_settings_rest(): array {
    452656        return get_peak_publisher_settings();
  • peak-publisher/tags/1.2.0/classes/AdminUI.php

    r3444907 r3477599  
    147147        );
    148148       
     149        require_once __DIR__ . '/AssetManager.php';
    149150        wp_localize_script(
    150151            'pblsh-admin',
     
    154155                'wpVersion' => function_exists('wp_get_wp_version') ? wp_get_wp_version() : $GLOBALS['wp_version'],
    155156                'phpVersion' => PHP_VERSION,
     157                'hasPlainPermalinks' => get_option('permalink_structure') === '',
     158                'permalinkSettingsUrl' => admin_url('options-permalink.php'),
     159                'assetSlots' => AssetManager::get_slots(),
     160                'i18n' => [
     161                    'permalinkPlain'      => __('Plain'),
     162                    'permalinkDayAndName' => __('Day and name'),
     163                ],
    156164            ]
    157165        );
  • peak-publisher/tags/1.2.0/classes/PublicAPI.php

    r3446146 r3477599  
    4242                'slug' => ['required' => true],
    4343                'version' => ['required' => false, 'default' => ''],
     44            ],
     45        ]);
     46
     47        // Geopattern fallback icon endpoint — public, no permission check.
     48        // Based on WordPress.org Plugin Directory.
     49        register_rest_route(self::NAMESPACE, '/plugins/geopattern-icon/(?P<file>[a-z0-9_-]+\.svg)', [
     50            'methods' => 'GET',
     51            'callback' => [$this, 'handle_geopattern_icon'],
     52            'permission_callback' => '__return_true',
     53            'args' => [
     54                'file' => ['required' => true],
    4455            ],
    4556        ]);
     
    283294            $result['sections'][$section_key] = apply_filters( 'the_content', $section_content, $section_key);
    284295        }
     296        $result['sections']['screenshots'] = ''; // placeholder to put screenshots prior to reviews at the end.
    285297
    286298        if ( ! empty( $result['sections']['faq'] ) ) {
     
    292304        $result['download_link']     = rest_url(self::NAMESPACE . '/plugins/download/' . $plugin->post_name . '/' . $latest_release->post_title);
    293305        $result['upgrade_notice']    = $latest_release_content['plugin_readme_txt']['content']['upgrade_notice'] ?? '';
     306
     307        require_once __DIR__ . '/AssetManager.php';
     308        $asset_manager = AssetManager::init();
     309
     310        // Reduce images to caption + src
     311        $result['screenshots'] = array_map(
     312            function( $image ) {
     313                return [
     314                    'src'     => $image['src'],
     315                    'caption' => $image['caption'],
     316                ];
     317            },
     318            $asset_manager->get_api_screenshots($plugin->post_name, $latest_release_content['plugin_readme_txt']['content']['screenshots'] ?? [])
     319        );
     320
     321        if ( $result['screenshots'] ) {
     322            $result['sections']['screenshots'] = $this->get_screenshot_markup( $result['screenshots'] );
     323        } else {
     324            unset( $result['sections']['screenshots'] );
     325        }
    294326
    295327        $terms = array_map(fn($term) => (object) [
     
    318350
    319351        $result['donate_link'] = $latest_release_content['plugin_readme_txt']['content']['donate_link'] ?? '';
     352
     353        // Banners & icons — mirrors the wordpress.org API structure.
     354        // @see https://github.com/WordPress/wordpress.org/blob/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
     355        // NOTE: Intentionally duplicated in handle_update_check() — kept as a 1:1 copy of the wp.org reference.
     356        $result['banners'] = array();
     357        if ( $banners = $asset_manager->get_plugin_banner($plugin->post_name) ) {
     358            if ( isset( $banners['banner'] ) ) {
     359                $result['banners']['low'] = $banners['banner'];
     360            }
     361            if ( isset( $banners['banner_2x'] ) ) {
     362                $result['banners']['high'] = $banners['banner_2x'];
     363            }
     364        }
     365
     366        $result['icons'] = array();
     367        if ( $icons = $asset_manager->get_plugin_icon($plugin->post_name) ) {
     368            if ( ! empty( $icons['icon'] ) && empty( $icons['generated'] ) ) {
     369                $result['icons']['1x'] = $icons['icon'];
     370            } elseif ( ! empty( $icons['icon'] ) && ! empty( $icons['generated'] ) ) {
     371                $result['icons']['default'] = $icons['icon'];
     372            }
     373            if ( ! empty( $icons['icon_2x'] ) ) {
     374                $result['icons']['2x'] = $icons['icon_2x'];
     375            }
     376            if ( ! empty( $icons['svg'] ) ) {
     377                $result['icons']['svg'] = $icons['svg'];
     378            }
     379        }
    320380
    321381        $expected_fields = $this->get_expected_fields('plugin_information');
     
    386446        $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
    387447
     448        require_once __DIR__ . '/AssetManager.php';
     449        $asset_manager = AssetManager::init();
     450
    388451        $results = [
    389452            'plugins' => [],
     
    408471            record_plugin_installation((int) $plugin->ID, (string) $user_agent, (string) $client_installed_version);
    409472           
     473            $result = [];
     474
     475            // Banners & icons — mirrors the wordpress.org API structure.
     476            // @see https://github.com/WordPress/wordpress.org/blob/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
     477            // NOTE: Intentionally duplicated in handle_info() — kept as a 1:1 copy of the wp.org reference.
     478            $result['banners'] = array();
     479            if ( $banners = $asset_manager->get_plugin_banner($plugin->post_name) ) {
     480                if ( isset( $banners['banner'] ) ) {
     481                    $result['banners']['low'] = $banners['banner'];
     482                }
     483                if ( isset( $banners['banner_2x'] ) ) {
     484                    $result['banners']['high'] = $banners['banner_2x'];
     485                }
     486            }
     487
     488            $result['icons'] = array();
     489            if ( $icons = $asset_manager->get_plugin_icon($plugin->post_name) ) {
     490                if ( ! empty( $icons['icon'] ) && empty( $icons['generated'] ) ) {
     491                    $result['icons']['1x'] = $icons['icon'];
     492                } elseif ( ! empty( $icons['icon'] ) && ! empty( $icons['generated'] ) ) {
     493                    $result['icons']['default'] = $icons['icon'];
     494                }
     495                if ( ! empty( $icons['icon_2x'] ) ) {
     496                    $result['icons']['2x'] = $icons['icon_2x'];
     497                }
     498                if ( ! empty( $icons['svg'] ) ) {
     499                    $result['icons']['svg'] = $icons['svg'];
     500                }
     501            }
     502
    410503            $results['plugins'][$plugin_basename] = array_merge(
    411504                ['slug' => $plugin->post_name],
     
    413506                ['version' => $latest_version],
    414507                ['package' => rest_url(self::NAMESPACE . '/plugins/download/' . $plugin->post_name . '/' . $latest_version)],
     508                empty($result['icons']) ? [] : [ 'icons' => $result['icons'] ],
     509                empty($result['banners']) ? [] : [ 'banners' => $result['banners'] ],
    415510                empty($plugin_data['RequiresWP']) ? [] : [ 'requires' => $plugin_data['RequiresWP'] ],
    416511                empty($plugin_data['RequiresPHP']) ? [] : [ 'requires_php' => $plugin_data['RequiresPHP'] ],
     
    632727        return $markup;
    633728    }
     729
     730    /**
     731     * Screenshots markup for the plugin information API
     732     *
     733     * It is intentional that $shot['caption'] is not escaped, as it may contain HTML.
     734     * It is reduced to allowed tags when parsing the readme.txt file, so $shot['caption'] is considered trusted HTML.
     735     *
     736     * @see https://github.com/WordPress/wordpress.org/blob/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
     737     * @param array $screenshots The existing Markup.
     738     * @return string Markup
     739     */
     740    protected function get_screenshot_markup( $screenshots ) {
     741        $markup = '<ol>';
     742
     743        foreach ( $screenshots as $shot ) {
     744            if ( $shot['caption'] ) {
     745                $markup .= sprintf(
     746                    '<li><a href="%1$s"><img src="%1$s" alt="%2$s"></a><p>%3$s</p></li>',
     747                    esc_attr( $shot['src'] ),
     748                    esc_attr( $shot['caption'] ),
     749                    $shot['caption']
     750                );
     751            } else {
     752                $markup .= sprintf(
     753                    '<li><a href="%1$s"><img src="%1$s" alt=""></a></li>',
     754                    esc_attr( $shot['src'] )
     755                );
     756            }
     757        }
     758
     759        $markup .= '</ol>';
     760        return $markup;
     761    }
     762
     763    /**
     764     * Output a SVG Geopattern for a given plugin slug.
     765     *
     766     * Based on WordPress.org Plugin Directory.
     767     * @see https://github.com/WordPress/wordpress.org — geopattern_icon_route()
     768     */
     769    public function handle_geopattern_icon( \WP_REST_Request $request ) {
     770        $file = $request->get_param( 'file' );
     771        // Strip .svg extension.
     772        $name = preg_replace( '/\.svg$/', '', $file );
     773
     774        // Parse slug and optional color from filename: {slug}_{6-hex-chars} or just {slug}.
     775        $slug  = $name;
     776        $color = '';
     777        if ( preg_match( '/^(.+)_([a-f0-9]{6})$/', $name, $m ) ) {
     778            $slug  = $m[1];
     779            $color = $m[2];
     780        }
     781
     782        require_once PBLSH_PLUGIN_DIR . 'libs/plugin-directory/libs/geopattern-1.1.0/geopattern_loader.php';
     783
     784        $icon = new \Pblsh\Vendor\RedeyeVentures\GeoPattern\GeoPattern();
     785        $icon->setString( $slug );
     786        if ( strlen( $color ) === 6 && strspn( $color, 'abcdef0123456789' ) === 6 ) {
     787            $icon->setColor( '#' . $color );
     788        }
     789
     790        $svg = $icon->toSVG();
     791        $year_in_seconds = 365 * DAY_IN_SECONDS;
     792
     793        header( 'Content-Type: image/svg+xml' );
     794        header( 'Cache-Control: public, max-age=' . $year_in_seconds );
     795        header( 'Expires: ' . gmdate( 'D, d M Y H:i:s \G\M\T', time() + $year_in_seconds ) );
     796
     797        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SVG output from trusted library.
     798        echo $svg;
     799        exit;
     800    }
    634801}
    635802
  • peak-publisher/tags/1.2.0/includes/functions.php

    r3460404 r3477599  
    275275
    276276/**
     277 * Gets the assets directory for a specific plugin slug.
     278 */
     279function get_plugin_assets_basedir(string $plugin_slug): string {
     280    return trailingslashit(peak_publisher_upload_basedir()) . 'plugins/' . sanitize_file_name($plugin_slug) . '/assets';
     281}
     282
     283
     284/**
     285 * Ensures the assets directory for a plugin slug exists and is publicly accessible.
     286 * Assets are served as direct static files (not via REST API).
     287 * Writes .htaccess to override the parent "Deny all" for Apache; Nginx serves files directly.
     288 */
     289function ensure_plugin_assets_dir(string $plugin_slug): void {
     290    $basedir  = get_plugin_assets_basedir($plugin_slug);
     291    wp_mkdir_p($basedir);
     292    if (!file_exists($basedir . '/index.php')) {
     293        file_put_contents($basedir . '/index.php', '<?php exit;');
     294    }
     295    if (!file_exists($basedir . '/.htaccess')) {
     296        file_put_contents($basedir . '/.htaccess',
     297            '# Allow direct access to image assets only' . "\n" .
     298            '<FilesMatch "\.(png|jpe?g|gif|svg)$">' . "\n" .
     299            '  <IfModule mod_authz_core.c>' . "\n" .
     300            '    Require all granted' . "\n" .
     301            '  </IfModule>' . "\n" .
     302            '  <IfModule !mod_authz_core.c>' . "\n" .
     303            '    Order Allow,Deny' . "\n" .
     304            '    Allow from all' . "\n" .
     305            '  </IfModule>' . "\n" .
     306            '</FilesMatch>' . "\n"
     307        );
     308    }
     309}
     310
     311
     312/**
    277313 * Gets the upload directory.
    278314 */
     
    493529
    494530/**
     531 * Retrieve the average color of a specified image.
     532 *
     533 * Samples five points (rule of thirds + center) and averages their RGB values.
     534 * Algorithm matches Jetpack's Tonesque library used by WordPress.org Plugin Directory.
     535 *
     536 * Based on WordPress.org Plugin Directory.
     537 * @see https://github.com/WordPress/wordpress.org — class-tools.php
     538 * @see Jetpack Tonesque — grab_points() / grab_color() / get_color()
     539 *
     540 * @param string $file_path Absolute filesystem path to the image.
     541 * @return string|false Average color as a 6-char lowercase hex value (no #), false on failure.
     542 */
     543function get_image_average_color( string $file_path ) {
     544    if ( ! function_exists( 'imagecreatefromstring' ) ) {
     545        return false;
     546    }
     547
     548    if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
     549        return false;
     550    }
     551
     552    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read.
     553    $data = file_get_contents( $file_path );
     554    if ( $data === false ) {
     555        return false;
     556    }
     557
     558    $img = @imagecreatefromstring( $data );
     559    if ( ! $img ) {
     560        return false;
     561    }
     562
     563    $width  = imagesx( $img );
     564    $height = imagesy( $img );
     565
     566    // Sample five points based on rule of thirds and center (same as Tonesque::grab_points).
     567    $left_x   = (int) round( $width / 3 );
     568    $right_x  = (int) round( ( $width / 3 ) * 2 );
     569    $top_y    = (int) round( $height / 3 );
     570    $bottom_y = (int) round( ( $height / 3 ) * 2 );
     571    $center_x = (int) round( $width / 2 );
     572    $center_y = (int) round( $height / 2 );
     573
     574    $points = [
     575        imagecolorat( $img, $left_x,   $top_y ),
     576        imagecolorat( $img, $right_x,  $top_y ),
     577        imagecolorat( $img, $left_x,   $bottom_y ),
     578        imagecolorat( $img, $right_x,  $bottom_y ),
     579        imagecolorat( $img, $center_x, $center_y ),
     580    ];
     581
     582    // Average the RGB channels (same as Tonesque::grab_color).
     583    $r = [];
     584    $g = [];
     585    $b = [];
     586    foreach ( $points as $color_index ) {
     587        $c  = imagecolorsforindex( $img, $color_index );
     588        $r[] = $c['red'];
     589        $g[] = $c['green'];
     590        $b[] = $c['blue'];
     591    }
     592
     593    imagedestroy( $img );
     594
     595    $red   = (int) round( array_sum( $r ) / 5 );
     596    $green = (int) round( array_sum( $g ) / 5 );
     597    $blue  = (int) round( array_sum( $b ) / 5 );
     598
     599    return sprintf( '%02x%02x%02x', $red, $green, $blue );
     600}
     601
     602
     603/**
     604 * Retrieve the Geopattern SVG URL for a given plugin.
     605 *
     606 * Based on WordPress.org Plugin Directory.
     607 * @see https://github.com/WordPress/wordpress.org — class-template.php
     608 *
     609 * @param \WP_Post|int|string $post   Post object, ID, or plugin slug.
     610 * @param string|null         $color  Optional hex color (6 chars, no #). If null, read from post meta.
     611 * @return string Geopattern icon URL.
     612 */
     613function get_geopattern_icon_url( $post = null, ?string $color = null ): string {
     614    if ( is_string( $post ) ) {
     615        // Treat as slug — look up the post.
     616        $plugin = get_page_by_path( $post, OBJECT, 'pblsh_plugin' );
     617    } else {
     618        $plugin = get_post( $post );
     619    }
     620
     621    if ( ! $plugin ) {
     622        return '';
     623    }
     624
     625    if ( is_null( $color ) ) {
     626        $color = get_post_meta( $plugin->ID, 'assets_banners_color', true );
     627    }
     628
     629    if ( strlen( $color ) === 6 && strspn( $color, 'abcdef0123456789' ) === 6 ) {
     630        $color = "_{$color}";
     631    } else {
     632        $color = '';
     633    }
     634
     635    // The slug + color combine to form the cache buster, like on wordpress.org.
     636    $url = rest_url( 'pblsh/v1/plugins/geopattern-icon/' . $plugin->post_name . $color . '.svg' );
     637
     638    return $url;
     639}
     640
     641
     642/**
    495643 * Polyfills for PHP 8.0 functions.
    496644 */
  • peak-publisher/tags/1.2.0/peak-publisher.php

    r3460404 r3477599  
    44 * Plugin Name: Peak Publisher
    55 * Description: The easiest way to self-host, manage and publish your own custom plugins.
    6  * Version: 1.1.3
     6 * Version: 1.2.0
    77 * Requires at least: 5.8
    88 * Requires PHP: 8.1
  • peak-publisher/tags/1.2.0/readme.txt

    r3460404 r3477599  
    99Requires PHP: 8.1
    1010Tested up to: 6.9
    11 Stable tag: 1.1.3
     11Stable tag: 1.2.0
    1212License: GPLv2 or later
    1313License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    109109== Changelog ==
    110110
     111= 1.2.0 - 2026-03-08 =
     112* Added asset management: upload, replace, reorder, and delete plugin icons, banners, and screenshots directly in the admin UI
     113   * Icons, banners, and screenshots are served to client sites via the wordpress.org-compatible API
     114   * Geopattern fallback icons for plugins without a custom icon (following the WordPress.org convention)
     115* Added permalink structure check: shows a notice when "Plain" permalinks are active (REST API requirement)
     116
    111117= 1.1.3 - 2026-02-12 =
    112118* New bootstrap code (basicV2): multisite support and safe handling when update transient has no response/no_update keys
  • peak-publisher/trunk/assets/css/admin.css

    r3444907 r3477599  
    6969    0% { transform: rotate(0deg); }
    7070    100% { transform: rotate(360deg); }
     71}
     72
     73/* Permalink Notice */
     74.pblsh--permalink-notice {
     75    display: flex;
     76    flex-direction: column;
     77    align-items: center;
     78    text-align: center;
     79    padding: 4rem var(--content-padding-inline);
     80    max-width: 32rem;
     81    margin: 0 auto;
     82}
     83
     84.pblsh--permalink-notice__icon svg {
     85    color: var(--color-blue);
     86}
     87
     88.pblsh--permalink-notice__title {
     89    font-size: 1.5rem;
     90    margin: 1rem 0 0.5rem;
     91}
     92
     93.pblsh--permalink-notice__text {
     94    color: #50575e;
     95    margin: 0.25rem 0;
     96    line-height: 1.5;
     97    font-size: 1rem;
     98    text-wrap: pretty;
     99}
     100
     101.pblsh--permalink-notice__button {
     102    margin-top: 1.5rem;
     103    text-decoration: none;
    71104}
    72105
     
    851884}
    852885
     886.pblsh--table__icon-header,
     887.pblsh--table__icon-cell {
     888    width: 3rem;
     889    text-align: right;
     890    padding: 0 !important;
     891}
     892.pblsh--table__icon {
     893    /* It is intentional that non-square images are distorted, as WordPress itself behaves in this way as well. */
     894    width: 3rem;
     895    height: 3rem;
     896    display: inline-block;
     897    vertical-align: middle;
     898}
     899.pblsh--table__name-header,
     900.pblsh--table__name-cell {
     901    padding-left: 1rem !important;
     902}
     903
    853904.pblsh--table__name-cell {
    854905    min-width: 12.5rem; /* 200px */
     
    903954
    904955/* Status Column */
    905 .pblsh--table__status-header {
    906     width: 6.25rem; /* 100px */
    907 }
    908 
    909956.pblsh--table__status-cell {
    910957    width: 6.25rem; /* 100px */
     
    9591006    border-bottom: 1px solid var(--border-color-medium);
    9601007    margin-bottom: var(--content-padding-block);
     1008}
     1009.pblsh--plugin-header__main {
     1010    display: flex;
     1011    align-items: center;
     1012    gap: 0.75rem;
     1013    min-width: 0;
     1014}
     1015.pblsh--plugin-header__actions {
     1016    display: flex;
     1017    align-items: center;
    9611018}
    9621019.pblsh--plugin-header code {
     
    10791136    max-width: 50rem; /* 800px */
    10801137}
     1138/* Tab panel (Releases / Assets) */
     1139.pblsh--tab-panel {
     1140    margin-top: 1.25rem;
     1141}
     1142
     1143.pblsh--tab-nav {
     1144    display: flex;
     1145    gap: 0;
     1146    padding: 0;
     1147    margin-bottom: 0;
     1148}
     1149
     1150.pblsh--tab-nav__tab {
     1151    background: #f3f4f6;
     1152    border: 1px solid var(--border-color-light);
     1153    border-bottom-color: var(--border-color-light);
     1154    border-radius: 0.4rem 0.4rem 0 0;
     1155    padding: 0.5rem 1.375rem;
     1156    font-size: 0.875rem;
     1157    font-weight: 500;
     1158    color: #666;
     1159    cursor: pointer;
     1160    position: relative;
     1161    z-index: 1;
     1162    margin-bottom: -1px;
     1163    margin-right: -1px;
     1164    transition: background 0.15s, color 0.15s;
     1165}
     1166
     1167.pblsh--tab-nav__tab:last-child {
     1168    margin-right: 0;
     1169}
     1170
     1171.pblsh--tab-nav__tab:hover:not(.pblsh--tab-nav__tab--active) {
     1172    background: #e9edf2;
     1173    color: #1d2327;
     1174}
     1175
     1176.pblsh--tab-nav__tab--active {
     1177    background: #fff;
     1178    color: #1d2327;
     1179    font-weight: 600;
     1180    z-index: 2;
     1181    border-bottom-color: #fff;
     1182}
     1183
     1184.pblsh--tab-panel__body {
     1185    position: relative;
     1186    z-index: 0;
     1187    background: #fff;
     1188    border: 1px solid var(--border-color-light);
     1189    border-radius: 0 0.5rem 0.5rem 0.5rem;
     1190    box-shadow: 0 0.0625rem 0.1875rem rgba(0, 0, 0, 0.1);
     1191    overflow: hidden;
     1192}
     1193
     1194/* Strip box styling from inner elements rendered directly inside the panel */
     1195.pblsh--tab-panel__body > .pblsh--table-container,
     1196.pblsh--tab-panel__body > .pblsh--card {
     1197    border: none;
     1198    box-shadow: none;
     1199    border-radius: 0;
     1200    margin-top: 0;
     1201}
     1202.pblsh--plugin-header__icon {
     1203    /* It is intentional that non-square images are distorted, as WordPress itself behaves in this way as well. */
     1204    width: 5rem;
     1205    height: 5rem;
     1206    flex-shrink: 0;
     1207}
    10811208
    10821209.pblsh--editor {
     
    14431570    margin-bottom: 1rem;
    14441571}
     1572
     1573
     1574/* ============================================================
     1575   Plugin Assets Section
     1576   ============================================================ */
     1577
     1578.pblsh--assets-card {
     1579    padding: 1.5rem;
     1580}
     1581
     1582.pblsh--assets-group {
     1583    margin-bottom: 5rem;
     1584}
     1585.pblsh--assets-group:last-child {
     1586    margin-bottom: 0;
     1587    border-bottom: 0;
     1588    padding-bottom: 0;
     1589}
     1590
     1591.pblsh--assets-group__label {
     1592    display: flex;
     1593    align-items: center;
     1594    gap: 0.5rem;
     1595    font-weight: 600;
     1596    letter-spacing: 0.05em;
     1597    text-transform: uppercase;
     1598    margin-bottom: 1rem;
     1599}
     1600
     1601.pblsh--assets-slots {
     1602    display: flex;
     1603    flex-wrap: wrap;
     1604    gap: 0.875rem;
     1605}
     1606
     1607/* Individual asset slot */
     1608.pblsh--asset-slot {
     1609    display: flex;
     1610    flex-direction: column;
     1611    border: 1.5px solid var(--border-color-medium);
     1612    border-radius: 8px;
     1613    background: #fff;
     1614    transition: border-color 0.15s, box-shadow 0.15s;
     1615    position: relative;
     1616}
     1617
     1618
     1619.pblsh--asset-slot--uploading {
     1620    opacity: 0.7;
     1621    pointer-events: none;
     1622}
     1623
     1624/* Upload progress */
     1625.pblsh--asset-slot__progress {
     1626    position: absolute;
     1627    inset: 0;
     1628    display: flex;
     1629    flex-direction: column;
     1630    align-items: center;
     1631    justify-content: center;
     1632    gap: 0.375rem;
     1633    background: rgba(255, 255, 255, 0.88);
     1634    padding: 0.5rem;
     1635}
     1636
     1637.pblsh--asset-slot__progress-bar {
     1638    width: 80%;
     1639    height: 4px;
     1640    background: #e0e4e8;
     1641    border-radius: 2px;
     1642    overflow: hidden;
     1643}
     1644.pblsh--asset-slot__progress-bar::after {
     1645    content: '';
     1646    display: block;
     1647    height: 100%;
     1648    width: var(--pct, 0%);
     1649    background: var(--color-blue);
     1650    transition: width 0.1s linear;
     1651    border-radius: 2px;
     1652}
     1653
     1654.pblsh--asset-slot__progress-label {
     1655    font-size: 0.6875rem;
     1656    color: #555d66;
     1657}
     1658
     1659/* Warnings */
     1660.pblsh--asset-slot__warnings {
     1661    margin-top: 0.25rem;
     1662}
     1663
     1664.pblsh--asset-slot__warning {
     1665    display: flex;
     1666    align-items: flex-start;
     1667    gap: 0.25rem;
     1668    font-size: 0.75rem; /* 12px */
     1669    color: #c07600;
     1670    line-height: 1.3;
     1671}
     1672.pblsh--asset-slot__warning svg {
     1673    flex-shrink: 0;
     1674    margin-top: 2px;
     1675}
     1676.pblsh--asset-slot__warning span {
     1677    white-space: pre-line;
     1678}
     1679
     1680/* Actions row */
     1681.pblsh--asset-slot__actions {
     1682    display: flex;
     1683    gap: 0;
     1684    align-items: center;
     1685    flex-shrink: 0;
     1686}
     1687
     1688.pblsh--asset-slot__actions .components-button.has-icon {
     1689    padding: 0 5px;
     1690    min-width: 32px;
     1691    height: 32px;
     1692    min-height: 32px;
     1693}
     1694
     1695/* Small loading indicator for assets section */
     1696.pblsh--loading--small {
     1697    min-height: 4rem;
     1698    padding: 1rem;
     1699}
     1700
     1701/* "Add new screenshot" slot styling */
     1702.pblsh--asset-slot--new {
     1703    border-style: dashed;
     1704    border-color: #c4d0db;
     1705    background: #fafbfc;
     1706    box-shadow: none;
     1707}
     1708
     1709/* ---- Assets: Box-Grid-Layout ---- */
     1710.pblsh--assets-slots--boxes {
     1711    gap: 1rem;
     1712    display: grid;
     1713    grid-template-columns: repeat(auto-fill, minmax(min(100%, 421px), 421px));
     1714}
     1715
     1716.pblsh--asset-slot--box {
     1717    display: flex;
     1718    flex-direction: column;
     1719    overflow: hidden;
     1720    position: relative;
     1721}
     1722
     1723.pblsh--asset-slot--box > .pblsh--asset-slot__actions {
     1724    position: absolute;
     1725    bottom: 0.25rem;
     1726    right: 0.25rem;
     1727    display: flex;
     1728    align-items: center;
     1729    gap: 0;
     1730}
     1731
     1732.pblsh--asset-slot__box-label {
     1733    font-weight: 600;
     1734    font-size: 0.9375rem;
     1735    color: #1e1e1e;
     1736}
     1737
     1738.pblsh--asset-slot__box-body {
     1739    display: flex;
     1740    flex-direction: row;
     1741    gap: 0.75rem;
     1742    padding: 0.75rem;
     1743    flex: 1;
     1744    align-items: flex-start;
     1745    flex-wrap: wrap;
     1746}
     1747
     1748.pblsh--asset-slot__box-image {
     1749    max-width: 100%;
     1750    border: 1px solid transparent;
     1751    transition: border-color 0.15s;
     1752}
     1753
     1754.pblsh--asset-slot__box-image-inner {
     1755    background-color: #fff;
     1756    background-image:
     1757        linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
     1758        linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
     1759        linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
     1760        linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
     1761    background-size: 16px 16px;
     1762    background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
     1763    display: flex;
     1764    align-items: center;
     1765    justify-content: center;
     1766    overflow: hidden;
     1767    transition: box-shadow 0.15s;
     1768    position: relative;
     1769    max-width: 100%;
     1770    height: 0px;
     1771    aspect-ratio: var(--x, 1) / var(--y, 1);
     1772    padding-top: calc(var(--y, 1) / var(--x, 1) * 100%);
     1773}
     1774.pblsh--asset-slot__box-image:hover {
     1775    border-color: rgba(0, 0, 0, 0.2);
     1776}
     1777.pblsh--asset-slot__box-image-inner:hover {
     1778    box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
     1779}
     1780.pblsh--asset-slot__box-image--icon .pblsh--asset-slot__box-image-inner {
     1781    --x: 1;
     1782    --y: 1;
     1783    width: 128px;
     1784}
     1785.pblsh--asset-slot__box-image--banner .pblsh--asset-slot__box-image-inner {
     1786    --x: 772;
     1787    --y: 250;
     1788    width: 395px;
     1789}
     1790.pblsh--asset-slot__box-image--screenshot .pblsh--asset-slot__box-image-inner {
     1791    --x: 16;
     1792    --y: 9;
     1793    width: 395px;
     1794}
     1795.pblsh--asset-slot__box-img {
     1796    width: 100%;
     1797    height: 100%;
     1798    object-fit: contain;
     1799    display: block;
     1800    position: absolute;
     1801    inset: 0;
     1802}
     1803
     1804.pblsh--asset-slot__box-info {
     1805    flex: 1;
     1806    min-width: 10rem;
     1807    display: flex;
     1808    flex-direction: column;
     1809}
     1810
     1811.pblsh--asset-slot__box-filename {
     1812    word-break: break-word;
     1813}
     1814
     1815.pblsh--asset-slot__box-meta {
     1816    color: #8c96a0;
     1817}
     1818
     1819.pblsh--asset-slot__box-body--empty {
     1820    justify-content: center;
     1821    align-items: center;
     1822    position: relative;
     1823    min-height: 5rem;
     1824    flex: 1;
     1825}
     1826
     1827.pblsh--asset-slot__box-empty {
     1828    display: flex;
     1829    flex-direction: column;
     1830    align-items: center;
     1831    text-align: center;
     1832    color: #6c7781;
     1833}
     1834.pblsh--asset-slot__box-empty-title {
     1835    font-size: 0.9375rem;
     1836    font-weight: 600;
     1837    color: #1e1e1e;
     1838}
     1839.pblsh--asset-slot__box-empty-expected {
     1840    color: #8c96a0;
     1841}
     1842.pblsh--asset-slot__box-upload-btn {
     1843    margin-top: 0.5rem;
     1844}
     1845
     1846.pblsh--asset-slot__box-footer {
     1847    display: flex;
     1848    justify-content: flex-end;
     1849    padding: 0.25rem 0.375rem;
     1850    border-top: 1px solid var(--border-color-light);
     1851    background: #f9fafb;
     1852}
     1853
     1854/* Screenshot drag: cursor on the image area (the draggable element) */
     1855.pblsh--asset-slot__box-image[draggable="true"] {
     1856    cursor: grab;
     1857}
     1858.pblsh--asset-slot__box-image[draggable="true"]:active {
     1859    cursor: grabbing;
     1860}
     1861
     1862/* Dragging state — only the image fades, not the whole slot */
     1863.pblsh--asset-slot--dragging .pblsh--asset-slot__box-image {
     1864    opacity: 0.4;
     1865}
     1866
     1867/* Drop target highlight */
     1868.pblsh--asset-slot--drag-target {
     1869    outline: 2px dashed var(--wp-admin-theme-color, #3858e9);
     1870    outline-offset: -2px;
     1871}
     1872
     1873/* Caption text — shown right after the slot label */
     1874.pblsh--asset-slot__caption {
     1875    font-size: 0.9375rem;
     1876    color: #1e1e1e;
     1877    line-height: 1.4;
     1878}
  • peak-publisher/trunk/assets/js/admin.js

    r3444907 r3477599  
    33    'use strict';
    44   
    5     const { __ } = wp.i18n;
     5    const { __, sprintf } = wp.i18n;
    66    const { useState, useEffect, useRef, createElement, render } = wp.element;
    77    const { useSelect } = wp.data;
     
    1010    const { showAlert, getDefaultConfig } = Pblsh.Utils;
    1111
     12    // Permalink check — shown instead of the app when permalinks are set to "Plain"
     13    const PermalinkNotice = () => {
     14        const { permalinkPlain, permalinkDayAndName } = PblshData.i18n;
     15        return createElement('div', { className: 'pblsh-app' },
     16            createElement('div', { className: 'pblsh--header' },
     17                createElement('h2', { className: 'pblsh--header__title' }, __('Peak Publisher', 'peak-publisher'))
     18            ),
     19            createElement('div', { className: 'pblsh--permalink-notice' },
     20                createElement('div', { className: 'pblsh--permalink-notice__icon' },
     21                    Pblsh.Utils.getSvgIcon('chat_alert', { size: 48 })
     22                ),
     23                createElement('h3', { className: 'pblsh--permalink-notice__title' },
     24                    __('Pretty Permalinks Required', 'peak-publisher')
     25                ),
     26                createElement('p', { className: 'pblsh--permalink-notice__text' },
     27                    sprintf(
     28                        /* translators: %s: name of the "Plain" permalink option (translated by WordPress) */
     29                        __('Peak Publisher uses the WordPress REST API, which requires pretty permalinks. Your permalink structure is currently set to "%s", which does not support REST API routes.', 'peak-publisher'),
     30                        permalinkPlain
     31                    )
     32                ),
     33                createElement('p', { className: 'pblsh--permalink-notice__text' },
     34                    createElement('strong', null,
     35                        sprintf(
     36                            /* translators: %1$s: "Plain" option name, %2$s: "Day and name" option name (both translated by WordPress) */
     37                            __('To fix this, go to the permalink settings and select any structure other than "%1$s" (e.g. "%2$s").', 'peak-publisher'),
     38                            permalinkPlain,
     39                            permalinkDayAndName
     40                        )
     41                    ),
     42                ),
     43                createElement('a', {
     44                    className: 'components-button is-primary pblsh--permalink-notice__button',
     45                    href: PblshData.permalinkSettingsUrl,
     46                }, __('Go to Permalink Settings', 'peak-publisher'))
     47            )
     48        );
     49    };
     50
    1251    // Main App Component
    1352    const PeakPublisherApp = () => {
     53        // Block the entire app when permalinks are set to "Plain"
     54        if (PblshData.hasPlainPermalinks) {
     55            return createElement(PermalinkNotice);
     56        }
     57
    1458        const [view, setView] = useState('list'); // 'list' | 'editor' | 'addition-process'
    1559        const [currentPluginId, setCurrentPluginId] = useState(null);
     60        const [initialTab, setInitialTab] = useState(null);
    1661        const isLoading = useSelect((select) => select('pblsh/plugins').isLoadingList(), []);
    1762        const hasLoadedList = useSelect((select) => {
     
    3479                    plugin: params.get('plugin'),
    3580                    view: params.get('view'),
     81                    tab: params.get('tab'),
    3682                };
    3783            } catch (e) {
     
    4793                if ('view' in next) {
    4894                    if (next.view) { params.set('view', String(next.view)); } else { params.delete('view'); }
     95                }
     96                if ('tab' in next) {
     97                    if (next.tab) { params.set('tab', String(next.tab)); } else { params.delete('tab'); }
    4998                }
    5099                const newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
     
    62111                    const idNum = Number(q.plugin);
    63112                    if (!isNaN(idNum)) {
     113                        if (q.tab) setInitialTab(q.tab);
    64114                        await handleEdit(idNum);
    65115                        return;
     
    135185            setCurrentPluginId(null);
    136186            setIsNew(false);
    137             setQuery({ plugin: null, view: null });
     187            setInitialTab(null);
     188            setQuery({ plugin: null, view: null, tab: null });
    138189        };
    139190
     
    254305                    isLoadingReleases: isLoadingReleases,
    255306                    onBack: handleCancel,
     307                    initialTab: initialTab,
     308                    onTabChange: (tab) => setQuery({ tab: tab === 'releases' ? null : tab }),
    256309                });
    257310            } else {
  • peak-publisher/trunk/assets/js/api.js

    r3444907 r3477599  
    116116        });
    117117    },
     118    // Get all assets for a plugin
     119    getPluginAssets: async (pluginId) => {
     120        return await window.Pblsh.API.request('plugins/' + pluginId + '/assets');
     121    },
     122    // Upload an asset file (slot: icon_128 | icon_256 | icon_svg | banner_sd | banner_hd | banner_svg | screenshot)
     123    // screenshotN: null = append new screenshot, number = replace specific screenshot
     124    uploadPluginAsset: async (pluginId, slot, screenshotN, file, onProgress) => {
     125        return new Promise((resolve, reject) => {
     126            const xhr = new XMLHttpRequest();
     127            xhr.open('POST', window.wpApiSettings.root + 'pblsh-admin/v1/plugins/' + pluginId + '/assets');
     128            xhr.setRequestHeader('X-WP-Nonce', window.wpApiSettings.nonce);
     129            xhr.responseType = 'json';
     130            xhr.upload.onprogress = (e) => {
     131                if (!e.lengthComputable) return;
     132                const percent = e.loaded * 100 / e.total;
     133                if (typeof onProgress === 'function') onProgress(percent);
     134            };
     135            xhr.onload = () => {
     136                if (xhr.status >= 200 && xhr.status < 300) {
     137                    resolve(xhr.response || {});
     138                } else {
     139                    const msg = xhr.response && xhr.response.message ? xhr.response.message : 'Upload failed (status ' + xhr.status + ')';
     140                    reject(new Error(msg));
     141                }
     142            };
     143            xhr.onerror = () => reject(new Error('Network error during asset upload.'));
     144            const form = new FormData();
     145            form.append('file', file, file.name);
     146            form.append('slot', slot);
     147            if (screenshotN !== null && screenshotN !== undefined) {
     148                form.append('screenshot_n', String(screenshotN));
     149            }
     150            xhr.send(form);
     151        });
     152    },
     153    // Delete an asset from a plugin slot
     154    deletePluginAsset: async (pluginId, slot, screenshotN) => {
     155        return await window.Pblsh.API.request('plugins/' + pluginId + '/assets', {
     156            method: 'DELETE',
     157            body: { slot, screenshot_n: screenshotN !== undefined ? screenshotN : null },
     158        });
     159    },
     160    // Move a screenshot from one position to another
     161    moveScreenshot: async (pluginId, fromN, toN) => {
     162        return await window.Pblsh.API.request('plugins/' + pluginId + '/assets/move', {
     163            method: 'POST',
     164            body: { slot: 'screenshot', from: fromN, to: toN },
     165        });
     166    },
    118167});
  • peak-publisher/trunk/assets/js/components/GlobalDropOverlay.js

    r3460404 r3477599  
    5353
    5454    useEffect(() => {
     55        // Check if a drag event carries external files (not an internal page drag like screenshot reordering)
     56        const isExternalFileDrag = (e) => e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.indexOf('Files') !== -1;
     57
    5558        const onDragEnter = (e) => {
     59            if (!isExternalFileDrag(e)) return;
    5660            e.preventDefault();
    5761            setDragCounter((c) => c + 1);
     
    5963        };
    6064        const onDragOver = (e) => {
     65            if (!isExternalFileDrag(e)) return;
    6166            e.preventDefault();
    6267        };
     
    6974        };
    7075        const onDrop = (e) => {
     76            if (!isExternalFileDrag(e)) return;
    7177            e.preventDefault();
    7278            setDragCounter(0);
  • peak-publisher/trunk/assets/js/components/PluginEditor.js

    r3444907 r3477599  
    11// PluginEditor Component (simplified overview + releases list)
    2 lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack }) => {
     2lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack, initialTab, onTabChange }) => {
    33    const { __ } = wp.i18n;
    4     const { createElement } = wp.element;
     4    const { createElement, useState, useEffect, useRef } = wp.element;
    55    const { useSelect } = wp.data;
    6     const { Tooltip, Button } = wp.components;
     6    const { Tooltip, Button, DropdownMenu, MenuItem } = wp.components;
    77    const { getSvgIcon } = Pblsh.Utils;
    88
    99    const safe = (val) => (val === undefined || val === null) ? '' : val;
     10    const formatFilesize = (bytes) => {
     11        if (!bytes || bytes <= 0) return null;
     12        if (bytes < 1024) return bytes + ' B';
     13        if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
     14        return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
     15    };
    1016    const serverSettings = useSelect((select) => select('pblsh/settings').getServer(), []);
    1117    const showInstallations = !!(serverSettings && serverSettings.count_plugin_installations);
     18
     19    // ---- Asset state ----
     20    const [assets, setAssets]               = useState(null);   // null = not yet loaded
     21    const [assetsLoading, setAssetsLoading] = useState(false);
     22    const [uploadingSlot, setUploadingSlot] = useState(null);   // 'icon_128' | 'screenshot-3' | null
     23    const [uploadProgress, setUploadProgress] = useState(0);
     24    const fileInputRefs = useRef({});  // { [slotKey]: HTMLInputElement }
     25    const validTabs = ['releases', 'assets'];
     26    const [activeTab, setActiveTab] = useState(initialTab && validTabs.includes(initialTab) ? initialTab : 'releases');
     27    const [draggingN, setDraggingN] = useState(null);     // screenshot_n being dragged
     28    const [dragOverN, setDragOverN] = useState(null);     // slot N being hovered during drag
     29    const dragOverTimeout = useRef(null);                   // auto-clear drag-over when cursor leaves
     30
     31    // Auto-fetch assets when the tab is pre-selected via deep link
     32    useEffect(() => {
     33        if (activeTab === 'assets' && assets === null) fetchAssets();
     34    }, [pluginData && pluginData.id]);
     35
     36    const fetchAssets = async () => {
     37        if (!pluginData || !pluginData.id) return;
     38        setAssetsLoading(true);
     39        try {
     40            const data = await Pblsh.API.getPluginAssets(pluginData.id);
     41            setAssets(data);
     42        } catch (e) {
     43            // Non-fatal: just show empty asset state
     44            setAssets({});
     45        } finally {
     46            setAssetsLoading(false);
     47        }
     48    };
     49
     50    const switchToTab = (tab) => {
     51        setActiveTab(tab);
     52        if (typeof onTabChange === 'function') onTabChange(tab);
     53        if (tab === 'assets' && assets === null) fetchAssets();
     54    };
     55
     56    const handleAssetUpload = async (slot, screenshotN, file) => {
     57        if (!file || !pluginData || !pluginData.id) return;
     58        const slotKey = slot === 'screenshot' ? 'screenshot-' + screenshotN : slot;
     59        setUploadingSlot(slotKey);
     60        setUploadProgress(0);
     61        try {
     62            const result = await Pblsh.API.uploadPluginAsset(
     63                pluginData.id, slot, screenshotN, file,
     64                (pct) => setUploadProgress(Math.floor(pct))
     65            );
     66            if (result && result.status === 'ok') {
     67                await fetchAssets();
     68                if (typeof refreshPlugin === 'function') refreshPlugin();
     69                if (result.warnings && result.warnings.length > 0) {
     70                    Pblsh.Utils.showAlert(result.warnings.map(w => w.message).join('\n'), 'warning');
     71                }
     72            } else {
     73                Pblsh.Utils.showAlert((result && result.message) || __('Upload failed.', 'peak-publisher'), 'error');
     74            }
     75        } catch (e) {
     76            Pblsh.Utils.showAlert(e.message || __('Upload failed.', 'peak-publisher'), 'error');
     77        } finally {
     78            setUploadingSlot(null);
     79            setUploadProgress(0);
     80        }
     81    };
     82
     83    const handleAssetDelete = async (slot, screenshotN) => {
     84        if (!pluginData || !pluginData.id) return;
     85        if (!confirm(__('Delete this asset?', 'peak-publisher'))) return;
     86        try {
     87            const result = await Pblsh.API.deletePluginAsset(pluginData.id, slot, screenshotN);
     88            if (result && result.assets) {
     89                setAssets(result.assets);
     90            } else {
     91                await fetchAssets();
     92            }
     93            if (typeof refreshPlugin === 'function') refreshPlugin();
     94        } catch (e) {
     95            Pblsh.Utils.showAlert(e.message || __('Delete failed.', 'peak-publisher'), 'error');
     96        }
     97    };
     98
     99    const handleScreenshotMove = async (fromN, toN) => {
     100        if (!pluginData || !pluginData.id || fromN === toN) return;
     101        try {
     102            const result = await Pblsh.API.moveScreenshot(pluginData.id, fromN, toN);
     103            if (result && result.assets) {
     104                setAssets(result.assets);
     105            } else {
     106                await fetchAssets();
     107            }
     108        } catch (e) {
     109            Pblsh.Utils.showAlert(e.message || __('Move failed.', 'peak-publisher'), 'error');
     110        }
     111    };
     112
     113    const openFilePicker = (slot, screenshotN) => {
     114        const key = slot === 'screenshot' ? 'screenshot-' + (screenshotN !== null && screenshotN !== undefined ? screenshotN : 'new') : slot;
     115        if (fileInputRefs.current[key]) {
     116            fileInputRefs.current[key].value = '';
     117            fileInputRefs.current[key].click();
     118        }
     119    };
     120
     121    // Slot configurations from server (single source of truth: AssetManager::get_slots())
     122    const ASSET_SLOTS = window.PblshData.assetSlots || {};
     123    // Helper: build accept string from exts array, e.g. ['png','jpg','gif'] → '.png,.jpg,.jpeg,.gif'
     124    const slotAccept = (s) => (s.exts || []).flatMap(e => e === 'jpg' ? ['.jpg', '.jpeg'] : ['.' + e]).join(',');
     125    const slotHint   = (s) => s.prefix + '.{' + (s.exts || []).join('|') + '}';
     126
     127    const renderAssetBox = (slot, assetData, screenshotN = null, caption = null) => {
     128        const stripTags = (html) => { const el = document.createElement('div'); el.innerHTML = html; return el.textContent || ''; };
     129        const screenshotLabel = caption
     130            ? screenshotN + '. ' + stripTags(caption)
     131            : __('Screenshot', 'peak-publisher') + ' ' + screenshotN;
     132        const raw = ASSET_SLOTS[slot];
     133        const def = slot === 'screenshot'
     134            ? { label: screenshotLabel, accept: slotAccept(raw), hint: raw.prefix + '-' + screenshotN + '.{' + raw.exts.join('|') + '}', group: raw.group, expectedW: raw.expectedW, expectedH: raw.expectedH }
     135            : raw ? { label: raw.label, accept: slotAccept(raw), hint: slotHint(raw), group: raw.group, expectedW: raw.expectedW, expectedH: raw.expectedH } : null;
     136        if (!def) return null;
     137        const slotKey = slot === 'screenshot' ? 'screenshot-' + screenshotN : slot;
     138        const isUploading = uploadingSlot === slotKey;
     139        const hasAsset = !!(assetData && assetData.filename);
     140        const warnings = (assetData && assetData.warnings) || [];
     141        const isScreenshot = slot === 'screenshot';
     142        const isDragging = isScreenshot && draggingN === screenshotN;
     143        const isDragOver = isScreenshot && dragOverN === screenshotN && draggingN !== screenshotN;
     144
     145        const parts = [];
     146        if (hasAsset && assetData.width && assetData.height) parts.push(assetData.width + '\u00d7' + assetData.height + '\u00a0px');
     147        const fs = hasAsset ? formatFilesize(assetData.filesize) : null;
     148        if (fs) parts.push(fs);
     149
     150        const acceptLabel = (raw.exts || []).map(e => e.toUpperCase()).join(' · ');
     151        const sizeLabel = def.expectedW && def.expectedH ? def.expectedW + '\u00d7' + def.expectedH + '\u00a0px' : null;
     152        const imageModClass = def.group === 'banners' ? 'pblsh--asset-slot__box-image--banner'
     153            : def.group === 'screenshots' ? 'pblsh--asset-slot__box-image--screenshot'
     154            : 'pblsh--asset-slot__box-image--icon';
     155
     156        // Drag-and-drop handlers for screenshot slots
     157        const dragProps = isScreenshot ? {
     158            onDragOver: (e) => {
     159                e.preventDefault();
     160                e.dataTransfer.dropEffect = 'move';
     161                setDragOverN(screenshotN);
     162                clearTimeout(dragOverTimeout.current);
     163                dragOverTimeout.current = setTimeout(() => setDragOverN(null), 150);
     164            },
     165            onDrop: (e) => {
     166                e.preventDefault();
     167                clearTimeout(dragOverTimeout.current);
     168                setDragOverN(null);
     169                setDraggingN(null);
     170                const fromN = parseInt(e.dataTransfer.getData('text/plain'), 10);
     171                if (!fromN || fromN === screenshotN) return;
     172                if (hasAsset) {
     173                    if (!confirm(__('Replace the existing screenshot at this position?', 'peak-publisher'))) return;
     174                }
     175                handleScreenshotMove(fromN, screenshotN);
     176            },
     177        } : {};
     178
     179        // Drag source props (only on the image area of filled screenshot slots)
     180        const dragSourceProps = (isScreenshot && hasAsset) ? {
     181            draggable: true,
     182            onDragStart: (e) => {
     183                e.dataTransfer.setData('text/plain', String(screenshotN));
     184                e.dataTransfer.effectAllowed = 'move';
     185                setDraggingN(screenshotN);
     186            },
     187            onDragEnd: () => { setDraggingN(null); setDragOverN(null); clearTimeout(dragOverTimeout.current); },
     188        } : {};
     189
     190        const classNames = [
     191            'pblsh--asset-slot',
     192            'pblsh--asset-slot--box',
     193            isUploading ? 'pblsh--asset-slot--uploading' : '',
     194            isDragging ? 'pblsh--asset-slot--dragging' : '',
     195            isDragOver ? 'pblsh--asset-slot--drag-target' : '',
     196        ].filter(Boolean).join(' ');
     197
     198        return createElement('div', {
     199            key: slotKey,
     200            className: classNames,
     201            ...dragProps,
     202        },
     203            createElement('input', {
     204                ref: (el) => { fileInputRefs.current[slotKey] = el; },
     205                type: 'file',
     206                accept: def.accept,
     207                className: 'pblsh--hidden-file-input',
     208                onChange: (e) => { const file = e.target.files && e.target.files[0]; if (file) handleAssetUpload(slot, screenshotN, file); },
     209            }),
     210            hasAsset
     211                ? createElement('div', { className: 'pblsh--asset-slot__box-body' },
     212                    createElement('div', {
     213                        className: 'pblsh--asset-slot__box-image ' + imageModClass,
     214                        ...dragSourceProps,
     215                    },
     216                        createElement('div', { className: 'pblsh--asset-slot__box-image-inner' },
     217                            isUploading && createElement('div', { className: 'pblsh--asset-slot__progress' },
     218                                createElement('div', { className: 'pblsh--asset-slot__progress-bar', style: { '--pct': uploadProgress + '%' } }),
     219                                createElement('div', { className: 'pblsh--asset-slot__progress-label' }, uploadProgress + '%'),
     220                            ),
     221                            createElement('img', {
     222                                src: assetData.url,
     223                                alt: assetData.filename,
     224                                className: 'pblsh--asset-slot__box-img',
     225                                draggable: false,
     226                            }),
     227                        ),
     228                    ),
     229                    createElement('div', { className: 'pblsh--asset-slot__box-info' },
     230                        createElement('div', { className: 'pblsh--asset-slot__box-label' }, def.label),
     231                        createElement('div', { className: 'pblsh--asset-slot__box-filename' }, assetData.filename),
     232                        parts.length > 0 && createElement('div', { className: 'pblsh--asset-slot__box-meta' },
     233                            parts.join('\u2002\u2022\u2002'),
     234                        ),
     235                        warnings.length > 0 && createElement('div', { className: 'pblsh--asset-slot__warnings' },
     236                            warnings.map((w, i) => createElement('div', { key: i, className: 'pblsh--asset-slot__warning', title: w.message },
     237                                getSvgIcon('information_outline', { size: 14 }),
     238                                createElement('span', null, w.message),
     239                            ))
     240                        ),
     241                    ),
     242                )
     243                : createElement('div', {
     244                    className: 'pblsh--asset-slot__box-body pblsh--asset-slot__box-body--empty',
     245                },
     246                    isUploading
     247                        ? createElement('div', { className: 'pblsh--asset-slot__progress' },
     248                            createElement('div', { className: 'pblsh--asset-slot__progress-bar', style: { '--pct': uploadProgress + '%' } }),
     249                            createElement('div', { className: 'pblsh--asset-slot__progress-label' }, uploadProgress + '%'),
     250                        )
     251                        : createElement('div', { className: 'pblsh--asset-slot__box-empty' },
     252                            createElement('div', { className: 'pblsh--asset-slot__box-empty-title' }, def.label),
     253                            createElement('div', { className: 'pblsh--asset-slot__box-empty-expected' },
     254                                __('Expected:', 'peak-publisher') + ' ' + [acceptLabel, sizeLabel].filter(Boolean).join(' · '),
     255                            ),
     256                            createElement(Button, {
     257                                isPrimary: true,
     258                                className: 'pblsh--asset-slot__box-upload-btn',
     259                                onClick: () => openFilePicker(slot, screenshotN),
     260                                disabled: isUploading,
     261                            }, __('Select File', 'peak-publisher')),
     262                        ),
     263                ),
     264            hasAsset && createElement('div', { className: 'pblsh--asset-slot__actions' },
     265                createElement(Button, {
     266                    isTertiary: true,
     267                    className: 'has-icon',
     268                    label: __('Replace', 'peak-publisher'),
     269                    icon: getSvgIcon('pencil', { size: 18 }),
     270                    onClick: () => openFilePicker(slot, screenshotN),
     271                    disabled: isUploading,
     272                }),
     273                createElement(DropdownMenu, {
     274                    icon: getSvgIcon('dots_horizontal', { size: 24 }),
     275                    label: __('More options', 'peak-publisher'),
     276                    children: ({ onClose }) => createElement(MenuItem, {
     277                        isDestructive: true,
     278                        onClick: () => { handleAssetDelete(slot, screenshotN); onClose(); },
     279                    },
     280                        getSvgIcon('delete_forever', { size: 24 }),
     281                        __('Delete', 'peak-publisher'),
     282                    ),
     283                }),
     284            ),
     285        );
     286    };
     287
     288    const renderAssetsSection = () => {
     289        if (assetsLoading && !assets) {
     290            return createElement('div', { className: 'pblsh--card pblsh--assets-card' },
     291                createElement('div', { className: 'pblsh--loading pblsh--loading--small' },
     292                    createElement('div', { className: 'pblsh--loading__spinner' }),
     293                ),
     294            );
     295        }
     296
     297        // Build screenshot slot map: { N: assetData } for quick lookup
     298        const screenshots = (assets && assets.screenshots) || [];
     299        const captions = (assets && assets.screenshot_captions) || {};
     300        const screenshotMap = {};
     301        screenshots.forEach(s => { screenshotMap[s.screenshot_n] = s; });
     302
     303        // Determine visible slot range
     304        const captionKeys = Object.keys(captions).map(Number).filter(n => n > 0);
     305        const screenshotKeys = screenshots.map(s => s.screenshot_n);
     306        const maxCaption = captionKeys.length > 0 ? Math.max(...captionKeys) : 0;
     307        const maxScreenshot = screenshotKeys.length > 0 ? Math.max(...screenshotKeys) : 0;
     308        const maxN = Math.max(maxCaption, maxScreenshot);
     309
     310        // Trim trailing empty slots that have no caption
     311        let visibleMaxN = maxN;
     312        while (visibleMaxN > 0 && !screenshotMap[visibleMaxN] && !captions[visibleMaxN]) {
     313            visibleMaxN--;
     314        }
     315
     316        // Build slot list: 1..visibleMaxN + "+new" at the end
     317        const nextN = visibleMaxN + 1;
     318        const slots = [];
     319        for (let i = 1; i <= visibleMaxN; i++) {
     320            slots.push({ n: i, screenshot: screenshotMap[i] || null, caption: captions[i] || null });
     321        }
     322
     323        // "+New" slot
     324        const newSlotKey = 'screenshot-new';
     325        const isUploadingNew = uploadingSlot === newSlotKey;
     326        const isDragOverNew = dragOverN === nextN && draggingN !== nextN;
     327
     328        const newScreenshotBox = createElement('div', {
     329            key: newSlotKey,
     330            className: ['pblsh--asset-slot', 'pblsh--asset-slot--box', 'pblsh--asset-slot--new', isUploadingNew ? 'pblsh--asset-slot--uploading' : '', isDragOverNew ? 'pblsh--asset-slot--drag-target' : ''].filter(Boolean).join(' '),
     331            onDragOver: (e) => {
     332                e.preventDefault();
     333                e.dataTransfer.dropEffect = 'move';
     334                setDragOverN(nextN);
     335                clearTimeout(dragOverTimeout.current);
     336                dragOverTimeout.current = setTimeout(() => setDragOverN(null), 150);
     337            },
     338            onDrop: (e) => {
     339                e.preventDefault();
     340                clearTimeout(dragOverTimeout.current);
     341                setDragOverN(null);
     342                setDraggingN(null);
     343                const fromN = parseInt(e.dataTransfer.getData('text/plain'), 10);
     344                if (!fromN || fromN === nextN) return;
     345                handleScreenshotMove(fromN, nextN);
     346            },
     347        },
     348            createElement('input', {
     349                ref: (el) => { fileInputRefs.current[newSlotKey] = el; },
     350                type: 'file',
     351                accept: slotAccept(ASSET_SLOTS.screenshot),
     352                className: 'pblsh--hidden-file-input',
     353                onChange: (e) => {
     354                    const file = e.target.files && e.target.files[0];
     355                    if (file) {
     356                        setUploadingSlot(newSlotKey);
     357                        handleAssetUpload('screenshot', nextN, file).finally(() => setUploadingSlot(null));
     358                    }
     359                },
     360            }),
     361            createElement('div', {
     362                className: 'pblsh--asset-slot__box-body pblsh--asset-slot__box-body--empty',
     363            },
     364                isUploadingNew
     365                    ? createElement('div', { className: 'pblsh--asset-slot__progress' },
     366                        createElement('div', { className: 'pblsh--asset-slot__progress-bar', style: { '--pct': uploadProgress + '%' } }),
     367                        createElement('div', { className: 'pblsh--asset-slot__progress-label' }, uploadProgress + '%'),
     368                    )
     369                    : createElement('div', { className: 'pblsh--asset-slot__box-empty' },
     370                        createElement('div', { className: 'pblsh--asset-slot__box-empty-title' },
     371                            __('Screenshot', 'peak-publisher') + ' ' + nextN + ' — ' + __('New', 'peak-publisher'),
     372                        ),
     373                        createElement('div', { className: 'pblsh--asset-slot__box-empty-expected' }, (ASSET_SLOTS.screenshot.exts || []).map(function(e) { return e.toUpperCase(); }).join(' · ')),
     374                        createElement(Button, {
     375                            isPrimary: true,
     376                            className: 'pblsh--asset-slot__box-upload-btn',
     377                            onClick: () => { fileInputRefs.current[newSlotKey] && (fileInputRefs.current[newSlotKey].value = '', fileInputRefs.current[newSlotKey].click()); },
     378                            disabled: isUploadingNew,
     379                        }, __('Select File', 'peak-publisher')),
     380                    ),
     381            ),
     382        );
     383
     384        return createElement('div', { className: 'pblsh--card pblsh--assets-card' },
     385            // Icons group
     386            createElement('div', { className: 'pblsh--assets-group' },
     387                createElement('div', { className: 'pblsh--assets-group__label' }, __('Icons', 'peak-publisher')),
     388                createElement('div', { className: 'pblsh--assets-slots pblsh--assets-slots--boxes' },
     389                    renderAssetBox('icon_svg', assets && assets.icon_svg),
     390                    renderAssetBox('icon_256', assets && assets.icon_256),
     391                    renderAssetBox('icon_128', assets && assets.icon_128),
     392                ),
     393            ),
     394            // Banners group
     395            createElement('div', { className: 'pblsh--assets-group' },
     396                createElement('div', { className: 'pblsh--assets-group__label' }, __('Banners', 'peak-publisher')),
     397                createElement('div', { className: 'pblsh--assets-slots pblsh--assets-slots--boxes' },
     398                    renderAssetBox('banner_svg', assets && assets.banner_svg),
     399                    renderAssetBox('banner_hd', assets && assets.banner_hd),
     400                    renderAssetBox('banner_sd', assets && assets.banner_sd),
     401                ),
     402            ),
     403            // Screenshots group (slot-based)
     404            createElement('div', { className: 'pblsh--assets-group' },
     405                createElement('div', { className: 'pblsh--assets-group__label' }, __('Screenshots', 'peak-publisher')),
     406                createElement('div', { className: 'pblsh--assets-slots pblsh--assets-slots--boxes' },
     407                    slots.map(({ n, screenshot, caption }) =>
     408                        renderAssetBox('screenshot', screenshot, n, caption)
     409                    ),
     410                    newScreenshotBox,
     411                ),
     412            ),
     413        );
     414    };
    12415
    13416    // Prefer releases from store (keeps UI in sync on toggles), fallback to pluginData.releases
     
    37440                        createElement('div', { className: 'pblsh--plugin-header' },
    38441                            createElement('div', { className: 'pblsh--plugin-header__main' },
    39                                 createElement('h3', { className: 'pblsh--plugin-title' }, pluginData?.name),
    40                                 createElement('div', { className: 'pblsh--plugin-meta' },
    41                                     createElement('strong', null, __('Slug', 'peak-publisher')),
    42                                     createElement('code', null, safe(pluginData?.slug) || '—'),
     442                                pluginData?.icon_url ? createElement('img', {
     443                                    className: 'pblsh--plugin-header__icon',
     444                                    src: pluginData.icon_url,
     445                                    alt: '',
     446                                    width: 80,
     447                                    height: 80,
     448                                }) : null,
     449                                createElement('div', null,
     450                                    createElement('h3', { className: 'pblsh--plugin-title' }, pluginData?.name),
     451                                    createElement('div', { className: 'pblsh--plugin-meta' },
     452                                        createElement('strong', null, __('Slug', 'peak-publisher')),
     453                                        createElement('code', null, safe(pluginData?.slug) || '—'),
     454                                    ),
    43455                                ),
    44456                            ),
     
    180592    };
    181593
     594    const renderTabNav = () => createElement('div', { className: 'pblsh--tab-nav' },
     595        createElement('button', {
     596            type: 'button',
     597            className: 'pblsh--tab-nav__tab' + (activeTab === 'releases' ? ' pblsh--tab-nav__tab--active' : ''),
     598            onClick: () => switchToTab('releases'),
     599        }, __('Releases', 'peak-publisher')),
     600        createElement('button', {
     601            type: 'button',
     602            className: 'pblsh--tab-nav__tab' + (activeTab === 'assets' ? ' pblsh--tab-nav__tab--active' : ''),
     603            onClick: () => switchToTab('assets'),
     604        }, __('Assets', 'peak-publisher')),
     605    );
     606
    182607    return createElement('div', { className: 'pblsh--editor' },
    183608        createElement('div', { className: 'pblsh--editor__content' },
     
    186611                    createElement('div', { className: 'pblsh--main__content' },
    187612                        renderInfoBox(),
    188                         renderReleasesTable(),
     613                        createElement('div', { className: 'pblsh--tab-panel', 'data-active-tab': activeTab },
     614                            renderTabNav(),
     615                            createElement('div', { className: 'pblsh--tab-panel__body' },
     616                                activeTab === 'releases' && renderReleasesTable(),
     617                                activeTab === 'assets'   && renderAssetsSection(),
     618                            ),
     619                        ),
    189620                    ),
    190621                ),
  • peak-publisher/trunk/assets/js/components/PluginList.js

    r3444907 r3477599  
    4343                        createElement('tr', null,
    4444                            createElement('th', { className: 'pblsh--table__status-header' }, __('Status', 'peak-publisher')),
    45                             //createElement('th', { className: 'pblsh--table__icon-header' }, __('Icon', 'peak-publisher')),
     45                            createElement('th', { className: 'pblsh--table__icon-header' }),
    4646                            createElement('th', { className: 'pblsh--table__name-header' }, __('Plugin Name', 'peak-publisher')),
    4747                            createElement('th', { className: 'pblsh--table__slug-header' }, __('Slug', 'peak-publisher')),
     
    6868                                    })
    6969                                ),
    70                                 /* createElement('td', { className: 'pblsh--table__icon-cell' },
    71                                     plugin.icon
    72                                         ? createElement('img', {
    73                                             src: plugin.icon,
    74                                             alt: plugin.name,
    75                                             className: 'pblsh--table__icon-thumbnail',
    76                                             width: 80,
    77                                             height: 60
    78                                         })
    79                                         : createElement('div', { className: 'pblsh--table__no-icon' },
    80                                             getSvgIcon('image')
    81                                         )
    82                                 ), */
     70                                createElement('td', { className: 'pblsh--table__icon-cell' },
     71                                    plugin.icon_url && createElement('img', {
     72                                        src: plugin.icon_url,
     73                                        alt: '',
     74                                        className: 'pblsh--table__icon',
     75                                        width: 48,
     76                                        height: 48,
     77                                    }),
     78                                ),
    8379                                createElement('td', { className: 'pblsh--table__name-cell' },
    8480                                    createElement('strong', null, plugin.name)
  • peak-publisher/trunk/assets/js/utils.js

    r3444907 r3477599  
    22lodash.set(window, 'Pblsh.Utils', {
    33
    4     // Show alert message (only for errors)
     4    // Show alert message (for errors and warnings)
    55    showAlert: (message, type = 'error') => {
    6         if (type === 'error') {
     6        if (type === 'error' || type === 'warning') {
    77            alert(message);
    88        }
     
    7474            chart_line: 'M16,11.78L20.24,4.45L21.97,5.45L16.74,14.5L10.23,10.75L5.46,19H22V21H2V3H4V17.54L9.5,8L16,11.78Z',
    7575            information_outline: 'M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z',
     76            chat_alert: 'M12,3C17.5,3 22,6.58 22,11C22,15.42 17.5,19 12,19C10.76,19 9.57,18.82 8.47,18.5C5.55,21 2,21 2,21C4.33,18.67 4.7,17.1 4.75,16.5C3.05,15.07 2,13.13 2,11C2,6.58 6.5,3 12,3M11,14V16H13V14H11M11,12H13V6H11V12Z',
    7677        };
    7778
  • peak-publisher/trunk/classes/AdminAPI.php

    r3444907 r3477599  
    1111    const NAMESPACE = 'pblsh-admin/v1';
    1212
     13    private ?AssetManager $asset_manager = null;
     14
    1315    /**
    1416     * Constructor.
     
    1618    private function __construct() {
    1719        $this->register_routes();
     20    }
     21
     22    /**
     23     * Lazy-load the AssetManager singleton.
     24     */
     25    private function assets(): AssetManager {
     26        if ($this->asset_manager === null) {
     27            require_once __DIR__ . '/AssetManager.php';
     28            $this->asset_manager = AssetManager::init();
     29        }
     30        return $this->asset_manager;
    1831    }
    1932
     
    113126            'methods' => 'POST',
    114127            'callback' => [$this, 'save_peak_publisher_settings_rest'],
     128            'permission_callback' => [$this, 'check_permission'],
     129        ]);
     130
     131        // Plugin assets
     132        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets', [
     133            'methods' => 'GET',
     134            'callback' => [$this, 'handle_get_assets'],
     135            'permission_callback' => [$this, 'check_permission'],
     136        ]);
     137        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets', [
     138            'methods' => 'POST',
     139            'callback' => [$this, 'handle_upload_asset'],
     140            'permission_callback' => [$this, 'check_permission'],
     141        ]);
     142        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets', [
     143            'methods' => 'DELETE',
     144            'callback' => [$this, 'handle_delete_asset'],
     145            'permission_callback' => [$this, 'check_permission'],
     146        ]);
     147        register_rest_route(self::NAMESPACE, '/plugins/(?P<id>\d+)/assets/move', [
     148            'methods' => 'POST',
     149            'callback' => [$this, 'handle_move_asset'],
    115150            'permission_callback' => [$this, 'check_permission'],
    116151        ]);
     
    166201                'name' => $plugin_post->post_title,
    167202                'slug' => $plugin_post->post_name,
    168                 'icon' => get_post_meta($plugin_post->ID, 'pblsh_icon', true),
     203                'icon_url' => $this->assets()->get_best_icon_url($plugin_post->post_name),
    169204                'version' => $latest_version,
    170205                'status' => $plugin_post->post_status,
     
    214249            'name' => $post->post_title,
    215250            'slug' => $post->post_name,
    216             'icon' => get_post_meta($post->ID, 'pblsh_icon', true),
     251            'icon_url' => $this->assets()->get_best_icon_url($post->post_name),
    217252            'version' => $latest_version,
    218253            'status' => $post->post_status,
     
    391426        }
    392427
     428        // Delete the plugin's assets directory.
     429        $assets_dir = get_plugin_assets_basedir($plugin->post_name);
     430        if (is_dir($assets_dir)) {
     431            get_wp_filesystem()->delete(trailingslashit($assets_dir), true);
     432        }
     433
    393434        // Remove all empty folders from the upload directory
    394435        remove_empty_folders(peak_publisher_upload_basedir());
     
    449490    }
    450491
     492    /**
     493     * Get all assets for a plugin.
     494     */
     495    public function handle_get_assets(\WP_REST_Request $request): array {
     496        $id   = (int) $request->get_param('id');
     497        $post = get_post($id);
     498        if (!$post || $post->post_type !== 'pblsh_plugin') {
     499            return ['status' => 'error', 'message' => 'Plugin not found.'];
     500        }
     501        $result  = $this->assets()->get_all($post->post_name);
     502        $result['screenshot_captions'] = $this->get_screenshot_captions($id);
     503        return $result;
     504    }
     505
     506    /**
     507     * Get screenshot captions from the latest published release's readme.txt.
     508     *
     509     * @return object Screenshot captions keyed by number, e.g. {1: "Caption", 2: "Caption"}.
     510     */
     511    private function get_screenshot_captions(int $plugin_id): object {
     512        $latest = get_posts([
     513            'post_type'      => 'pblsh_release',
     514            'post_status'    => 'publish',
     515            'post_parent'    => $plugin_id,
     516            'posts_per_page' => 1,
     517            'orderby'        => 'date',
     518            'order'          => 'DESC',
     519        ]);
     520        if (empty($latest)) {
     521            return (object) [];
     522        }
     523        $content = json_decode((string) $latest[0]->post_content, true);
     524        $screenshots = $content['plugin_readme_txt']['content']['screenshots'] ?? [];
     525        if (empty($screenshots) || !is_array($screenshots)) {
     526            return (object) [];
     527        }
     528        // Ensure keys are integers and values are strings.
     529        $captions = [];
     530        foreach ($screenshots as $n => $caption) {
     531            $captions[(int) $n] = (string) $caption;
     532        }
     533        return (object) $captions;
     534    }
     535
     536    /**
     537     * Upload an asset file to a plugin slot.
     538     * Expects multipart/form-data with: file (binary), slot (string), screenshot_n (int, optional).
     539     */
     540    public function handle_upload_asset(\WP_REST_Request $request): array {
     541        $id   = (int) $request->get_param('id');
     542        $post = get_post($id);
     543        if (!$post || $post->post_type !== 'pblsh_plugin') {
     544            return ['status' => 'error', 'message' => 'Plugin not found.'];
     545        }
     546
     547        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below.
     548        if (empty($_FILES['file']) || (int) ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
     549            $err_code = (int) ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE);
     550            return ['status' => 'error', 'message' => 'No file uploaded (error code ' . $err_code . ').'];
     551        }
     552
     553        $slot         = sanitize_key((string) ($request->get_param('slot') ?? ''));
     554        $screenshot_n_raw = $request->get_param('screenshot_n');
     555        $screenshot_n = $screenshot_n_raw !== null && $screenshot_n_raw !== '' ? (int) $screenshot_n_raw : null;
     556
     557        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Passed to AssetManager which validates it.
     558        $file_data = $_FILES['file'];
     559        $result = $this->assets()->upload($id, $post->post_name, $slot, $screenshot_n, $file_data);
     560
     561        // Calculate banner average color for geopattern fallback icons.
     562        // Based on WordPress.org Plugin Directory.
     563        if ( in_array( $slot, [ 'banner_sd', 'banner_hd' ], true ) && ( $result['status'] ?? '' ) !== 'error' ) {
     564            $this->update_banner_color( $id, $post->post_name );
     565        }
     566
     567        return $result;
     568    }
     569
     570    /**
     571     * Delete an asset from a plugin slot.
     572     * Expects JSON body: { slot: string, screenshot_n?: int }.
     573     */
     574    public function handle_delete_asset(\WP_REST_Request $request): array {
     575        $id   = (int) $request->get_param('id');
     576        $post = get_post($id);
     577        if (!$post || $post->post_type !== 'pblsh_plugin') {
     578            return ['status' => 'error', 'message' => 'Plugin not found.'];
     579        }
     580
     581        $params       = $request->get_json_params();
     582        $slot         = sanitize_key((string) ($params['slot'] ?? ''));
     583        $screenshot_n_raw = $params['screenshot_n'] ?? null;
     584        $screenshot_n = $screenshot_n_raw !== null ? (int) $screenshot_n_raw : null;
     585
     586        $deleted = $this->assets()->delete($id, $post->post_name, $slot, $screenshot_n);
     587
     588        // Recalculate banner average color for geopattern fallback icons.
     589        // Based on WordPress.org Plugin Directory.
     590        if ( in_array( $slot, [ 'banner_sd', 'banner_hd' ], true ) ) {
     591            $this->update_banner_color( $id, $post->post_name );
     592        }
     593
     594        $assets  = $this->assets()->get_all($post->post_name);
     595        $assets['screenshot_captions'] = $this->get_screenshot_captions($id);
     596        return ['status' => 'ok', 'deleted' => $deleted, 'assets' => $assets];
     597    }
     598
     599    /**
     600     * Move a screenshot from one position to another.
     601     * Expects JSON body: { slot: "screenshot", from: int, to: int }.
     602     */
     603    public function handle_move_asset(\WP_REST_Request $request): array {
     604        $id   = (int) $request->get_param('id');
     605        $post = get_post($id);
     606        if (!$post || $post->post_type !== 'pblsh_plugin') {
     607            return ['status' => 'error', 'message' => 'Plugin not found.'];
     608        }
     609
     610        $params = $request->get_json_params();
     611        $from   = isset($params['from']) ? (int) $params['from'] : 0;
     612        $to     = isset($params['to'])   ? (int) $params['to']   : 0;
     613
     614        $result = $this->assets()->move_screenshot($id, $post->post_name, $from, $to);
     615        if ($result['status'] === 'error') {
     616            return $result;
     617        }
     618
     619        $assets = $this->assets()->get_all($post->post_name);
     620        $assets['screenshot_captions'] = $this->get_screenshot_captions($id);
     621        return ['status' => 'ok', 'assets' => $assets];
     622    }
     623
     624    /**
     625     * Recalculate and store the banner average color for geopattern fallback icons.
     626     *
     627     * Based on WordPress.org Plugin Directory.
     628     * @see https://github.com/WordPress/wordpress.org — class-tools.php
     629     */
     630    private function update_banner_color( int $plugin_id, string $plugin_slug ): void {
     631        $banner_average_color = '';
     632
     633        // Find the first available banner file (prefer HD, then SD) via asset meta.
     634        foreach ( [ 'banner_hd', 'banner_sd' ] as $slot ) {
     635            $info = $this->assets()->find_file_in_slot( $plugin_slug, $slot );
     636            if ( $info !== null ) {
     637                $filepath = trailingslashit( get_plugin_assets_basedir( $plugin_slug ) ) . $info['filename'];
     638                if ( file_exists( $filepath ) ) {
     639                    $banner_average_color = get_image_average_color( $filepath );
     640                    if ( ! is_string( $banner_average_color ) ) {
     641                        $banner_average_color = '';
     642                    }
     643                }
     644                break;
     645            }
     646        }
     647
     648        if ( $banner_average_color !== '' ) {
     649            update_post_meta( $plugin_id, 'assets_banners_color', wp_slash( $banner_average_color ) );
     650        } else {
     651            delete_post_meta( $plugin_id, 'assets_banners_color' );
     652        }
     653    }
     654
    451655    public function get_peak_publisher_settings_rest(): array {
    452656        return get_peak_publisher_settings();
  • peak-publisher/trunk/classes/AdminUI.php

    r3444907 r3477599  
    147147        );
    148148       
     149        require_once __DIR__ . '/AssetManager.php';
    149150        wp_localize_script(
    150151            'pblsh-admin',
     
    154155                'wpVersion' => function_exists('wp_get_wp_version') ? wp_get_wp_version() : $GLOBALS['wp_version'],
    155156                'phpVersion' => PHP_VERSION,
     157                'hasPlainPermalinks' => get_option('permalink_structure') === '',
     158                'permalinkSettingsUrl' => admin_url('options-permalink.php'),
     159                'assetSlots' => AssetManager::get_slots(),
     160                'i18n' => [
     161                    'permalinkPlain'      => __('Plain'),
     162                    'permalinkDayAndName' => __('Day and name'),
     163                ],
    156164            ]
    157165        );
  • peak-publisher/trunk/classes/PublicAPI.php

    r3446146 r3477599  
    4242                'slug' => ['required' => true],
    4343                'version' => ['required' => false, 'default' => ''],
     44            ],
     45        ]);
     46
     47        // Geopattern fallback icon endpoint — public, no permission check.
     48        // Based on WordPress.org Plugin Directory.
     49        register_rest_route(self::NAMESPACE, '/plugins/geopattern-icon/(?P<file>[a-z0-9_-]+\.svg)', [
     50            'methods' => 'GET',
     51            'callback' => [$this, 'handle_geopattern_icon'],
     52            'permission_callback' => '__return_true',
     53            'args' => [
     54                'file' => ['required' => true],
    4455            ],
    4556        ]);
     
    283294            $result['sections'][$section_key] = apply_filters( 'the_content', $section_content, $section_key);
    284295        }
     296        $result['sections']['screenshots'] = ''; // placeholder to put screenshots prior to reviews at the end.
    285297
    286298        if ( ! empty( $result['sections']['faq'] ) ) {
     
    292304        $result['download_link']     = rest_url(self::NAMESPACE . '/plugins/download/' . $plugin->post_name . '/' . $latest_release->post_title);
    293305        $result['upgrade_notice']    = $latest_release_content['plugin_readme_txt']['content']['upgrade_notice'] ?? '';
     306
     307        require_once __DIR__ . '/AssetManager.php';
     308        $asset_manager = AssetManager::init();
     309
     310        // Reduce images to caption + src
     311        $result['screenshots'] = array_map(
     312            function( $image ) {
     313                return [
     314                    'src'     => $image['src'],
     315                    'caption' => $image['caption'],
     316                ];
     317            },
     318            $asset_manager->get_api_screenshots($plugin->post_name, $latest_release_content['plugin_readme_txt']['content']['screenshots'] ?? [])
     319        );
     320
     321        if ( $result['screenshots'] ) {
     322            $result['sections']['screenshots'] = $this->get_screenshot_markup( $result['screenshots'] );
     323        } else {
     324            unset( $result['sections']['screenshots'] );
     325        }
    294326
    295327        $terms = array_map(fn($term) => (object) [
     
    318350
    319351        $result['donate_link'] = $latest_release_content['plugin_readme_txt']['content']['donate_link'] ?? '';
     352
     353        // Banners & icons — mirrors the wordpress.org API structure.
     354        // @see https://github.com/WordPress/wordpress.org/blob/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
     355        // NOTE: Intentionally duplicated in handle_update_check() — kept as a 1:1 copy of the wp.org reference.
     356        $result['banners'] = array();
     357        if ( $banners = $asset_manager->get_plugin_banner($plugin->post_name) ) {
     358            if ( isset( $banners['banner'] ) ) {
     359                $result['banners']['low'] = $banners['banner'];
     360            }
     361            if ( isset( $banners['banner_2x'] ) ) {
     362                $result['banners']['high'] = $banners['banner_2x'];
     363            }
     364        }
     365
     366        $result['icons'] = array();
     367        if ( $icons = $asset_manager->get_plugin_icon($plugin->post_name) ) {
     368            if ( ! empty( $icons['icon'] ) && empty( $icons['generated'] ) ) {
     369                $result['icons']['1x'] = $icons['icon'];
     370            } elseif ( ! empty( $icons['icon'] ) && ! empty( $icons['generated'] ) ) {
     371                $result['icons']['default'] = $icons['icon'];
     372            }
     373            if ( ! empty( $icons['icon_2x'] ) ) {
     374                $result['icons']['2x'] = $icons['icon_2x'];
     375            }
     376            if ( ! empty( $icons['svg'] ) ) {
     377                $result['icons']['svg'] = $icons['svg'];
     378            }
     379        }
    320380
    321381        $expected_fields = $this->get_expected_fields('plugin_information');
     
    386446        $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
    387447
     448        require_once __DIR__ . '/AssetManager.php';
     449        $asset_manager = AssetManager::init();
     450
    388451        $results = [
    389452            'plugins' => [],
     
    408471            record_plugin_installation((int) $plugin->ID, (string) $user_agent, (string) $client_installed_version);
    409472           
     473            $result = [];
     474
     475            // Banners & icons — mirrors the wordpress.org API structure.
     476            // @see https://github.com/WordPress/wordpress.org/blob/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
     477            // NOTE: Intentionally duplicated in handle_info() — kept as a 1:1 copy of the wp.org reference.
     478            $result['banners'] = array();
     479            if ( $banners = $asset_manager->get_plugin_banner($plugin->post_name) ) {
     480                if ( isset( $banners['banner'] ) ) {
     481                    $result['banners']['low'] = $banners['banner'];
     482                }
     483                if ( isset( $banners['banner_2x'] ) ) {
     484                    $result['banners']['high'] = $banners['banner_2x'];
     485                }
     486            }
     487
     488            $result['icons'] = array();
     489            if ( $icons = $asset_manager->get_plugin_icon($plugin->post_name) ) {
     490                if ( ! empty( $icons['icon'] ) && empty( $icons['generated'] ) ) {
     491                    $result['icons']['1x'] = $icons['icon'];
     492                } elseif ( ! empty( $icons['icon'] ) && ! empty( $icons['generated'] ) ) {
     493                    $result['icons']['default'] = $icons['icon'];
     494                }
     495                if ( ! empty( $icons['icon_2x'] ) ) {
     496                    $result['icons']['2x'] = $icons['icon_2x'];
     497                }
     498                if ( ! empty( $icons['svg'] ) ) {
     499                    $result['icons']['svg'] = $icons['svg'];
     500                }
     501            }
     502
    410503            $results['plugins'][$plugin_basename] = array_merge(
    411504                ['slug' => $plugin->post_name],
     
    413506                ['version' => $latest_version],
    414507                ['package' => rest_url(self::NAMESPACE . '/plugins/download/' . $plugin->post_name . '/' . $latest_version)],
     508                empty($result['icons']) ? [] : [ 'icons' => $result['icons'] ],
     509                empty($result['banners']) ? [] : [ 'banners' => $result['banners'] ],
    415510                empty($plugin_data['RequiresWP']) ? [] : [ 'requires' => $plugin_data['RequiresWP'] ],
    416511                empty($plugin_data['RequiresPHP']) ? [] : [ 'requires_php' => $plugin_data['RequiresPHP'] ],
     
    632727        return $markup;
    633728    }
     729
     730    /**
     731     * Screenshots markup for the plugin information API
     732     *
     733     * It is intentional that $shot['caption'] is not escaped, as it may contain HTML.
     734     * It is reduced to allowed tags when parsing the readme.txt file, so $shot['caption'] is considered trusted HTML.
     735     *
     736     * @see https://github.com/WordPress/wordpress.org/blob/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/api/routes/class-plugin.php
     737     * @param array $screenshots The existing Markup.
     738     * @return string Markup
     739     */
     740    protected function get_screenshot_markup( $screenshots ) {
     741        $markup = '<ol>';
     742
     743        foreach ( $screenshots as $shot ) {
     744            if ( $shot['caption'] ) {
     745                $markup .= sprintf(
     746                    '<li><a href="%1$s"><img src="%1$s" alt="%2$s"></a><p>%3$s</p></li>',
     747                    esc_attr( $shot['src'] ),
     748                    esc_attr( $shot['caption'] ),
     749                    $shot['caption']
     750                );
     751            } else {
     752                $markup .= sprintf(
     753                    '<li><a href="%1$s"><img src="%1$s" alt=""></a></li>',
     754                    esc_attr( $shot['src'] )
     755                );
     756            }
     757        }
     758
     759        $markup .= '</ol>';
     760        return $markup;
     761    }
     762
     763    /**
     764     * Output a SVG Geopattern for a given plugin slug.
     765     *
     766     * Based on WordPress.org Plugin Directory.
     767     * @see https://github.com/WordPress/wordpress.org — geopattern_icon_route()
     768     */
     769    public function handle_geopattern_icon( \WP_REST_Request $request ) {
     770        $file = $request->get_param( 'file' );
     771        // Strip .svg extension.
     772        $name = preg_replace( '/\.svg$/', '', $file );
     773
     774        // Parse slug and optional color from filename: {slug}_{6-hex-chars} or just {slug}.
     775        $slug  = $name;
     776        $color = '';
     777        if ( preg_match( '/^(.+)_([a-f0-9]{6})$/', $name, $m ) ) {
     778            $slug  = $m[1];
     779            $color = $m[2];
     780        }
     781
     782        require_once PBLSH_PLUGIN_DIR . 'libs/plugin-directory/libs/geopattern-1.1.0/geopattern_loader.php';
     783
     784        $icon = new \Pblsh\Vendor\RedeyeVentures\GeoPattern\GeoPattern();
     785        $icon->setString( $slug );
     786        if ( strlen( $color ) === 6 && strspn( $color, 'abcdef0123456789' ) === 6 ) {
     787            $icon->setColor( '#' . $color );
     788        }
     789
     790        $svg = $icon->toSVG();
     791        $year_in_seconds = 365 * DAY_IN_SECONDS;
     792
     793        header( 'Content-Type: image/svg+xml' );
     794        header( 'Cache-Control: public, max-age=' . $year_in_seconds );
     795        header( 'Expires: ' . gmdate( 'D, d M Y H:i:s \G\M\T', time() + $year_in_seconds ) );
     796
     797        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SVG output from trusted library.
     798        echo $svg;
     799        exit;
     800    }
    634801}
    635802
  • peak-publisher/trunk/includes/functions.php

    r3460404 r3477599  
    275275
    276276/**
     277 * Gets the assets directory for a specific plugin slug.
     278 */
     279function get_plugin_assets_basedir(string $plugin_slug): string {
     280    return trailingslashit(peak_publisher_upload_basedir()) . 'plugins/' . sanitize_file_name($plugin_slug) . '/assets';
     281}
     282
     283
     284/**
     285 * Ensures the assets directory for a plugin slug exists and is publicly accessible.
     286 * Assets are served as direct static files (not via REST API).
     287 * Writes .htaccess to override the parent "Deny all" for Apache; Nginx serves files directly.
     288 */
     289function ensure_plugin_assets_dir(string $plugin_slug): void {
     290    $basedir  = get_plugin_assets_basedir($plugin_slug);
     291    wp_mkdir_p($basedir);
     292    if (!file_exists($basedir . '/index.php')) {
     293        file_put_contents($basedir . '/index.php', '<?php exit;');
     294    }
     295    if (!file_exists($basedir . '/.htaccess')) {
     296        file_put_contents($basedir . '/.htaccess',
     297            '# Allow direct access to image assets only' . "\n" .
     298            '<FilesMatch "\.(png|jpe?g|gif|svg)$">' . "\n" .
     299            '  <IfModule mod_authz_core.c>' . "\n" .
     300            '    Require all granted' . "\n" .
     301            '  </IfModule>' . "\n" .
     302            '  <IfModule !mod_authz_core.c>' . "\n" .
     303            '    Order Allow,Deny' . "\n" .
     304            '    Allow from all' . "\n" .
     305            '  </IfModule>' . "\n" .
     306            '</FilesMatch>' . "\n"
     307        );
     308    }
     309}
     310
     311
     312/**
    277313 * Gets the upload directory.
    278314 */
     
    493529
    494530/**
     531 * Retrieve the average color of a specified image.
     532 *
     533 * Samples five points (rule of thirds + center) and averages their RGB values.
     534 * Algorithm matches Jetpack's Tonesque library used by WordPress.org Plugin Directory.
     535 *
     536 * Based on WordPress.org Plugin Directory.
     537 * @see https://github.com/WordPress/wordpress.org — class-tools.php
     538 * @see Jetpack Tonesque — grab_points() / grab_color() / get_color()
     539 *
     540 * @param string $file_path Absolute filesystem path to the image.
     541 * @return string|false Average color as a 6-char lowercase hex value (no #), false on failure.
     542 */
     543function get_image_average_color( string $file_path ) {
     544    if ( ! function_exists( 'imagecreatefromstring' ) ) {
     545        return false;
     546    }
     547
     548    if ( ! file_exists( $file_path ) || ! is_readable( $file_path ) ) {
     549        return false;
     550    }
     551
     552    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read.
     553    $data = file_get_contents( $file_path );
     554    if ( $data === false ) {
     555        return false;
     556    }
     557
     558    $img = @imagecreatefromstring( $data );
     559    if ( ! $img ) {
     560        return false;
     561    }
     562
     563    $width  = imagesx( $img );
     564    $height = imagesy( $img );
     565
     566    // Sample five points based on rule of thirds and center (same as Tonesque::grab_points).
     567    $left_x   = (int) round( $width / 3 );
     568    $right_x  = (int) round( ( $width / 3 ) * 2 );
     569    $top_y    = (int) round( $height / 3 );
     570    $bottom_y = (int) round( ( $height / 3 ) * 2 );
     571    $center_x = (int) round( $width / 2 );
     572    $center_y = (int) round( $height / 2 );
     573
     574    $points = [
     575        imagecolorat( $img, $left_x,   $top_y ),
     576        imagecolorat( $img, $right_x,  $top_y ),
     577        imagecolorat( $img, $left_x,   $bottom_y ),
     578        imagecolorat( $img, $right_x,  $bottom_y ),
     579        imagecolorat( $img, $center_x, $center_y ),
     580    ];
     581
     582    // Average the RGB channels (same as Tonesque::grab_color).
     583    $r = [];
     584    $g = [];
     585    $b = [];
     586    foreach ( $points as $color_index ) {
     587        $c  = imagecolorsforindex( $img, $color_index );
     588        $r[] = $c['red'];
     589        $g[] = $c['green'];
     590        $b[] = $c['blue'];
     591    }
     592
     593    imagedestroy( $img );
     594
     595    $red   = (int) round( array_sum( $r ) / 5 );
     596    $green = (int) round( array_sum( $g ) / 5 );
     597    $blue  = (int) round( array_sum( $b ) / 5 );
     598
     599    return sprintf( '%02x%02x%02x', $red, $green, $blue );
     600}
     601
     602
     603/**
     604 * Retrieve the Geopattern SVG URL for a given plugin.
     605 *
     606 * Based on WordPress.org Plugin Directory.
     607 * @see https://github.com/WordPress/wordpress.org — class-template.php
     608 *
     609 * @param \WP_Post|int|string $post   Post object, ID, or plugin slug.
     610 * @param string|null         $color  Optional hex color (6 chars, no #). If null, read from post meta.
     611 * @return string Geopattern icon URL.
     612 */
     613function get_geopattern_icon_url( $post = null, ?string $color = null ): string {
     614    if ( is_string( $post ) ) {
     615        // Treat as slug — look up the post.
     616        $plugin = get_page_by_path( $post, OBJECT, 'pblsh_plugin' );
     617    } else {
     618        $plugin = get_post( $post );
     619    }
     620
     621    if ( ! $plugin ) {
     622        return '';
     623    }
     624
     625    if ( is_null( $color ) ) {
     626        $color = get_post_meta( $plugin->ID, 'assets_banners_color', true );
     627    }
     628
     629    if ( strlen( $color ) === 6 && strspn( $color, 'abcdef0123456789' ) === 6 ) {
     630        $color = "_{$color}";
     631    } else {
     632        $color = '';
     633    }
     634
     635    // The slug + color combine to form the cache buster, like on wordpress.org.
     636    $url = rest_url( 'pblsh/v1/plugins/geopattern-icon/' . $plugin->post_name . $color . '.svg' );
     637
     638    return $url;
     639}
     640
     641
     642/**
    495643 * Polyfills for PHP 8.0 functions.
    496644 */
  • peak-publisher/trunk/peak-publisher.php

    r3460404 r3477599  
    44 * Plugin Name: Peak Publisher
    55 * Description: The easiest way to self-host, manage and publish your own custom plugins.
    6  * Version: 1.1.3
     6 * Version: 1.2.0
    77 * Requires at least: 5.8
    88 * Requires PHP: 8.1
  • peak-publisher/trunk/readme.txt

    r3460404 r3477599  
    99Requires PHP: 8.1
    1010Tested up to: 6.9
    11 Stable tag: 1.1.3
     11Stable tag: 1.2.0
    1212License: GPLv2 or later
    1313License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    109109== Changelog ==
    110110
     111= 1.2.0 - 2026-03-08 =
     112* Added asset management: upload, replace, reorder, and delete plugin icons, banners, and screenshots directly in the admin UI
     113   * Icons, banners, and screenshots are served to client sites via the wordpress.org-compatible API
     114   * Geopattern fallback icons for plugins without a custom icon (following the WordPress.org convention)
     115* Added permalink structure check: shows a notice when "Plain" permalinks are active (REST API requirement)
     116
    111117= 1.1.3 - 2026-02-12 =
    112118* New bootstrap code (basicV2): multisite support and safe handling when update transient has no response/no_update keys
Note: See TracChangeset for help on using the changeset viewer.