Skip to content
32 changes: 32 additions & 0 deletions backend/internal/compile/client.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,44 @@
window.addEventListener('error', (event) => {
window.parent.postMessage(
{
source: 'robin-platform',
type: 'appError',
error: String(event.error?.stack || event.error),
},
'*',
);
});

(() => {
let lastLocation = null;
let lastTitle = null;

setInterval(() => {
if (lastLocation !== window.location.href) {
window.parent.postMessage(
{
source: 'robin-platform',
type: 'locationUpdate',
location: window.location.href,
},
'*',
);
lastLocation = window.location.href;
}

if (lastTitle !== document.title) {
window.parent.postMessage(
{
source: 'robin-platform',
type: 'titleUpdate',
title: document.title,
},
'*',
);
lastReportedTitle = document.title;
}
}, 250);
})();
</script>
<script>
{{.ScriptSource}}
Expand Down
50 changes: 29 additions & 21 deletions example/my-ext/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { getAppSettings } from '@robinplatform/toolkit';
import { renderApp } from '@robinplatform/toolkit/react';
import { useRpcQuery } from '@robinplatform/toolkit/react/rpc';
import React from 'react';
import { getSelfSource } from './page.server';
import '@robinplatform/toolkit/styles.css';
import './ext.scss';
import { z } from 'zod';
Expand All @@ -12,29 +11,38 @@ function Page() {
getAppSettings,
z.object({ filename: z.string().optional() }),
);
const { data, error: errFetchingFile } = useRpcQuery(
getSelfSource,
{
filename: String(settings?.filename ?? './package.json'),
},
{
enabled: !!settings,
},
);

const error = errFetchingSettings || errFetchingFile;
const error = errFetchingSettings;

return (
<pre
style={{
margin: '1rem',
padding: '1rem',
background: '#e3e3e3',
borderRadius: 'var(--robin-border-radius)',
}}
>
<code>{error ? String(error) : data ? String(data) : 'Loading ...'}</code>
</pre>
<div>
<div>
LOCATION: {String(window.location.href)}
<a href="./blahblah">Link</a>
<button
onClick={() => window.history.pushState(null, '', './blahblah2')}
>
History Change
</button>
</div>

<pre
style={{
margin: '1rem',
padding: '1rem',
background: '#e3e3e3',
borderRadius: 'var(--robin-border-radius)',
}}
>
<code>
{error
? JSON.stringify(error)
: settings
? JSON.stringify(settings, undefined, 2)
: 'Loading ...'}
</code>
</pre>
</div>
);
}

Expand Down
3 changes: 2 additions & 1 deletion example/robin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"name": "example",
"apps": [
"./my-ext/robin.app.json",
"./bad-js-ext/robin.app.json",
"./failing-ext/robin.app.json",
"https://esm.sh/@robinplatform/app-example@0.0.11"
]
}
}
3 changes: 0 additions & 3 deletions frontend/components/AppToolbar.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

