feat: add 'Open in StackBlitz' button to header (#10)
This commit is contained in:
15
packages/bolt/app/components/header/Header.tsx
Normal file
15
packages/bolt/app/components/header/Header.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { OpenStackBlitz } from './OpenStackBlitz.client';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="flex items-center bg-white p-4 border-b border-gray-200 h-[var(--header-height)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-semibold text-accent">Bolt</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<ClientOnly>{() => <OpenStackBlitz />}</ClientOnly>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import path from 'path';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import sdk from '@stackblitz/sdk';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import { workbenchStore, type ArtifactState } from '~/lib/stores/workbench';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import type { ActionState } from '~/lib/runtime/action-runner';
|
||||
|
||||
// return false if some file-writing actions haven't completed
|
||||
const fileActionsComplete = (actions: Record<string, ActionState>) => {
|
||||
return !Object.values(actions).some((action) => action.type === 'file' && action.status !== 'complete');
|
||||
};
|
||||
|
||||
// extract relative path and content from file, wrapped in array for flatMap use
|
||||
const extractContent = ([file, value]: [string, FileMap[string]]) => {
|
||||
// ignore directory entries
|
||||
if (!value || value.type !== 'file') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const relative = path.relative(WORK_DIR, file);
|
||||
const parts = relative.split(path.sep);
|
||||
|
||||
// ignore hidden files
|
||||
if (parts.some((part) => part.startsWith('.'))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[relative, value.content]];
|
||||
};
|
||||
|
||||
// subscribe to changes in first artifact's runner actions
|
||||
const useFirstArtifact = (): [boolean, ArtifactState] => {
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const artifacts = useStore(workbenchStore.artifacts);
|
||||
const firstArtifact = artifacts[workbenchStore.artifactList[0]];
|
||||
|
||||
const handleActionChange = useCallback(
|
||||
(actions: Record<string, ActionState>) => setHasLoaded(fileActionsComplete(actions)),
|
||||
[firstArtifact],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstArtifact) {
|
||||
return firstArtifact.runner.actions.subscribe(handleActionChange);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [firstArtifact]);
|
||||
|
||||
return [hasLoaded, firstArtifact];
|
||||
};
|
||||
|
||||
export const OpenStackBlitz = memo(() => {
|
||||
const [artifactLoaded, artifact] = useFirstArtifact();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// extract relative path and content from files map
|
||||
const workbenchFiles = workbenchStore.files.get();
|
||||
const files = Object.fromEntries(Object.entries(workbenchFiles).flatMap(extractContent));
|
||||
|
||||
// we use the first artifact's title for the StackBlitz project
|
||||
const { title } = artifact;
|
||||
|
||||
sdk.openProject({
|
||||
title,
|
||||
template: 'node',
|
||||
files,
|
||||
});
|
||||
}, [artifact]);
|
||||
|
||||
if (!artifactLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<a onClick={handleClick} className="cursor-pointer">
|
||||
<img alt="Open in StackBlitz" src="https://developer.stackblitz.com/img/open_in_stackblitz.svg" />
|
||||
</a>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user