Skip to content

Commit a31325e

Browse files
committed
refactor: breakdown stores/playground
1 parent 9c90ef4 commit a31325e

13 files changed

+264
-239
lines changed

README.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,19 @@ The development server will be running at [http://localhost:3000](http://localho
4343
- [ ] Content
4444
- [ ] Allow each guide to configure file filter
4545
- [ ] Persist user changes when toggling solutions
46-
- [ ] Only make necessary changes when navigating between guides
4746
- [ ] Verification for tutorial tasks
4847
- [ ] Search feature
4948
- [ ] Embedded Nuxt Docs (update CORS headers)
49+
- [x] Only make necessary changes when navigating between guides
5050
- [x] Switch playgrounds on different guides
5151
- [x] Allow each guide to toggle features
5252
- [x] Solution for each guide
5353
- [x] A button of "Edit this page"
54+
- [ ] SEO
55+
- [ ] OG Image
56+
- [ ] Meta tags
57+
- [ ] Sitemap
58+
- [ ] RSS
5459
- [ ] Command K System
5560
- [ ] About Page
5661
- [ ] Welcome Screen

components/PanelEditor.vue

+2-8
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,15 @@ import { filesToVirtualFsTree } from '~/templates/utils'
44
55
const play = usePlaygroundStore()
66
const ui = useUiState()
7+
const guide = useGuideStore()
78
89
const files = computed(() => Array.from(play.files.values()).filter(file => !isFileIgnored(file.filepath)))
910
const directory = computed(() => filesToVirtualFsTree(files.value))
1011
1112
const input = ref<string>('')
1213
1314
watch(
14-
() => play.fileSelected,
15-
() => {
16-
input.value = play.fileSelected?.read() || ''
17-
},
18-
)
19-
20-
watch(
21-
() => [play.mountedGuide, play.showingSolution],
15+
() => [play.fileSelected, guide.currentGuide, guide.showingSolution],
2216
() => {
2317
input.value = play.fileSelected?.read() || ''
2418
},

components/PanelPreview.vue

+18-13
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
<script setup lang="ts">
22
const play = usePlaygroundStore()
3+
const preview = usePreviewStore()
34
45
const inputUrl = ref<string>('')
56
const inner = ref<{ iframe?: HTMLIFrameElement | undefined }>()
67
78
// auto update inputUrl when location value changed
8-
syncRef(computed(() => play.previewLocation.fullPath), inputUrl, { direction: 'ltr' })
9+
syncRef(
10+
computed(() => preview.location.fullPath),
11+
inputUrl,
12+
{ direction: 'ltr' },
13+
)
914
1015
function refreshIframe() {
11-
play.updatePreviewUrl()
12-
if (play.previewUrl && inner.value?.iframe) {
13-
inner.value.iframe.src = play.previewUrl
14-
inputUrl.value = play.previewLocation.fullPath
16+
preview.updateUrl()
17+
if (preview.url && inner.value?.iframe) {
18+
inner.value.iframe.src = preview.url
19+
inputUrl.value = preview.location.fullPath
1520
}
1621
}
1722
@@ -25,8 +30,8 @@ watch(
2530
)
2631
2732
function navigate() {
28-
play.previewLocation.fullPath = inputUrl.value
29-
play.updatePreviewUrl()
33+
preview.location.fullPath = inputUrl.value
34+
preview.updateUrl()
3035
const activeElement = document.activeElement
3136
if (activeElement instanceof HTMLElement)
3237
activeElement.blur()
@@ -50,7 +55,7 @@ function navigate() {
5055
mx-auto min-w-100 w-full rounded bg-faded px2 text-sm
5156
border="base 1 hover:gray-500/30"
5257
:class="{
53-
'pointer-events-none': !play.previewUrl,
58+
'pointer-events-none': !preview.url,
5459
}"
5560
>
5661
<form w-full @submit.prevent="navigate">
@@ -61,7 +66,7 @@ function navigate() {
6166
</form>
6267
<div flex="~ items-center justify-end">
6368
<button
64-
v-if="play.previewUrl"
69+
v-if="preview.url"
6570
mx1 op-75 hover:op-100
6671
@click="refreshIframe"
6772
>
@@ -85,22 +90,22 @@ function navigate() {
8590
<div flex="~ gap-2 items-center">
8691
Vue version:
8792
<div
88-
v-if="!play.clientInfo?.versionVue"
93+
v-if="!preview.clientInfo?.versionVue"
8994
i-svg-spinners-90-ring-with-bg
9095
/>
9196
<code v-else>
92-
v{{ play.clientInfo.versionVue }}
97+
v{{ preview.clientInfo.versionVue }}
9398
</code>
9499
</div>
95100
<div i-simple-icons-nuxtdotjs text-xl />
96101
<div flex="~ gap-2 items-center">
97102
Nuxt version:
98103
<div
99-
v-if="!play.clientInfo?.versionNuxt"
104+
v-if="!preview.clientInfo?.versionNuxt"
100105
i-svg-spinners-90-ring-with-bg
101106
/>
102107
<code v-else>
103-
v{{ play.clientInfo.versionNuxt }}
108+
v{{ preview.clientInfo.versionNuxt }}
104109
</code>
105110
</div>
106111
</div>

components/PanelPreviewClient.client.vue

+5-4
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import type { FrameFunctions, ParentFunctions } from '~/types/rpc'
55
const ui = useUiState()
66
const play = usePlaygroundStore()
77
const colorMode = useColorMode()
8+
const preview = usePreviewStore()
89
910
const iframe = ref<HTMLIFrameElement>()
1011
1112
const rpc = createBirpc<FrameFunctions, ParentFunctions>({
1213
onNavigate(path) {
13-
play.previewLocation.fullPath = path
14+
preview.location.fullPath = path
1415
},
1516
async onReady(info) {
1617
play.status = 'ready'
17-
play.clientInfo = info
18+
preview.clientInfo = info
1819
syncColorMode()
1920
},
2021
}, {
@@ -52,9 +53,9 @@ defineExpose({
5253

5354
<template>
5455
<iframe
55-
v-if="play.previewUrl"
56+
v-if="preview.url"
5657
ref="iframe"
57-
:src="play.previewUrl"
58+
:src="preview.url"
5859
:style="play.status === 'ready' ? '' : 'opacity: 0.001; pointer-events: none;'"
5960
:class="{ 'pointer-events-none': ui.isPanelDragging }"
6061
absolute inset-0 h-full w-full bg-transparent allow="geolocation; microphone; camera; payment; autoplay; serial; cross-origin-isolated"

components/TheNav.vue

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
const ui = useUiState()
33
const play = usePlaygroundStore()
4+
const guide = useGuideStore()
45
const runtime = useRuntimeConfig()
56
67
const repo = 'https://github.com/nuxt/learn.nuxt.com'
@@ -16,17 +17,17 @@ const timeAgo = useTimeAgo(buildTime)
1617

1718
<div flex-auto />
1819
<button
19-
v-if="play.mountedGuide?.solutions"
20-
@click="play.mountGuide(play.mountedGuide, !play.showingSolution)"
20+
v-if="guide.currentGuide?.solutions"
21+
@click="guide.toggleSolutions()"
2122
>
2223
Toggle Solution
2324
</button>
2425
<button
25-
v-if="play.status === 'ready' && play.features.download !== false"
26+
v-if="play.status === 'ready' && guide.features.download !== false"
2627
rounded p2
2728
hover="bg-active"
2829
title="Download as ZIP"
29-
@click="play.downloadZip()"
30+
@click="downloadZip(play.webcontainer!)"
3031
>
3132
<div i-ph-download-duotone text-2xl />
3233
</button>
@@ -50,7 +51,6 @@ const timeAgo = useTimeAgo(buildTime)
5051
</template>
5152
</VDropdown>
5253
<button
53-
v-if="play.features.terminal !== false"
5454
rounded p2
5555
title="Toggle terminal"
5656
hover="bg-active"

composables/download.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { WebContainer } from '@webcontainer/api'
2+
3+
export async function downloadZip(wc: WebContainer) {
4+
if (!import.meta.client)
5+
return
6+
7+
const { default: JSZip } = await import('jszip')
8+
const zip = new JSZip()
9+
10+
type Zip = typeof zip
11+
12+
const crawlFiles = async (dir: string, zip: Zip) => {
13+
const files = await wc.fs.readdir(dir, { withFileTypes: true })
14+
15+
await Promise.all(
16+
files.map(async (file) => {
17+
if (isFileIgnored(file.name))
18+
return
19+
20+
if (file.isFile()) {
21+
// TODO: If it's package.json, we modify to remove some fields
22+
const content = await wc.fs.readFile(`${dir}/${file.name}`, 'utf8')
23+
zip.file(file.name, content)
24+
}
25+
else if (file.isDirectory()) {
26+
const folder = zip.folder(file.name)!
27+
return crawlFiles(`${dir}/${file.name}`, folder)
28+
}
29+
}),
30+
)
31+
}
32+
33+
await crawlFiles('.', zip)
34+
35+
const blob = await zip.generateAsync({ type: 'blob' })
36+
const url = URL.createObjectURL(blob)
37+
const date = new Date()
38+
const dateString = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`
39+
const link = document.createElement('a')
40+
link.href = url
41+
// TODO: have a better name with the current tutorial name
42+
link.download = `nuxt-playground-${dateString}.zip`
43+
link.click()
44+
link.remove()
45+
URL.revokeObjectURL(url)
46+
}

package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@
5959
"typescript": "^5.3.3",
6060
"vue-tsc": "^1.8.27"
6161
},
62-
"resolutions": {
63-
"shikiji": "^0.9.12"
64-
},
6562
"pnpm": {
6663
"patchedDependencies": {
6764
6865
}
66+
},
67+
"resolutions": {
68+
"shikiji": "^0.9.12"
6969
}
7070
}

pages/[...slug].vue

+7-11
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,24 @@
11
<script setup lang="ts">
22
const router = useRouter()
3-
const play = usePlaygroundStore()
3+
const guide = useGuideStore()
44
55
const templatesMap = Object.fromEntries(
66
Object.entries(import.meta.glob('~/content/**/.template/index.ts'))
77
.map(([key, loader]) => [
88
key
99
.replace(/^\/content/, '')
1010
.replace(/\/\.template\/index\.ts$/, '')
11-
.replace(/\/\d+\./g, '/') || '',
11+
.replace(/\/\d+\./g, '/'),
1212
loader,
1313
]),
1414
)
1515
16-
if (process.dev)
17-
// eslint-disable-next-line no-console
18-
console.log('templates', Object.keys(templatesMap))
19-
2016
async function mount(path: string) {
21-
path = path.replace(/\/$/, '')
22-
if (templatesMap[path])
23-
play.mountGuide(await templatesMap[path]().then((m: any) => m.meta))
24-
else
25-
play.mountGuide() // unmount previous guide
17+
path = path.replace(/\/$/, '') // remove trailing slash
18+
await guide.mount(
19+
await templatesMap[path]?.().then((m: any) => m.meta),
20+
false,
21+
)
2622
}
2723
2824
router.afterEach(async (to) => {

stores/guide.ts

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Raw } from 'vue'
2+
import type { GuideMeta, PlaygroundFeatures } from '~/types/guides'
3+
4+
export const useGuideStore = defineStore('guide', () => {
5+
const play = usePlaygroundStore()
6+
const ui = useUiState()
7+
const preview = usePreviewStore()
8+
9+
const features = ref<PlaygroundFeatures>({})
10+
const currentGuide = shallowRef<Raw<GuideMeta>>()
11+
const showingSolution = ref(false)
12+
13+
watch(features, () => {
14+
if (features.value.fileTree === true) {
15+
if (ui.panelFileTree <= 0)
16+
ui.panelFileTree = 20
17+
}
18+
else if (features.value.fileTree === false) {
19+
ui.panelFileTree = 0
20+
}
21+
22+
if (features.value.terminal === true)
23+
ui.showTerminal = true
24+
else if (features.value.terminal === false)
25+
ui.showTerminal = false
26+
})
27+
28+
async function mount(guide?: GuideMeta, withSolution = false) {
29+
await play.init
30+
31+
// eslint-disable-next-line no-console
32+
console.log('mounting guide', guide)
33+
34+
await play.mount({
35+
...guide?.files,
36+
...withSolution ? guide?.solutions : {},
37+
})
38+
39+
play.fileSelected = play.files.get(guide?.startingFile || 'app.vue')
40+
preview.location.fullPath = guide?.startingUrl || '/'
41+
preview.updateUrl()
42+
43+
features.value = guide?.features || {}
44+
currentGuide.value = guide
45+
showingSolution.value = withSolution
46+
47+
return undefined
48+
}
49+
50+
async function toggleSolutions() {
51+
await mount(currentGuide.value, !showingSolution.value)
52+
}
53+
54+
return {
55+
mount,
56+
toggleSolutions,
57+
features,
58+
currentGuide,
59+
showingSolution,
60+
}
61+
})
62+
63+
if (import.meta.hot)
64+
import.meta.hot.accept(acceptHMRUpdate(useGuideStore, import.meta.hot))

0 commit comments

Comments
 (0)