Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 55 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,75 +59,103 @@ Originally offered as a standalone premium platform at https://nuxt.studio, Stud

> **Note**: This alpha release provides both a Monaco code editor and a TipTap visual WYSIWYG editor for Markdown content. You can switch between them at any time.

### 1. Module Installation
### 1. Install

Install the module in your Nuxt application with one command:
Install the module in your Nuxt application:

```bash
npx nuxi module add nuxt-studio@alpha
```

Add it to your `nuxt.config` and configure your repository.
### 2. Configure

Add it to your `nuxt.config.ts` and configure your repository:

```ts
export default defineNuxtConfig({
modules: [
'@nuxt/content',
'nuxt-studio'
],

studio: {
// Your configuration
// Studio admin route (default: '/_studio')
route: '/_studio',

// Git repository configuration (owner and repo are required)
repository: {
provider: 'github', // default: only GitHub supported currently
owner: 'your-username', // your GitHub owner
repo: 'your-repo', // your GitHub repository name
branch: 'main',
rootDir: '' // optional: location of your content app
provider: 'github', // 'github' or 'gitlab'
owner: 'your-username', // your GitHub/GitLab username or organization
repo: 'your-repo', // your repository name
branch: 'main', // the branch to commit to (default: main)
}
}
})
```

### 2. Create a GitHub OAuth App
### 3. Dev Mode

🚀 **That's all you need to enable Studio locally!**

Run your Nuxt app and navigate to `/_studio` to start editing. Any file changes will be synchronized in real time with the file system.

> **Note**: The publish system is only available in production mode. Use your classical workflow (IDE, CLI, GitHub Desktop...) to publish your changes locally.

### 4. Production Mode

To enable publishing directly from your production website, you need to configure OAuth authentication.

#### Create a GitHub OAuth App

1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click **"New OAuth App"**
3. Fill in the application details:
- **Application name**: Your App Name
- **Homepage URL**: Your website homepage URL
- **Authorization callback URL**: `${YOUR_WEBSITE_URL}/${options.route}/auth/github` (default: `${YOUR_WEBSITE_URL}/_studio/auth/github`)
- **Authorization callback URL**: `${YOUR_WEBSITE_URL}/_studio/auth/github`
4. Copy the **Client ID** and generate a **Client Secret**
5. Add them to your deployment environment variables (see next section)

### 3. Environment Variables

Nuxt Studio requires environment variables for authentication and publication on your repository.

Add the previsously generated Client ID and Client Secret to your deployment environment variables.
5. Add them to your deployment environment variables:

```bash
STUDIO_GITHUB_CLIENT_ID=your_github_client_id
STUDIO_GITHUB_CLIENT_SECRET=your_github_client_secret
```

