Skip to content
Draft
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
226 changes: 226 additions & 0 deletions docs/app/components/content/AIContentWidget.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<script lang="ts">
export interface AIConfig {
/** Placeholder text for the AI prompt input */
placeholder?: string
/** Custom label for the button */
buttonLabel?: string
}

export interface AIContentWidgetProps {
/** The component name (e.g., 'accordion', 'page-card') */
componentName: string
/** AI configuration - true to enable with defaults, object to customize */
ai?: boolean | AIConfig
}

export interface AIContentWidgetEmits {
generated: [content: AIContentWidgetEmits]
}
</script>

<script setup lang="ts">
import { motion, AnimatePresence } from 'motion-v'
import type { FormSubmitEvent } from '@nuxt/ui'
import { z } from 'zod'

const props = withDefaults(defineProps<AIContentWidgetProps>(), {
ai: false
})

const emit = defineEmits<{
generated: [content: AIContentWidgetEmits]
}>()

const SURFACE_WIDTH = 320
const SURFACE_HEIGHT = 160

const schema = z.object({
prompt: z.string()
})

type Schema = z.output<typeof schema>

const open = ref(false)
const state = reactive({
prompt: ''
})

const isGenerating = ref(false)
const surfaceRef = ref<HTMLElement>()
const textareaRef = ref<any>()

function toggleSurface() {
if (open.value) {
closeSurface()
} else {
openSurface()
}
}

function openSurface() {
open.value = true
nextTick(() => {
if (textareaRef.value?.$el) {
textareaRef.value.$el.focus()
}
})
}

function closeSurface() {
open.value = false
state.prompt = ''
if (textareaRef.value?.$el) {
textareaRef.value.$el.blur()
}
}

async function onSubmit(event: FormSubmitEvent<Schema>) {
closeSurface()
try {
isGenerating.value = true
const result = await $fetch('/api/content-generation', {
method: 'POST',
body: {
prompt: event.data.prompt,
componentName: props.componentName
}
})

if (result) {
emit('generated', result)
}
} catch (error) {
console.error('AI generation error:', error)
} finally {
isGenerating.value = false
}
}

onClickOutside(surfaceRef, closeSurface)
</script>

<template>
<div v-if="ai" class="absolute top-0 right-0 z-50 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<UButton
color="neutral"
variant="link"
size="xs"
:disabled="isGenerating"
:loading="isGenerating"
class="bg-default rounded-br-none rounded-tl-none group/shimmer"
@click="toggleSurface"
>
<template #leading>
<div v-if="!isGenerating" class="text-muted duration-300 group-hover/shimmer:text-highlighted size-3">
<UIcon name="i-lucide-sparkles" />
</div>
<UIcon v-else name="i-lucide-loader-circle" class="animate-spin" />
</template>

<span class="relative z-10 transition-colors duration-300 text-muted">
{{ typeof ai === 'object' && ai.buttonLabel ? ai.buttonLabel : 'Generate with AI' }}
<span
class="absolute inset-0 bg-clip-text bg-inverted text-transparent opacity-0 group-hover/shimmer:opacity-100 group-hover/shimmer:animate-shimmer-once transition-opacity duration-200"
style="background-image:linear-gradient(90deg, transparent calc(50% - 40px), #12A594, #E93D82, #FFB224, transparent calc(50% + 40px));background-size:200% 100%;background-position:-50% center"
>
{{ typeof ai === 'object' && ai.buttonLabel ? ai.buttonLabel : 'Generate with AI' }}
</span>
</span>
</UButton>

<AnimatePresence>
<motion.div
v-if="open"
key="surface"
ref="surfaceRef"
class="absolute top-full right-0.5 bg-default border border-default shadow-2xl overflow-hidden z-50"
:initial="{
opacity: 0,
scale: 0.8,
width: 100,
height: 44,
borderRadius: '10px 0px 10px 10px'
}"
:animate="{
opacity: 1,
scale: 1,
width: SURFACE_WIDTH,
height: SURFACE_HEIGHT,
borderRadius: '10px 0px 10px 10px'
}"
:exit="{
opacity: 0,
scale: 0.8,
width: 100,
height: 44,
borderRadius: '10px 0px 10px 10px'
}"
:transition="{
type: 'spring' as const,
stiffness: 400,
damping: 30,
mass: 0.8
}"
>
<UForm
:state="state"
:schema="schema"
:validate-on="[]"
class="p-4 h-full flex flex-col gap-3"
@submit="onSubmit"
>
<motion.div
class="flex flex-col gap-3 h-full"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
:transition="{ delay: 0.1 }"
>
<UFormField name="prompt">
<UTextarea
ref="textareaRef"
v-model="state.prompt"
required
autofocus
:placeholder="typeof ai === 'object' && ai.placeholder ? ai.placeholder : 'Describe the content you want to generate...'"
:rows="4"
class="resize-none size-full"
@keydown.escape="closeSurface"
/>
</UFormField>

