Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(admin): admin improvements #524

Merged
merged 18 commits into from Jul 12, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Expand Up @@ -69,6 +69,7 @@
"defu": "^5.0.0",
"detab": "^3.0.0",
"directory-tree": "^2.2.9",
"fast-glob": "^3.2.6",
"flat": "^5.0.2",
"graceful-fs": "^4.2.6",
"gray-matter": "^4.0.3",
Expand Down Expand Up @@ -115,7 +116,8 @@
"vue-docgen-api": "^4.40.0",
"vue-plausible": "^1.1.4",
"vue3": "npm:vue@next",
"vue3-router": "npm:vue-router@next"
"vue3-router": "npm:vue-router@next",
"windicss-analysis": "^0.3.4"
},
"devDependencies": {
"@iconify/json": "^1.1.360",
Expand Down
65 changes: 65 additions & 0 deletions src/admin/api/functions/components.ts
@@ -0,0 +1,65 @@
import { promises as fs } from 'fs'
import { join, extname } from 'path'
import { createError, Middleware, useBody } from 'h3'
import dirTree from 'directory-tree'
import { FileData, File } from '../../type'
import { normalizeFiles, r } from '../utils'

export default <Middleware>async function componentsHandler(req) {
const url = req.url

if (req.method === 'GET') {
// List all files in components/
if (url === '/') {
const tree = dirTree(r('components'))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will most likely come in a second step, but I think we need these functions to go a little bit deeper concerning components detection.

I like the idea of having a distinction between "user-level" components and "project-level" ones.

But I think we want users to discover all the components they have access to from that UI, and so I think we should try to refer to .nuxt/components/index.js (this is generated once the Nuxt server starts) to get a list of the current project components.

We could show the editor in read-only mode for these components that are outside the user-level scope, and ask to the user if they want to duplicate it in their own components/ directory once he tries to edit it?

I know that @pi0 told me .nuxt will be removed in Nuxt 3, so maybe we could postpone that task an open an issue about this instead of searching for a fix in this branch.

WDYT @Atinux @pi0 ? 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use components:extend hook also to read list of discovered components and filter based on level property

return normalizeFiles(tree.children, r('components'))
}
// Read a single content file
try {
const path = join(r('components'), url)
const file = await fs.readFile(path, 'utf-8')

return <File>{
path: path.replace(r('components'), ''),
extension: extname(path),
raw: file
}
} catch (err) {
return createError({
statusCode: 404,
statusMessage: 'File not found'
})
}
}

// Update changes
if (req.method === 'PUT') {
const { raw } = await useBody<FileData>(req)
if (raw == null) {
return createError({
statusCode: 400,
statusMessage: '"raw" key is required'
})
}

const path = join(r('components'), url)

try {
// @ts-ignore
// await fs.stat(path, 'utf-8')
await fs.writeFile(path, raw)

return { ok: true }
} catch (err) {
return createError({
statusCode: 404,
statusMessage: 'File not found'
})
}
}

return createError({
statusCode: 400,
statusMessage: 'Bad Request'
})
}
49 changes: 49 additions & 0 deletions src/admin/api/functions/config.ts
@@ -0,0 +1,49 @@
import { parse } from 'path'
import fs from 'fs-extra'
import { createError, Middleware, useBody } from 'h3'
import { FileData } from '../../type'
import { r } from '../utils'

export default <Middleware>async function configHandler(req) {
const root = r()
let path = [r('nuxt.config.ts'), r('nuxt.config.js')].find(i => fs.existsSync(i))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is nice to see we can edit nuxt.config from there too.

I think the point of that config editor is to edit docus.config instead.

Should only need to update that reference and/or to add a new route for it. 😄

Maybe we could support both, wdyt @Atinux ?

const exist = Boolean(path)
path = path || r('nuxt.config.ts')

if (req.method === 'GET') {
// List all files in content/
antfu marked this conversation as resolved.
Show resolved Hide resolved
return {
path: path.replace(root, ''),
exist,
extension: parse(path).ext,
raw: exist ? await fs.readFile(path, 'utf-8') : ''
}
}

// Update changes
antfu marked this conversation as resolved.
Show resolved Hide resolved
if (req.method === 'PUT') {
const { raw } = await useBody<FileData>(req)
if (raw == null) {
return createError({
statusCode: 400,
statusMessage: '"raw" key is required'
})
}

try {
await fs.writeFile(path, raw)

return { ok: true }
} catch (err) {
return createError({
statusCode: 404,
statusMessage: 'File not found'
})
}
}

return createError({
statusCode: 400,
statusMessage: 'Bad Request'
})
}
4 changes: 4 additions & 0 deletions src/admin/api/index.ts
Expand Up @@ -2,11 +2,15 @@ import { createApp } from 'h3'
import contentHandler from './functions/content'
import previewHandler from './functions/preview'
import staticHandler from './functions/static'
import componentsHandler from './functions/components'
import configHandler from './functions/config'

const app = createApp()

app.useAsync('/content', contentHandler)
app.useAsync('/preview', previewHandler)
app.useAsync('/static', staticHandler)
app.useAsync('/components', componentsHandler)
app.useAsync('/config', configHandler)

export default app._handle
18 changes: 17 additions & 1 deletion src/admin/app/components/AppHeaderNav.vue
Expand Up @@ -9,11 +9,27 @@
<RouterLink
to="/static"
class="relative flex-none px-4 py-1 text-sm font-medium leading-5 d-border border rounded-md"
:class="[$route.path.includes('static') ? 'router-link-active' : '']"
:class="[$route.path.startsWith('/static') ? 'router-link-active' : '']"
>
Static
</RouterLink>

<RouterLink
to="/components"
class="relative flex-none px-4 py-1 text-sm font-medium leading-5 d-border border rounded-md"
:class="[$route.path.startsWith('/components') ? 'router-link-active' : '']"
>
Components
</RouterLink>

<RouterLink
to="/config"
class="relative flex-none px-4 py-1 text-sm font-medium leading-5 d-border border rounded-md"
:class="[$route.path.startsWith('/config') ? 'router-link-active' : '']"
>
Config
</RouterLink>

<div class="flex-auto"></div>

<button class="p-1 opacity-50 hover:opacity-100 !outline-none" @click="toggleDark">
Expand Down
17 changes: 14 additions & 3 deletions src/admin/app/components/Editor.vue
@@ -1,10 +1,10 @@
<template>
<Monaco :value="raw" language="markdown" @change="update" />
<Monaco :value="raw" :language="language" @change="update" />
</template>

<script setup lang="ts">
import type { PropType } from 'vue3'
import { ref, watch, defineProps } from 'vue3'
import { ref, watch, defineProps, computed } from 'vue3'
import type { File } from '../../type'
import { useApi } from '../plugins/api'
import Monaco from './Monaco.vue'
Expand All @@ -13,12 +13,23 @@ const props = defineProps({
file: {
type: Object as PropType<File>,
required: true
},
apiEntry: {
type: String,
default: '/content'
}
})

const api = useApi()
const raw = ref(props.file.raw)

const language = computed(() => {
if (props.file.extension === '.vue') return 'html'
if (props.file.extension === '.ts') return 'javascript'
if (props.file.extension === '.js') return 'javascript'
return 'markdown'
})

// Sync local data when file changes
watch(
() => props.file,
Expand All @@ -28,7 +39,7 @@ watch(
)

function update(content) {
api.put(`/content${props.file.path}`, {
api.put(props.apiEntry + props.file.path, {
raw: content
})
}
Expand Down
17 changes: 15 additions & 2 deletions src/admin/app/components/Preview.vue
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onBeforeMount, ref, watch } from 'vue3'
import { onPreviewNavigated } from '../composables/content'
import { fetchPreviewOrigin, previewOrigin, previewUrl, previewPath } from '../composables/preview'

const iframe = ref<HTMLIFrameElement>()
Expand All @@ -18,9 +19,10 @@ watch(

function updateIframe(url: string) {
if (!iframe.value) return
if (!url.startsWith(previewOrigin.value)) return updateIframeHard(url)
if (!url.startsWith(previewOrigin.value) || previewPath.value.startsWith('/admin')) return updateIframeHard(url)

try {
// use nuxt router
iframe.value.contentWindow.$nuxt.$router.push(previewPath.value)
} catch (e) {
// fallback to hard refresh when working with cross-origin
Expand All @@ -41,6 +43,17 @@ function onUrlInput() {

updateIframe(url.value)
}

function onIframeLoad() {
try {
iframe.value.contentWindow.$nuxt.$router.afterEach(to => {
previewPath.value = to.path
onPreviewNavigated(to.path)
})
} catch (e) {
console.warn(e)
}
}
</script>

<template>
Expand All @@ -58,6 +71,6 @@ function onUrlInput() {
<heroicons-outline:external-link class="m-auto" />
</a>
</div>
<iframe ref="iframe" :src="previewOrigin" class="w-full h-full" />
<iframe ref="iframe" :src="previewOrigin" class="w-full h-full" @load="onIframeLoad" />
</div>
</template>
37 changes: 37 additions & 0 deletions src/admin/app/composables/content.ts
@@ -0,0 +1,37 @@
import { ref } from 'vue3'
import { useApi } from '../plugins/api'
import { File } from '../../type'
import { getRoutePath, navigateToFile } from './preview'

const api = useApi()

export const files = ref<File[]>([])
export const currentFile = ref(null)

export const openFile = async (file: File) => {
if (currentFile.value?.path === file.path) return

navigateToFile(file.path)
currentFile.value = await api.get(`/content${file.path}`)
}

export async function fetchFiles() {
files.value = await api.get('/content')
}

export function findFileFromRoute(routePath: string, fileList: File[] = files.value) {
for (const file of fileList) {
if (getRoutePath(file.path) === routePath) {
return file
}
if (file.children) {
const result = findFileFromRoute(routePath, file.children)
if (result) return result
}
}
}

export function onPreviewNavigated(routePath: string) {
const file = findFileFromRoute(routePath)
if (file) openFile(file)
}
5 changes: 2 additions & 3 deletions src/admin/app/composables/monaco.ts
Expand Up @@ -41,7 +41,8 @@ const setupMonaco = createSingletonPromise(async () => {
getWorker(_: any, label: string) {
if (label === 'json') return new JsonWorker()
if (label === 'css' || label === 'scss' || label === 'less') return new CssWorker()
if (label === 'html' || label === 'handlebars' || label === 'razor') return new HtmlWorker()
if (label === 'html' || label === 'handlebars' || label === 'razor' || label === 'vue')
return new HtmlWorker()
if (label === 'typescript' || label === 'javascript') return new TsWorker()
return new EditorWorker()
}
Expand Down Expand Up @@ -110,10 +111,8 @@ export function useMonaco(

isSetup.value = true

// const plugins = editorPlugins.filter(({ language }) => language === options.language)
editor.getModel()?.onDidChangeContent(() => {
options.onChanged?.(editor.getValue())
// plugins.forEach(({ onContentChanged }) => onContentChanged(editor))
})
},
{
Expand Down
10 changes: 7 additions & 3 deletions src/admin/app/composables/preview.ts
Expand Up @@ -13,9 +13,13 @@ export async function fetchPreviewOrigin() {
previewOrigin.value = url
}

export function navigateToFile(filepath: string) {
previewPath.value = filepath
.replace(/\/\d+\./g, '/')
export function getRoutePath(filepath: string) {
return filepath
.replace(/\/\d+\./g, '/') // remove digit index
.replace(/\.md$/, '')
.replace(/\/index$/, '/')
}

export function navigateToFile(filepath: string) {
previewPath.value = getRoutePath(filepath)
}
42 changes: 42 additions & 0 deletions src/admin/app/pages/components.vue
@@ -0,0 +1,42 @@
<template>
<Splitpanes class="h-full default-theme" :push-other-panes="false">
<Pane size="15" max-size="30">
<div class="h-full overflow-y-scroll">
<FilesTree :files="files" :current-file="currentFile" @open="openFile" />
</div>
</Pane>
<Pane>
<Editor v-if="currentFile" :file="currentFile" api-entry="/components" />
<p v-else class="p-4 opacity-75">👈 &nbsp;Select a file to edit.</p>
</Pane>
<Pane>
<KeepAlive>
<Preview />
</KeepAlive>
</Pane>
</Splitpanes>
</template>

<script setup lang="ts">
import { Splitpanes, Pane } from 'splitpanes'
import { ref, onMounted } from 'vue3'
import FilesTree from '../components/FilesTree.vue'
import Editor from '../components/Editor.vue'
import Preview from '../components/Preview.vue'
import { useApi } from '../plugins/api'
import { navigateToFile } from '../composables/preview'

const api = useApi()

const files = ref([])
const currentFile = ref(null)

const openFile = async file => {
navigateToFile(`/__components${file.path}`)
currentFile.value = await api.get(`/components${file.path}`)
}

onMounted(async () => {
files.value = await api.get('/components')
})
</script>