adding some cards and a composable

This commit is contained in:
2026-06-01 17:15:00 -06:00
parent 0b1ab9261e
commit 23a0d8dc2f
12 changed files with 645 additions and 0 deletions
@@ -0,0 +1,52 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
image: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
action: {
type: Object,
default: () => ({ route: '#', label: 'Read more', color: 'white' }),
},
})
const linkColors = {
white: 'text-white',
primary: 'text-primary',
secondary: 'text-secondary',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
info: 'text-info',
dark: 'text-dark',
}
const linkClass = computed(() => linkColors[props.action.color] ?? linkColors.white)
</script>
<template>
<div class="group relative h-64 cursor-pointer overflow-hidden rounded-2xl">
<!-- Background image with subtle zoom on hover -->
<div
class="absolute inset-0 bg-cover bg-center transition-transform duration-500 group-hover:scale-105"
:style="{ backgroundImage: `url(${image})` }"
/>
<!-- Gradient overlay bottom-heavy for text legibility -->
<div class="absolute inset-0 bg-gradient-to-t from-black/75 via-black/30 to-black/10" />
<!-- Content -->
<div class="absolute inset-0 flex flex-col justify-end p-6">
<h2 class="text-xl font-bold text-white leading-snug">{{ title }}</h2>
<p class="mt-1 text-sm text-white/80 line-clamp-2">{{ description }}</p>
<a
:href="action.route"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium transition-opacity hover:opacity-80"
:class="linkClass"
>
{{ action.label }}
<span class="material-icons text-base leading-none">arrow_forward</span>
</a>
</div>
</div>
</template>
@@ -0,0 +1,53 @@
<script setup>
defineProps({
image: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
action: {
type: Object,
default: () => ({ color: 'success', label: 'Find Out More', route: '#' }),
},
})
// action.color is a design-system color name: 'success', 'primary', etc.
const btnClasses = {
primary: 'bg-gradient-primary shadow-primary text-white',
secondary: 'bg-gradient-secondary shadow-secondary text-white',
success: 'bg-gradient-success shadow-success text-white',
warning: 'bg-gradient-warning shadow-warning text-white',
danger: 'bg-gradient-danger shadow-danger text-white',
info: 'bg-gradient-info shadow-info text-white',
dark: 'bg-gradient-dark shadow-dark text-white',
light: 'bg-gradient-light shadow-soft-sm text-dark',
}
</script>
<template>
<div class="rounded-2xl bg-white shadow-soft-md overflow-visible">
<!-- Floating image header overlaps the card top -->
<div class="relative -mt-6 mx-4 z-10 overflow-hidden rounded-xl shadow-soft-lg">
<a :href="action.route" class="block">
<img
:src="image"
:alt="title"
class="h-48 w-full object-cover transition-transform duration-500 hover:scale-105"
/>
</a>
</div>
<!-- Body -->
<div class="px-6 pb-6 pt-4 text-center">
<h5 class="font-semibold text-dark">
<a :href="action.route" class="hover:text-primary transition-colors">{{ title }}</a>
</h5>
<p class="mt-2 text-sm text-secondary leading-relaxed">{{ description }}</p>
<button
type="button"
class="mt-4 inline-flex items-center justify-center rounded-lg px-5 py-2 text-sm font-medium transition-all hover:-translate-y-0.5"
:class="btnClasses[action.color] ?? btnClasses.success"
>
{{ action.label }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,48 @@
<script setup>
import { computed } from 'vue'
import { useCountUp } from '../../composables/useCountUp.js'
const props = defineProps({
count: { type: Number, required: true },
suffix: { type: String, default: '' },
duration: { type: Number, default: 2000 },
title: { type: String, required: true },
description: { type: String, required: true },
color: {
type: String,
default: 'success',
validator: v => ['primary','secondary','info','success','warning','danger','error','light','dark'].includes(v),
},
// 'horizontal' | 'vertical' | '' (none)
divider: { type: String, default: '' },
})
const { count } = useCountUp(props.count, props.duration)
// Gradient class for the count value — text-gradient clips the bg-gradient to text
const gradientMap = {
primary: 'bg-gradient-primary',
secondary: 'bg-gradient-secondary',
info: 'bg-gradient-info',
success: 'bg-gradient-success',
warning: 'bg-gradient-warning',
danger: 'bg-gradient-danger',
error: 'bg-gradient-danger',
light: 'bg-gradient-light',
dark: 'bg-gradient-dark',
}
const gradientClass = computed(() => gradientMap[props.color] ?? gradientMap.success)
</script>
<template>
<div class="text-center">
<h1 class="text-5xl font-black text-gradient" :class="gradientClass">
{{ count }}{{ suffix }}
</h1>
<h5 class="mt-3 font-semibold text-dark">{{ title }}</h5>
<p class="mt-1 text-sm text-secondary leading-relaxed">{{ description }}</p>
<div v-if="divider === 'horizontal'" class="mx-auto mt-4 h-px w-16 bg-gray-200" />
<div v-else-if="divider === 'vertical'" class="mx-auto mt-4 h-12 w-px bg-gray-200" />
</div>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
// String icon name, or { component, color, size }
icon: { type: [String, Object], default: '' },
// { component (img src), class }
image: { type: Object, default: null },
// String, or { text, class }
title: { type: [String, Object], required: true },
// String, or { text, class }
description: { type: [String, Object], required: true },
})
const iconName = computed(() => typeof props.icon === 'object' ? props.icon.component : props.icon)
const iconColor = computed(() => typeof props.icon === 'object' ? `text-${props.icon.color}` : 'text-success')
const iconSize = computed(() => typeof props.icon === 'object' ? `text-${props.icon.size}` : 'text-3xl')
const titleText = computed(() => typeof props.title === 'string' ? props.title : props.title?.text ?? '')
const titleClass = computed(() => typeof props.title === 'object' ? props.title?.class ?? '' : 'text-lg font-bold text-dark')
const descText = computed(() => typeof props.description === 'string' ? props.description : props.description?.text ?? '')
const descClass = computed(() => typeof props.description === 'object' ? props.description?.class ?? '' : 'text-sm text-secondary')
// Icon text color — explicit map so Tailwind scanner finds these classes
const textColorMap = {
primary: 'text-primary',
secondary: 'text-secondary',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
info: 'text-info',
dark: 'text-dark',
light: 'text-light',
white: 'text-white',
}
const resolvedIconColor = computed(() =>
typeof props.icon === 'object'
? (textColorMap[props.icon.color] ?? 'text-success')
: 'text-success'
)
</script>
<template>
<div v-bind="$attrs">
<!-- Icon -->
<span
v-if="iconName"
class="material-icons text-gradient"
:class="[resolvedIconColor, iconSize]"
>
{{ iconName }}
</span>
<!-- Image fallback -->
<img
v-else-if="image"
:src="image.component"
:class="image.class"
:alt="titleText"
/>
<h5 :class="['mt-3', titleClass]">{{ titleText }}</h5>
<p :class="['mt-1 leading-relaxed', descClass]">{{ descText }}</p>
</div>
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
image: { type: String, default: '' },
color: { type: String, default: '' }, // color name: 'success', 'primary', etc. — empty = plain card
name: { type: String, required: true },
date: { type: String, required: true },
review: { type: String, required: true },
rating: { type: Number, required: true },
})
const cardMap = {
primary: 'bg-gradient-primary shadow-primary',
secondary: 'bg-gradient-secondary shadow-secondary',
success: 'bg-gradient-success shadow-success',
warning: 'bg-gradient-warning shadow-warning',
danger: 'bg-gradient-danger shadow-danger',
info: 'bg-gradient-info shadow-info',
dark: 'bg-gradient-dark shadow-dark',
}
const hasColor = computed(() => !!props.color)
const cardClass = computed(() => hasColor.value ? cardMap[props.color] ?? '' : 'bg-white shadow-soft-md')
const onDark = computed(() => hasColor.value)
// Build an array of star states: 'full', 'empty'
const stars = computed(() =>
Array.from({ length: 5 }, (_, i) => i < Math.round(props.rating) ? 'star' : 'star_border')
)
</script>
<template>
<div class="rounded-2xl p-6" :class="cardClass">
<!-- Floating avatar (overlaps top) -->
<img
v-if="image"
:src="image"
:alt="name"
class="h-14 w-14 rounded-xl object-cover shadow-soft-md -mt-10 mb-4"
/>
<!-- Author -->
<div class="flex items-center gap-2 mb-1">
<h6 class="font-bold" :class="onDark ? 'text-white' : 'text-dark'">{{ name }}</h6>
</div>
<p class="flex items-center gap-1 text-xs" :class="onDark ? 'text-white/70' : 'text-secondary'">
<span class="material-icons text-sm leading-none">schedule</span>
{{ date }}
</p>
<!-- Review text -->
<p class="mt-4 text-sm leading-relaxed" :class="onDark ? 'text-white/90' : 'text-secondary'">
"{{ review }}"
</p>
<!-- Star rating using Material Icons -->
<div class="mt-3 flex items-center gap-0.5">
<span
v-for="(star, i) in stars"
:key="i"
class="material-icons text-lg leading-none"
:class="onDark ? 'text-white' : 'text-warning'"
>
{{ star }}
</span>
</div>
</div>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
// { text: 'white'|'dark'|'', background: color name or full class }
color: {
type: Object,
default: () => ({ text: '', background: '' }),
},
// { component: material-icon-name, color: color-name }
icon: { type: Object, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
// { route, label: { text, color } }
action: { type: Object, required: true },
})
// Background: accepts a color name → gradient class, or passes through a full class string
const bgMap = {
primary: 'bg-gradient-primary',
secondary: 'bg-gradient-secondary',
success: 'bg-gradient-success',
warning: 'bg-gradient-warning',
danger: 'bg-gradient-danger',
info: 'bg-gradient-info',
dark: 'bg-gradient-dark',
light: 'bg-light',
white: 'bg-white',
}
const bgClass = computed(() => bgMap[props.color.background] ?? props.color.background ?? '')
const textMap = {
white: 'text-white',
dark: 'text-dark',
'': 'text-dark',
}
const textClass = computed(() => textMap[props.color.text] ?? 'text-dark')
const iconColorMap = {
primary: 'text-primary',
secondary: 'text-secondary',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
info: 'text-info',
dark: 'text-dark',
white: 'text-white',
}
const iconClass = computed(() => iconColorMap[props.icon.color] ?? 'text-success')
const linkColorMap = {
primary: 'text-primary',
secondary: 'text-secondary',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
info: 'text-info',
dark: 'text-dark',
white: 'text-white',
}
const linkClass = computed(() => linkColorMap[props.action.label?.color] ?? 'text-success')
</script>
<template>
<div
class="flex flex-col gap-4 rounded-2xl p-6 md:flex-row md:items-start"
:class="bgClass"
>
<!-- Icon -->
<span class="material-icons shrink-0 text-3xl" :class="iconClass">
{{ icon.component }}
</span>
<!-- Text -->
<div class="flex-1">
<h5 class="font-bold" :class="textClass">{{ title }}</h5>
<p class="mt-1 text-sm leading-relaxed" :class="textClass">{{ description }}</p>
<a
:href="action.route"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium transition-opacity hover:opacity-80"
:class="linkClass"
>
{{ action.label.text }}
<span class="material-icons text-base leading-none">arrow_forward</span>
</a>
</div>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
image: { type: String, required: true },
// { name, link }
profile: { type: Object, required: true },
// { label, color }
position: { type: Object, required: true },
description: { type: String, required: true },
})
const positionColorMap = {
primary: 'text-primary',
secondary: 'text-secondary',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
info: 'text-info',
dark: 'text-dark',
light: 'text-secondary',
}
const positionClass = computed(() => positionColorMap[props.position.color] ?? 'text-secondary')
</script>
<template>
<div class="overflow-hidden rounded-2xl bg-white shadow-soft-md">
<div class="flex flex-col sm:flex-row">
<!-- Image column overlaps the card top on mobile -->
<div class="sm:w-2/5 shrink-0">
<a :href="profile.link ?? '#'">
<img
:src="image"
:alt="profile.name"
class="h-48 w-full object-cover sm:h-full sm:rounded-l-2xl"
/>
</a>
</div>
<!-- Bio column -->
<div class="flex flex-1 flex-col justify-center p-6">
<h5 class="font-bold text-dark">
<a :href="profile.link ?? '#'" class="hover:text-primary transition-colors">
{{ profile.name }}
</a>
</h5>
<h6 class="mt-0.5 text-sm font-medium" :class="positionClass">
{{ position.label }}
</h6>
<p class="mt-3 text-sm text-secondary leading-relaxed">{{ description }}</p>
</div>
</div>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup>
const props = defineProps({
color: { type: String, default: 'success' },
minHeight: { type: String, default: '26rem' },
})
// Colored shadow on the flip card — safelisted in style.css @source inline()
const shadowMap = {
primary: 'shadow-primary',
secondary: 'shadow-secondary',
success: 'shadow-success',
warning: 'shadow-warning',
danger: 'shadow-danger',
info: 'shadow-info',
dark: 'shadow-dark',
}
</script>
<template>
<div class="rotating-card-container">
<!--
card-rotate is defined in style.css @layer utilities:
transform-style: preserve-3d
transition: transform 0.6s
On parent hover rotateY(180deg)
-->
<div
class="card-rotate relative rounded-2xl"
:class="shadowMap[color] ?? shadowMap.success"
:style="{ minHeight: minHeight }"
>
<slot />
</div>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
image: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
// Array of { route, label, color }
action: { type: Array, required: true },
})
const btnMap = {
white: 'bg-white text-dark hover:bg-gray-100',
primary: 'bg-primary text-white hover:bg-primary/90',
secondary: 'bg-secondary text-white hover:bg-secondary/90',
success: 'bg-success text-white hover:bg-success/90',
warning: 'bg-warning text-white hover:bg-warning/90',
danger: 'bg-danger text-white hover:bg-danger/90',
info: 'bg-info text-white hover:bg-info/90',
dark: 'bg-dark text-white hover:bg-dark/90',
}
function btnClass(color) {
return btnMap[color] ?? btnMap.white
}
</script>
<template>
<!--
.back is defined in style.css:
backface-visibility: hidden
position: absolute; inset: 0
transform: rotateY(180deg) pre-rotated; shows when parent flips
-->
<div
class="back overflow-hidden rounded-2xl bg-cover bg-center"
:style="{ backgroundImage: `url(${image})` }"
>
<div class="absolute inset-0 rounded-2xl bg-black/60" />
<div class="relative flex h-full flex-col items-center justify-center px-8 py-10 text-center">
<h3 class="text-2xl font-bold text-white leading-snug" v-html="title" />
<p class="mt-3 text-sm text-white/80 leading-relaxed">{{ description }}</p>
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
<a
v-for="(btn, i) in action"
:key="i"
:href="btn.route"
target="_blank"
rel="noopener"
class="rounded-lg px-5 py-2 text-sm font-medium transition-all hover:-translate-y-0.5"
:class="btnClass(btn.color)"
>
{{ btn.label }}
</a>
</div>
</div>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup>
defineProps({
image: { type: String, required: true },
icon: { type: String, default: '' },
label: { type: String, default: '' },
title: { type: String, required: true },
description: { type: String, required: true },
})
</script>
<template>
<!--
.front is defined in style.css:
backface-visibility: hidden
position: absolute; inset: 0
-->
<div
class="front overflow-hidden rounded-2xl bg-cover bg-center"
:style="{ backgroundImage: `url(${image})` }"
>
<!-- Dark overlay for text legibility -->
<div class="absolute inset-0 rounded-2xl bg-black/50" />
<div class="relative flex h-full flex-col items-center justify-center px-8 py-10 text-center">
<span v-if="icon" class="material-icons mb-3 text-5xl text-white">{{ icon }}</span>
<p v-if="label" class="mb-2 text-xs font-medium uppercase tracking-widest text-white/70">
{{ label }}
</p>
<h3 class="text-2xl font-bold text-white leading-snug" v-html="title" />
<p class="mt-3 text-sm text-white/80 leading-relaxed">{{ description }}</p>
</div>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue'
defineProps({
image: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
action: {
type: Object,
default: () => ({ route: '#', color: 'success', label: 'Read more' }),
},
})
const linkColors = {
primary: 'text-primary',
secondary: 'text-secondary',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
info: 'text-info',
dark: 'text-dark',
white: 'text-white',
}
</script>
<template>
<div class="flex flex-col">
<!-- Image no card wrapper, just soft shadow and rounded corners -->
<div class="overflow-hidden rounded-xl shadow-soft-md">
<a :href="action.route" class="block">
<img
:src="image"
:alt="title"
loading="lazy"
class="h-48 w-full object-cover transition-transform duration-500 hover:scale-105"
/>
</a>
</div>
<!-- Body no background, plain text -->
<div class="mt-4">
<h5 class="font-bold text-dark">
<a :href="action.route" class="hover:text-primary transition-colors">{{ title }}</a>
</h5>
<p class="mt-2 text-sm text-secondary leading-relaxed">{{ description }}</p>
<a
:href="action.route"
class="mt-3 inline-flex items-center gap-1 text-sm font-medium transition-opacity hover:opacity-80"
:class="linkColors[action.color] ?? linkColors.success"
>
{{ action.label }}
<span class="material-icons text-base leading-none">arrow_forward</span>
</a>
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* Animates a number from 0 to `endVal` over `duration` ms using
* easeOutQuart easing. Replaces the vue-count-to dependency.
*/
export function useCountUp(endVal, duration = 2000) {
const count = ref(0)
let rafId = null
onMounted(() => {
const startTime = performance.now()
function tick(now) {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 4) // easeOutQuart
count.value = Math.round(eased * endVal)
if (progress < 1) rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
})
onUnmounted(() => {
if (rafId !== null) cancelAnimationFrame(rafId)
})
return { count }
}