Skip to main content
Version: v1 (Current)

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)

ComponentSourceComplexityNotes
SpinnerApp/components/Spinner.vueLowLoading indicator
HeaderApp/components/Header.vueLowPage header
CountdownApp/components/Countdown.vueMediumTimer display
VideoPlayerApp/components/VideoPlayer.vueHighVideo playback
FileUploaderApp/components/FileUploader.vueHighFile upload
BarcodeScannerApp/components/BarcodeScanner.vueHighQR/barcode scanning
LeaderboardApp/components/Leaderboard/HighMulti-file component
AudioVisualizerApp/components/AudioVisualizer.vueMediumAudio waveform

Composables

ComposableSourceNotes
useMediaApp/composables/media.jsVideo/audio recording
useScanningApp/composables/scanning.jsBarcode scanning
useAnimationsApp/composables/animations.jsAnimation utilities
useErrorsApp/composables/errors.jsError handling
useNfcListenerApp/composables/nfcListener.jsNFC events

Utilities

UtilitySourceNotes
imageToBlobApp/helpers/imageToBlob.jsImage 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

  1. Extract core logic into useVideoPlayer composable
  2. Use shadcn primitives for UI (Slider for progress, Button for controls)
  3. CSS variables for theming
  4. 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:

CategoryReasonAlternative
Controllers/InterfacesAPI-specific@gxp-dev/api-client package
Page VariablesApp-specific configKeep in app-manifest.json
Dashboard ConfigsAdmin UI specific@gxp-dev/dashboard-configs package
Stores (gxpStore)Platform-specificUse CSS variables instead
Page TemplatesFull 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