Changeset 3477599
- Timestamp:
- 03/08/2026 08:38:54 PM (3 weeks ago)
- Location:
- peak-publisher
- Files:
-
- 30 added
- 13 edited
- 20 copied
-
tags/1.2.0 (copied) (copied from peak-publisher/trunk)
-
tags/1.2.0/assets/bootstrap-codes/basicV2.php.txt (copied) (copied from peak-publisher/trunk/assets/bootstrap-codes/basicV2.php.txt)
-
tags/1.2.0/assets/css/admin.css (copied) (copied from peak-publisher/trunk/assets/css/admin.css) (6 diffs)
-
tags/1.2.0/assets/js/admin.js (copied) (copied from peak-publisher/trunk/assets/js/admin.js) (7 diffs)
-
tags/1.2.0/assets/js/api.js (copied) (copied from peak-publisher/trunk/assets/js/api.js) (1 diff)
-
tags/1.2.0/assets/js/components/GlobalDropOverlay.js (copied) (copied from peak-publisher/trunk/assets/js/components/GlobalDropOverlay.js) (3 diffs)
-
tags/1.2.0/assets/js/components/PluginEditor.js (copied) (copied from peak-publisher/trunk/assets/js/components/PluginEditor.js) (4 diffs)
-
tags/1.2.0/assets/js/components/PluginList.js (copied) (copied from peak-publisher/trunk/assets/js/components/PluginList.js) (2 diffs)
-
tags/1.2.0/assets/js/components/Settings.js (copied) (copied from peak-publisher/trunk/assets/js/components/Settings.js)
-
tags/1.2.0/assets/js/stores (copied) (copied from peak-publisher/trunk/assets/js/stores)
-
tags/1.2.0/assets/js/utils-upload.js (copied) (copied from peak-publisher/trunk/assets/js/utils-upload.js)
-
tags/1.2.0/assets/js/utils.js (copied) (copied from peak-publisher/trunk/assets/js/utils.js) (2 diffs)
-
tags/1.2.0/classes/AdminAPI.php (copied) (copied from peak-publisher/trunk/classes/AdminAPI.php) (7 diffs)
-
tags/1.2.0/classes/AdminUI.php (copied) (copied from peak-publisher/trunk/classes/AdminUI.php) (2 diffs)
-
tags/1.2.0/classes/AssetManager.php (added)
-
tags/1.2.0/classes/PublicAPI.php (copied) (copied from peak-publisher/trunk/classes/PublicAPI.php) (8 diffs)
-
tags/1.2.0/classes/UploadWorkflow.php (copied) (copied from peak-publisher/trunk/classes/UploadWorkflow.php)
-
tags/1.2.0/includes/functions.php (copied) (copied from peak-publisher/trunk/includes/functions.php) (2 diffs)
-
tags/1.2.0/libs (copied) (copied from peak-publisher/trunk/libs)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0 (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/GeoPattern.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVG.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Base.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Circle.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Group.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Path.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Polyline.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Rectangle.php (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/LICENSE (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/README.md (added)
-
tags/1.2.0/libs/plugin-directory/libs/geopattern-1.1.0/geopattern_loader.php (added)
-
tags/1.2.0/peak-publisher.php (copied) (copied from peak-publisher/trunk/peak-publisher.php) (1 diff)
-
tags/1.2.0/readme.txt (copied) (copied from peak-publisher/trunk/readme.txt) (2 diffs)
-
trunk/assets/css/admin.css (modified) (6 diffs)
-
trunk/assets/js/admin.js (modified) (7 diffs)
-
trunk/assets/js/api.js (modified) (1 diff)
-
trunk/assets/js/components/GlobalDropOverlay.js (modified) (3 diffs)
-
trunk/assets/js/components/PluginEditor.js (modified) (4 diffs)
-
trunk/assets/js/components/PluginList.js (modified) (2 diffs)
-
trunk/assets/js/utils.js (modified) (2 diffs)
-
trunk/classes/AdminAPI.php (modified) (7 diffs)
-
trunk/classes/AdminUI.php (modified) (2 diffs)
-
trunk/classes/AssetManager.php (added)
-
trunk/classes/PublicAPI.php (modified) (8 diffs)
-
trunk/includes/functions.php (modified) (2 diffs)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0 (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/GeoPattern.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVG.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Base.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Circle.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Group.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Path.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Polyline.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/GeoPattern/SVGElements/Rectangle.php (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/LICENSE (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/README.md (added)
-
trunk/libs/plugin-directory/libs/geopattern-1.1.0/geopattern_loader.php (added)
-
trunk/peak-publisher.php (modified) (1 diff)
-
trunk/readme.txt (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
peak-publisher/tags/1.2.0/assets/css/admin.css
r3444907 r3477599 69 69 0% { transform: rotate(0deg); } 70 70 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; 71 104 } 72 105 … … 851 884 } 852 885 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 853 904 .pblsh--table__name-cell { 854 905 min-width: 12.5rem; /* 200px */ … … 903 954 904 955 /* Status Column */ 905 .pblsh--table__status-header {906 width: 6.25rem; /* 100px */907 }908 909 956 .pblsh--table__status-cell { 910 957 width: 6.25rem; /* 100px */ … … 959 1006 border-bottom: 1px solid var(--border-color-medium); 960 1007 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; 961 1018 } 962 1019 .pblsh--plugin-header code { … … 1079 1136 max-width: 50rem; /* 800px */ 1080 1137 } 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 } 1081 1208 1082 1209 .pblsh--editor { … … 1443 1570 margin-bottom: 1rem; 1444 1571 } 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 3 3 'use strict'; 4 4 5 const { __ } = wp.i18n;5 const { __, sprintf } = wp.i18n; 6 6 const { useState, useEffect, useRef, createElement, render } = wp.element; 7 7 const { useSelect } = wp.data; … … 10 10 const { showAlert, getDefaultConfig } = Pblsh.Utils; 11 11 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 12 51 // Main App Component 13 52 const PeakPublisherApp = () => { 53 // Block the entire app when permalinks are set to "Plain" 54 if (PblshData.hasPlainPermalinks) { 55 return createElement(PermalinkNotice); 56 } 57 14 58 const [view, setView] = useState('list'); // 'list' | 'editor' | 'addition-process' 15 59 const [currentPluginId, setCurrentPluginId] = useState(null); 60 const [initialTab, setInitialTab] = useState(null); 16 61 const isLoading = useSelect((select) => select('pblsh/plugins').isLoadingList(), []); 17 62 const hasLoadedList = useSelect((select) => { … … 34 79 plugin: params.get('plugin'), 35 80 view: params.get('view'), 81 tab: params.get('tab'), 36 82 }; 37 83 } catch (e) { … … 47 93 if ('view' in next) { 48 94 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'); } 49 98 } 50 99 const newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : ''); … … 62 111 const idNum = Number(q.plugin); 63 112 if (!isNaN(idNum)) { 113 if (q.tab) setInitialTab(q.tab); 64 114 await handleEdit(idNum); 65 115 return; … … 135 185 setCurrentPluginId(null); 136 186 setIsNew(false); 137 setQuery({ plugin: null, view: null }); 187 setInitialTab(null); 188 setQuery({ plugin: null, view: null, tab: null }); 138 189 }; 139 190 … … 254 305 isLoadingReleases: isLoadingReleases, 255 306 onBack: handleCancel, 307 initialTab: initialTab, 308 onTabChange: (tab) => setQuery({ tab: tab === 'releases' ? null : tab }), 256 309 }); 257 310 } else { -
peak-publisher/tags/1.2.0/assets/js/api.js
r3444907 r3477599 116 116 }); 117 117 }, 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 }, 118 167 }); -
peak-publisher/tags/1.2.0/assets/js/components/GlobalDropOverlay.js
r3460404 r3477599 53 53 54 54 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 55 58 const onDragEnter = (e) => { 59 if (!isExternalFileDrag(e)) return; 56 60 e.preventDefault(); 57 61 setDragCounter((c) => c + 1); … … 59 63 }; 60 64 const onDragOver = (e) => { 65 if (!isExternalFileDrag(e)) return; 61 66 e.preventDefault(); 62 67 }; … … 69 74 }; 70 75 const onDrop = (e) => { 76 if (!isExternalFileDrag(e)) return; 71 77 e.preventDefault(); 72 78 setDragCounter(0); -
peak-publisher/tags/1.2.0/assets/js/components/PluginEditor.js
r3444907 r3477599 1 1 // PluginEditor Component (simplified overview + releases list) 2 lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack }) => {2 lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack, initialTab, onTabChange }) => { 3 3 const { __ } = wp.i18n; 4 const { createElement } = wp.element;4 const { createElement, useState, useEffect, useRef } = wp.element; 5 5 const { useSelect } = wp.data; 6 const { Tooltip, Button } = wp.components;6 const { Tooltip, Button, DropdownMenu, MenuItem } = wp.components; 7 7 const { getSvgIcon } = Pblsh.Utils; 8 8 9 9 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 }; 10 16 const serverSettings = useSelect((select) => select('pblsh/settings').getServer(), []); 11 17 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 }; 12 415 13 416 // Prefer releases from store (keeps UI in sync on toggles), fallback to pluginData.releases … … 37 440 createElement('div', { className: 'pblsh--plugin-header' }, 38 441 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 ), 43 455 ), 44 456 ), … … 180 592 }; 181 593 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 182 607 return createElement('div', { className: 'pblsh--editor' }, 183 608 createElement('div', { className: 'pblsh--editor__content' }, … … 186 611 createElement('div', { className: 'pblsh--main__content' }, 187 612 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 ), 189 620 ), 190 621 ), -
peak-publisher/tags/1.2.0/assets/js/components/PluginList.js
r3444907 r3477599 43 43 createElement('tr', null, 44 44 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' }), 46 46 createElement('th', { className: 'pblsh--table__name-header' }, __('Plugin Name', 'peak-publisher')), 47 47 createElement('th', { className: 'pblsh--table__slug-header' }, __('Slug', 'peak-publisher')), … … 68 68 }) 69 69 ), 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 ), 83 79 createElement('td', { className: 'pblsh--table__name-cell' }, 84 80 createElement('strong', null, plugin.name) -
peak-publisher/tags/1.2.0/assets/js/utils.js
r3444907 r3477599 2 2 lodash.set(window, 'Pblsh.Utils', { 3 3 4 // Show alert message ( only for errors)4 // Show alert message (for errors and warnings) 5 5 showAlert: (message, type = 'error') => { 6 if (type === 'error' ) {6 if (type === 'error' || type === 'warning') { 7 7 alert(message); 8 8 } … … 74 74 chart_line: 'M16,11.78L20.24,4.45L21.97,5.45L16.74,14.5L10.23,10.75L5.46,19H22V21H2V3H4V17.54L9.5,8L16,11.78Z', 75 75 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', 76 77 }; 77 78 -
peak-publisher/tags/1.2.0/classes/AdminAPI.php
r3444907 r3477599 11 11 const NAMESPACE = 'pblsh-admin/v1'; 12 12 13 private ?AssetManager $asset_manager = null; 14 13 15 /** 14 16 * Constructor. … … 16 18 private function __construct() { 17 19 $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; 18 31 } 19 32 … … 113 126 'methods' => 'POST', 114 127 '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'], 115 150 'permission_callback' => [$this, 'check_permission'], 116 151 ]); … … 166 201 'name' => $plugin_post->post_title, 167 202 '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), 169 204 'version' => $latest_version, 170 205 'status' => $plugin_post->post_status, … … 214 249 'name' => $post->post_title, 215 250 '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), 217 252 'version' => $latest_version, 218 253 'status' => $post->post_status, … … 391 426 } 392 427 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 393 434 // Remove all empty folders from the upload directory 394 435 remove_empty_folders(peak_publisher_upload_basedir()); … … 449 490 } 450 491 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 451 655 public function get_peak_publisher_settings_rest(): array { 452 656 return get_peak_publisher_settings(); -
peak-publisher/tags/1.2.0/classes/AdminUI.php
r3444907 r3477599 147 147 ); 148 148 149 require_once __DIR__ . '/AssetManager.php'; 149 150 wp_localize_script( 150 151 'pblsh-admin', … … 154 155 'wpVersion' => function_exists('wp_get_wp_version') ? wp_get_wp_version() : $GLOBALS['wp_version'], 155 156 '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 ], 156 164 ] 157 165 ); -
peak-publisher/tags/1.2.0/classes/PublicAPI.php
r3446146 r3477599 42 42 'slug' => ['required' => true], 43 43 '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], 44 55 ], 45 56 ]); … … 283 294 $result['sections'][$section_key] = apply_filters( 'the_content', $section_content, $section_key); 284 295 } 296 $result['sections']['screenshots'] = ''; // placeholder to put screenshots prior to reviews at the end. 285 297 286 298 if ( ! empty( $result['sections']['faq'] ) ) { … … 292 304 $result['download_link'] = rest_url(self::NAMESPACE . '/plugins/download/' . $plugin->post_name . '/' . $latest_release->post_title); 293 305 $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 } 294 326 295 327 $terms = array_map(fn($term) => (object) [ … … 318 350 319 351 $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 } 320 380 321 381 $expected_fields = $this->get_expected_fields('plugin_information'); … … 386 446 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; 387 447 448 require_once __DIR__ . '/AssetManager.php'; 449 $asset_manager = AssetManager::init(); 450 388 451 $results = [ 389 452 'plugins' => [], … … 408 471 record_plugin_installation((int) $plugin->ID, (string) $user_agent, (string) $client_installed_version); 409 472 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 410 503 $results['plugins'][$plugin_basename] = array_merge( 411 504 ['slug' => $plugin->post_name], … … 413 506 ['version' => $latest_version], 414 507 ['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'] ], 415 510 empty($plugin_data['RequiresWP']) ? [] : [ 'requires' => $plugin_data['RequiresWP'] ], 416 511 empty($plugin_data['RequiresPHP']) ? [] : [ 'requires_php' => $plugin_data['RequiresPHP'] ], … … 632 727 return $markup; 633 728 } 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 } 634 801 } 635 802 -
peak-publisher/tags/1.2.0/includes/functions.php
r3460404 r3477599 275 275 276 276 /** 277 * Gets the assets directory for a specific plugin slug. 278 */ 279 function 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 */ 289 function 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 /** 277 313 * Gets the upload directory. 278 314 */ … … 493 529 494 530 /** 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 */ 543 function 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 */ 613 function 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 /** 495 643 * Polyfills for PHP 8.0 functions. 496 644 */ -
peak-publisher/tags/1.2.0/peak-publisher.php
r3460404 r3477599 4 4 * Plugin Name: Peak Publisher 5 5 * Description: The easiest way to self-host, manage and publish your own custom plugins. 6 * Version: 1. 1.36 * Version: 1.2.0 7 7 * Requires at least: 5.8 8 8 * Requires PHP: 8.1 -
peak-publisher/tags/1.2.0/readme.txt
r3460404 r3477599 9 9 Requires PHP: 8.1 10 10 Tested up to: 6.9 11 Stable tag: 1. 1.311 Stable tag: 1.2.0 12 12 License: GPLv2 or later 13 13 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 109 109 == Changelog == 110 110 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 111 117 = 1.1.3 - 2026-02-12 = 112 118 * 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 69 69 0% { transform: rotate(0deg); } 70 70 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; 71 104 } 72 105 … … 851 884 } 852 885 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 853 904 .pblsh--table__name-cell { 854 905 min-width: 12.5rem; /* 200px */ … … 903 954 904 955 /* Status Column */ 905 .pblsh--table__status-header {906 width: 6.25rem; /* 100px */907 }908 909 956 .pblsh--table__status-cell { 910 957 width: 6.25rem; /* 100px */ … … 959 1006 border-bottom: 1px solid var(--border-color-medium); 960 1007 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; 961 1018 } 962 1019 .pblsh--plugin-header code { … … 1079 1136 max-width: 50rem; /* 800px */ 1080 1137 } 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 } 1081 1208 1082 1209 .pblsh--editor { … … 1443 1570 margin-bottom: 1rem; 1444 1571 } 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 3 3 'use strict'; 4 4 5 const { __ } = wp.i18n;5 const { __, sprintf } = wp.i18n; 6 6 const { useState, useEffect, useRef, createElement, render } = wp.element; 7 7 const { useSelect } = wp.data; … … 10 10 const { showAlert, getDefaultConfig } = Pblsh.Utils; 11 11 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 12 51 // Main App Component 13 52 const PeakPublisherApp = () => { 53 // Block the entire app when permalinks are set to "Plain" 54 if (PblshData.hasPlainPermalinks) { 55 return createElement(PermalinkNotice); 56 } 57 14 58 const [view, setView] = useState('list'); // 'list' | 'editor' | 'addition-process' 15 59 const [currentPluginId, setCurrentPluginId] = useState(null); 60 const [initialTab, setInitialTab] = useState(null); 16 61 const isLoading = useSelect((select) => select('pblsh/plugins').isLoadingList(), []); 17 62 const hasLoadedList = useSelect((select) => { … … 34 79 plugin: params.get('plugin'), 35 80 view: params.get('view'), 81 tab: params.get('tab'), 36 82 }; 37 83 } catch (e) { … … 47 93 if ('view' in next) { 48 94 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'); } 49 98 } 50 99 const newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : ''); … … 62 111 const idNum = Number(q.plugin); 63 112 if (!isNaN(idNum)) { 113 if (q.tab) setInitialTab(q.tab); 64 114 await handleEdit(idNum); 65 115 return; … … 135 185 setCurrentPluginId(null); 136 186 setIsNew(false); 137 setQuery({ plugin: null, view: null }); 187 setInitialTab(null); 188 setQuery({ plugin: null, view: null, tab: null }); 138 189 }; 139 190 … … 254 305 isLoadingReleases: isLoadingReleases, 255 306 onBack: handleCancel, 307 initialTab: initialTab, 308 onTabChange: (tab) => setQuery({ tab: tab === 'releases' ? null : tab }), 256 309 }); 257 310 } else { -
peak-publisher/trunk/assets/js/api.js
r3444907 r3477599 116 116 }); 117 117 }, 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 }, 118 167 }); -
peak-publisher/trunk/assets/js/components/GlobalDropOverlay.js
r3460404 r3477599 53 53 54 54 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 55 58 const onDragEnter = (e) => { 59 if (!isExternalFileDrag(e)) return; 56 60 e.preventDefault(); 57 61 setDragCounter((c) => c + 1); … … 59 63 }; 60 64 const onDragOver = (e) => { 65 if (!isExternalFileDrag(e)) return; 61 66 e.preventDefault(); 62 67 }; … … 69 74 }; 70 75 const onDrop = (e) => { 76 if (!isExternalFileDrag(e)) return; 71 77 e.preventDefault(); 72 78 setDragCounter(0); -
peak-publisher/trunk/assets/js/components/PluginEditor.js
r3444907 r3477599 1 1 // PluginEditor Component (simplified overview + releases list) 2 lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack }) => {2 lodash.set(window, 'Pblsh.Components.PluginEditor', ({ pluginData, refreshPlugin, onToggleReleaseStatus, pendingReleaseIds, onTogglePluginStatus, pendingPluginStatus, isLoadingReleases, onBack, initialTab, onTabChange }) => { 3 3 const { __ } = wp.i18n; 4 const { createElement } = wp.element;4 const { createElement, useState, useEffect, useRef } = wp.element; 5 5 const { useSelect } = wp.data; 6 const { Tooltip, Button } = wp.components;6 const { Tooltip, Button, DropdownMenu, MenuItem } = wp.components; 7 7 const { getSvgIcon } = Pblsh.Utils; 8 8 9 9 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 }; 10 16 const serverSettings = useSelect((select) => select('pblsh/settings').getServer(), []); 11 17 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 }; 12 415 13 416 // Prefer releases from store (keeps UI in sync on toggles), fallback to pluginData.releases … … 37 440 createElement('div', { className: 'pblsh--plugin-header' }, 38 441 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 ), 43 455 ), 44 456 ), … … 180 592 }; 181 593 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 182 607 return createElement('div', { className: 'pblsh--editor' }, 183 608 createElement('div', { className: 'pblsh--editor__content' }, … … 186 611 createElement('div', { className: 'pblsh--main__content' }, 187 612 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 ), 189 620 ), 190 621 ), -
peak-publisher/trunk/assets/js/components/PluginList.js
r3444907 r3477599 43 43 createElement('tr', null, 44 44 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' }), 46 46 createElement('th', { className: 'pblsh--table__name-header' }, __('Plugin Name', 'peak-publisher')), 47 47 createElement('th', { className: 'pblsh--table__slug-header' }, __('Slug', 'peak-publisher')), … … 68 68 }) 69 69 ), 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 ), 83 79 createElement('td', { className: 'pblsh--table__name-cell' }, 84 80 createElement('strong', null, plugin.name) -
peak-publisher/trunk/assets/js/utils.js
r3444907 r3477599 2 2 lodash.set(window, 'Pblsh.Utils', { 3 3 4 // Show alert message ( only for errors)4 // Show alert message (for errors and warnings) 5 5 showAlert: (message, type = 'error') => { 6 if (type === 'error' ) {6 if (type === 'error' || type === 'warning') { 7 7 alert(message); 8 8 } … … 74 74 chart_line: 'M16,11.78L20.24,4.45L21.97,5.45L16.74,14.5L10.23,10.75L5.46,19H22V21H2V3H4V17.54L9.5,8L16,11.78Z', 75 75 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', 76 77 }; 77 78 -
peak-publisher/trunk/classes/AdminAPI.php
r3444907 r3477599 11 11 const NAMESPACE = 'pblsh-admin/v1'; 12 12 13 private ?AssetManager $asset_manager = null; 14 13 15 /** 14 16 * Constructor. … … 16 18 private function __construct() { 17 19 $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; 18 31 } 19 32 … … 113 126 'methods' => 'POST', 114 127 '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'], 115 150 'permission_callback' => [$this, 'check_permission'], 116 151 ]); … … 166 201 'name' => $plugin_post->post_title, 167 202 '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), 169 204 'version' => $latest_version, 170 205 'status' => $plugin_post->post_status, … … 214 249 'name' => $post->post_title, 215 250 '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), 217 252 'version' => $latest_version, 218 253 'status' => $post->post_status, … … 391 426 } 392 427 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 393 434 // Remove all empty folders from the upload directory 394 435 remove_empty_folders(peak_publisher_upload_basedir()); … … 449 490 } 450 491 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 451 655 public function get_peak_publisher_settings_rest(): array { 452 656 return get_peak_publisher_settings(); -
peak-publisher/trunk/classes/AdminUI.php
r3444907 r3477599 147 147 ); 148 148 149 require_once __DIR__ . '/AssetManager.php'; 149 150 wp_localize_script( 150 151 'pblsh-admin', … … 154 155 'wpVersion' => function_exists('wp_get_wp_version') ? wp_get_wp_version() : $GLOBALS['wp_version'], 155 156 '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 ], 156 164 ] 157 165 ); -
peak-publisher/trunk/classes/PublicAPI.php
r3446146 r3477599 42 42 'slug' => ['required' => true], 43 43 '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], 44 55 ], 45 56 ]); … … 283 294 $result['sections'][$section_key] = apply_filters( 'the_content', $section_content, $section_key); 284 295 } 296 $result['sections']['screenshots'] = ''; // placeholder to put screenshots prior to reviews at the end. 285 297 286 298 if ( ! empty( $result['sections']['faq'] ) ) { … … 292 304 $result['download_link'] = rest_url(self::NAMESPACE . '/plugins/download/' . $plugin->post_name . '/' . $latest_release->post_title); 293 305 $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 } 294 326 295 327 $terms = array_map(fn($term) => (object) [ … … 318 350 319 351 $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 } 320 380 321 381 $expected_fields = $this->get_expected_fields('plugin_information'); … … 386 446 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; 387 447 448 require_once __DIR__ . '/AssetManager.php'; 449 $asset_manager = AssetManager::init(); 450 388 451 $results = [ 389 452 'plugins' => [], … … 408 471 record_plugin_installation((int) $plugin->ID, (string) $user_agent, (string) $client_installed_version); 409 472 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 410 503 $results['plugins'][$plugin_basename] = array_merge( 411 504 ['slug' => $plugin->post_name], … … 413 506 ['version' => $latest_version], 414 507 ['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'] ], 415 510 empty($plugin_data['RequiresWP']) ? [] : [ 'requires' => $plugin_data['RequiresWP'] ], 416 511 empty($plugin_data['RequiresPHP']) ? [] : [ 'requires_php' => $plugin_data['RequiresPHP'] ], … … 632 727 return $markup; 633 728 } 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 } 634 801 } 635 802 -
peak-publisher/trunk/includes/functions.php
r3460404 r3477599 275 275 276 276 /** 277 * Gets the assets directory for a specific plugin slug. 278 */ 279 function 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 */ 289 function 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 /** 277 313 * Gets the upload directory. 278 314 */ … … 493 529 494 530 /** 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 */ 543 function 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 */ 613 function 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 /** 495 643 * Polyfills for PHP 8.0 functions. 496 644 */ -
peak-publisher/trunk/peak-publisher.php
r3460404 r3477599 4 4 * Plugin Name: Peak Publisher 5 5 * Description: The easiest way to self-host, manage and publish your own custom plugins. 6 * Version: 1. 1.36 * Version: 1.2.0 7 7 * Requires at least: 5.8 8 8 * Requires PHP: 8.1 -
peak-publisher/trunk/readme.txt
r3460404 r3477599 9 9 Requires PHP: 8.1 10 10 Tested up to: 6.9 11 Stable tag: 1. 1.311 Stable tag: 1.2.0 12 12 License: GPLv2 or later 13 13 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 109 109 == Changelog == 110 110 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 111 117 = 1.1.3 - 2026-02-12 = 112 118 * 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.