## Configuration
> **Note**: GitLab is also supported. See the [providers documentation](https://content.nuxt.com/docs/studio/providers) for setup instructions.

#### Deployment

Nuxt Studio requires server-side routes for authentication. Your site must be **deployed on a platform that supports SSR** using `nuxt build`.

If you want to pre-render all your pages, use hybrid rendering:

```ts
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/'],
crawlLinks: true
}
}
})
```

Configure Nuxt Studio in your `nuxt.config.ts`:
## Configuration Options

```ts
export default defineNuxtConfig({
modules: ['nuxt-studio'],
studio: {
// Studio admin login route
route: '/_studio', // default

// Git repository configuration (required)
// Git repository configuration
repository: {
provider: 'github', // only GitHub is supported currently (default)
owner: 'your-username', // your GitHub owner
repo: 'your-repo', // your GitHub repository name
branch: 'main', // your GitHub branch
rootDir: '' // optional: root directory for
provider: 'github', // 'github' or 'gitlab' (default: 'github')
owner: 'your-username', // your GitHub/GitLab owner (required)
repo: 'your-repo', // your repository name (required)
branch: 'main', // branch to commit to (default: 'main')
rootDir: '', // subdirectory for monorepos (default: '')
private: true, // request access to private repos (default: true)
},
}
})
Expand Down
10 changes: 10 additions & 0 deletions src/app/src/components/content/ContentEditorTipTap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { tiptapToMDC } from '../../utils/tiptap/tiptapToMdc'
import { getStandardToolbarItems, getStandardSuggestionItems, standardNuxtUIComponents, computeStandardDragActions, removeLastEmptyParagraph } from '../../utils/tiptap/editor'
import { Element } from '../../utils/tiptap/extensions/element'
import { ImagePicker } from '../../utils/tiptap/extensions/image-picker'
import { VideoPicker } from '../../utils/tiptap/extensions/video-picker'
import { Video } from '../../utils/tiptap/extensions/video'
import { Slot } from '../../utils/tiptap/extensions/slot'
import { Frontmatter } from '../../utils/tiptap/extensions/frontmatter'
import { CodeBlock } from '../../utils/tiptap/extensions/code-block'
Expand Down Expand Up @@ -118,6 +120,12 @@ const customHandlers = computed(() => ({
isActive: (editor: Editor) => editor.isActive('image-picker'),
isDisabled: undefined,
},
video: {
canExecute: (editor: Editor) => editor.can().insertContent({ type: 'video-picker' }),
execute: (editor: Editor) => editor.chain().focus().insertContent({ type: 'video-picker' }),
isActive: (editor: Editor) => editor.isActive('video-picker'),
isDisabled: undefined,
},
...Object.fromEntries(
componentItems.value.map(item => [
item.kind,
Expand Down Expand Up @@ -175,6 +183,8 @@ const toolbarItems = computed(() => getStandardToolbarItems(t))
:extensions="[
Frontmatter,
ImagePicker,
VideoPicker,
Video,
Element,
InlineElement,
Slot,
Expand Down
98 changes: 72 additions & 26 deletions src/app/src/components/shared/ModalMediaPicker.vue
Original file line number Diff line number Diff line change
@@ -1,41 +1,51 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useStudio } from '../../composables/useStudio'
import { isImageFile } from '../../utils/file'
import { isImageFile, isVideoFile } from '../../utils/file'
import { Image } from '@unpic/vue'
import type { TreeItem } from '../../types'
import { StudioFeature } from '../../types'

const { mediaTree, context } = useStudio()

defineProps<{ open: boolean }>()
const props = defineProps<{ open: boolean, type: 'image' | 'video' }>()

const emit = defineEmits<{
select: [image: TreeItem]
cancel: []
}>()

const imageFiles = computed(() => {
const images: TreeItem[] = []
const isValidFileType = (item: TreeItem) => {
if (props.type === 'image') {
return isImageFile(item.fsPath)
}
if (props.type === 'video') {
return isVideoFile(item.fsPath)
}
return false
}

const collectImages = (items: TreeItem[]) => {
const mediaFiles = computed(() => {
const medias: TreeItem[] = []

const collectMedias = (items: TreeItem[]) => {
for (const item of items) {
if (item.type === 'file' && isImageFile(item.fsPath)) {
images.push(item)
if (item.type === 'file' && isValidFileType(item)) {
medias.push(item)
}
if (item.children) {
collectImages(item.children)
collectMedias(item.children)
}
}
}

collectImages(mediaTree.root.value)
collectMedias(mediaTree.root.value)

return images
return medias
})

const handleImageSelect = (image: TreeItem) => {
emit('select', image)
const handleMediaSelect = (media: TreeItem) => {
emit('select', media)
}

const handleUpload = () => {
Expand All @@ -47,21 +57,21 @@ const handleUpload = () => {
<template>
<UModal
:open="open"
:title="$t('studio.mediaPicker.title')"
:description="$t('studio.mediaPicker.description')"
:title="$t(`studio.mediaPicker.${type}.title`)"
:description="$t(`studio.mediaPicker.${type}.description`)"
@update:open="(value: boolean) => !value && emit('cancel')"
>
<template #body>
<div
v-if="imageFiles.length === 0"
v-if="mediaFiles.length === 0"
class="text-center py-4 text-muted"
>
<UIcon
name="i-lucide-image-off"
:name="type === 'image' ? 'i-lucide-image-off' : 'i-lucide-video-off'"
class="size-8 mx-auto mb-2"
/>
<p class="text-sm">
{{ $t('studio.mediaPicker.noImagesAvailable') }}
{{ $t(`studio.mediaPicker.${type}.notAvailable`) }}
</p>
</div>

Expand All @@ -70,23 +80,59 @@ const handleUpload = () => {
class="grid grid-cols-3 gap-4"
>
<button
v-for="image in imageFiles"
:key="image.fsPath"
class="aspect-square rounded-lg overflow-hidden border border-default hover:border-accented hover:ring-1 hover:ring-accented transition-all cursor-pointer group relative"
@click="handleImageSelect(image)"
v-for="media in mediaFiles"
:key="media.fsPath"
class="aspect-square rounded-lg cursor-pointer group relative"
@click="handleMediaSelect(media)"
>
<!-- Image Preview -->
<div
class="w-full h-full"
v-if="type === 'image'"
class="w-full h-full overflow-hidden rounded-lg border border-default hover:border-muted hover:ring-1 hover:ring-muted transition-all"
style="background: repeating-linear-gradient(45deg, #d4d4d8 0 12px, #a1a1aa 0 24px), repeating-linear-gradient(-45deg, #a1a1aa 0 12px, #d4d4d8 0 24px); background-blend-mode: overlay; background-size: 24px 24px;"
>
<Image
:src="image.routePath || image.fsPath"
:src="media.routePath || media.fsPath"
width="200"
height="200"
:alt="image.name"
class="w-full h-full object-cover"
:alt="media.name"
class="w-full h-full object-cover rounded-lg group-hover:scale-105 transition-transform duration-300 ease-out"
/>
</div>

<!-- Video Preview -->
<div
v-else
class="w-full h-full bg-linear-to-br from-neutral-900 via-neutral-800 to-neutral-900 flex flex-col items-center justify-center relative overflow-hidden rounded-lg"
>
<!-- Decorative film strip pattern -->
<div class="absolute inset-y-0 left-0 w-3 bg-neutral-950 flex flex-col justify-around py-1">
<div
v-for="i in 6"
:key="i"
class="w-1.5 h-2 bg-neutral-700 mx-auto rounded-sm"
/>
</div>
<div class="absolute inset-y-0 right-0 w-3 bg-neutral-950 flex flex-col justify-around py-1">
<div
v-for="i in 6"
:key="i"
class="w-1.5 h-2 bg-neutral-700 mx-auto rounded-sm"
/>
</div>

<div class="size-14 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center group-hover:bg-white/20 group-hover:scale-110 transition-all duration-300 shadow-lg">
<UIcon
name="i-lucide-video"
class="size-7 text-white ml-0.5"
/>
</div>

<!-- Filename -->
<p class="absolute bottom-0 inset-x-0 text-[10px] text-neutral-300 truncate px-4 py-2 bg-linear-to-t from-black/60 to-transparent text-center font-medium">
{{ media.name }}
</p>
</div>
</button>
</div>
</template>
Expand All @@ -97,7 +143,7 @@ const handleUpload = () => {
icon="i-lucide-upload"
@click="handleUpload"
>
{{ $t('studio.mediaPicker.upload') }}
{{ $t(`studio.mediaPicker.${type}.upload`) }}
</UButton>
</template>
</UModal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const handleCancel = () => {
<NodeViewWrapper>
<ModalMediaPicker
:open="isOpen"
type="image"
@select="handleImageSelect"
@cancel="handleCancel"
/>
Expand Down
25 changes: 25 additions & 0 deletions src/app/src/components/tiptap/extension/TiptapExtensionVideo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<template>
<NodeViewWrapper as="div">
<div
:contenteditable="false"
class="relative group rounded-md overflow-hidden"
:ui="{ body: { padding: '' } }"
>
<video
:src="props.src"
controls
class="my-0 w-full"
/>
</div>
<NodeViewContent as="span" />
</NodeViewWrapper>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { nodeViewProps, NodeViewWrapper, NodeViewContent } from '@tiptap/vue-3'

const nodeProps = defineProps(nodeViewProps)

const props = computed(() => nodeProps.node.attrs?.props || {})
</script>
Loading
Loading