Skip to content

Commit e3a86bd

Browse files
feat: implement new project template selection and setup
1 parent 74b682b commit e3a86bd

20 files changed

Lines changed: 675 additions & 470 deletions

File tree

packages/cli-bundle/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import './volume';
22

33
import path from 'node:path';
4+
import type stream from 'node:stream';
45
import {
56
createVitePlugin,
67
build as vivliostyleBuild,
@@ -213,7 +214,7 @@ export async function setupTemplate(options: VivliostyleInlineConfig) {
213214
},
214215
stdout: {
215216
write: () => true,
216-
} as any,
217+
} as unknown as stream.Writable,
217218
cwd: '/workdir',
218219
logLevel: 'debug',
219220
projectPath: '.',

packages/ui/src/custom/stacked-radio.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
44
import type * as React from 'react';
55

6+
import { Loader2 } from '../icon';
67
import { Label } from '../label';
78
import { cn } from '../lib/utils';
89
import { RadioGroupItem } from '../radio';
@@ -23,16 +24,28 @@ export function StackedRadioGroup({
2324
export function StackedRadioGroupItem({
2425
className,
2526
children,
27+
isLoading,
2628
...props
27-
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
29+
}: React.ComponentProps<typeof RadioGroupPrimitive.Item> & {
30+
isLoading?: boolean;
31+
}) {
2832
return (
2933
<Label
3034
className={cn(
3135
'cursor-pointer rounded-md transition-colors px-4 py-3 border border-input bg-background text-sm hover:bg-accent hover:text-accent-foreground has-checked:border-accent-foreground',
3236
className,
3337
)}
3438
>
35-
<RadioGroupItem {...props} />
39+
{isLoading && (
40+
<span className="size-4 shrink-0 *:size-full">
41+
<Loader2
42+
className="animate-spin stroke-foreground"
43+
strokeWidth={2.5}
44+
aria-hidden="true"
45+
/>
46+
</span>
47+
)}
48+
<RadioGroupItem className={cn(isLoading && 'hidden')} {...props} />
3649
<div className="grid gap-1">{children}</div>
3750
</Label>
3851
);
598 KB
Binary file not shown.
579 Bytes
Binary file not shown.

packages/viola/src/components/layout.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { useRouter } from '@tanstack/react-router';
22
import { invariant } from 'outvariant';
3-
import { Suspense, useCallback, useEffect, useState } from 'react';
3+
import { useCallback, useEffect, useState } from 'react';
44
import { useSnapshot } from 'valtio';
55

66
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@v/ui/dialog';
77
import { SidebarProvider, SidebarTrigger } from '@v/ui/sidebar';
88
import { $ui } from '../stores/accessors';
99
import type { PaneContent } from '../stores/proxies/ui';
10-
import { IframeSandbox } from './iframe-sandbox';
1110
import { Pane, panes } from './pane';
1211
import { PaneContext } from './panes/util';
12+
import { SandboxPortal } from './sandbox-portal';
1313
import { SideMenu } from './side-menu';
1414

1515
function DedicatedModal() {
@@ -102,9 +102,7 @@ export function Layout(_: { children?: React.ReactNode }) {
102102
</div>
103103

104104
<DedicatedModal />
105-
<Suspense>
106-
<IframeSandbox />
107-
</Suspense>
105+
<SandboxPortal />
108106
</SidebarProvider>
109107
);
110108
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { LANGUAGES } from '@vivliostyle/cli/constants';
2+
import { useMolecule } from 'bunshi/react';
3+
import { useCallback, useState } from 'react';
4+
import { useSnapshot } from 'valtio';
5+
6+
import {
7+
Command,
8+
CommandEmpty,
9+
CommandGroup,
10+
CommandInput,
11+
CommandItem,
12+
CommandList,
13+
} from '@v/ui/command';
14+
import {
15+
StackedRadioGroup,
16+
StackedRadioGroupItem,
17+
} from '@v/ui/custom/stacked-radio';
18+
import { ChevronDownIcon } from '@v/ui/icon';
19+
import { Input } from '@v/ui/input';
20+
import { cn } from '@v/ui/lib/utils';
21+
import { Popover, PopoverContent, PopoverTrigger } from '@v/ui/popover';
22+
import { useLiveInputField } from '../../../hooks/use-live-field';
23+
import { $draftProject } from '../../../stores/accessors';
24+
import { setupProjectFromDraft } from '../../../stores/actions/setup-project-from-draft';
25+
import type { ProjectId } from '../../../stores/proxies/project';
26+
import { Theme } from '../../../stores/proxies/theme';
27+
import { TemplateStoreMolecule } from './store';
28+
29+
function BookTitleInput({ children }: React.PropsWithChildren) {
30+
const inputProps = useLiveInputField(
31+
() => $draftProject.valueOrThrow().bibliography.title,
32+
{
33+
onSave: (value) => {
34+
const title = value.trim();
35+
$draftProject.valueOrThrow().bibliography.title = title;
36+
return title;
37+
},
38+
},
39+
);
40+
41+
return (
42+
<label className="grid gap-2">
43+
{children}
44+
<div>
45+
<Input type="text" name="bookTitle" {...inputProps} />
46+
</div>
47+
</label>
48+
);
49+
}
50+
51+
function AuthorInput({ children }: React.PropsWithChildren) {
52+
const inputProps = useLiveInputField(
53+
() => $draftProject.valueOrThrow().bibliography.author,
54+
{
55+
onSave: (value) => {
56+
const author = value.trim();
57+
$draftProject.valueOrThrow().bibliography.author = author;
58+
return author;
59+
},
60+
},
61+
);
62+
63+
return (
64+
<label className="grid gap-2">
65+
{children}
66+
<div>
67+
<Input type="text" name="author" {...inputProps} />
68+
</div>
69+
</label>
70+
);
71+
}
72+
73+
function LanguageSelect({ children }: React.PropsWithChildren) {
74+
const snap = useSnapshot($draftProject).valueOrThrow();
75+
const [open, setOpen] = useState(false);
76+
77+
const handleSelect = useCallback(
78+
(value: string) => {
79+
setOpen(false);
80+
$draftProject.valueOrThrow().bibliography.language = value;
81+
},
82+
[snap],
83+
);
84+
85+
return (
86+
<div className="grid gap-2">
87+
{children}
88+
<Popover open={open} onOpenChange={setOpen}>
89+
{/** biome-ignore lint/a11y/useSemanticElements: Combobox with search */}
90+
<PopoverTrigger
91+
role="combobox"
92+
className={cn(
93+
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
94+
'w-xs',
95+
)}
96+
>
97+
{LANGUAGES[snap.bibliography.language as keyof typeof LANGUAGES] ||
98+
'Select language'}
99+
<ChevronDownIcon className="ml-2 size-4 shrink-0 opacity-50" />
100+
</PopoverTrigger>
101+
<PopoverContent className="w-xs p-0">
102+
<Command>
103+
<CommandInput placeholder="Search language..." />
104+
<CommandList>
105+
<CommandEmpty>No language found.</CommandEmpty>
106+
<CommandGroup>
107+
{Object.entries(LANGUAGES).map(([code, name]) => (
108+
<CommandItem key={code} value={code} onSelect={handleSelect}>
109+
{name}
110+
<span aria-hidden="true" className="text-muted-foreground">
111+
({code})
112+
</span>
113+
</CommandItem>
114+
))}
115+
</CommandGroup>
116+
</CommandList>
117+
</Command>
118+
</PopoverContent>
119+
</Popover>
120+
</div>
121+
);
122+
}
123+
124+
function ThemeSelect({ children }: React.PropsWithChildren) {
125+
const snap = useSnapshot($draftProject).valueOrThrow();
126+
127+
const handleSelect = useCallback((value: string) => {
128+
$draftProject.valueOrThrow().theme.packageName = value;
129+
}, []);
130+
131+
return (
132+
<div className="grid gap-2">
133+
{children}
134+
<StackedRadioGroup
135+
className="grid-cols-2"
136+
value={snap.theme.packageName}
137+
onValueChange={handleSelect}
138+
>
139+
{Object.entries(Theme.officialThemes).map(([value, { title }]) => (
140+
<StackedRadioGroupItem key={value} value={value}>
141+
{title}
142+
</StackedRadioGroupItem>
143+
))}
144+
</StackedRadioGroup>
145+
</div>
146+
);
147+
}
148+
149+
export function ProjectDetailForm({ children }: React.PropsWithChildren) {
150+
const { templateStoreProxy } = useMolecule(TemplateStoreMolecule);
151+
152+
return (
153+
<form
154+
className="contents"
155+
onSubmit={async (e) => {
156+
e.preventDefault();
157+
if (!templateStoreProxy.selected) {
158+
return;
159+
}
160+
await setupProjectFromDraft({
161+
projectId: 'alpha-v1' as ProjectId,
162+
templateValue: templateStoreProxy.selected,
163+
});
164+
}}
165+
>
166+
<section className="grid gap-4">
167+
<h3 className="text-xl font-bold">Project Details</h3>
168+
169+
<p className="text-sm">
170+
All fields are optional and can be changed later in project settings.
171+
</p>
172+
173+
<BookTitleInput>
174+
<span className="text-l font-bold">Book title</span>
175+
</BookTitleInput>
176+
177+
<AuthorInput>
178+
<span className="text-l font-bold">Author</span>
179+
</AuthorInput>
180+
181+
<LanguageSelect>
182+
<span className="text-l font-bold">Language</span>
183+
</LanguageSelect>
184+
185+
<ThemeSelect>
186+
<span className="text-l font-bold">Theme</span>
187+
</ThemeSelect>
188+
</section>
189+
190+
{children}
191+
</form>
192+
);
193+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { createScope, molecule, use } from 'bunshi';
2+
import { invariant } from 'outvariant';
3+
import { proxy } from 'valtio';
4+
5+
import { $draftProject } from '../../../stores/accessors';
6+
import { Sandbox } from '../../../stores/proxies/sandbox';
7+
8+
export const NewProjectPaneScope = createScope(undefined);
9+
10+
export const TemplateStoreMolecule = molecule(() => {
11+
use(NewProjectPaneScope);
12+
13+
const templateStoreProxy = proxy({
14+
selected: undefined as string | undefined,
15+
installTemplatePromise: undefined as Promise<void> | undefined,
16+
17+
selectTemplate(value: string) {
18+
const project = $draftProject.valueOrThrow();
19+
const template =
20+
Sandbox.officialTemplates[
21+
value as keyof typeof Sandbox.officialTemplates
22+
];
23+
invariant(template, 'Template not found: %s', value);
24+
this.selected = value;
25+
this.installTemplatePromise = (async () => {
26+
const sandbox = await project.sandboxPromise;
27+
const cli = await sandbox.cli.createRemotePromise();
28+
await cli.setupTemplate({
29+
title: 'Title',
30+
author: 'Author',
31+
language: 'en',
32+
template: template.source,
33+
theme: false,
34+
});
35+
})();
36+
return this.installTemplatePromise;
37+
},
38+
});
39+
40+
return { templateStoreProxy };
41+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useMolecule } from 'bunshi/react';
2+
import { useTransition } from 'react';
3+
import { useSnapshot } from 'valtio';
4+
5+
import {
6+
StackedRadioGroup,
7+
StackedRadioGroupItem,
8+
} from '@v/ui/custom/stacked-radio';
9+
import { Sandbox } from '../../../stores/proxies/sandbox';
10+
import { TemplateStoreMolecule } from './store';
11+
12+
export function TemplateSelectForm() {
13+
const { templateStoreProxy } = useMolecule(TemplateStoreMolecule);
14+
const snap = useSnapshot(templateStoreProxy);
15+
const [isPending, startTransition] = useTransition();
16+
17+
return (
18+
<form className="contents">
19+
<div className="grid gap-4">
20+
<h3 className="text-xl font-bold">Choose Template</h3>
21+
<StackedRadioGroup
22+
className="grid-cols-2"
23+
value={snap.selected}
24+
onValueChange={(value) => {
25+
startTransition(() => templateStoreProxy.selectTemplate(value));
26+
}}
27+
>
28+
{Object.entries(Sandbox.officialTemplates).map(
29+
([value, { title, description }]) => (
30+
<StackedRadioGroupItem
31+
key={value}
32+
value={value}
33+
disabled={isPending}
34+
isLoading={isPending && snap.selected === value}
35+
>
36+
{title}
37+
<p className="text-xs text-muted-foreground">{description}</p>
38+
</StackedRadioGroupItem>
39+
),
40+
)}
41+
</StackedRadioGroup>
42+
</div>
43+
</form>
44+
);
45+
}

0 commit comments

Comments
 (0)