Skip to content

Commit c9943c8

Browse files
committed
Add custom apps
1 parent f615d93 commit c9943c8

File tree

3 files changed

+291
-21
lines changed

3 files changed

+291
-21
lines changed

packages/playground/personal-wp/src/components/menu-overlay/index.tsx

Lines changed: 163 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react';
1+
import { useState, useEffect, useCallback } from 'react';
22
import { external, trash } from '@wordpress/icons';
33
import { Icon } from '@wordpress/icons';
44
import { Spinner } from '@wordpress/components';
@@ -8,6 +8,7 @@ import { opfsSiteStorage } from '../../lib/state/opfs/opfs-site-storage';
88
import { broadcastSiteReset } from '../../lib/state/redux/tab-coordinator';
99
import { useBackup } from '../../lib/hooks/use-backup';
1010
import useFetch from '../../lib/hooks/use-fetch';
11+
import { useCustomApps } from '../../lib/hooks/use-custom-apps';
1112
import { WordPressIcon } from '@wp-playground/components';
1213
import {
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+
4993
export 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(/\.json$/, '')
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
)}

packages/playground/personal-wp/src/components/menu-overlay/style.module.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,47 @@
187187
color: #9ca3af;
188188
line-height: 1.4;
189189
}
190+
191+
.author {
192+
color: #6b7280;
193+
}
194+
195+
.appRow {
196+
display: flex;
197+
align-items: center;
198+
}
199+
200+
.appRow .featureItem {
201+
flex: 1;
202+
min-width: 0;
203+
}
204+
205+
.removeButton {
206+
display: flex;
207+
align-items: center;
208+
justify-content: center;
209+
width: 28px;
210+
height: 28px;
211+
margin-left: -8px;
212+
background: none;
213+
border: none;
214+
border-radius: 6px;
215+
color: #9ca3af;
216+
cursor: pointer;
217+
flex-shrink: 0;
218+
opacity: 0;
219+
transition: all 0.15s ease;
220+
}
221+
222+
.removeButton svg {
223+
fill: currentColor;
224+
}
225+
226+
.appRow:hover .removeButton {
227+
opacity: 1;
228+
}
229+
230+
.removeButton:hover {
231+
background: rgba(220, 38, 38, 0.15);
232+
color: #ef4444;
233+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useState, useEffect, useCallback } from 'react';
2+
3+
export type CustomApp = {
4+
id: string;
5+
title: string;
6+
description: string;
7+
author?: string;
8+
blueprintUrl: string;
9+
};
10+
11+
const STORAGE_KEY = 'wp-playground-custom-apps';
12+
13+
function loadCustomApps(): CustomApp[] {
14+
try {
15+
const stored = localStorage.getItem(STORAGE_KEY);
16+
if (!stored) return [];
17+
const apps = JSON.parse(stored);
18+
// eslint-disable-next-line no-console
19+
console.log('[CustomApps] Loaded from localStorage:', apps);
20+
return apps;
21+
} catch {
22+
return [];
23+
}
24+
}
25+
26+
function saveCustomApps(apps: CustomApp[]): void {
27+
try {
28+
localStorage.setItem(STORAGE_KEY, JSON.stringify(apps));
29+
} catch {
30+
// Storage full or unavailable
31+
}
32+
}
33+
34+
export function useCustomApps() {
35+
const [customApps, setCustomApps] = useState<CustomApp[]>(() =>
36+
loadCustomApps()
37+
);
38+
39+
useEffect(() => {
40+
saveCustomApps(customApps);
41+
}, [customApps]);
42+
43+
const addApp = useCallback((app: Omit<CustomApp, 'id'>) => {
44+
setCustomApps((prev) => {
45+
const existingIndex = prev.findIndex(
46+
(a) => a.title.toLowerCase() === app.title.toLowerCase()
47+
);
48+
49+
if (existingIndex !== -1) {
50+
const updated = [...prev];
51+
updated[existingIndex] = {
52+
...app,
53+
id: prev[existingIndex].id,
54+
};
55+
// eslint-disable-next-line no-console
56+
console.log(
57+
'[CustomApps] Replacing app:',
58+
updated[existingIndex]
59+
);
60+
return updated;
61+
}
62+
63+
const newApp: CustomApp = {
64+
...app,
65+
id: crypto.randomUUID(),
66+
};
67+
// eslint-disable-next-line no-console
68+
console.log('[CustomApps] Adding app:', newApp);
69+
return [...prev, newApp];
70+
});
71+
}, []);
72+
73+
const removeApp = useCallback((id: string) => {
74+
// eslint-disable-next-line no-console
75+
console.log('[CustomApps] Removing app:', id);
76+
setCustomApps((prev) => prev.filter((app) => app.id !== id));
77+
}, []);
78+
79+
return {
80+
customApps,
81+
addApp,
82+
removeApp,
83+
};
84+
}

0 commit comments

Comments
 (0)