.toolbar {
padding: 0.5rem;
margin: 1.25rem;
margin-bottom: 0;

border-radius: 0.25rem;
background: $dark-blue;

display: flex;
Expand Down
100 changes: 78 additions & 22 deletions frontend/components/AppWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import styles from './AppToolbar.module.scss';
type AppWindowProps = {
id: string;
setTitle: React.Dispatch<React.SetStateAction<string>>;
route: string;
setRoute: (route: string) => void;
};

const RestartAppButton: React.FC = () => {
Expand Down Expand Up @@ -54,50 +56,99 @@ const RestartAppButton: React.FC = () => {
);
};

function AppWindowContent({ id, setTitle }: AppWindowProps) {
const router = useRouter();

// NOTE: Changes to the route here will create an additional history entry.
function AppWindowContent({ id, setTitle, route, setRoute }: AppWindowProps) {
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const [error, setError] = React.useState<string | null>(null);
const subRoute = React.useMemo(
() =>
router.isReady
? router.asPath.substring('/app/'.length + id.length)
: null,
[router.isReady, router.asPath, id],
);
const mostCurrentRouteRef = React.useRef<string>(route);
const mostCurrentLocationUpdateRef = React.useRef<string | null>(null);

React.useEffect(() => {
mostCurrentRouteRef.current = route;
}, [route]);

React.useEffect(() => {
if (!iframeRef.current) {
return;
}

const target = `http://localhost:9010/api/app-resources/${id}/base${route}`;
if (target === mostCurrentLocationUpdateRef.current) {
return;
}

if (iframeRef.current.src !== target) {
console.log('switching to', target, 'from', iframeRef.current.src);
iframeRef.current.src = target;
}
}, [id, route]);

React.useEffect(() => {
const onMessage = (message: MessageEvent) => {
try {
if (message.data.source !== 'robin-platform') {
// e.g. react-dev-tools uses iframe messages, so we shouldn't
// handle them.
return;
}

switch (message.data.type) {
case 'locationUpdate': {
const location = {
pathname: window.location.pathname,
search: new URL(message.data.location).search,
};
router.push(location, undefined, { shallow: true });
const location = message.data.location;
if (!location || typeof location !== 'string') {
break;
}

console.log('received location update', location);

const url = new URL(location);
const newRoute = url.pathname.substring(
`/api/app-resources/${id}/base`.length,
);

const currentRoute = mostCurrentRouteRef.current;
if (newRoute !== currentRoute) {
setRoute(newRoute);
mostCurrentLocationUpdateRef.current = url.href;
}
break;
}

case 'titleUpdate':
setTitle((title) => message.data.title || title);
if (message.data.title) {
setTitle(message.data.title);
}
break;

case 'appError':
setError(message.data.error);
break;

default:
// toast.error(`Unknown app message type: ${message.data.type}`, {
// id: 'unknown-message-type',
// });
console.warn(
`Unknown app message type on message: ${JSON.stringify(
message.data,
)}`,
);
}
} catch {}
} catch (e: any) {
toast.error(
`Error when receiving app message: ${String(e)}\ndata:\n${
message.data
}`,
{ id: 'unknown-message-type' },
);
}
};

window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [router, setTitle]);
}, [id, setTitle, setRoute]);

React.useEffect(() => {
setTitle(id);

if (!iframeRef.current) return;
const iframe = iframeRef.current;

Expand Down Expand Up @@ -150,7 +201,6 @@ function AppWindowContent({ id, setTitle }: AppWindowProps) {

<iframe
ref={iframeRef}
src={`http://localhost:9010/api/app-resources/${id}/base${subRoute}`}
style={{ border: '0', flexGrow: 1, width: '100%', height: '100%' }}
/>
</>
Expand All @@ -162,5 +212,11 @@ function AppWindowContent({ id, setTitle }: AppWindowProps) {
export function AppWindow(props: AppWindowProps) {
const numRestarts = useIsMutating({ mutationKey: ['RestartApp'] });

return <AppWindowContent key={String(props.id) + numRestarts} {...props} />;
return (
<AppWindowContent
key={String(props.id) + numRestarts}
{...props}
route={!!props.route ? props.route : '/'}
/>
);
}
114 changes: 114 additions & 0 deletions frontend/pages/app-settings/[id]/settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useRouter } from 'next/router';
import { AppToolbar } from '../../../components/AppToolbar';
import { Settings } from '../../../components/Settings';
import { z } from 'zod';
import { useRpcMutation, useRpcQuery } from '../../../hooks/useRpcQuery';
import { Button } from '../../../components/Button';
import { ArrowLeftIcon } from '@primer/octicons-react';
import { toast } from 'react-hot-toast';
import { Alert } from '../../../components/Alert';
import { Spinner } from '../../../components/Spinner';
import { useQueryClient } from '@tanstack/react-query';
import Head from 'next/head';

export default function AppSettings() {
const router = useRouter();
const id = typeof router.query.id === 'string' ? router.query.id : null;

const {
data: appSettings,
error: errLoadingAppSettings,
isLoading,
} = useRpcQuery({
method: 'GetAppSettingsById',
pathPrefix: '/api/apps/rpc',
data: { appId: id },
result: z.record(z.string(), z.unknown()),
});

const queryClient = useQueryClient();
const { mutate: updateAppSettings } = useRpcMutation({
method: 'UpdateAppSettings',
pathPrefix: '/api/apps/rpc',
result: z.record(z.string(), z.unknown()),

onSuccess: () => {
toast.success('Updated app settings');
router.push(`/app/${id}`);
queryClient.invalidateQueries(['GetAppSettingsById']);
},
onError: (err) => {
toast.error(`Failed to update app settings: ${String(err)}`);
},
});

if (!id) {
return null;
}
return (
<>
<Head>
<title>{id} Settings</title>
</Head>

<div className="full">
<AppToolbar
appId={id}
actions={
<>
<Button
size="sm"
variant="primary"
onClick={() => router.push(`/app/${id}`)}
>
<span style={{ marginRight: '.5rem' }}>
<ArrowLeftIcon />
</span>
Back
</Button>
</>
}
/>

<div className={'full robin-pad'}>
<>
{isLoading && (
<div
className="full"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<p style={{ display: 'flex', alignItems: 'center' }}>
<Spinner />
<span style={{ marginLeft: '.5rem' }}>Loading...</span>
</p>
</div>
)}
{errLoadingAppSettings && (
<Alert variant="error" title={'Failed to load app settings'}>
{String(errLoadingAppSettings)}
</Alert>
)}
{appSettings && (
<Settings
schema={z.unknown()}
isLoading={false}
error={undefined}
value={appSettings}
onChange={(value) =>
updateAppSettings({
appId: id,
settings: value,
})
}
/>
)}
</>
</div>
</div>
</>
);
}
Loading