1- import { useState } from 'react' ;
1+ import { useState , useEffect , useCallback } from 'react' ;
22import { external , trash } from '@wordpress/icons' ;
33import { Icon } from '@wordpress/icons' ;
44import { Spinner } from '@wordpress/components' ;
@@ -8,6 +8,7 @@ import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage';
88import { broadcastSiteReset } from '../../lib/state/redux/tab-coordinator' ;
99import { useBackup } from '../../lib/hooks/use-backup' ;
1010import useFetch from '../../lib/hooks/use-fetch' ;
11+ import { useCustomApps } from '../../lib/hooks/use-custom-apps' ;
1112import { WordPressIcon } from '@wp-playground/components' ;
1213import {
1314 Overlay ,
@@ -46,28 +47,144 @@ interface MenuOverlayProps {
4647 onClose : ( ) => void ;
4748}
4849
50+ function isValidUrl ( str : string ) : boolean {
51+ try {
52+ new URL ( str ) ;
53+ return true ;
54+ } catch {
55+ return false ;
56+ }
57+ }
58+
59+ function blueprintToDataUrl ( blueprint : string ) : string {
60+ const encoded = btoa ( unescape ( encodeURIComponent ( blueprint ) ) ) ;
61+ return `data:application/json;base64,${ encoded } ` ;
62+ }
63+
64+ function getBlueprintPreview ( url : string ) : string {
65+ if ( url . startsWith ( 'data:application/json;base64,' ) ) {
66+ try {
67+ const base64 = url . replace ( 'data:application/json;base64,' , '' ) ;
68+ const json = decodeURIComponent ( escape ( atob ( base64 ) ) ) ;
69+ return json ;
70+ } catch {
71+ return url ;
72+ }
73+ }
74+ return url ;
75+ }
76+
77+ function looksLikeBlueprint ( text : string ) : boolean {
78+ const trimmed = text . trim ( ) ;
79+ if ( isValidUrl ( trimmed ) ) {
80+ return true ;
81+ }
82+ if ( trimmed . startsWith ( '{' ) ) {
83+ try {
84+ JSON . parse ( trimmed ) ;
85+ return true ;
86+ } catch {
87+ return false ;
88+ }
89+ }
90+ return false ;
91+ }
92+
4993export function MenuOverlay ( { onClose } : MenuOverlayProps ) {
5094 const activeSite = useActiveSite ( ) ;
5195 const { isDependentMode } = useBackup ( ) ;
96+ const { customApps, addApp, removeApp } = useCustomApps ( ) ;
5297
5398 const [ showDeleteButton , setShowDeleteButton ] = useState ( false ) ;
5499 const [ isDeleting , setIsDeleting ] = useState ( false ) ;
55100 const [ showRecoveryButton , setShowRecoveryButton ] = useState ( false ) ;
56101
102+ const handlePaste = useCallback (
103+ ( e : ClipboardEvent ) => {
104+ const text = e . clipboardData ?. getData ( 'text' ) ;
105+ if ( ! text ) return ;
106+
107+ const trimmed = text . trim ( ) ;
108+ if ( ! looksLikeBlueprint ( trimmed ) ) return ;
109+
110+ e . preventDefault ( ) ;
111+
112+ let title = 'Custom app' ;
113+ let description = '' ;
114+ let author = '' ;
115+ let blueprintUrl : string ;
116+
117+ if ( isValidUrl ( trimmed ) ) {
118+ blueprintUrl = trimmed ;
119+ const urlPath = new URL ( trimmed ) . pathname ;
120+ const filename = urlPath . split ( '/' ) . pop ( ) || '' ;
121+ if ( filename ) {
122+ title = filename
123+ . replace ( / \. j s o n $ / , '' )
124+ . replace ( / [ - _ ] / g, ' ' ) ;
125+ }
126+ } else {
127+ try {
128+ const blueprint = JSON . parse ( trimmed ) ;
129+ if ( blueprint . meta ?. title ) {
130+ title = blueprint . meta . title ;
131+ }
132+ if ( blueprint . meta ?. description ) {
133+ description = blueprint . meta . description ;
134+ }
135+ if ( blueprint . meta ?. author ) {
136+ author = blueprint . meta . author ;
137+ }
138+ // eslint-disable-next-line no-console
139+ console . log (
140+ '[CustomApps] Blueprint pasted with meta:' ,
141+ blueprint . meta
142+ ) ;
143+ } catch {
144+ return ;
145+ }
146+ blueprintUrl = blueprintToDataUrl ( trimmed ) ;
147+ }
148+
149+ addApp ( {
150+ title,
151+ description : description || 'Custom app' ,
152+ author : author || undefined ,
153+ blueprintUrl,
154+ } ) ;
155+ } ,
156+ [ addApp ]
157+ ) ;
158+
159+ useEffect ( ( ) => {
160+ document . addEventListener ( 'paste' , handlePaste ) ;
161+ return ( ) => document . removeEventListener ( 'paste' , handlePaste ) ;
162+ } , [ handlePaste ] ) ;
163+
57164 const {
58165 data : appsData ,
59166 isLoading : appsLoading ,
60167 isError : appsError ,
61168 } = useFetch < Record < string , AppEntry > > ( APPS_INDEX_URL ) ;
62169
63- const apps = appsData
170+ const remoteApps = appsData
64171 ? Object . entries ( appsData ) . map ( ( [ path , entry ] ) => ( {
65172 ...entry ,
66173 path,
67174 blueprintUrl : `${ APPS_BASE_URL } ${ path } ` ,
175+ isCustom : false as const ,
68176 } ) )
69177 : [ ] ;
70178
179+ const allApps = [
180+ ...remoteApps ,
181+ ...customApps . map ( ( app ) => ( {
182+ ...app ,
183+ path : app . id ,
184+ isCustom : true as const ,
185+ } ) ) ,
186+ ] ;
187+
71188 async function handleStartOver ( ) {
72189 if ( ! activeSite || activeSite . metadata . storage === 'none' ) {
73190 return ;
@@ -109,32 +226,57 @@ export function MenuOverlay({ onClose }: MenuOverlayProps) {
109226 < div className = { css . loadingContainer } >
110227 < Spinner />
111228 </ div >
112- ) : appsError ? (
229+ ) : appsError && customApps . length === 0 ? (
113230 < p className = { css . errorMessage } >
114231 Unable to load apps. Check your connection.
115232 </ p >
116233 ) : (
117234 < div className = { css . featuresList } >
118- { apps . map ( ( app ) => (
119- < a
120- key = { app . path }
121- className = { css . featureItem }
122- href = { getAppBlueprintUrl ( app . blueprintUrl ) }
123- >
124- < span className = { css . featureIcon } >
125- < WordPressIcon />
126- </ span >
127- < span className = { css . featureContent } >
128- < span className = { css . featureTitle } >
129- { app . title }
235+ { allApps . map ( ( app ) => (
236+ < div key = { app . path } className = { css . appRow } >
237+ < a
238+ className = { css . featureItem }
239+ href = { getAppBlueprintUrl (
240+ app . blueprintUrl
241+ ) }
242+ title = { getBlueprintPreview (
243+ app . blueprintUrl
244+ ) }
245+ >
246+ < span className = { css . featureIcon } >
247+ < WordPressIcon />
130248 </ span >
131- < span
132- className = { css . featureDescription }
133- >
134- { app . description }
249+ < span className = { css . featureContent } >
250+ < span className = { css . featureTitle } >
251+ { app . title }
252+ </ span >
253+ < span
254+ className = {
255+ css . featureDescription
256+ }
257+ >
258+ { app . description }
259+ { app . author && (
260+ < span
261+ className = { css . author }
262+ >
263+ { ' ' }
264+ by { app . author }
265+ </ span >
266+ ) }
267+ </ span >
135268 </ span >
136- </ span >
137- </ a >
269+ </ a >
270+ { app . isCustom && (
271+ < button
272+ className = { css . removeButton }
273+ onClick = { ( ) => removeApp ( app . path ) }
274+ title = "Remove app"
275+ >
276+ < Icon icon = { trash } size = { 16 } />
277+ </ button >
278+ ) }
279+ </ div >
138280 ) ) }
139281 </ div >
140282 ) }
0 commit comments