diff --git a/playground/docus/content/2.studio/2.setup.md b/playground/docus/content/2.studio/2.setup.md deleted file mode 100644 index 9c26426c..00000000 --- a/playground/docus/content/2.studio/2.setup.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -title: Setup -description: Learn how to install and configure Nuxt Studio to edit your content in production with GitHub authentication. -navigation: - title: Setup - icon: i-lucide-settings -seo: - title: Nuxt Studio Setup Guide - description: Learn how to install and configure the self-hosted Nuxt Studio module for your Nuxt Content website. Edit content in production with GitHub authentication. ---- - -::tip{to="https://nuxt.studio/docs/setup"} -This documentation covers only the new open-source Nuxt Studio module. -Click here to view the documentation for the legacy standalone platform. -:: - -## Install - -Add the Nuxt Studio module to your project: - -::code-group -```bash [pnpm] -pnpm add nuxt-studio -``` - -```bash [npm] -npm install nuxt-studio -``` - -```bash [yarn] -yarn add nuxt-studio -``` - -```bash [bun] -bun add nuxt-studio -``` -:: - -Alternatively, use the Nuxt CLI to automatically add the module: - -```bash -npx nuxi module add nuxt-studio -``` - -## Configure - -Add the module to your `nuxt.config.ts` and configure your GitHub repository: - -```ts [nuxt.config.ts] -export default defineNuxtConfig({ - modules: [ - '@nuxt/content', - 'nuxt-studio' - ], - - studio: { - // Studio admin route (default: '/_studio') - route: '/_studio', - - // GitHub repository configuration (required) - repository: { - provider: 'github', // only GitHub is currently supported - owner: 'your-username', // your GitHub username or organization - repo: 'your-repo', // your repository name - branch: 'main', // the branch to commit to - rootDir: '', // optional: if your Nuxt app is in a subdirectory - private: true // optional: whether the repository is private or public (default: true) - } - } -}) -``` - -::prose-tip -If your Nuxt Content application is in a monorepo or subdirectory, specify the `rootDir` option to point to the correct location. -:: - -## Create GitHub OAuth App - -Nuxt Studio uses GitHub OAuth for authentication. - -::prose-steps -> If you changed the `route` option in your config, update the callback URL accordingly (the route set instead of `/studio`) - -### Navigate to GitHub Developer Settings - -Go to [GitHub Developer Settings](https://github.com/settings/developers) and click **New OAuth App** - -### Configure the OAuth Application - -Fill in the required fields: - -- **Application name**: Your app name -- **Homepage URL**: Your production website homepage URL -- **Authorization callback URL**: `https://yourdomain.com/_studio/auth/github`:tip [If you want to try studio on project running in local, you can simply set the callback url to your local url `http://localhost:3000`.] - -### Copy Your Credentials - -After creating the OAuth app, you'll receive: - -- A **Client ID** (visible immediately) -- A **Client Secret** (click **Generate a new client secret**) - -### Set your environment Variables - -Add the GitHub OAuth credentials to your deployment platform's environment variables or in your `.env` file in local - -```bash [.env] -STUDIO_GITHUB_CLIENT_ID=your_github_client_id -STUDIO_GITHUB_CLIENT_SECRET=your_github_client_secret -``` -:: - -## Deployment - -::warning -The new Nuxt Studio module requires a server-side route for authentication. -While static generation remains supported with [Nuxt hybrid rendering](https://nuxt.com/docs/4.x/guide/concepts/rendering#route-rules), your site must be **deployed on a platform that supports server-side rendering (SSR)**. -:: - -### Accessing Studio - -After deployment, access the Studio interface by navigating to your configured route (default: `/_studio`): - -1. Click **Login with GitHub** if it does not directly redirect to the OAuth app authorization page -2. Authorize the OAuth application -3. You'll be redirected back to Studio, ready to edit your content - -::prose-note -Secure OAuth-based login with **Google** should be available quickly in the Beta release. -:: - -## Development mode - -Nuxt Studio includes an **experimental** development mode that enables real-time file system synchronization: - -```ts [nuxt.config.ts] -export default defineNuxtConfig({ - studio: { - development: { - sync: true // Enable development mode - } - } -}) -``` - -When enabled, Nuxt Studio will: - -- ✅ Write changes directly to your local `content/` directory -- ✅ Write media changes to your local `public/` directory -- ❌ Listen for file system changes and update the editor -- ❌ Commit changes to your repository (use your classical workflow instead) diff --git a/src/app/src/components/AppFooter.vue b/src/app/src/components/AppFooter.vue index 33c0dc97..b2c98a39 100644 --- a/src/app/src/components/AppFooter.vue +++ b/src/app/src/components/AppFooter.vue @@ -2,9 +2,10 @@ import { computed } from 'vue' import { useStudio } from '../composables/useStudio' import { useStudioState } from '../composables/useStudioState' +import type { DropdownMenuItem } from '@nuxt/ui' const { ui, host, git } = useStudio() -const { preferences, updatePreference, unsetActiveLocation } = useStudioState() +const { devMode, preferences, updatePreference, unsetActiveLocation } = useStudioState() const user = host.user.get() // const showTechnicalMode = computed({ @@ -16,19 +17,19 @@ const user = host.user.get() const repositoryUrl = computed(() => git.getBranchUrl()) const userMenuItems = computed(() => [ - [ - // [{ - // slot: 'view-mode' as const, - // } - repositoryUrl.value - ? { + repositoryUrl.value + ? [ + // [{ + // slot: 'view-mode' as const, + // } + { label: `${host.repository.owner}/${host.repository.repo}`, icon: 'i-simple-icons:github', to: repositoryUrl.value, target: '_blank', - } - : undefined, - ].filter(Boolean), + }, + ] + : undefined, [{ label: 'Sign out', icon: 'i-lucide-log-out', @@ -38,7 +39,7 @@ const userMenuItems = computed(() => [ }) }, }], -]) +].filter(Boolean) as DropdownMenuItem[][]) function closeStudio() { unsetActiveLocation() @@ -50,7 +51,14 @@ function closeStudio() {
+ + Using local filesystem + diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index 9f22dc94..337d363a 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -234,6 +234,10 @@ export const useContext = createSharedComposable(( actionInProgress.value = null } + function switchFeature(feature: StudioFeature) { + router.push(`/${feature}`) + } + return { currentFeature, activeTree, @@ -246,5 +250,6 @@ export const useContext = createSharedComposable(( draftCount, isDraftInProgress, unsetActionInProgress, + switchFeature, } }) diff --git a/src/app/src/composables/useDraftBase.ts b/src/app/src/composables/useDraftBase.ts index 0d35cec3..a3eed97f 100644 --- a/src/app/src/composables/useDraftBase.ts +++ b/src/app/src/composables/useDraftBase.ts @@ -2,12 +2,12 @@ import type { Storage } from 'unstorage' import { joinURL } from 'ufo' import type { DraftItem, StudioHost, GithubFile, DatabaseItem, MediaItem, BaseItem } from '../types' import { ContentFileExtension } from '../types' -import { studioFlags } from './useStudio' import { DraftStatus } from '../types/draft' import { checkConflict, findDescendantsFromFsPath } from '../utils/draft' import type { useGit } from './useGit' import { useHooks } from './useHooks' import { ref } from 'vue' +import { useStudioState } from './useStudioState' export function useDraftBase( type: 'media' | 'document', @@ -25,6 +25,7 @@ export function useDraftBase( const areDocumentsEqual = host.document.utils.areEqual const hooks = useHooks() + const { devMode } = useStudioState() async function get(fsPath: string): Promise | undefined> { return list.value.find(item => item.fsPath === fsPath) as DraftItem @@ -73,40 +74,45 @@ export function useDraftBase( await storage.removeItem(fsPath) await hostDb.delete(fsPath) - let deleteDraftItem: DraftItem | null = null - if (existingDraftItem) { - if (existingDraftItem.status === DraftStatus.Deleted) return + if (!devMode.value) { + let deleteDraftItem: DraftItem | null = null + if (existingDraftItem) { + if (existingDraftItem.status === DraftStatus.Deleted) return - if (existingDraftItem.status === DraftStatus.Created) { - list.value = list.value.filter(item => item.fsPath !== fsPath) + if (existingDraftItem.status === DraftStatus.Created) { + list.value = list.value.filter(item => item.fsPath !== fsPath) + } + else { + // TODO: check if gh file has been updated + const githubFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GithubFile + + deleteDraftItem = { + fsPath: existingDraftItem.fsPath, + status: DraftStatus.Deleted, + original: existingDraftItem.original, + githubFile, + } + + list.value = list.value.map(item => item.fsPath === fsPath ? deleteDraftItem! : item) as DraftItem[] + } } else { + // TODO: check if gh file has been updated + const githubFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GithubFile + deleteDraftItem = { - fsPath: existingDraftItem.fsPath, + fsPath, status: DraftStatus.Deleted, - original: existingDraftItem.original, - githubFile: existingDraftItem.githubFile, + original: originalDbItem, + githubFile, } - list.value = list.value.map(item => item.fsPath === fsPath ? deleteDraftItem! : item) as DraftItem[] + list.value.push(deleteDraftItem) } - } - else { - // TODO: check if gh file has been updated - const githubFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GithubFile - - deleteDraftItem = { - fsPath, - status: DraftStatus.Deleted, - original: originalDbItem, - githubFile, - } - - list.value.push(deleteDraftItem) - } - if (deleteDraftItem) { - await storage.setItem(fsPath, deleteDraftItem) + if (deleteDraftItem) { + await storage.setItem(fsPath, deleteDraftItem) + } } if (rerender) { @@ -215,7 +221,7 @@ export function useDraftBase( } function getStatus(modified: BaseItem, original: BaseItem): DraftStatus { - if (studioFlags.dev) { + if (devMode.value) { return DraftStatus.Pristine } diff --git a/src/app/src/composables/useStudio.ts b/src/app/src/composables/useStudio.ts index 5e6742e6..76edecaa 100644 --- a/src/app/src/composables/useStudio.ts +++ b/src/app/src/composables/useStudio.ts @@ -7,20 +7,20 @@ import { useDraftMedias } from './useDraftMedias' import { ref } from 'vue' import { useTree } from './useTree' import type { RouteLocationNormalized } from 'vue-router' -import type { StudioHost, GitOptions, DatabaseItem } from '../types' +import type { GitOptions, DatabaseItem } from '../types' import { StudioFeature } from '../types' import { documentStorage, mediaStorage, nullStorageDriver } from '../utils/storage' import { useHooks } from './useHooks' import { useStudioState } from './useStudioState' -export const studioFlags = { - dev: false, -} - export const useStudio = createSharedComposable(() => { const isReady = ref(false) const host = window.useStudioHost() - studioFlags.dev = host.meta.dev + const { devMode, enableDevMode, preferences, setManifestId } = useStudioState() + + if (host.meta.dev) { + enableDevMode() + } const gitOptions: GitOptions = { owner: host.repository.owner, @@ -32,8 +32,7 @@ export const useStudio = createSharedComposable(() => { authorEmail: host.user.get().email, } - const git = studioFlags.dev ? useDevelopmentGit(gitOptions) : useGit(gitOptions) - const { preferences, setManifestId } = useStudioState() + const git = devMode.value ? useDevelopmentGit(gitOptions) : useGit(gitOptions) const ui = useUI(host) const draftDocuments = useDraftDocuments(host, git) const documentTree = useTree(StudioFeature.Content, host, draftDocuments) @@ -42,8 +41,8 @@ export const useStudio = createSharedComposable(() => { const context = useContext(host, git, documentTree, mediaTree) host.on.mounted(async () => { - if (studioFlags.dev) { - initDevelopmentMode(host, documentTree, mediaTree) + if (devMode.value) { + initDevelopmentMode() } await draftDocuments.load() @@ -80,7 +79,8 @@ export const useStudio = createSharedComposable(() => { } }) -function initDevelopmentMode(host: StudioHost, documentTree: ReturnType, mediaTree: ReturnType) { +function initDevelopmentMode() { + const { host, documentTree, mediaTree, context, ui } = useStudio() const hooks = useHooks() // Disable browser storages @@ -129,4 +129,13 @@ function initDevelopmentMode(host: StudioHost, documentTree: ReturnType { + if (context.currentFeature.value !== StudioFeature.Content) { + context.switchFeature(StudioFeature.Content) + } + + documentTree.selectItemByFsPath(fsPath) + ui.open() + }) } diff --git a/src/app/src/composables/useStudioState.ts b/src/app/src/composables/useStudioState.ts index 624efb37..91643b3a 100644 --- a/src/app/src/composables/useStudioState.ts +++ b/src/app/src/composables/useStudioState.ts @@ -4,6 +4,7 @@ import type { StudioConfig, StudioLocation } from '../types' import { StudioFeature } from '../types/context' export const useStudioState = createSharedComposable(() => { + const devMode = ref(false) const manifestId = ref('') const preferences = useStorage('studio-preferences', { syncEditorAndRoute: true, showTechnicalMode: false }) const location = useStorage('studio-active', { active: false, feature: StudioFeature.Content, fsPath: '/' }) @@ -20,14 +21,20 @@ export const useStudioState = createSharedComposable(() => { manifestId.value = id } + function enableDevMode() { + devMode.value = true + } + function updatePreference(key: K, value: StudioConfig[K]) { preferences.value = { ...preferences.value, [key]: value } } return { + devMode: readonly(devMode), manifestId: readonly(manifestId), preferences: readonly(preferences), location: readonly(location), + enableDevMode, setLocation, unsetActiveLocation, setManifestId, diff --git a/src/app/src/composables/useTree.ts b/src/app/src/composables/useTree.ts index 99071a75..67d35959 100644 --- a/src/app/src/composables/useTree.ts +++ b/src/app/src/composables/useTree.ts @@ -10,7 +10,7 @@ import { useStudioState } from './useStudioState' export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType) => { const hooks = useHooks() - const { preferences, setLocation } = useStudioState() + const { preferences, setLocation, devMode } = useStudioState() const tree = ref([]) @@ -101,7 +101,7 @@ export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType const hostDb = type === StudioFeature.Content ? host.document.db : host.media const list = await hostDb.list() as DatabaseItem[] - tree.value = buildTree(list, draft.list.value) + tree.value = buildTree(list, draft.list.value, devMode.value) // Reselect current item to update status if (selectItem) { diff --git a/src/app/src/types/index.ts b/src/app/src/types/index.ts index a2a46ec8..155871f8 100644 --- a/src/app/src/types/index.ts +++ b/src/app/src/types/index.ts @@ -30,6 +30,7 @@ export interface StudioHost { manifestUpdate: (fn: (id: string) => void) => void documentUpdate: (fn: (fsPath: string, type: 'remove' | 'update') => void) => void mediaUpdate: (fn: (fsPath: string, type: 'remove' | 'update') => void) => void + requestDocumentEdit: (fn: (fsPath: string) => void) => void } ui: { colorMode: 'light' | 'dark' diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index 61aa7b91..e99d1a3c 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -6,7 +6,6 @@ import { } from '../types' import type { RouteLocationNormalized } from 'vue-router' import type { BaseItem } from '../types/item' -import { studioFlags } from '../composables/useStudio' import { getFileExtension, parseName } from './file' export const COLOR_STATUS_MAP: { [key in TreeStatus]?: string } = { @@ -25,7 +24,7 @@ export const COLOR_UI_STATUS_MAP: { [key in TreeStatus]?: string } = { [TreeStatus.Opened]: 'neutral', } as const -export function buildTree(dbItems: BaseItem[], draftList: DraftItem[] | null): +export function buildTree(dbItems: BaseItem[], draftList: DraftItem[] | null, isDev = false): TreeItem[] { const tree: TreeItem[] = [] const directoryMap = new Map() @@ -156,7 +155,7 @@ TreeItem[] { directoryChildren.push(fileItem) } - calculateDirectoryStatuses(tree) + calculateDirectoryStatuses(tree, isDev) return tree } @@ -283,8 +282,8 @@ export function findDescendantsFileItemsFromFsPath(tree: TreeItem[], fsPath: str return descendants } -function calculateDirectoryStatuses(items: TreeItem[]) { - if (studioFlags.dev) { +function calculateDirectoryStatuses(items: TreeItem[], isDev = false) { + if (isDev) { return } @@ -293,7 +292,7 @@ function calculateDirectoryStatuses(items: TreeItem[]) { continue } - calculateDirectoryStatuses(item.children) + calculateDirectoryStatuses(item.children, isDev) const childrenWithStatus = item.children.filter(child => child.status && child.status !== TreeStatus.Opened) diff --git a/src/module/src/module.ts b/src/module/src/module.ts index cbe7fb4f..2af8456b 100644 --- a/src/module/src/module.ts +++ b/src/module/src/module.ts @@ -71,8 +71,12 @@ interface ModuleOptions { } /** * Enable Nuxt Studio to edit content and media files on your filesystem. - * Currently experimental. - * @experimental + */ + dev: boolean + /** + * Enable Nuxt Studio to edit content and media files on your filesystem. + * + * @deprecated Use the 'dev' option instead. */ development?: { sync?: boolean @@ -103,16 +107,14 @@ export default defineNuxtModule({ clientSecret: process.env.STUDIO_GITHUB_CLIENT_SECRET, }, }, - development: { - sync: false, - }, + dev: true, }, async setup(options, nuxt) { const resolver = createResolver(import.meta.url) const runtime = (...args: string[]) => resolver.resolve('./runtime', ...args) - if (!nuxt.options.dev) { - options.development!.sync = false + if (nuxt.options.dev === false || options.development?.sync === false) { + options.dev = false } if (!nuxt.options.dev && !nuxt.options._prepare) { @@ -130,8 +132,8 @@ export default defineNuxtModule({ nuxt.options.runtimeConfig.public.studio = { route: options.route!, + dev: Boolean(options.dev), development: { - sync: Boolean(options.development!.sync), server: process.env.STUDIO_DEV_SERVER, }, // @ts-expect-error Autogenerated type does not match with options @@ -176,7 +178,7 @@ export default defineNuxtModule({ ] }) - if (options.development!.sync) { + if (options.dev) { nuxt.options.nitro.storage = { ...nuxt.options.nitro.storage, nuxt_studio_content: { @@ -185,7 +187,7 @@ export default defineNuxtModule({ }, nuxt_studio_public_assets: { driver: 'fs', - base: resolve(nuxt.options.srcDir, 'public'), + base: resolve(nuxt.options.rootDir, 'public'), }, } addServerHandler({ @@ -220,7 +222,7 @@ export default defineNuxtModule({ }) addTemplate({ filename: 'studio-public-assets.mjs', - getContents: () => options.development!.sync + getContents: () => options.dev ? getAssetsStorageDevTemplate(assetsStorage, nuxt) : getAssetsStorageTemplate(assetsStorage, nuxt), }) diff --git a/src/module/src/runtime/host.dev.ts b/src/module/src/runtime/host.dev.ts index 6e5a41fc..13a5d6e2 100644 --- a/src/module/src/runtime/host.dev.ts +++ b/src/module/src/runtime/host.dev.ts @@ -12,7 +12,7 @@ import { getCollectionSourceById } from './utils/source' export function useStudioHost(user: StudioUser, repository: Repository) { const host = useStudioHostBase(user, repository) - if (!useRuntimeConfig().public.studio.development.sync) { + if (!useRuntimeConfig().public.studio.dev) { return host } diff --git a/src/module/src/runtime/host.ts b/src/module/src/runtime/host.ts index 0a64d85e..3b6c70fc 100644 --- a/src/module/src/runtime/host.ts +++ b/src/module/src/runtime/host.ts @@ -142,6 +142,10 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH mediaUpdate: (_fn: (id: string, type: 'remove' | 'update') => void) => { // no operation }, + requestDocumentEdit: (fn: (fsPath: string) => void) => { + // @ts-expect-error studio:document:edit is not defined in types + useNuxtApp().hooks.hook('studio:document:edit', fn) + }, }, ui: { colorMode, @@ -347,6 +351,27 @@ export function useStudioHost(user: StudioUser, repository: Repository): StudioH } return meta.fetch() }) + + document.body.addEventListener('dblclick', (event: MouseEvent) => { + let element = event.target as HTMLElement + while (element) { + if (element.getAttribute('data-content-id')) { + break + } + element = element.parentElement as HTMLElement + } + if (element) { + const id = element.getAttribute('data-content-id')! + const collection = getCollectionById(id, useContentCollections()) + const source = getCollectionSourceById(id, collection.source) + const fsPath = generateFsPathFromId(id, source!) + + // @ts-expect-error studio:document:edit is not defined in types + useNuxtApp().hooks.callHook('studio:document:edit', fsPath) + } + }) + // Initialize styles + host.ui.updateStyles() })() return host diff --git a/src/module/src/runtime/server/routes/dev/public/[...path].ts b/src/module/src/runtime/server/routes/dev/public/[...path].ts index 8b7066fe..b224da49 100644 --- a/src/module/src/runtime/server/routes/dev/public/[...path].ts +++ b/src/module/src/runtime/server/routes/dev/public/[...path].ts @@ -1,6 +1,7 @@ import type { H3Event } from 'h3' import { createError, eventHandler, getRequestHeader, readRawBody, setResponseHeader } from 'h3' import type { Storage, StorageMeta } from 'unstorage' +import { withLeadingSlash } from 'ufo' // @ts-expect-error useStorage is not defined in .nuxt/imports.d.ts import { useStorage } from '#imports' @@ -30,6 +31,7 @@ export default eventHandler(async (event) => { extension: key.split('.').pop(), stem: key.split('.').join('.'), path: '/' + key.replace(/:/g, '/'), + fsPath: withLeadingSlash(key.replace(/:/g, '/')), version: new Date(item.mtime || new Date()).getTime(), } } diff --git a/src/module/src/runtime/utils/activation.ts b/src/module/src/runtime/utils/activation.ts index 71d4aead..274c10a4 100644 --- a/src/module/src/runtime/utils/activation.ts +++ b/src/module/src/runtime/utils/activation.ts @@ -5,6 +5,17 @@ export async function defineStudioActivationPlugin(onStudioActivation: (user: St const user = useState('studio-session', () => null) const config = useRuntimeConfig().public.studio + if (config.dev) { + return await onStudioActivation({ + provider: 'github', + email: 'dev@nuxt.com', + name: 'Dev', + githubToken: '', + githubId: '', + avatar: '', + }) + } + await $fetch<{ user: StudioUser }>('/__nuxt_studio/auth/session').then((session) => { user.value = session?.user ?? null })