adding some cards and a composable
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user