PluginProbe ʕ •ᴥ•ʔ
Presto Player / 4.3.3
Presto Player v4.3.3
4.4.0 4.3.3 4.3.2 4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / pages / settings / general / BrandingPage.js
presto-player / src / admin / dashboard / pages / settings / general Last commit date
BrandingPage.js 3 months ago ContributePage.js 3 months ago CustomCssPage.js 3 months ago LicensePage.js 3 months ago MediaHubPage.js 3 months ago PresetsPage.js 3 months ago UninstallPage.js 3 months ago ViewingAnalyticsPage.js 3 months ago
BrandingPage.js
276 lines
1 import { __ } from '@wordpress/i18n';
2 import { useCallback, useId, useMemo } from 'react';
3 import { flushSync } from 'react-dom';
4 import {
5 Container,
6 Input,
7 Label,
8 Button,
9 FilePreview,
10 toast,
11 } from '@bsf/force-ui';
12 import { Upload } from 'lucide-react';
13 import SettingsPageShell from '../shared/SettingsPageShell';
14 import SectionCard from '../shared/SectionCard';
15 import ProGate from '../shared/ProGate';
16 import useSettingOption from '../../../hooks/useSettingOption';
17 import useRegisterActivePage from '../../../hooks/useRegisterActivePage';
18 import { OPTION_KEYS } from '../config';
19 import { useMediaLibrary } from '../../../hooks/useMediaLibrary';
20 import ColorPicker from '../../../components/ColorPicker';
21
22 const DEFAULT_BRANDING = {
23 logo: '',
24 logo_width: 150,
25 color: '#00b3ff',
26 player_css: '',
27 };
28
29 const DEFAULT_LOGO_WIDTH = 150;
30 const LOGO_WIDTH_MIN = 1;
31 const LOGO_WIDTH_MAX = 400;
32 const BRAND_PRIMARY_600 = '#4f46e5';
33
34 const isPremium = window.prestoPlayer?.isPremium ?? false;
35
36 // MIME type for the FilePreview chip. Browsers tolerate any `image/*` value
37 // for the preview icon, but using the right one keeps the chip's affordance
38 // honest for SVG / PNG / WebP logos.
39 const mimeFromUrl = ( url ) => {
40 const match = /\.([a-z0-9]+)(?:\?|#|$)/i.exec( url || '' );
41 const ext = match ? match[ 1 ].toLowerCase() : '';
42 switch ( ext ) {
43 case 'png':
44 return 'image/png';
45 case 'gif':
46 return 'image/gif';
47 case 'webp':
48 return 'image/webp';
49 case 'svg':
50 return 'image/svg+xml';
51 case 'avif':
52 return 'image/avif';
53 case 'jpg':
54 case 'jpeg':
55 default:
56 return 'image/jpeg';
57 }
58 };
59
60 const clampLogoWidth = ( value ) => {
61 const parsed = parseInt( value, 10 );
62 if ( ! Number.isFinite( parsed ) ) {
63 return DEFAULT_LOGO_WIDTH;
64 }
65 return Math.min( LOGO_WIDTH_MAX, Math.max( LOGO_WIDTH_MIN, parsed ) );
66 };
67
68 const BrandingPage = ( { registerActivePage } ) => {
69 const logoInputId = useId();
70 const logoWidthInputId = useId();
71 const colorInputId = useId();
72
73 const { data, setData, save, reset, isDirty, isSaving, isLoading } =
74 useSettingOption( OPTION_KEYS.branding, DEFAULT_BRANDING );
75
76 const update = useCallback(
77 ( patch ) =>
78 setData( ( prev ) => ( {
79 ...( prev || DEFAULT_BRANDING ),
80 ...patch,
81 } ) ),
82 [ setData ]
83 );
84
85 const { openMediaLibrary, getFilename } = useMediaLibrary( update );
86
87 const handleSave = useCallback( async () => {
88 try {
89 // Blank field → fall back to the default so the input always shows
90 // a usable value after save instead of staying empty.
91 if ( data.logo_width === undefined || data.logo_width === '' ) {
92 flushSync( () => update( { logo_width: DEFAULT_LOGO_WIDTH } ) );
93 }
94 await save();
95 toast.success( __( 'Settings saved.', 'presto-player' ), {
96 autoDismiss: 3000,
97 } );
98 } catch ( err ) {
99 toast.error( __( 'Could not save settings.', 'presto-player' ), {
100 autoDismiss: 5000,
101 } );
102 }
103 }, [ data, save, update ] );
104
105 useRegisterActivePage( registerActivePage, {
106 isDirty,
107 save: handleSave,
108 discard: reset,
109 } );
110
111 // Slider needs a numeric position even when the input is empty; falls back
112 // to the default so the thumb has somewhere to sit. The number input keeps
113 // the empty state and surfaces the default via the placeholder instead.
114 const logoWidth = data.logo_width ?? DEFAULT_LOGO_WIDTH;
115 const logoWidthInputValue = data.logo_width ?? '';
116
117 const handleLogoWidthChange = ( val ) => {
118 const next = String( val ?? '' );
119 // Clearing the field unsets the key entirely — JSON serialization drops
120 // `undefined` and the server-side `! empty()` fallback restores 150.
121 // Avoids saving 0/null, both of which the REST schema (min:1, max:400)
122 // would reject and which a stale stored 0 would propagate as 0px CSS.
123 if ( next === '' ) {
124 update( { logo_width: undefined } );
125 return;
126 }
127 update( { logo_width: clampLogoWidth( next ) } );
128 };
129
130 const logoFilename = getFilename( data.logo );
131 const logoFile = useMemo(
132 () =>
133 data.logo
134 ? {
135 type: mimeFromUrl( data.logo ),
136 name: logoFilename,
137 url: data.logo,
138 }
139 : null,
140 [ data.logo, logoFilename ]
141 );
142 const sliderFillPercent =
143 ( ( logoWidth - LOGO_WIDTH_MIN ) / ( LOGO_WIDTH_MAX - LOGO_WIDTH_MIN ) ) *
144 100;
145
146 return (
147 <SettingsPageShell
148 title={ __( 'Branding', 'presto-player' ) }
149 isDirty={ isDirty }
150 isSaving={ isSaving }
151 isLoading={ isLoading }
152 onSave={ handleSave }
153 >
154 <SectionCard>
155 <Container direction="column" className="gap-4">
156 <ProGate
157 enabled={ isPremium }
158 title={ __( 'Custom Branding', 'presto-player' ) }
159 description={ __(
160 'Add your logo and personalize the player appearance.',
161 'presto-player'
162 ) }
163 >
164 <Container.Item className="flex flex-col gap-2">
165 <Label size="sm" htmlFor={ logoInputId }>
166 { __( 'Logo', 'presto-player' ) }
167 </Label>
168 <Input
169 id={ logoInputId }
170 type="text"
171 size="md"
172 readOnly
173 className="cursor-pointer"
174 value={ logoFilename || '' }
175 placeholder={ __( 'No image selected', 'presto-player' ) }
176 aria-label={ __( 'Choose a logo image', 'presto-player' ) }
177 suffix={
178 <Button
179 variant="ghost"
180 size="xs"
181 icon={ <Upload className="w-4 h-4" /> }
182 onClick={ openMediaLibrary( 'logo' ) }
183 aria-label={ __(
184 'Open media library to choose a logo',
185 'presto-player'
186 ) }
187 />
188 }
189 onClick={ openMediaLibrary( 'logo' ) }
190 />
191 { logoFile && (
192 <FilePreview
193 file={ logoFile }
194 onRemove={ () => update( { logo: '' } ) }
195 size="sm"
196 />
197 ) }
198 <Label size="xs" variant="help">
199 { __(
200 'Add a logo to display in the player.',
201 'presto-player'
202 ) }
203 </Label>
204 </Container.Item>
205
206 <Container.Item className="flex flex-col gap-2">
207 <Label size="sm" htmlFor={ logoWidthInputId }>
208 { __( 'Logo Max Width', 'presto-player' ) }
209 </Label>
210 <div className="flex items-center gap-3">
211 <input
212 type="range"
213 min={ LOGO_WIDTH_MIN }
214 max={ LOGO_WIDTH_MAX }
215 value={ logoWidth }
216 aria-label={ __( 'Logo max width', 'presto-player' ) }
217 onChange={ ( e ) =>
218 update( {
219 logo_width: clampLogoWidth( e.target.value ),
220 } )
221 }
222 className="flex-1 h-1 rounded-full appearance-none cursor-pointer accent-brand-primary-600"
223 style={ {
224 background: `linear-gradient(to right, ${ BRAND_PRIMARY_600 } ${ sliderFillPercent }%, #e5e7eb ${ sliderFillPercent }%)`,
225 } }
226 />
227 <Input
228 id={ logoWidthInputId }
229 type="number"
230 size="md"
231 className="w-24"
232 value={ logoWidthInputValue }
233 placeholder={ String( DEFAULT_LOGO_WIDTH ) }
234 min={ LOGO_WIDTH_MIN }
235 max={ LOGO_WIDTH_MAX }
236 onChange={ handleLogoWidthChange }
237 suffix={
238 <span className="text-xs text-gray-400 pr-1">px</span>
239 }
240 />
241 </div>
242 <Label size="xs" variant="help">
243 { __(
244 'Set the maximum width of the logo in pixels.',
245 'presto-player'
246 ) }
247 </Label>
248 </Container.Item>
249 </ProGate>
250
251 <Container.Item className="flex flex-col gap-2">
252 <Label size="sm" htmlFor={ colorInputId }>
253 { __( 'Brand Color', 'presto-player' ) }
254 </Label>
255 <ColorPicker
256 id={ colorInputId }
257 name="color"
258 value={ data.color }
259 defaultColor="#00b3ff"
260 onChange={ ( _name, color ) => update( { color } ) }
261 />
262 <Label size="xs" variant="help">
263 { __(
264 'Set the primary brand color for the player UI.',
265 'presto-player'
266 ) }
267 </Label>
268 </Container.Item>
269 </Container>
270 </SectionCard>
271 </SettingsPageShell>
272 );
273 };
274
275 export default BrandingPage;
276