feat: block preview
This commit is contained in:
parent
09cf6d56a8
commit
cb962888ab
234
apps/www/.vitepress/theme/components/BlockPreview.vue
Normal file
234
apps/www/.vitepress/theme/components/BlockPreview.vue
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
<script setup lang="ts">
|
||||
import { CircleHelp, Info, Monitor, Phone, Smartphone, Tablet } from 'lucide-vue-next'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { codeToHtml } from 'shiki'
|
||||
import { compileScript, parse, walk } from 'vue/compiler-sfc'
|
||||
import MagicString from 'magic-string'
|
||||
import { cssVariables } from '../config/shiki'
|
||||
import StyleSwitcher from './StyleSwitcher.vue'
|
||||
import Spinner from './Spinner.vue'
|
||||
import ComponentLoader from './ComponentLoader.vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
|
||||
// import { BlockCopyCodeButton } from '@/components/block-copy-code-button'
|
||||
// import { Icons } from '@/components/icons'
|
||||
// import { V0Button } from '@/components/v0-button'
|
||||
import { Badge } from '@/lib/registry/new-york/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/lib/registry/new-york/ui/popover'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/lib/registry/new-york/ui/resizable'
|
||||
import { Separator } from '@/lib/registry/new-york/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/lib/registry/new-york/ui/tabs'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/lib/registry/new-york/ui/toggle-group'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const { style, codeConfig } = useConfigStore()
|
||||
|
||||
const resizableRef = ref<InstanceType<typeof ResizablePanel>>()
|
||||
const rawString = ref('')
|
||||
const codeHtml = ref('')
|
||||
const metadata = reactive({
|
||||
description: null as string | null,
|
||||
iframeHeight: null as string | null,
|
||||
containerClass: null as string | null,
|
||||
})
|
||||
|
||||
function removeScript(code: string) {
|
||||
const s = new MagicString(code)
|
||||
const scriptTagRegex = /<script\s+lang="ts"\s*>[\s\S]+?<\/script>/g
|
||||
let match
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = scriptTagRegex.exec(code)) !== null) {
|
||||
const start = match.index
|
||||
const end = match.index + match[0].length
|
||||
s.overwrite(start, end, '') // Replace the script tag with an empty string
|
||||
}
|
||||
return s.trimStart().toString()
|
||||
}
|
||||
|
||||
function transformImportPath(code: string) {
|
||||
const s = new MagicString(code)
|
||||
s.replaceAll(`@/lib/registry/${style.value}`, codeConfig.value.componentsPath)
|
||||
s.replaceAll(`@/lib/utils`, codeConfig.value.utilsPath)
|
||||
return s.toString()
|
||||
}
|
||||
|
||||
watch([style, codeConfig], async () => {
|
||||
try {
|
||||
rawString.value = await import(`../../../src/lib/registry/${style.value}/block/${props.name}.vue?raw`).then(res => res.default.trim())
|
||||
if (!metadata.description) {
|
||||
const { descriptor } = parse(rawString.value)
|
||||
const ast = compileScript(descriptor, { id: '' })
|
||||
walk(ast.scriptAst, {
|
||||
enter(node: any) {
|
||||
const declaration = node.declaration
|
||||
// Check if the declaration is a variable declaration
|
||||
if (declaration?.type === 'VariableDeclaration') {
|
||||
// Extract variable names and their values
|
||||
declaration.declarations.forEach((decl: any) => {
|
||||
// @ts-expect-error ignore missing type
|
||||
metadata[decl.id.name] = decl.init ? decl.init.value : null
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
codeHtml.value = await codeToHtml(transformImportPath(removeScript(rawString.value)), {
|
||||
lang: 'vue',
|
||||
theme: cssVariables,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs
|
||||
:id="name"
|
||||
default-value="preview"
|
||||
class="relative grid w-full scroll-m-20 gap-4"
|
||||
:style=" {
|
||||
'--container-height': metadata.iframeHeight,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 sm:flex-row">
|
||||
<div class="flex items-center gap-2">
|
||||
<TabsList class="hidden sm:flex">
|
||||
<TabsTrigger value="preview">
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="code">
|
||||
Code
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div class="hidden items-center gap-2 sm:flex">
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="mx-2 hidden h-4 md:flex"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<a :href="`#${name}`">
|
||||
<Badge variant="outline">{{ name }}</Badge>
|
||||
</a>
|
||||
<Popover>
|
||||
<PopoverTrigger class="hidden text-muted-foreground hover:text-foreground sm:flex">
|
||||
<Info class="h-3.5 w-3.5" />
|
||||
<span class="sr-only">Block description</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
:side-offset="10"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ metadata.description }}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pr-[14px] sm:ml-auto">
|
||||
<div class="hidden h-[28px] items-center gap-1.5 rounded-md border p-[2px] shadow-sm md:flex">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
default-value="100"
|
||||
@update:model-value="(value) => {
|
||||
resizableRef?.resize(parseInt(value))
|
||||
}"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="100"
|
||||
class="h-[22px] w-[22px] rounded-sm p-0"
|
||||
>
|
||||
<Monitor class="h-3.5 w-3.5" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="60"
|
||||
class="h-[22px] w-[22px] rounded-sm p-0"
|
||||
>
|
||||
<Tablet class="h-3.5 w-3.5" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="25"
|
||||
class="h-[22px] w-[22px] rounded-sm p-0"
|
||||
>
|
||||
<Smartphone class="h-3.5 w-3.5" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="mx-2 hidden h-4 md:flex"
|
||||
/>
|
||||
<StyleSwitcher class="h-7" />
|
||||
<Popover>
|
||||
<PopoverTrigger class="hidden text-muted-foreground hover:text-foreground sm:flex">
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
<span class="sr-only">Block description</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
:side-offset="20"
|
||||
class="space-y-3 rounded-[0.5rem] text-sm"
|
||||
>
|
||||
<p class="font-medium">
|
||||
What is the difference between the New York and Default style?
|
||||
</p>
|
||||
<p>
|
||||
A style comes with its own set of components, animations,
|
||||
icons and more.
|
||||
</p>
|
||||
<p>
|
||||
The <span class="font-medium">Default</span> style has
|
||||
larger inputs, uses lucide-react for icons and
|
||||
tailwindcss-animate for animations.
|
||||
</p>
|
||||
<p>
|
||||
The <span class="font-medium">New York</span> style ships
|
||||
with smaller buttons and inputs. It also uses shadows on cards
|
||||
and buttons.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<!-- <Separator orientation="vertical" class="mx-2 h-4" />
|
||||
<BlockCopyCodeButton name="{block.name}" code="{block.code}" />
|
||||
<V0Button
|
||||
name="{block.name}"
|
||||
description="{block.description" || "Edit in v0"}
|
||||
code="{block.code}"
|
||||
style="{block.style}"
|
||||
/> -->
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent
|
||||
value="preview"
|
||||
class="relative after:absolute after:inset-0 after:right-3 after:z-0 after:rounded-lg after:bg-muted h-[--container-height] px-0"
|
||||
>
|
||||
<ResizablePanelGroup id="block-resizable" direction="horizontal" class="relative z-10">
|
||||
<ResizablePanel
|
||||
id="block-resizable-panel-1"
|
||||
ref="resizableRef"
|
||||
class="relative rounded-lg border bg-background transition-all "
|
||||
:default-size="100"
|
||||
:min-size="25"
|
||||
>
|
||||
<div :class="metadata.containerClass">
|
||||
<ComponentLoader :key="style" :name="name" :type-name="'block'" />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="block-resizable-handle" class="relative hidden w-3 bg-transparent p-0 after:absolute after:right-0 after:top-1/2 after:h-8 after:w-[6px] after:-translate-y-1/2 after:translate-x-[-1px] after:rounded-full after:bg-border after:transition-all after:hover:h-10 sm:block" />
|
||||
<ResizablePanel id="block-resizable-panel-2" :default-size="0" :min-size="0" />
|
||||
</ResizablePanelGroup>
|
||||
</TabsContent>
|
||||
<TabsContent value="code" class="h-[--container-height]">
|
||||
<div
|
||||
class="language-vue !h-full !mt-0"
|
||||
v-html="codeHtml"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
63
apps/www/.vitepress/theme/components/Blocks.vue
Normal file
63
apps/www/.vitepress/theme/components/Blocks.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import PageHeaderHeading from '../components/PageHeaderHeading.vue'
|
||||
import PageHeaderDescription from '../components/PageHeaderDescription.vue'
|
||||
import PageAction from '../components/PageAction.vue'
|
||||
import { announcementConfig } from '../config/site'
|
||||
import BlockPreview from './BlockPreview.vue'
|
||||
import GitHubIcon from '~icons/radix-icons/github-logo'
|
||||
|
||||
import { buttonVariants } from '@/lib/registry/new-york/ui/button'
|
||||
import { Separator } from '@/lib/registry/new-york/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const blocks = ref<string[]>([])
|
||||
|
||||
import('../../../__registry__/index').then((res) => {
|
||||
blocks.value = Object.values(res.Index.default).filter(i => i.type === 'components:block').map(i => i.name)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader class="page-header pb-8">
|
||||
<a
|
||||
:href="announcementConfig.link"
|
||||
class="inline-flex items-center rounded-lg bg-muted px-3 py-1 text-sm font-medium"
|
||||
>
|
||||
{{ announcementConfig.icon }} <Separator class="mx-2 h-4" orientation="vertical" />
|
||||
<span class="sm:hidden">{{ announcementConfig.title }}</span>
|
||||
<span class="hidden sm:inline">{{ announcementConfig.title }}
|
||||
</span>
|
||||
<!-- <ArrowRightIcon class="ml-1 h-4 w-4" /> -->
|
||||
</a>
|
||||
<PageHeaderHeading>Building Blocks for the Web</PageHeaderHeading>
|
||||
<PageHeaderDescription>
|
||||
Beautifully designed. Copy and paste into your apps. Open Source.
|
||||
</PageHeaderDescription>
|
||||
|
||||
<PageAction>
|
||||
<a
|
||||
href="/blocks#blocks"
|
||||
:class="cn(buttonVariants(), 'rounded-[6px]')"
|
||||
>
|
||||
Browse
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/radix-vue/shadcn-vue"
|
||||
target="_blank"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'rounded-[6px]',
|
||||
)"
|
||||
>
|
||||
<GitHubIcon class="mr-2 h-4 w-4" />
|
||||
GitHub
|
||||
</a>
|
||||
</PageAction>
|
||||
</PageHeader>
|
||||
|
||||
<section>
|
||||
<BlockPreview v-for="block in blocks" :key="block" :name="block" />
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -5,12 +5,13 @@ import { useConfigStore } from '@/stores/config'
|
|||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
typeName?: 'example' | 'block'
|
||||
}>()
|
||||
const { style } = useConfigStore()
|
||||
|
||||
const Component = defineAsyncComponent({
|
||||
loadingComponent: Spinner,
|
||||
loader: () => import(`../../../src/lib/registry/${style.value}/example/${props.name}.vue`),
|
||||
loader: () => import(`../../../src/lib/registry/${style.value}/${props.typeName}/${props.name}.vue`),
|
||||
timeout: 5000,
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ watch([style, codeConfig], async () => {
|
|||
'items-end': align === 'end',
|
||||
})"
|
||||
>
|
||||
<ComponentLoader v-bind="$attrs" :key="style" :name="name" />
|
||||
<ComponentLoader v-bind="$attrs" :key="style" :name="name" :type-name="'example'" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="code">
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ export const docsConfig: DocsConfig = {
|
|||
title: 'Examples',
|
||||
href: '/examples/mail',
|
||||
},
|
||||
{
|
||||
title: 'Blocks',
|
||||
href: '/blocks',
|
||||
},
|
||||
{
|
||||
title: 'GitHub',
|
||||
href: 'https://github.com/radix-vue/shadcn-vue',
|
||||
|
|
|
|||
9
apps/www/src/content/blocks.md
Normal file
9
apps/www/src/content/blocks.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: Blocks - shadcn-vue
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import Blocks from "../../.vitepress/theme/components/Blocks.vue"
|
||||
</script>
|
||||
|
||||
<Blocks />
|
||||
41
apps/www/src/lib/registry/default/block/Authentication01.vue
Normal file
41
apps/www/src/lib/registry/default/block/Authentication01.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
export const description
|
||||
= 'A simple login form with email and password. The submit button says \'Sign in\'.'
|
||||
export const iframeHeight = '600px'
|
||||
export const containerClass = 'w-full h-full flex items-center justify-center px-4'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/lib/registry/default/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/lib/registry/default/ui/card'
|
||||
import { Input } from '@/lib/registry/default/ui/input'
|
||||
import { Label } from '@/lib/registry/default/ui/label'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">
|
||||
Login
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button class="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
export const description
|
||||
= 'A simple login form with email and password. The submit button says \'Sign in\'.'
|
||||
export const iframeHeight = '600px'
|
||||
export const containerClass = 'w-full h-full flex items-center justify-center px-4'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/lib/registry/new-york/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/lib/registry/new-york/ui/card'
|
||||
import { Input } from '@/lib/registry/new-york/ui/input'
|
||||
import { Label } from '@/lib/registry/new-york/ui/label'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">
|
||||
Login
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input id="email" type="email" placeholder="m@example.com" required />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button class="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
Loading…
Reference in New Issue
Block a user