Porting Components from z-plugin-components
This guide covers how to port domain-specific components from the z-plugin-components library to the UI Kit.
Source Location
The original components are located at:
/Users/stephenpeek/Develop/eventfinity/z-plugin-components/
├── App/
│ ├── components/ # Vue components
│ ├── composables/ # Vue composables
│ └── helpers/ # Utility functions
└── Dashboard/ # Admin UI (out of scope)
What to Port
Components (Priority Order)
| Component | Source | Complexity | Notes |
|---|---|---|---|
| Spinner | App/components/Spinner.vue | Low | Loading indicator |
| Header | App/components/Header.vue | Low | Page header |
| Countdown | App/components/Countdown.vue | Medium | Timer display |
| VideoPlayer | App/components/VideoPlayer.vue | High | Video playback |
| FileUploader | App/components/FileUploader.vue | High | File upload |
| BarcodeScanner | App/components/BarcodeScanner.vue | High | QR/barcode scanning |
| Leaderboard | App/components/Leaderboard/ | High | Multi-file component |
| AudioVisualizer | App/components/AudioVisualizer.vue | Medium | Audio waveform |
Composables
| Composable | Source | Notes |
|---|---|---|
| useMedia | App/composables/media.js | Video/audio recording |
| useScanning | App/composables/scanning.js | Barcode scanning |
| useAnimations | App/composables/animations.js | Animation utilities |
| useErrors | App/composables/errors.js | Error handling |
| useNfcListener | App/composables/nfcListener.js | NFC events |
Utilities
| Utility | Source | Notes |
|---|---|---|
| imageToBlob | App/helpers/imageToBlob.js | Image conversion |
Porting Process
Step 1: Analyze the Source
Before porting, understand the component:
# Read the original component
cat /path/to/z-plugin-components/App/components/Spinner.vue
Identify:
- Props - What inputs does it accept?
- Events - What does it emit?
- Dependencies - External packages, composables, stores
- Platform coupling - References to
useGxpStore(), API interfaces
Step 2: Create the Structure
mkdir -p src/components/domain/spinner
Create files:
src/components/domain/spinner/
├── Spinner.vue
├── index.ts
├── Spinner.test.ts
└── Spinner.stories.ts
Step 3: Convert to TypeScript
Before (JavaScript):
// z-plugin-components/App/components/Spinner.vue
<script>
export default {
name: 'Spinner',
props: {
size: {
type: String,
default: 'md'
},
color: {
type: String,
default: null
}
}
}
</script>
After (TypeScript):
<!-- src/components/domain/spinner/Spinner.vue -->
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
size?: 'sm' | 'md' | 'lg' | 'xl'
color?: string
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
size: 'md'
})
</script>
Step 4: Remove Platform Dependencies
Identify platform code:
// Before - Platform dependent
import { useGxpStore } from '@/stores/gxpStore'
const store = useGxpStore()
const theme = store.theme
Replace with CSS variables:
<!-- After - CSS variable based -->
<template>
<div
class="spinner"
:style="{
'--spinner-color': color || 'var(--spinner_color)',
'--spinner-bg': 'var(--spinner_background_color)'
}"
/>
</template>
Step 5: Use shadcn Primitives
Replace custom implementations with shadcn components where possible:
Before:
<!-- Custom modal implementation -->
<div v-if="isOpen" class="modal-overlay">
<div class="modal-content">
<slot />
</div>
</div>
After:
<!-- Use shadcn Dialog -->
<Dialog v-model:open="isOpen">
<DialogContent>
<slot />
</DialogContent>
</Dialog>
Step 6: Add Tests
// src/components/domain/spinner/Spinner.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Spinner from './Spinner.vue'
describe('Spinner', () => {
it('renders with default size', () => {
const wrapper = mount(Spinner)
expect(wrapper.classes()).toContain('spinner-md')
})
it('renders with custom size', () => {
const wrapper = mount(Spinner, {
props: { size: 'lg' }
})
expect(wrapper.classes()).toContain('spinner-lg')
})
it('applies custom color', () => {
const wrapper = mount(Spinner, {
props: { color: '#ff0000' }
})
expect(wrapper.attributes('style')).toContain('--spinner-color: #ff0000')
})
it('uses CSS variable when no color prop', () => {
const wrapper = mount(Spinner)
expect(wrapper.attributes('style')).toContain('var(--spinner_color)')
})
})
Step 7: Add Stories
// src/components/domain/spinner/Spinner.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import Spinner from './Spinner.vue'
const meta: Meta<typeof Spinner> = {
title: 'Domain/Spinner',
component: Spinner,
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl']
},
color: {
control: 'color'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Sizes: Story = {
render: () => ({
components: { Spinner },
template: `
<div class="flex items-center gap-8">
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
<Spinner size="xl" />
</div>
`
})
}
export const CustomColor: Story = {
args: {
color: '#3b82f6'
}
}
export const OnDarkBackground: Story = {
render: () => ({
components: { Spinner },
template: `
<div class="bg-background p-8 rounded">
<Spinner />
</div>
`
}),
parameters: {
backgrounds: { default: 'platform' }
}
}
Step 8: Export
// src/components/domain/spinner/index.ts
export { default as Spinner } from './Spinner.vue'
// src/index.ts
// ... existing exports
// Domain components
export { Spinner } from './components/domain/spinner'
Porting Composables
TypeScript Conversion Pattern
Before (JavaScript):
// z-plugin-components/App/composables/media.js
import { ref } from 'vue'
export function useMedia() {
const isRecording = ref(false)
const error = ref(null)
const devices = ref([])
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
isRecording.value = true
return stream
} catch (e) {
error.value = e.message
throw e
}
}
async function loadDevices() {
const allDevices = await navigator.mediaDevices.enumerateDevices()
devices.value = allDevices.filter(d => d.kind === 'videoinput')
}
return {
isRecording,
error,
devices,
startRecording,
loadDevices
}
}
After (TypeScript):
// src/composables/useMedia.ts
import { ref, type Ref } from 'vue'
export interface MediaDevice {
deviceId: string
kind: 'videoinput' | 'audioinput' | 'audiooutput'
label: string
groupId: string
}
export interface UseMediaOptions {
video?: boolean | MediaTrackConstraints
audio?: boolean | MediaTrackConstraints
}
export interface UseMediaReturn {
isRecording: Ref<boolean>
error: Ref<string | null>
devices: Ref<MediaDevice[]>
stream: Ref<MediaStream | null>
startRecording: (options?: UseMediaOptions) => Promise<MediaStream>
stopRecording: () => void
loadDevices: () => Promise<void>
switchDevice: (deviceId: string) => Promise<void>
}
export function useMedia(): UseMediaReturn {
const isRecording = ref(false)
const error = ref<string | null>(null)
const devices = ref<MediaDevice[]>([])
const stream = ref<MediaStream | null>(null)
async function startRecording(options: UseMediaOptions = { video: true }): Promise<MediaStream> {
try {
error.value = null
stream.value = await navigator.mediaDevices.getUserMedia(options)
isRecording.value = true
return stream.value
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to start recording'
error.value = message
throw new Error(message)
}
}
function stopRecording(): void {
if (stream.value) {
stream.value.getTracks().forEach(track => track.stop())
stream.value = null
}
isRecording.value = false
}
async function loadDevices(): Promise<void> {
try {
const allDevices = await navigator.mediaDevices.enumerateDevices()
devices.value = allDevices
.filter(d => d.kind === 'videoinput' || d.kind === 'audioinput')
.map(d => ({
deviceId: d.deviceId,
kind: d.kind as MediaDevice['kind'],
label: d.label || `Device ${d.deviceId.slice(0, 8)}`,
groupId: d.groupId
}))
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to load devices'
error.value = message
}
}
async function switchDevice(deviceId: string): Promise<void> {
if (isRecording.value) {
stopRecording()
await startRecording({
video: { deviceId: { exact: deviceId } }
})
}
}
return {
isRecording,
error,
devices,
stream,
startRecording,
stopRecording,
loadDevices,
switchDevice
}
}
Complex Component Example: VideoPlayer
Analysis
The VideoPlayer component typically has:
- Video element management
- Play/pause controls
- Progress bar
- Volume controls
- Fullscreen support
- Event callbacks
Porting Strategy
- Extract core logic into
useVideoPlayercomposable - Use shadcn primitives for UI (Slider for progress, Button for controls)
- CSS variables for theming
- Props for customization
Implementation
<!-- src/components/domain/video-player/VideoPlayer.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import { Play, Pause, Volume2, VolumeX, Maximize } from 'lucide-vue-next'
interface Props {
src: string
poster?: string
autoplay?: boolean
muted?: boolean
loop?: boolean
controls?: boolean
class?: string
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
muted: false,
loop: false,
controls: true
})
const emit = defineEmits<{
(e: 'play'): void
(e: 'pause'): void
(e: 'ended'): void
(e: 'timeupdate', time: number): void
(e: 'error', error: Error): void
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
const isPlaying = ref(false)
const isMuted = ref(props.muted)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(100)
const progress = computed(() =>
duration.value ? (currentTime.value / duration.value) * 100 : 0
)
function togglePlay() {
if (!videoRef.value) return
if (isPlaying.value) {
videoRef.value.pause()
} else {
videoRef.value.play()
}
}
function toggleMute() {
if (!videoRef.value) return
isMuted.value = !isMuted.value
videoRef.value.muted = isMuted.value
}
function seek(value: number[]) {
if (!videoRef.value) return
videoRef.value.currentTime = (value[0] / 100) * duration.value
}
function setVolume(value: number[]) {
if (!videoRef.value) return
volume.value = value[0]
videoRef.value.volume = value[0] / 100
}
function toggleFullscreen() {
if (!videoRef.value) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
videoRef.value.requestFullscreen()
}
}
function handleTimeUpdate() {
if (!videoRef.value) return
currentTime.value = videoRef.value.currentTime
emit('timeupdate', currentTime.value)
}
function handleLoadedMetadata() {
if (!videoRef.value) return
duration.value = videoRef.value.duration
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
</script>
<template>
<div :class="cn('relative group', props.class)">
<video
ref="videoRef"
:src="src"
:poster="poster"
:autoplay="autoplay"
:muted="isMuted"
:loop="loop"
class="w-full rounded-lg"
@play="isPlaying = true; emit('play')"
@pause="isPlaying = false; emit('pause')"
@ended="emit('ended')"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@error="emit('error', new Error('Video failed to load'))"
/>
<!-- Controls overlay -->
<div
v-if="controls"
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 opacity-0 group-hover:opacity-100 transition-opacity"
>
<!-- Progress bar -->
<Slider
:model-value="[progress]"
:max="100"
:step="0.1"
class="mb-4"
@update:model-value="seek"
/>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Play/Pause -->
<Button
variant="ghost"
size="icon"
class="text-white hover:bg-white/20"
@click="togglePlay"
>
<Pause v-if="isPlaying" class="h-5 w-5" />
<Play v-else class="h-5 w-5" />
</Button>
<!-- Volume -->
<Button
variant="ghost"
size="icon"
class="text-white hover:bg-white/20"
@click="toggleMute"
>
<VolumeX v-if="isMuted" class="h-5 w-5" />
<Volume2 v-else class="h-5 w-5" />
</Button>
<Slider
:model-value="[isMuted ? 0 : volume]"
:max="100"
class="w-24"
@update:model-value="setVolume"
/>
<!-- Time -->
<span class="text-white text-sm">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<!-- Fullscreen -->
<Button
variant="ghost"
size="icon"
class="text-white hover:bg-white/20"
@click="toggleFullscreen"
>
<Maximize class="h-5 w-5" />
</Button>
</div>
</div>
</div>
</template>
Out of Scope
These items should NOT be ported to the UI Kit:
| Category | Reason | Alternative |
|---|---|---|
| Controllers/Interfaces | API-specific | @gxp-dev/api-client package |
| Page Variables | App-specific config | Keep in app-manifest.json |
| Dashboard Configs | Admin UI specific | @gxp-dev/dashboard-configs package |
| Stores (gxpStore) | Platform-specific | Use CSS variables instead |
| Page Templates | Full page components | @gxp-dev/page-templates package |
See OUT_OF_SCOPE.md for the complete list.
Checklist
Before completing a port:
- Converted to TypeScript
- Removed platform dependencies (useGxpStore, etc.)
- Uses CSS variables for theming
- Uses shadcn primitives where applicable
- Has comprehensive tests
- Has Storybook stories
- Exported from index.ts
- Build passes
- Tests pass