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