<div class="flex gap-2 justify-end">
<UButton
variant="outline"
label="Cancel"
size="xs"
@click="closeSurface"
/>
<UButton
label="Submit"
size="xs"
type="submit"
/>
</div>
</motion.div>
</UForm>
</motion.div>
</AnimatePresence>
</div>
</template>

<style>
@keyframes shimmer {
0% {
background-position: 100%
}

to {
background-position: -50%
}
}

.group\/shimmer:hover .group-hover\/shimmer\:animate-shimmer-once {
animation: shimmer 0.8s ease-in-out forwards
}
</style>
27 changes: 26 additions & 1 deletion docs/app/components/content/ComponentCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { hash } from 'ohash'
import { CalendarDate } from '@internationalized/date'
import * as theme from '#build/ui'
import { get, set } from '#ui/utils'
import type { AIContentWidgetProps } from './AIContentWidget.vue'

interface Cast {
get: (args: any) => any
Expand Down Expand Up @@ -80,6 +81,10 @@ const props = defineProps<{
* Whether to add overflow-hidden to wrapper
*/
overflowHidden?: boolean
/**
* AI generation configuration - true to enable with defaults, object to customize
*/
ai?: AIContentWidgetProps['ai']
}>()

const route = useRoute()
Expand Down Expand Up @@ -300,6 +305,21 @@ ${props.slots?.default}
return code
})

function handleAIGenerated(generatedContent: any) {
try {
// Apply generated content to component props
Object.entries(generatedContent).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
setComponentProp(key, value)
}
})

console.log('Applied generated content:', generatedContent)
} catch (error) {
console.error('Error applying generated content:', error)
}
}

const { data: ast } = await useAsyncData(`component-code-${name}-${hash({ props: componentProps, slots: props.slots, external: props.external, externalTypes: props.externalTypes, collapse: props.collapse })}`, async () => {
if (!props.prettier) {
return parseMarkdown(code.value)
Expand All @@ -322,7 +342,7 @@ const { data: ast } = await useAsyncData(`component-code-${name}-${hash({ props:
</script>

<template>
<div class="my-5" :style="{ '--ui-header-height': '4rem' }">
<div class="my-5 group" :style="{ '--ui-header-height': '4rem' }">
<div class="relative">
<div v-if="options.length" class="flex flex-wrap items-center gap-2.5 border border-muted border-b-0 relative rounded-t-md px-4 py-2.5 overflow-x-auto">
<template v-for="option in options" :key="option.name">
Expand Down Expand Up @@ -373,6 +393,11 @@ const { data: ast } = await useAsyncData(`component-code-${name}-${hash({ props:
</div>

<div v-if="component" class="flex justify-center border border-b-0 border-muted relative p-4 z-[1]" :class="[!options.length && 'rounded-t-md', props.class, { 'overflow-hidden': props.overflowHidden }]">
<AIContentWidget
:component-name="name.replace('U', '').replace('Prose', '')"
:ai="props.ai"
@generated="handleAIGenerated"
/>
<component :is="component" v-bind="{ ...componentProps, ...componentEvents }">
<template v-for="slot in Object.keys(slots || {})" :key="slot" #[slot]>
<slot :name="slot" mdc-unwrap="p">
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/2.components/accordion.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ externalTypes:
- AccordionItem[]
hide:
- class
ai: true
props:
class: 'px-4'
items:
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/2.components/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Use the `title` prop to set the title of the Alert.

::component-code
---
ai: true
props:
title: 'Heads up!'
---
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/2.components/select-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ ignore:
external:
- items
- modelValue
ai: true
props:
modelValue: 'Backlog'
items:
Expand Down
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"better-sqlite3": "^12.2.0",
"capture-website": "^5.0.0",
"joi": "^18.0.1",
"json-schema-to-zod": "^2.6.1",
"maska": "^3.2.0",
"motion-v": "^1.7.1",
"nuxt": "^4.0.3",
Expand Down
Loading
Loading