Compare commits
8 Commits
b08d347727
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
fdf0d5865b
|
|||
|
8ad6132dfa
|
|||
|
db2ddadff3
|
|||
|
d34d61458b
|
|||
|
9dfe4a9a01
|
|||
|
23a0d8dc2f
|
|||
|
0b1ab9261e
|
|||
|
79a8720fb0
|
@@ -1,5 +1,119 @@
|
||||
# Vue Material Kit Design System
|
||||
# MK Design System
|
||||
|
||||
This is a fork/derivative of [Creative Tim's Vue Material Kit 2](https://www.creative-tim.com/product/vue-material-kit), source found [here](https://github.com/creativetimofficial/vue-material-kit).
|
||||
A Vue 3 + Tailwind CSS v4 design system derived from [Creative Tim's Vue Material Kit 2](https://github.com/creativetimofficial/vue-material-kit). Built to be handed off to another Claude instance for consistent UI work across projects.
|
||||
|
||||
I am going to iterate over it with an AI agent and extract styles and functionality into a design system that can be plugged in to another project. The end goal is to have a working directory than can be handed off to another AI agent that is used when building a new site to keep a consistent and clean design.
|
||||
**30+ components** · **Design tokens in CSS** · **Roboto variable font** · **Material Icons Round**
|
||||
|
||||
---
|
||||
|
||||
## Using in your project
|
||||
|
||||
### Option A — Copy components (simplest)
|
||||
|
||||
1. Copy `src/components/`, `src/composables/`, and `src/style.css` into your project.
|
||||
|
||||
2. Ensure your project has Tailwind CSS v4 with `@tailwindcss/vite`. Your `style.css` must contain the `@import "tailwindcss"` line and the full `@theme` block from this project's `src/style.css`.
|
||||
|
||||
3. Get the font files (4 woff2 files — see **Fonts** below) and place them in `public/fonts/`.
|
||||
|
||||
4. Import components directly:
|
||||
```js
|
||||
import MkButton from '@/components/MkButton.vue'
|
||||
import MkInput from '@/components/MkInput.vue'
|
||||
```
|
||||
|
||||
### Option B — Build as a library
|
||||
|
||||
```bash
|
||||
pnpm build:lib
|
||||
```
|
||||
|
||||
This outputs `dist/mk-design-system.es.js`, `dist/mk-design-system.cjs.js`, and `dist/style.css`.
|
||||
|
||||
In the consuming project:
|
||||
|
||||
```js
|
||||
// main.js — global registration
|
||||
import { MkDesignSystem } from './mk-design-system.es.js'
|
||||
import './mk-design-system.css' // design tokens + @font-face
|
||||
app.use(MkDesignSystem)
|
||||
```
|
||||
|
||||
Or import components individually:
|
||||
|
||||
```js
|
||||
import { MkButton, MkInput, MkNavbar } from './mk-design-system.es.js'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # dashboard + showcase at localhost:5173
|
||||
pnpm build # production app build → dist/
|
||||
pnpm build:lib # library build → dist/mk-design-system.*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fonts
|
||||
|
||||
Place these woff2 files in `public/fonts/` before running the dev server:
|
||||
|
||||
```
|
||||
public/fonts/roboto/
|
||||
Roboto-VariableFont_wdth-wght.woff2
|
||||
Roboto-Italic-VariableFont_wdth-wght.woff2
|
||||
|
||||
public/fonts/roboto-slab/
|
||||
RobotoSlab-VariableFont_wght.woff2
|
||||
|
||||
public/fonts/material-icons-round/
|
||||
material-icons-round-latin-400-normal.woff2
|
||||
```
|
||||
|
||||
Download script (run from `/app`):
|
||||
|
||||
```bash
|
||||
# Roboto (official Google Fonts variable build)
|
||||
curl -L "https://github.com/google/fonts/raw/main/apache/roboto/Roboto%5Bwdth%2Cwght%5D.ttf" \
|
||||
-o public/fonts/roboto/Roboto-VariableFont_wdth-wght.woff2
|
||||
|
||||
# Material Icons Round
|
||||
curl -L "https://cdn.jsdelivr.net/npm/@fontsource/material-icons-round@5/files/material-icons-round-latin-400-normal.woff2" \
|
||||
-o public/fonts/material-icons-round/material-icons-round-latin-400-normal.woff2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `design-system.md` | Token reference, component patterns, 12 quick-start rules |
|
||||
| `conformity.md` | 17-rule checklist — feed this + generated code to Claude to score conformity |
|
||||
| `tokens.json` | Machine-readable design token values |
|
||||
| `src/style.css` | Live source of truth — `@theme` block defines all tokens |
|
||||
|
||||
---
|
||||
|
||||
## For Claude
|
||||
|
||||
When starting a new project using this design system, provide Claude with:
|
||||
|
||||
1. `design-system.md` — the rules to follow
|
||||
2. `conformity.md` — to score and verify generated code
|
||||
3. The component list from `src/index.js` — so it knows what's available
|
||||
|
||||
Prompt template:
|
||||
> "Use the MK Design System (design-system.md attached). Available components are listed in src/index.js. Build [feature]. After generating, score it against conformity.md."
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `MkSocialButton` renders Font Awesome brand icons (`fab fa-*`). Load Font Awesome Free 6 externally if social icons are needed.
|
||||
- `MkRotatingCard` requires its parent to set an explicit height (e.g. `style="height: 22rem"`).
|
||||
- `MkNavbar` scroll-blur is automatic in `transparent` mode — no JS required.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# MK Design System — Conformity Checklist
|
||||
|
||||
## How to Use
|
||||
Provide this file plus the code to review to Claude with this prompt:
|
||||
"Review the following code against the conformity checklist below. For each rule, output PASS or FAIL with a specific code callout. Then provide a conformity score."
|
||||
|
||||
## Rules
|
||||
|
||||
### Visual Token Conformity
|
||||
- [ ] **C-V1**: Colors use semantic Tailwind classes (`bg-primary`, `text-success`, `border-danger`) — no raw hex values in HTML class attributes or `:class` bindings
|
||||
- [ ] **C-V2**: No hardcoded hex in inline `:style` attributes or `@layer` CSS rules; use `var(--color-*)` or Tailwind classes instead
|
||||
- [ ] **C-V3**: Spacing uses Tailwind scale classes (`mt-4`, `px-6`, `py-16`) — no inline `style="margin: ..."` or `style="padding: ..."`
|
||||
- [ ] **C-V4**: Shadows use `shadow-soft-*` or `shadow-{color}` utilities — no custom `box-shadow` values in class attributes
|
||||
- [ ] **C-V5**: Border radius uses `rounded-*` utilities (`rounded-lg`, `rounded-2xl`, `rounded-full`) — no inline `border-radius` style values
|
||||
|
||||
### Component Conformity
|
||||
- [ ] **C-C1**: All buttons use `MkButton` component or the manual gradient pattern (`bg-gradient-{color} shadow-{color} text-white rounded-lg px-6 py-2.5`); dark-section buttons use `color="white"`; icon-only buttons are square with `p-2`
|
||||
- [ ] **C-C2**: Cards follow `rounded-2xl bg-white shadow-soft-md` base; card image headers use `-mt-6 mx-4 overflow-hidden rounded-xl` floating pattern; interactive cards use `move-on-hover`
|
||||
- [ ] **C-C3**: Form inputs use `MkInput` with `v-model`; raw `<input>` elements (if used) must have `rounded-lg border border-gray-200 px-3 py-2 focus:border-primary focus:ring-1 focus:ring-primary`
|
||||
- [ ] **C-C4**: Navigation uses `MkNavbar` with `navItems` prop; custom navbars must replicate scroll-blur behavior (`bg-white/90 backdrop-blur-sm`) and responsive collapse
|
||||
- [ ] **C-C5**: Alerts use `MkAlert` component or `rounded-lg px-4 py-3 text-sm bg-{color} text-white` — no plain unstyled `<div role="alert">`
|
||||
|
||||
### Layout Conformity
|
||||
- [ ] **C-L1**: Marketing pages use `max-w-7xl mx-auto px-4` as the page container — not full-bleed or custom `max-width` values
|
||||
- [ ] **C-L2**: Responsive breakpoints use Tailwind prefixes (`sm:`, `md:`, `lg:`, `xl:`) — no CSS media queries in `<style>` blocks unless unavoidable
|
||||
- [ ] **C-L3**: Content grids use `grid gap-6` with `grid-cols-{n}` and responsive column modifiers — not `flex flex-wrap` with manual width percentages
|
||||
- [ ] **C-L4**: Flex layouts use Tailwind utilities (`flex`, `items-center`, `justify-between`, `gap-4`) — no `display: flex` in inline styles
|
||||
|
||||
### Naming Conformity
|
||||
- [ ] **C-N1**: CSS custom properties follow the `@theme` naming convention: `--color-*`, `--shadow-*`, `--radius-*`, `--font-*`, `--background-image-*`
|
||||
- [ ] **C-N2**: No Bootstrap class names present (`.container`, `.row`, `.col-*`, `.btn`, `.badge`, `.card`, `.navbar`, `.form-control`, etc.)
|
||||
- [ ] **C-N3**: Vue components use PascalCase with `Mk` prefix (`MkButton`, `MkNavbar`, `MkDefaultCounterCard`); custom utility classes use kebab-case (`move-on-hover`, `text-gradient`)
|
||||
|
||||
## Scoring Guide
|
||||
- 15–17 rules pass → **Fully conformant** — ready for production
|
||||
- 12–14 rules pass → **Mostly conformant** — minor cleanup needed
|
||||
- 8–11 rules pass → **Partially conformant** — significant drift, review required
|
||||
- Below 8 → **Non-conformant** — major rework needed before shipping
|
||||
@@ -0,0 +1,212 @@
|
||||
# MK Design System
|
||||
|
||||
## Overview
|
||||
|
||||
- **Framework**: Vue 3.5+
|
||||
- **Styling**: Tailwind CSS v4 (CSS-first config, no tailwind.config.js)
|
||||
- **Font**: Roboto variable (self-hosted woff2), Roboto Slab, Material Icons Round
|
||||
- **Icons**: Material Icons Round via `.material-icons` class
|
||||
- **Color mode**: Light (CSS custom properties ready for dark mode extension)
|
||||
- **Token source**: `tokens.json` — `src/style.css` `@theme` block is the live source of truth
|
||||
|
||||
---
|
||||
|
||||
## Visual Tokens
|
||||
|
||||
### Colors
|
||||
| Token | CSS var | Hex | Usage |
|
||||
|-------|---------|-----|-------|
|
||||
| primary | `--color-primary` | #e91e63 | Buttons, links, key UI (pink/rose) |
|
||||
| secondary | `--color-secondary` | #7b809a | Secondary actions, muted text |
|
||||
| success | `--color-success` | #4caf50 | Positive states |
|
||||
| warning | `--color-warning` | #fb8c00 | Warning states |
|
||||
| danger | `--color-danger` | #f44335 | Error / destructive |
|
||||
| info | `--color-info` | #1a73e8 | Informational |
|
||||
| light | `--color-light` | #f0f2f5 | Page backgrounds |
|
||||
| dark | `--color-dark` | #344767 | Dark text / navy |
|
||||
|
||||
All generate `bg-*`, `text-*`, `border-*`, `ring-*` utilities automatically.
|
||||
|
||||
### Gradients
|
||||
195° linear gradients — this angle is the signature of the kit. **Do not change it.**
|
||||
|
||||
| Class | Direction |
|
||||
|-------|-----------|
|
||||
| `bg-gradient-primary` | #EC407A → #D81B60 |
|
||||
| `bg-gradient-success` | #66bb6a → #43a047 |
|
||||
| `bg-gradient-info` | #49a3f1 → #1a73e8 |
|
||||
| `bg-gradient-dark` | #42424a → #191919 |
|
||||
| …and 4 more (secondary/warning/danger/light) | |
|
||||
|
||||
Pair gradient classes with their colored shadow for the floating-card effect:
|
||||
```html
|
||||
<div class="bg-gradient-primary shadow-primary rounded-xl p-4 text-white">
|
||||
```
|
||||
|
||||
### Shadows
|
||||
|
||||
| Class | Use |
|
||||
|-------|-----|
|
||||
| `shadow-soft-xs` | Subtle hover feedback |
|
||||
| `shadow-soft-sm` | Default card lift |
|
||||
| `shadow-soft-md` | Standard card |
|
||||
| `shadow-soft-lg` | Elevated panels |
|
||||
| `shadow-blur` | Frosted-glass containers |
|
||||
| `shadow-primary` / `shadow-success` / … | Colored glow — always paired with matching gradient |
|
||||
|
||||
### Typography
|
||||
|
||||
| Class | Value | Use |
|
||||
|-------|-------|-----|
|
||||
| `font-sans` | Roboto variable | Body and UI |
|
||||
| `font-serif` | Roboto Slab variable | Display/editorial |
|
||||
| `font-mono` | SFMono / system | Code |
|
||||
| `font-light` | 300 | Large display headings |
|
||||
| `font-normal` | 400 | Body copy |
|
||||
| `font-medium` | 500 | Labels, nav |
|
||||
| `font-bold` | 700 | Headings, emphasis |
|
||||
| `font-black` | 900 | Counter cards, hero numbers |
|
||||
|
||||
### Spacing
|
||||
Use Tailwind's default scale. Common values: `p-4` (1rem), `p-6` (1.5rem), `py-16` (4rem) for section padding. No custom spacing tokens.
|
||||
|
||||
### Border Radius
|
||||
| Class | Value | Use |
|
||||
|-------|-------|-----|
|
||||
| `rounded-xs` | 0.1rem | Micro elements |
|
||||
| `rounded-lg` | 0.5rem | Inputs, small cards |
|
||||
| `rounded-xl` | 0.75rem | Card images, avatars |
|
||||
| `rounded-2xl` | 1rem | Cards, panels |
|
||||
| `rounded-full` | 9999px | Badges (pill), avatars (circle) |
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Button
|
||||
```html
|
||||
<!-- Via component (recommended) -->
|
||||
<MkButton variant="gradient" color="primary">Get Started</MkButton>
|
||||
<MkButton variant="outline" color="primary" size="sm">Learn More</MkButton>
|
||||
|
||||
<!-- Manual (dark sections) -->
|
||||
<button class="bg-white text-dark rounded-lg px-6 py-2.5 text-sm font-medium">
|
||||
Contact
|
||||
</button>
|
||||
```
|
||||
**Variants**: `contained` (flat fill) · `gradient` (195° gradient + colored shadow) · `outline`
|
||||
**Colors**: primary / secondary / success / warning / danger / info / light / white / dark
|
||||
**Sizes**: sm · md (default) · lg
|
||||
**Do**: Use `variant="gradient"` for primary CTAs. Use `color="white"` on dark/gradient backgrounds.
|
||||
**Don't**: Use `bg-primary` manually when `MkButton` is available.
|
||||
|
||||
### Card (marketing)
|
||||
```html
|
||||
<div class="rounded-2xl bg-white shadow-soft-md">
|
||||
<!-- Floating image header (overlaps card top) -->
|
||||
<div class="-mt-6 mx-4 z-10 overflow-hidden rounded-xl shadow-soft-lg">
|
||||
<img src="..." class="h-48 w-full object-cover" />
|
||||
</div>
|
||||
<div class="px-6 pb-6 pt-4">
|
||||
<h5 class="font-semibold text-dark">Title</h5>
|
||||
<p class="text-sm text-secondary">Body text.</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Navbar
|
||||
```html
|
||||
<MkNavbar
|
||||
:brand="{ name: 'My App', route: '/' }"
|
||||
:nav-items="[
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Pages', icon: 'dashboard', children: [
|
||||
{ label: 'About', href: '/about', description: 'Our story' },
|
||||
]},
|
||||
]"
|
||||
:action="{ label: 'Get Started', href: '/signup', color: 'primary' }"
|
||||
:transparent="true"
|
||||
/>
|
||||
```
|
||||
Scroll-triggered frosted glass is automatic in transparent mode.
|
||||
|
||||
### Form Inputs
|
||||
```html
|
||||
<MkInput label="Full Name" v-model="name" placeholder="e.g. Jane Doe" />
|
||||
<MkInput label="Email" type="email" v-model="email" :error="true" />
|
||||
<MkInput label="Search" icon="search" v-model="query" />
|
||||
<MkTextArea label="Message" v-model="msg" :rows="4" />
|
||||
<MkCheckbox id="terms" v-model="agreed" color="primary">I agree</MkCheckbox>
|
||||
<MkSwitch id="notify" v-model="notify" color="primary">Email notifications</MkSwitch>
|
||||
```
|
||||
|
||||
### Hero Section
|
||||
```html
|
||||
<MkHeader
|
||||
image="/img/hero.jpg"
|
||||
:title="{ text: 'Build Faster', variant: 'h1' }"
|
||||
description="A design system for Vue 3 and Tailwind v4."
|
||||
mask="dark"
|
||||
:mask-opacity="0.55"
|
||||
:center="true"
|
||||
min-height="80vh"
|
||||
>
|
||||
<MkButton variant="gradient" color="primary" class="mt-6">Get Started</MkButton>
|
||||
</MkHeader>
|
||||
```
|
||||
|
||||
### Gradient text
|
||||
```html
|
||||
<h1 class="text-gradient bg-gradient-primary text-5xl font-black">42,000+</h1>
|
||||
```
|
||||
`text-gradient` clips the background image to the text shape. Always pair with a `bg-gradient-*` class.
|
||||
|
||||
### Move-on-hover (interactive cards)
|
||||
```html
|
||||
<div class="move-on-hover rounded-2xl bg-white shadow-soft-md p-6">
|
||||
<!-- Lifts 4px on hover with ease transition -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout & Grid
|
||||
|
||||
| Element | Classes |
|
||||
|---------|---------|
|
||||
| Page container | `max-w-7xl mx-auto px-4` |
|
||||
| Section spacing | `py-16` (4rem) or `py-24` (6rem) for hero |
|
||||
| Grid (3-col) | `grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3` |
|
||||
| Grid (4-col) | `grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4` |
|
||||
| Flex row | `flex items-center gap-4` |
|
||||
| Flex wrap | `flex flex-wrap gap-3` |
|
||||
|
||||
Breakpoints (Tailwind v4 defaults): `sm` 640px · `md` 768px · `lg` 1024px · `xl` 1280px.
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **CSS custom properties**: `--color-*`, `--shadow-*`, `--radius-*`, `--font-*`, `--background-image-*`
|
||||
- **Vue components**: PascalCase with `Mk` prefix — `MkButton`, `MkNavbar`, `MkDefaultCounterCard`
|
||||
- **Custom CSS classes**: kebab-case — `move-on-hover`, `text-gradient`, `rotating-card-container`
|
||||
- **No Bootstrap class names** anywhere in the codebase
|
||||
|
||||
---
|
||||
|
||||
## Quick-Start Rules for Code Generation
|
||||
|
||||
When generating code using this design system:
|
||||
|
||||
1. Use Tailwind v4 utility classes only — no Bootstrap, no custom SCSS
|
||||
2. Reference colors via semantic class names (`bg-primary`, `text-success`) — never raw hex in HTML
|
||||
3. Spacing: use Tailwind scale (`mt-4`, `px-6`, `py-16`) — no inline `style` for spacing
|
||||
4. Primary CTAs: `MkButton variant="gradient" color="primary"` or `bg-gradient-primary shadow-primary text-white rounded-lg px-6 py-2.5`
|
||||
5. Dark-section buttons: `color="white"` (white background, dark text) — not `color="light"`
|
||||
6. Cards: `rounded-2xl bg-white shadow-soft-md` as the base; floating headers use `-mt-6 mx-4 rounded-xl overflow-hidden`
|
||||
7. Page layout: `max-w-7xl mx-auto px-4` container with `grid gap-6` inside
|
||||
8. Form fields: always use `MkInput`, `MkTextArea`, `MkCheckbox`, `MkSwitch` — not raw `<input>`
|
||||
9. Images inside cards: `rounded-xl object-cover` for consistent soft appearance
|
||||
10. Interactive cards: add `move-on-hover` class for hover lift effect
|
||||
11. Use `Mk*` components throughout — consult `src/components/` for available primitives
|
||||
12. Navbar scroll blur is automatic; pass `navItems` as data, not hardcoded HTML
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:lib": "BUILD_TARGET=lib vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
color: {
|
||||
type: String,
|
||||
default: 'success',
|
||||
validator: v => ['primary','secondary','success','warning','danger','error','info','light','dark','white'].includes(v),
|
||||
},
|
||||
dismissible: { type: Boolean, default: false },
|
||||
fontWeight: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['dismissed'])
|
||||
|
||||
const visible = ref(true)
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
emit('dismissed')
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'bg-primary text-white',
|
||||
secondary: 'bg-secondary text-white',
|
||||
success: 'bg-success text-white',
|
||||
warning: 'bg-warning text-white',
|
||||
danger: 'bg-danger text-white',
|
||||
error: 'bg-danger text-white',
|
||||
info: 'bg-info text-white',
|
||||
light: 'bg-light text-dark',
|
||||
dark: 'bg-dark text-white',
|
||||
white: 'bg-white text-dark border border-gray-200',
|
||||
}
|
||||
|
||||
const weightClasses = {
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
bold: 'font-bold',
|
||||
bolder: 'font-black',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'relative flex items-start gap-3 rounded-lg px-4 py-3 text-sm',
|
||||
colorClasses[props.color] ?? colorClasses.success,
|
||||
props.fontWeight ? weightClasses[props.fontWeight] : '',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="alert-fade">
|
||||
<div v-if="visible" :class="classes" role="alert">
|
||||
<slot />
|
||||
<button
|
||||
v-if="dismissible"
|
||||
type="button"
|
||||
class="ml-auto shrink-0 opacity-70 transition-opacity hover:opacity-100"
|
||||
aria-label="Close"
|
||||
@click="dismiss"
|
||||
>
|
||||
<span class="material-icons text-lg leading-none">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.alert-fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, max-height 0.25s ease;
|
||||
overflow: hidden;
|
||||
/* Must be a numeric value — CSS cannot transition from 'auto' to 0 */
|
||||
max-height: 200px;
|
||||
}
|
||||
.alert-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
max-height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
image: { type: String, required: true },
|
||||
alt: { type: String, required: true },
|
||||
size: { type: String, default: 'md' },
|
||||
borderRadius: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const sizes = {
|
||||
xxs: 'h-5 w-5',
|
||||
xs: 'h-6 w-6',
|
||||
sm: 'h-9 w-9',
|
||||
md: 'h-11 w-11',
|
||||
lg: 'h-14 w-14',
|
||||
xl: 'h-[4.5rem] w-[4.5rem]',
|
||||
xxl: 'h-24 w-24',
|
||||
}
|
||||
|
||||
const radii = {
|
||||
'': 'rounded-xl',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
'2xl':'rounded-2xl',
|
||||
full: 'rounded-full',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'object-cover',
|
||||
sizes[props.size] ?? sizes.md,
|
||||
radii[props.borderRadius] ?? radii[''],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="image" :alt="alt" :class="classes" />
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: v => ['sm', 'md', 'lg'].includes(v),
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'success',
|
||||
validator: v => ['primary','secondary','info','success','warning','error','danger','light','dark','white'].includes(v),
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'fill',
|
||||
validator: v => ['fill', 'gradient'].includes(v),
|
||||
},
|
||||
rounded: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const fill = {
|
||||
primary: 'bg-primary text-white',
|
||||
secondary: 'bg-secondary text-white',
|
||||
info: 'bg-info text-white',
|
||||
success: 'bg-success text-white',
|
||||
warning: 'bg-warning text-white',
|
||||
danger: 'bg-danger text-white',
|
||||
error: 'bg-danger text-white',
|
||||
light: 'bg-light text-dark',
|
||||
dark: 'bg-dark text-white',
|
||||
white: 'bg-white text-dark shadow-soft-xs',
|
||||
}
|
||||
|
||||
const grad = {
|
||||
primary: 'bg-gradient-primary shadow-primary text-white',
|
||||
secondary: 'bg-gradient-secondary shadow-secondary text-white',
|
||||
info: 'bg-gradient-info shadow-info 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',
|
||||
error: 'bg-gradient-danger shadow-danger text-white',
|
||||
light: 'bg-gradient-light shadow-soft-xs text-dark',
|
||||
dark: 'bg-gradient-dark shadow-dark text-white',
|
||||
white: 'bg-white shadow-soft-xs text-dark',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-xs',
|
||||
lg: 'px-3 py-1.5 text-sm',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center font-medium',
|
||||
props.rounded ? 'rounded-full' : 'rounded-md',
|
||||
sizes[props.size],
|
||||
(props.variant === 'gradient' ? grad : fill)[props.color] ?? fill.success,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="classes">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'contained',
|
||||
validator: v => ['contained', 'gradient', 'outline'].includes(v),
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: v => ['primary','secondary','info','success','warning','danger','error','light','white','dark','none'].includes(v),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: v => ['sm', 'md', 'lg'].includes(v),
|
||||
},
|
||||
fullWidth: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
// Static string maps — Tailwind scanner reads these to include the classes.
|
||||
const contained = {
|
||||
primary: 'bg-primary text-white',
|
||||
secondary: 'bg-secondary text-white',
|
||||
success: 'bg-success text-white',
|
||||
warning: 'bg-warning text-white',
|
||||
danger: 'bg-danger text-white',
|
||||
error: 'bg-danger text-white',
|
||||
info: 'bg-info text-white',
|
||||
light: 'bg-light text-dark',
|
||||
white: 'bg-white text-dark shadow-soft-sm',
|
||||
dark: 'bg-dark text-white',
|
||||
none: 'bg-transparent text-dark',
|
||||
}
|
||||
|
||||
const gradient = {
|
||||
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',
|
||||
error: 'bg-gradient-danger shadow-danger text-white',
|
||||
info: 'bg-gradient-info shadow-info text-white',
|
||||
light: 'bg-gradient-light shadow-soft-sm text-dark',
|
||||
white: 'bg-white shadow-soft-md text-dark',
|
||||
dark: 'bg-gradient-dark shadow-dark text-white',
|
||||
none: 'bg-transparent text-dark',
|
||||
}
|
||||
|
||||
const outline = {
|
||||
primary: 'border border-primary text-primary hover:bg-primary/10',
|
||||
secondary: 'border border-secondary text-secondary hover:bg-secondary/10',
|
||||
success: 'border border-success text-success hover:bg-success/10',
|
||||
warning: 'border border-warning text-warning hover:bg-warning/10',
|
||||
danger: 'border border-danger text-danger hover:bg-danger/10',
|
||||
error: 'border border-danger text-danger hover:bg-danger/10',
|
||||
info: 'border border-info text-info hover:bg-info/10',
|
||||
light: 'border border-gray-300 text-dark hover:bg-light',
|
||||
white: 'border border-white text-white hover:bg-white/10',
|
||||
dark: 'border border-dark text-dark hover:bg-dark/10',
|
||||
none: 'border border-gray-300 text-dark hover:bg-gray-100',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-4 py-1.5 text-xs',
|
||||
md: 'px-6 py-2.5 text-sm',
|
||||
lg: 'px-8 py-3.5 text-base',
|
||||
}
|
||||
|
||||
const colorMap = { contained, gradient, outline }
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center justify-center gap-2 font-medium rounded-lg cursor-pointer select-none',
|
||||
'transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0',
|
||||
colorMap[props.variant]?.[props.color] ?? '',
|
||||
sizes[props.size],
|
||||
props.fullWidth && 'w-full',
|
||||
props.disabled && 'opacity-60 pointer-events-none cursor-not-allowed',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="classes" :disabled="disabled">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, default: '' },
|
||||
color: { type: String, default: 'dark' },
|
||||
modelValue: { type: Boolean, default: false },
|
||||
inputClass: { type: String, default: '' },
|
||||
labelClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
// Use CSS custom properties from @theme to drive accent-color.
|
||||
// This avoids generating per-color accent-* utilities.
|
||||
const accentStyle = computed(() => ({
|
||||
accentColor: props.color ? `var(--color-${props.color})` : undefined,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start gap-2.5">
|
||||
<input
|
||||
:id="id"
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:class="['h-4 w-4 cursor-pointer rounded border-gray-300 transition-colors', inputClass]"
|
||||
:style="accentStyle"
|
||||
v-bind="$attrs"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<label
|
||||
v-if="$slots.default"
|
||||
:for="id"
|
||||
:class="['cursor-pointer select-none text-sm text-dark', labelClass]"
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
label: { type: [String, Object],default: '' },
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
size: { type: String, default: 'md' },
|
||||
error: { type: Boolean, default: false },
|
||||
success: { type: Boolean, default: false },
|
||||
isRequired: { type: Boolean, default: false },
|
||||
isDisabled: { type: Boolean, default: false },
|
||||
inputClass: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const labelText = computed(() =>
|
||||
typeof props.label === 'string' ? props.label : props.label?.text ?? ''
|
||||
)
|
||||
const labelExtraClass = computed(() =>
|
||||
typeof props.label === 'object' ? props.label?.class ?? '' : ''
|
||||
)
|
||||
|
||||
const sizes = {
|
||||
sm: 'py-1.5 text-xs',
|
||||
md: 'py-2 text-sm',
|
||||
lg: 'py-2.5 text-base',
|
||||
}
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (props.error) return 'border-danger focus:border-danger focus:ring-1 focus:ring-danger'
|
||||
if (props.success) return 'border-success focus:border-success focus:ring-1 focus:ring-success'
|
||||
return 'border-gray-200 focus:border-primary focus:ring-1 focus:ring-primary'
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
'w-full rounded-lg border bg-white text-dark outline-none transition-colors placeholder:text-gray-400',
|
||||
props.icon ? 'pl-9 pr-3' : 'px-3',
|
||||
sizes[props.size] ?? sizes.md,
|
||||
borderClass.value,
|
||||
props.isDisabled && 'cursor-not-allowed opacity-60 bg-gray-50',
|
||||
props.inputClass,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
v-if="labelText"
|
||||
:for="id"
|
||||
:class="['mb-1 block text-xs font-medium text-secondary', labelExtraClass]"
|
||||
>
|
||||
{{ labelText }}
|
||||
<span v-if="isRequired" class="text-danger">*</span>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="icon"
|
||||
class="material-icons pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-base text-secondary"
|
||||
>
|
||||
{{ icon }}
|
||||
</span>
|
||||
|
||||
<input
|
||||
:id="id"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:required="isRequired"
|
||||
:disabled="isDisabled"
|
||||
:class="inputClasses"
|
||||
v-bind="$attrs"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error && typeof error === 'string'" class="mt-1 text-xs text-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-if="success && typeof success === 'string'" class="mt-1 text-xs text-success">
|
||||
{{ success }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { provide } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
color: { type: String, default: 'primary' },
|
||||
size: { type: String, default: 'md' },
|
||||
})
|
||||
|
||||
provide('mk-pagination-color', props.color)
|
||||
provide('mk-pagination-size', props.size)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex list-none items-center gap-1 p-0">
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, default: '' },
|
||||
active: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
prev: { type: Boolean, default: false },
|
||||
next: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const color = inject('mk-pagination-color', 'primary')
|
||||
const size = inject('mk-pagination-size', 'md')
|
||||
|
||||
const activeClasses = {
|
||||
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',
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-7 min-w-7 text-xs',
|
||||
md: 'h-9 min-w-9 text-sm',
|
||||
lg: 'h-11 min-w-11 text-base',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'flex cursor-pointer select-none items-center justify-center rounded-lg px-2 font-medium transition-all duration-200',
|
||||
sizeClasses[size] ?? sizeClasses.md,
|
||||
props.active
|
||||
? (activeClasses[color] ?? activeClasses.primary)
|
||||
: 'text-secondary hover:bg-gray-100 hover:text-dark',
|
||||
props.disabled && 'pointer-events-none opacity-40',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<button :class="classes" @click="$emit('click')">
|
||||
<span v-if="prev" class="material-icons text-base leading-none">chevron_left</span>
|
||||
<span v-else-if="next" class="material-icons text-base leading-none">chevron_right</span>
|
||||
<span v-else>{{ label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'contained',
|
||||
validator: v => ['contained', 'gradient'].includes(v),
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: v => ['primary','secondary','info','success','warning','danger','error','light','dark'].includes(v),
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const contained = {
|
||||
primary: 'bg-primary',
|
||||
secondary: 'bg-secondary',
|
||||
info: 'bg-info',
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
danger: 'bg-danger',
|
||||
error: 'bg-danger',
|
||||
light: 'bg-light',
|
||||
dark: 'bg-dark',
|
||||
}
|
||||
|
||||
const grad = {
|
||||
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 barClass = computed(() =>
|
||||
(props.variant === 'gradient' ? grad : contained)[props.color] ?? contained.primary
|
||||
)
|
||||
|
||||
const pct = computed(() => Math.min(100, Math.max(0, props.value)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full overflow-hidden rounded-full bg-gray-200" style="height: 6px">
|
||||
<div
|
||||
:class="barClass"
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
role="progressbar"
|
||||
:style="{ width: `${pct}%` }"
|
||||
:aria-valuenow="pct"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
route: { type: String, required: true },
|
||||
color: { type: String, required: true },
|
||||
component: { type: String, required: true },
|
||||
label: { type: String, default: '' },
|
||||
})
|
||||
|
||||
// Same contained map as MkButton — drives bg + text color.
|
||||
const colorClasses = {
|
||||
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',
|
||||
white: 'bg-white text-dark shadow-soft-sm hover:shadow-soft-md',
|
||||
facebook: 'bg-[#3b5998] text-white hover:bg-[#344e86]',
|
||||
twitter: 'bg-[#1da1f2] text-white hover:bg-[#0d8fd9]',
|
||||
instagram: 'bg-[#e1306c] text-white hover:bg-[#c82761]',
|
||||
youtube: 'bg-[#ff0000] text-white hover:bg-[#e60000]',
|
||||
linkedin: 'bg-[#0077b5] text-white hover:bg-[#00669c]',
|
||||
github: 'bg-[#333333] text-white hover:bg-[#222222]',
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||
colorClasses[props.color] ?? colorClasses.secondary,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a :href="route" :class="classes" target="_blank" rel="noopener">
|
||||
<!--
|
||||
Renders a Font Awesome brand icon. Requires Font Awesome Free 6
|
||||
to be loaded in the project (e.g. via CDN or npm).
|
||||
The `component` prop maps to the FA icon slug: "facebook", "twitter", etc.
|
||||
-->
|
||||
<i class="fab" :class="`fa-${component}`" aria-hidden="true" />
|
||||
<span v-if="label">{{ label }}</span>
|
||||
</a>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
modelValue: { type: Boolean, default: false },
|
||||
color: { type: String, default: 'primary' },
|
||||
labelClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
// Drive the checked background via CSS custom property so we don't
|
||||
// need per-color utility classes for the track.
|
||||
const trackStyle = computed(() =>
|
||||
props.modelValue
|
||||
? { backgroundColor: `var(--color-${props.color})` }
|
||||
: {}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
:id="id"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="String(modelValue)"
|
||||
class="relative inline-flex h-5 w-10 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-300 transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
|
||||
:style="trackStyle"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200"
|
||||
:class="modelValue ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<label
|
||||
v-if="$slots.default"
|
||||
:for="id"
|
||||
:class="['cursor-pointer select-none text-sm text-dark', labelClass]"
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
|
||||
<div v-if="$slots.description" class="mt-0.5 text-xs text-secondary">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
headers: {
|
||||
type: Array,
|
||||
default: () => ['Author', 'Function', 'Status', 'Employed', 'Action'],
|
||||
},
|
||||
/**
|
||||
* Each row: {
|
||||
* image?: string — avatar URL (falls back to initials)
|
||||
* initials?: string — shown when no image
|
||||
* color?: string — gradient color for initials avatar
|
||||
* name: string
|
||||
* email: string
|
||||
* position: [string, string] — [label, sublabel]
|
||||
* status: boolean — true = Online, false = Offline
|
||||
* date: string
|
||||
* action: { label, route }
|
||||
* }
|
||||
*/
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const avatarGradients = {
|
||||
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',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-soft-md">
|
||||
|
||||
<!-- Optional slot for a card header (title, actions, etc.) -->
|
||||
<div v-if="$slots.header" class="border-b border-gray-100 px-6 py-4">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-100">
|
||||
<th
|
||||
v-for="(header, i) in headers"
|
||||
:key="header"
|
||||
class="px-6 py-3 text-xs font-bold uppercase tracking-wide text-secondary"
|
||||
:class="i > 1 ? 'text-center' : ''"
|
||||
>
|
||||
{{ header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, i) in rows"
|
||||
:key="i"
|
||||
:class="i < rows.length - 1 ? 'border-b border-gray-100' : ''"
|
||||
>
|
||||
<!-- Author: avatar + name + email -->
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Photo avatar -->
|
||||
<img
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
:alt="row.name"
|
||||
class="h-9 w-9 shrink-0 rounded-xl object-cover"
|
||||
/>
|
||||
<!-- Initials avatar -->
|
||||
<div
|
||||
v-else
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-bold text-white"
|
||||
:class="avatarGradients[row.color] ?? avatarGradients.dark"
|
||||
>
|
||||
{{ row.initials ?? row.name?.slice(0, 2).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-medium text-dark">{{ row.name }}</p>
|
||||
<p class="text-xs text-secondary">{{ row.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Position: two-line label -->
|
||||
<td class="px-6 py-3">
|
||||
<p class="font-medium text-dark">{{ row.position?.[0] ?? '—' }}</p>
|
||||
<p class="text-xs text-secondary">{{ row.position?.[1] ?? '' }}</p>
|
||||
</td>
|
||||
|
||||
<!-- Status badge -->
|
||||
<td class="px-6 py-3 text-center">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:class="row.status
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-gray-100 text-secondary'"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="row.status ? 'bg-success' : 'bg-gray-400'"
|
||||
/>
|
||||
{{ row.status ? 'Online' : 'Offline' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Date -->
|
||||
<td class="px-6 py-3 text-center text-xs text-secondary">
|
||||
{{ row.date }}
|
||||
</td>
|
||||
|
||||
<!-- Action -->
|
||||
<td class="px-6 py-3 text-center">
|
||||
<a
|
||||
:href="row.action?.route ?? '#'"
|
||||
class="text-xs font-medium text-secondary no-underline transition-colors hover:text-primary"
|
||||
>
|
||||
{{ row.action?.label ?? 'Edit' }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Optional slot for pagination or footer -->
|
||||
<div v-if="$slots.footer" class="border-t border-gray-100 px-6 py-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, default: 'message' },
|
||||
rows: { type: Number, default: 4 },
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
labelClass: { type: String, default: '' },
|
||||
error: { type: Boolean, default: false },
|
||||
success: { type: Boolean, default: false },
|
||||
isDisabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const borderClass = computed(() => {
|
||||
if (props.error) return 'border-danger focus:border-danger focus:ring-1 focus:ring-danger'
|
||||
if (props.success) return 'border-success focus:border-success focus:ring-1 focus:ring-success'
|
||||
return 'border-gray-200 focus:border-primary focus:ring-1 focus:ring-primary'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
v-if="$slots.default"
|
||||
:for="id"
|
||||
:class="['mb-1 block text-xs font-medium text-secondary', labelClass]"
|
||||
>
|
||||
<slot />
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
:id="id"
|
||||
:rows="rows"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="isDisabled"
|
||||
:class="[
|
||||
'w-full resize-none rounded-lg border bg-white px-3 py-2 text-sm text-dark outline-none transition-colors placeholder:text-gray-400',
|
||||
borderClass,
|
||||
isDisabled && 'cursor-not-allowed opacity-60 bg-gray-50',
|
||||
]"
|
||||
v-bind="$attrs"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,47 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
// Accepts HTML — e.g. "<span class='font-bold'>+15%</span> increase today"
|
||||
subtitle: { type: String, default: '' },
|
||||
// Timestamp label shown in footer — e.g. "updated 4 min ago"
|
||||
update: { type: String, default: '' },
|
||||
color: { type: String, default: 'primary' },
|
||||
})
|
||||
|
||||
const gradientMap = {
|
||||
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',
|
||||
light: 'bg-gradient-light shadow-soft-sm',
|
||||
}
|
||||
|
||||
const headerClass = computed(() => gradientMap[props.color] ?? gradientMap.primary)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-2xl bg-white shadow-soft-md overflow-visible">
|
||||
<!-- Floating chart header — slot content sits inside the gradient area -->
|
||||
<div class="relative -mt-6 mx-4 z-10 overflow-hidden rounded-xl px-3 py-3" :class="headerClass">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-4 pt-3 pb-4">
|
||||
<h6 class="mb-0 font-bold text-dark">{{ title }}</h6>
|
||||
<p class="text-sm text-secondary mt-0.5" v-html="subtitle" />
|
||||
|
||||
<div class="my-3 border-t border-gray-100" />
|
||||
|
||||
<div class="flex items-center gap-1 text-sm text-secondary">
|
||||
<span class="material-icons text-base leading-none">schedule</span>
|
||||
{{ update }}
|
||||
</div>
|
||||
</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,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
// v-bind="$attrs" in the template passes attrs to the info wrapper, not the root
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
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,61 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// { text: 'Label', value: '$53k' }
|
||||
title: { type: Object, required: true },
|
||||
// HTML string — e.g. "<span class='text-success'>+55%</span> than last week"
|
||||
detail: { type: String, default: '' },
|
||||
// { name: 'weekend', color: 'white'|color-name, background: color-name }
|
||||
icon: {
|
||||
type: Object,
|
||||
default: () => ({ name: 'bar_chart', color: 'white', background: 'dark' }),
|
||||
},
|
||||
// Swap icon to the right, value to the left
|
||||
directionReverse: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const gradientMap = {
|
||||
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',
|
||||
light: 'bg-gradient-light shadow-soft-sm',
|
||||
}
|
||||
|
||||
const iconClass = computed(() => gradientMap[props.icon.background] ?? gradientMap.dark)
|
||||
const iconTextClass = computed(() =>
|
||||
props.icon.color === 'white' ? 'text-white' : `text-${props.icon.color}`
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-2xl bg-white shadow-soft-md">
|
||||
<!-- Header: floating icon + metric -->
|
||||
<div class="relative px-4 pt-2 pb-3">
|
||||
<div
|
||||
class="absolute -top-5 flex h-14 w-14 items-center justify-center rounded-xl transition-all"
|
||||
:class="[iconClass, directionReverse ? 'right-4' : 'left-4']"
|
||||
>
|
||||
<span class="material-icons text-2xl opacity-90" :class="iconTextClass">
|
||||
{{ icon.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pt-1" :class="directionReverse ? 'text-left pl-16' : 'text-right'">
|
||||
<p class="mb-0 text-sm text-secondary capitalize">{{ title.text }}</p>
|
||||
<h4 class="mb-0 text-xl font-bold text-dark">{{ title.value }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 border-t border-gray-100" />
|
||||
|
||||
<!-- Footer: detail text -->
|
||||
<div class="px-4 py-3">
|
||||
<p class="mb-0 text-sm text-secondary" v-html="detail" />
|
||||
</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,64 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// Material icon name, e.g. 'notifications'
|
||||
icon: { type: String, default: 'circle' },
|
||||
// Design-system color name for the icon dot
|
||||
color: { type: String, default: 'success' },
|
||||
title: { type: String, required: true },
|
||||
dateTime: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
// Pass true on the last item to hide the connector line
|
||||
last: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const dark = inject('mk-timeline-dark', false)
|
||||
|
||||
// Explicit map — avoids dynamic `bg-${color}/10` which Tailwind can't scan
|
||||
const dotMap = {
|
||||
primary: 'bg-primary/10 text-primary',
|
||||
secondary: 'bg-secondary/10 text-secondary',
|
||||
success: 'bg-success/10 text-success',
|
||||
warning: 'bg-warning/10 text-warning',
|
||||
danger: 'bg-danger/10 text-danger',
|
||||
info: 'bg-info/10 text-info',
|
||||
dark: 'bg-dark/10 text-dark',
|
||||
}
|
||||
|
||||
const dotClass = computed(() => dotMap[props.color] ?? dotMap.success)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-4" :class="last ? '' : 'mb-1'">
|
||||
<!-- Icon dot + vertical connector -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
:class="dotClass"
|
||||
>
|
||||
<span class="material-icons text-base leading-none">{{ icon }}</span>
|
||||
</div>
|
||||
<!-- Connector line — hidden on last item -->
|
||||
<div v-if="!last" class="mt-1 w-px flex-1 bg-gray-200" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="pb-4" :class="last ? 'pb-1' : ''">
|
||||
<h6
|
||||
class="mb-0 text-sm font-bold leading-snug"
|
||||
:class="dark ? 'text-white' : 'text-dark'"
|
||||
>
|
||||
{{ title }}
|
||||
</h6>
|
||||
<p class="mt-0.5 mb-0 text-xs text-secondary">{{ dateTime }}</p>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-2 mb-0 text-sm"
|
||||
:class="dark ? 'text-white/80' : 'text-secondary'"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { provide } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
description: { type: String, default: '' },
|
||||
dark: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
// MkTimelineItem reads this to adapt its text colors
|
||||
provide('mk-timeline-dark', props.dark)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-2xl shadow-soft-md h-full"
|
||||
:class="dark ? 'bg-gradient-dark' : 'bg-white'"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="px-4 pt-4 pb-0">
|
||||
<h6 class="font-bold" :class="dark ? 'text-white' : 'text-dark'">{{ title }}</h6>
|
||||
<p class="text-sm text-secondary" v-html="description" />
|
||||
</div>
|
||||
|
||||
<!-- Timeline items -->
|
||||
<div class="px-4 pt-2 pb-4">
|
||||
<slot />
|
||||
</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,48 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
// [{ route: '/path', label: 'Label' }]
|
||||
routes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<li
|
||||
v-for="({ route, label }, index) in routes"
|
||||
:key="index"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<!-- Separator (skip for first item) -->
|
||||
<span
|
||||
v-if="index > 0"
|
||||
class="material-icons text-base leading-none text-gray-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
|
||||
<!-- Link (all except last) -->
|
||||
<a
|
||||
v-if="index < routes.length - 1"
|
||||
:href="route"
|
||||
class="font-medium text-secondary no-underline transition-colors hover:text-dark"
|
||||
>
|
||||
{{ label }}
|
||||
</a>
|
||||
|
||||
<!-- Current page (last item) -->
|
||||
<span
|
||||
v-else
|
||||
class="font-medium text-dark"
|
||||
aria-current="page"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
brand: {
|
||||
type: Object,
|
||||
default: () => ({ name: 'Brand', logo: '', route: '/' }),
|
||||
},
|
||||
// [{ icon: '<i class="fab fa-...">' or material-icon-name, link, label? }]
|
||||
socials: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// [{ name: 'Column Title', items: [{ name, href }] }]
|
||||
menus: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
copyright: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
// Detect HTML-string icons vs material icon names
|
||||
function isHtml(str) {
|
||||
return typeof str === 'string' && str.trimStart().startsWith('<')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-gray-100 bg-white pt-12 pb-8">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<div class="grid gap-8 lg:grid-cols-5">
|
||||
|
||||
<!-- Brand column -->
|
||||
<div class="lg:col-span-1">
|
||||
<a :href="brand.route" class="inline-block">
|
||||
<img v-if="brand.logo" :src="brand.logo" :alt="brand.name" class="mb-3 h-8 w-auto" />
|
||||
<span v-else class="text-base font-bold text-dark">{{ brand.name }}</span>
|
||||
</a>
|
||||
<p v-if="!brand.logo" class="mt-2 text-xs text-secondary">{{ brand.name }}</p>
|
||||
|
||||
<!-- Socials -->
|
||||
<div v-if="socials.length" class="mt-4 flex items-center gap-3">
|
||||
<a
|
||||
v-for="s in socials"
|
||||
:key="s.link"
|
||||
:href="s.link"
|
||||
:aria-label="s.label"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-secondary transition-colors hover:bg-gray-100 hover:text-dark"
|
||||
>
|
||||
<!-- HTML icon string (Font Awesome etc.) -->
|
||||
<span v-if="isHtml(s.icon)" v-html="s.icon" />
|
||||
<!-- Material icon name -->
|
||||
<span v-else class="material-icons text-base leading-none">{{ s.icon }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu columns -->
|
||||
<div
|
||||
v-for="menu in menus"
|
||||
:key="menu.name"
|
||||
class="lg:col-span-1"
|
||||
>
|
||||
<h6 class="mb-3 text-xs font-bold uppercase tracking-wider text-dark">
|
||||
{{ menu.name }}
|
||||
</h6>
|
||||
<ul class="space-y-2 list-none p-0">
|
||||
<li v-for="item in menu.items" :key="item.name">
|
||||
<a
|
||||
:href="item.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm text-secondary no-underline transition-colors hover:text-dark"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Copyright -->
|
||||
<div class="mt-10 border-t border-gray-100 pt-6 text-center">
|
||||
<p class="text-xs text-secondary">
|
||||
{{ copyright || `Copyright © ${year} — All rights reserved.` }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
// [{ name, href }]
|
||||
links: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// [{ icon: HTML-string or material-icon-name, link, label? }]
|
||||
socials: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
copyright: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
function isHtml(str) {
|
||||
return typeof str === 'string' && str.trimStart().startsWith('<')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="border-t border-gray-100 bg-white py-10">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
|
||||
<!-- Nav links -->
|
||||
<div v-if="links.length" class="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
|
||||
<a
|
||||
v-for="link in links"
|
||||
:key="link.name"
|
||||
:href="link.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm text-secondary no-underline transition-colors hover:text-dark"
|
||||
>
|
||||
{{ link.name }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Social icons -->
|
||||
<div v-if="socials.length" class="mt-6 flex items-center justify-center gap-4">
|
||||
<a
|
||||
v-for="s in socials"
|
||||
:key="s.link"
|
||||
:href="s.link"
|
||||
:aria-label="s.label"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-secondary transition-colors hover:bg-gray-100 hover:text-dark"
|
||||
>
|
||||
<span v-if="isHtml(s.icon)" v-html="s.icon" />
|
||||
<span v-else class="material-icons text-lg leading-none">{{ s.icon }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Copyright -->
|
||||
<p class="mt-6 text-center text-xs text-secondary">
|
||||
{{ copyright || `Copyright © ${year} — All rights reserved.` }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
image: { type: String, default: '' },
|
||||
// String, or { text, variant: 'h1'–'h6', class }
|
||||
title: { type: [String, Object], default: '' },
|
||||
// String, or { text, class }
|
||||
description: { type: [String, Object], default: '' },
|
||||
// Overlay color name, or full class string, or '' for no overlay
|
||||
mask: { type: String, default: 'dark' },
|
||||
// 0.0–1.0 overlay opacity
|
||||
maskOpacity: { type: Number, default: 0.5 },
|
||||
center: { type: Boolean, default: false },
|
||||
minHeight: { type: String, default: '75vh' },
|
||||
fullWidth: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const titleText = computed(() => typeof props.title === 'string' ? props.title : props.title?.text ?? '')
|
||||
const titleTag = computed(() => typeof props.title === 'object' && props.title?.variant ? props.title.variant : 'h1')
|
||||
const titleClass = computed(() => typeof props.title === 'object' ? props.title?.class ?? '' : '')
|
||||
|
||||
const descText = computed(() => typeof props.description === 'string' ? props.description : props.description?.text ?? '')
|
||||
const descClass = computed(() => typeof props.description === 'object' ? props.description?.class ?? '' : '')
|
||||
|
||||
// Resolve mask to a gradient background class or custom class
|
||||
const gradientMap = {
|
||||
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-gradient-light',
|
||||
}
|
||||
const maskClass = computed(() => gradientMap[props.mask] ?? props.mask ?? 'bg-gradient-dark')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div
|
||||
class="relative flex items-center overflow-hidden bg-cover bg-center"
|
||||
:style="{ backgroundImage: image ? `url(${image})` : undefined, minHeight }"
|
||||
>
|
||||
<!-- Gradient mask overlay -->
|
||||
<div
|
||||
v-if="mask"
|
||||
class="absolute inset-0"
|
||||
:class="maskClass"
|
||||
:style="{ opacity: maskOpacity }"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div
|
||||
class="relative z-10 w-full"
|
||||
:class="fullWidth ? 'px-6' : 'mx-auto max-w-7xl px-6'"
|
||||
>
|
||||
<div :class="center ? 'mx-auto max-w-2xl text-center' : 'max-w-xl'">
|
||||
<component
|
||||
:is="titleTag"
|
||||
class="font-bold text-white"
|
||||
:class="[titleClass, center ? 'text-4xl lg:text-5xl' : 'text-3xl lg:text-4xl']"
|
||||
>
|
||||
{{ titleText }}
|
||||
</component>
|
||||
|
||||
<p
|
||||
v-if="descText"
|
||||
class="mt-4 text-lg text-white/80 leading-relaxed"
|
||||
:class="descClass"
|
||||
>
|
||||
{{ descText }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,251 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
brand: {
|
||||
type: Object,
|
||||
default: () => ({ name: 'Brand', route: '/' }),
|
||||
},
|
||||
// [{ label, href?, icon?, children?: [{ label, href, description? }] }]
|
||||
navItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// { label, href, color }
|
||||
action: {
|
||||
type: Object,
|
||||
default: () => ({ label: 'Get Started', href: '#', color: 'primary' }),
|
||||
},
|
||||
transparent: { type: Boolean, default: false },
|
||||
dark: { type: Boolean, default: false },
|
||||
sticky: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
// ── Scroll blur ───────────────────────────────────────────────
|
||||
const scrolled = ref(false)
|
||||
|
||||
function onScroll() { scrolled.value = window.scrollY > 20 }
|
||||
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }))
|
||||
onUnmounted(() => window.removeEventListener('scroll', onScroll))
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────────
|
||||
const mobileOpen = ref(false)
|
||||
const openDropdown = ref(null)
|
||||
|
||||
function toggleDropdown(label) {
|
||||
openDropdown.value = openDropdown.value === label ? null : label
|
||||
}
|
||||
|
||||
// Close dropdowns on outside click
|
||||
function onDocClick(e) {
|
||||
if (!e.target.closest('[data-nav-item]')) openDropdown.value = null
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
|
||||
// ── Computed styles ───────────────────────────────────────────
|
||||
const isBlurred = computed(() => props.transparent && scrolled.value)
|
||||
|
||||
const navClass = computed(() => {
|
||||
if (props.dark) return 'bg-gradient-dark shadow-dark'
|
||||
if (props.transparent && !scrolled.value) return 'bg-transparent'
|
||||
return 'bg-white/90 backdrop-blur-sm shadow-soft-sm'
|
||||
})
|
||||
|
||||
const textClass = computed(() => {
|
||||
if (props.dark) return 'text-white'
|
||||
if (props.transparent && !scrolled.value) return 'text-white'
|
||||
return 'text-dark'
|
||||
})
|
||||
|
||||
const actionColors = {
|
||||
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',
|
||||
white: 'bg-white text-dark shadow-soft-sm',
|
||||
}
|
||||
const actionClass = computed(() => actionColors[props.action.color] ?? actionColors.primary)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="top-0 z-30 w-full transition-all duration-300"
|
||||
:class="[navClass, sticky ? 'sticky' : 'absolute']"
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
|
||||
<!-- Brand -->
|
||||
<a
|
||||
:href="brand.route"
|
||||
class="text-sm font-bold no-underline transition-opacity hover:opacity-80"
|
||||
:class="textClass"
|
||||
>
|
||||
{{ brand.name }}
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<ul class="hidden items-center gap-1 list-none lg:flex">
|
||||
<li
|
||||
v-for="item in navItems"
|
||||
:key="item.label"
|
||||
class="relative"
|
||||
data-nav-item
|
||||
>
|
||||
<!-- Item with children (dropdown) -->
|
||||
<template v-if="item.children?.length">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="[textClass, 'hover:bg-black/5']"
|
||||
@click.stop="toggleDropdown(item.label)"
|
||||
>
|
||||
<span v-if="item.icon" class="material-icons text-base leading-none opacity-60">{{ item.icon }}</span>
|
||||
{{ item.label }}
|
||||
<span class="material-icons text-sm leading-none opacity-60 transition-transform duration-200"
|
||||
:class="openDropdown === item.label ? 'rotate-180' : ''">
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown panel -->
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="openDropdown === item.label"
|
||||
class="absolute left-0 top-full mt-2 w-52 rounded-xl bg-white p-2 shadow-soft-lg"
|
||||
>
|
||||
<template v-for="child in item.children" :key="child.label">
|
||||
<a
|
||||
:href="child.href"
|
||||
class="flex flex-col rounded-lg px-3 py-2.5 text-sm text-dark no-underline transition-colors hover:bg-gray-50"
|
||||
@click="openDropdown = null"
|
||||
>
|
||||
<span class="font-medium">{{ child.label }}</span>
|
||||
<span v-if="child.description" class="mt-0.5 text-xs text-secondary">{{ child.description }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- Plain link -->
|
||||
<a
|
||||
v-else
|
||||
:href="item.href"
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium no-underline transition-colors"
|
||||
:class="[textClass, 'hover:bg-black/5']"
|
||||
>
|
||||
<span v-if="item.icon" class="material-icons text-base leading-none opacity-60">{{ item.icon }}</span>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- CTA + mobile toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
:href="action.href"
|
||||
class="hidden rounded-lg px-5 py-2 text-sm font-medium no-underline transition-all hover:-translate-y-0.5 lg:inline-flex"
|
||||
:class="actionClass"
|
||||
>
|
||||
{{ action.label }}
|
||||
</a>
|
||||
|
||||
<!-- Hamburger -->
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-black/5 lg:hidden"
|
||||
:class="textClass"
|
||||
@click="mobileOpen = !mobileOpen"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<span class="material-icons">{{ mobileOpen ? 'close' : 'menu' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<Transition name="mobile-menu">
|
||||
<div v-if="mobileOpen" class="border-t border-gray-100 bg-white lg:hidden">
|
||||
<div class="mx-auto max-w-7xl px-4 py-3 space-y-1">
|
||||
<template v-for="item in navItems" :key="item.label">
|
||||
<!-- Mobile dropdown -->
|
||||
<template v-if="item.children?.length">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium text-dark hover:bg-gray-50"
|
||||
@click="toggleDropdown(item.label)"
|
||||
>
|
||||
<span v-if="item.icon" class="material-icons text-base leading-none opacity-60">{{ item.icon }}</span>
|
||||
{{ item.label }}
|
||||
<span class="material-icons ml-auto text-sm leading-none opacity-60 transition-transform duration-200"
|
||||
:class="openDropdown === item.label ? 'rotate-180' : ''">expand_more</span>
|
||||
</button>
|
||||
<div v-if="openDropdown === item.label" class="ml-6 space-y-1">
|
||||
<a
|
||||
v-for="child in item.children"
|
||||
:key="child.label"
|
||||
:href="child.href"
|
||||
class="block rounded-lg px-3 py-2 text-sm text-secondary no-underline hover:bg-gray-50 hover:text-dark"
|
||||
@click="mobileOpen = false"
|
||||
>
|
||||
{{ child.label }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mobile plain link -->
|
||||
<a
|
||||
v-else
|
||||
:href="item.href"
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium text-dark no-underline hover:bg-gray-50"
|
||||
@click="mobileOpen = false"
|
||||
>
|
||||
<span v-if="item.icon" class="material-icons text-base leading-none opacity-60">{{ item.icon }}</span>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Mobile CTA -->
|
||||
<div class="pt-2 pb-1">
|
||||
<a
|
||||
:href="action.href"
|
||||
class="flex w-full items-center justify-center rounded-lg px-5 py-2.5 text-sm font-medium no-underline"
|
||||
:class="actionClass"
|
||||
@click="mobileOpen = false"
|
||||
>
|
||||
{{ action.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.mobile-menu-enter-active,
|
||||
.mobile-menu-leave-active {
|
||||
transition: opacity 0.2s ease, max-height 0.25s ease;
|
||||
overflow: hidden;
|
||||
max-height: 600px;
|
||||
}
|
||||
.mobile-menu-enter-from,
|
||||
.mobile-menu-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// ── Core UI ───────────────────────────────────────────────────
|
||||
export { default as MkAlert } from './components/MkAlert.vue'
|
||||
export { default as MkAvatar } from './components/MkAvatar.vue'
|
||||
export { default as MkBadge } from './components/MkBadge.vue'
|
||||
export { default as MkButton } from './components/MkButton.vue'
|
||||
export { default as MkCheckbox } from './components/MkCheckbox.vue'
|
||||
export { default as MkInput } from './components/MkInput.vue'
|
||||
export { default as MkPagination } from './components/MkPagination.vue'
|
||||
export { default as MkPaginationItem } from './components/MkPaginationItem.vue'
|
||||
export { default as MkProgress } from './components/MkProgress.vue'
|
||||
export { default as MkSocialButton } from './components/MkSocialButton.vue'
|
||||
export { default as MkSwitch } from './components/MkSwitch.vue'
|
||||
export { default as MkTable } from './components/MkTable.vue'
|
||||
export { default as MkTextArea } from './components/MkTextArea.vue'
|
||||
|
||||
// ── Cards ─────────────────────────────────────────────────────
|
||||
export { default as MkBackgroundBlogCard } from './components/cards/MkBackgroundBlogCard.vue'
|
||||
export { default as MkCenteredBlogCard } from './components/cards/MkCenteredBlogCard.vue'
|
||||
export { default as MkDefaultCounterCard } from './components/cards/MkDefaultCounterCard.vue'
|
||||
export { default as MkDefaultInfoCard } from './components/cards/MkDefaultInfoCard.vue'
|
||||
export { default as MkDefaultReviewCard } from './components/cards/MkDefaultReviewCard.vue'
|
||||
export { default as MkFilledInfoCard } from './components/cards/MkFilledInfoCard.vue'
|
||||
export { default as MkHorizontalTeamCard } from './components/cards/MkHorizontalTeamCard.vue'
|
||||
export { default as MkRotatingCard } from './components/cards/MkRotatingCard.vue'
|
||||
export { default as MkRotatingCardBack } from './components/cards/MkRotatingCardBack.vue'
|
||||
export { default as MkRotatingCardFront } from './components/cards/MkRotatingCardFront.vue'
|
||||
export { default as MkTransparentBlogCard } from './components/cards/MkTransparentBlogCard.vue'
|
||||
export { default as MkMiniStatisticsCard } from './components/cards/MkMiniStatisticsCard.vue'
|
||||
export { default as MkChartCard } from './components/cards/MkChartCard.vue'
|
||||
export { default as MkTimelineList } from './components/cards/MkTimelineList.vue'
|
||||
export { default as MkTimelineItem } from './components/cards/MkTimelineItem.vue'
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────
|
||||
export { default as MkBreadcrumbs } from './components/layout/MkBreadcrumbs.vue'
|
||||
export { default as MkFooter } from './components/layout/MkFooter.vue'
|
||||
export { default as MkFooterCentered } from './components/layout/MkFooterCentered.vue'
|
||||
export { default as MkHeader } from './components/layout/MkHeader.vue'
|
||||
export { default as MkNavbar } from './components/layout/MkNavbar.vue'
|
||||
|
||||
// ── Composables ───────────────────────────────────────────────
|
||||
export { useCountUp } from './composables/useCountUp.js'
|
||||
|
||||
// ── Vue plugin (global registration) ─────────────────────────
|
||||
import MkAlert from './components/MkAlert.vue'
|
||||
import MkAvatar from './components/MkAvatar.vue'
|
||||
import MkBadge from './components/MkBadge.vue'
|
||||
import MkButton from './components/MkButton.vue'
|
||||
import MkCheckbox from './components/MkCheckbox.vue'
|
||||
import MkInput from './components/MkInput.vue'
|
||||
import MkPagination from './components/MkPagination.vue'
|
||||
import MkPaginationItem from './components/MkPaginationItem.vue'
|
||||
import MkProgress from './components/MkProgress.vue'
|
||||
import MkSocialButton from './components/MkSocialButton.vue'
|
||||
import MkSwitch from './components/MkSwitch.vue'
|
||||
import MkTable from './components/MkTable.vue'
|
||||
import MkTextArea from './components/MkTextArea.vue'
|
||||
import MkBackgroundBlogCard from './components/cards/MkBackgroundBlogCard.vue'
|
||||
import MkCenteredBlogCard from './components/cards/MkCenteredBlogCard.vue'
|
||||
import MkDefaultCounterCard from './components/cards/MkDefaultCounterCard.vue'
|
||||
import MkDefaultInfoCard from './components/cards/MkDefaultInfoCard.vue'
|
||||
import MkDefaultReviewCard from './components/cards/MkDefaultReviewCard.vue'
|
||||
import MkFilledInfoCard from './components/cards/MkFilledInfoCard.vue'
|
||||
import MkHorizontalTeamCard from './components/cards/MkHorizontalTeamCard.vue'
|
||||
import MkRotatingCard from './components/cards/MkRotatingCard.vue'
|
||||
import MkRotatingCardBack from './components/cards/MkRotatingCardBack.vue'
|
||||
import MkRotatingCardFront from './components/cards/MkRotatingCardFront.vue'
|
||||
import MkTransparentBlogCard from './components/cards/MkTransparentBlogCard.vue'
|
||||
import MkMiniStatisticsCard from './components/cards/MkMiniStatisticsCard.vue'
|
||||
import MkChartCard from './components/cards/MkChartCard.vue'
|
||||
import MkTimelineList from './components/cards/MkTimelineList.vue'
|
||||
import MkTimelineItem from './components/cards/MkTimelineItem.vue'
|
||||
import MkBreadcrumbs from './components/layout/MkBreadcrumbs.vue'
|
||||
import MkFooter from './components/layout/MkFooter.vue'
|
||||
import MkFooterCentered from './components/layout/MkFooterCentered.vue'
|
||||
import MkHeader from './components/layout/MkHeader.vue'
|
||||
import MkNavbar from './components/layout/MkNavbar.vue'
|
||||
|
||||
const components = [
|
||||
MkAlert, MkAvatar, MkBadge, MkButton, MkCheckbox, MkInput,
|
||||
MkPagination, MkPaginationItem, MkProgress, MkSocialButton,
|
||||
MkSwitch, MkTable, MkTextArea,
|
||||
MkBackgroundBlogCard, MkCenteredBlogCard, MkDefaultCounterCard,
|
||||
MkDefaultInfoCard, MkDefaultReviewCard, MkFilledInfoCard,
|
||||
MkHorizontalTeamCard, MkRotatingCard, MkRotatingCardBack,
|
||||
MkRotatingCardFront, MkTransparentBlogCard,
|
||||
MkMiniStatisticsCard, MkChartCard, MkTimelineList, MkTimelineItem,
|
||||
MkBreadcrumbs, MkFooter, MkFooterCentered, MkHeader, MkNavbar,
|
||||
]
|
||||
|
||||
export const MkDesignSystem = {
|
||||
install(app) {
|
||||
components.forEach(c => app.component(c.__name, c))
|
||||
},
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const navItems = [
|
||||
{ label: 'Icons', path: '/icons', icon: 'auto_awesome' },
|
||||
{ label: 'Maps', path: '/maps', icon: 'map' },
|
||||
{ label: 'Notifications', path: '/notifications',icon: 'notifications' },
|
||||
{ label: 'Showcase', path: '/showcase', icon: 'widgets' },
|
||||
]
|
||||
|
||||
function isActive(path) {
|
||||
|
||||
@@ -11,5 +11,6 @@ export default createRouter({
|
||||
{ path: '/maps', component: () => import('../views/MapsView.vue'), meta: { title: 'Maps' } },
|
||||
{ path: '/notifications',component: () => import('../views/NotificationsView.vue'), meta: { title: 'Notifications' } },
|
||||
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { title: 'Profile' } },
|
||||
{ path: '/showcase', component: () => import('../views/ShowcaseView.vue'), meta: { title: 'Component Showcase' } },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -23,9 +23,6 @@ const statusClass = {
|
||||
cancelled: 'bg-danger/10 text-danger',
|
||||
}
|
||||
|
||||
const gradientProgress = (value, gradient) => ({
|
||||
width: `${value}%`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
+226
-78
@@ -1,31 +1,88 @@
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import DashboardLayout from '../layouts/DashboardLayout.vue'
|
||||
|
||||
// ── Cover background ─────────────────────────────────────────
|
||||
const coverInputRef = ref(null)
|
||||
const coverAppliedUrl = ref(null) // committed
|
||||
const coverStagedUrl = ref(null) // pending preview
|
||||
|
||||
function onCoverPicked(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
if (coverStagedUrl.value) URL.revokeObjectURL(coverStagedUrl.value)
|
||||
coverStagedUrl.value = URL.createObjectURL(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function applyCover() {
|
||||
if (coverAppliedUrl.value) URL.revokeObjectURL(coverAppliedUrl.value)
|
||||
coverAppliedUrl.value = coverStagedUrl.value
|
||||
coverStagedUrl.value = null
|
||||
}
|
||||
|
||||
function cancelCover() {
|
||||
if (coverStagedUrl.value) URL.revokeObjectURL(coverStagedUrl.value)
|
||||
coverStagedUrl.value = null
|
||||
}
|
||||
|
||||
// ── Avatar ────────────────────────────────────────────────────
|
||||
const avatarInputRef = ref(null)
|
||||
const avatarAppliedUrl = ref(null)
|
||||
const avatarStagedUrl = ref(null)
|
||||
|
||||
function onAvatarPicked(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
if (avatarStagedUrl.value) URL.revokeObjectURL(avatarStagedUrl.value)
|
||||
avatarStagedUrl.value = URL.createObjectURL(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function applyAvatar() {
|
||||
if (avatarAppliedUrl.value) URL.revokeObjectURL(avatarAppliedUrl.value)
|
||||
avatarAppliedUrl.value = avatarStagedUrl.value
|
||||
avatarStagedUrl.value = null
|
||||
}
|
||||
|
||||
function cancelAvatar() {
|
||||
if (avatarStagedUrl.value) URL.revokeObjectURL(avatarStagedUrl.value)
|
||||
avatarStagedUrl.value = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (coverStagedUrl.value) URL.revokeObjectURL(coverStagedUrl.value)
|
||||
if (coverAppliedUrl.value) URL.revokeObjectURL(coverAppliedUrl.value)
|
||||
if (avatarStagedUrl.value) URL.revokeObjectURL(avatarStagedUrl.value)
|
||||
if (avatarAppliedUrl.value)URL.revokeObjectURL(avatarAppliedUrl.value)
|
||||
})
|
||||
|
||||
// ── Static data ───────────────────────────────────────────────
|
||||
const stats = [
|
||||
{ label: 'Followers', value: '4,812' },
|
||||
{ label: 'Following', value: '294' },
|
||||
{ label: 'Projects', value: '32' },
|
||||
{ label: 'Projects', value: '32' },
|
||||
]
|
||||
|
||||
const socials = [
|
||||
{ icon: 'facebook', color: 'text-blue-600', label: 'Facebook' },
|
||||
{ icon: 'twitter', color: 'text-sky-500', label: 'Twitter' },
|
||||
{ icon: 'instagram', color: 'text-pink-500', label: 'Instagram' },
|
||||
{ icon: 'facebook', color: 'text-blue-600', label: 'Facebook' },
|
||||
{ icon: 'twitter', color: 'text-sky-500', label: 'Twitter' },
|
||||
{ icon: 'instagram', color: 'text-pink-500', label: 'Instagram' },
|
||||
]
|
||||
|
||||
const conversations = [
|
||||
{ initials: 'OP', color: 'primary', name: 'Olivia Park', preview: 'Hey, I loved the new component library!', time: '5m' },
|
||||
{ initials: 'JD', color: 'info', name: 'James Donovan', preview: 'Can you review my PR when you get a chance?', time: '22m' },
|
||||
{ initials: 'SR', color: 'success', name: 'Sofia Reyes', preview: 'The design tokens look great in production.', time: '1h' },
|
||||
{ initials: 'ML', color: 'warning', name: 'Marco Lin', preview: 'Pushed the icon fix — all shadows resolved.', time: '3h' },
|
||||
{ initials: 'AM', color: 'secondary', name: 'Ally Maria', preview: 'Updated the font preload entries in index.html.', time: '1d' },
|
||||
{ initials: 'OP', color: 'primary', name: 'Olivia Park', preview: 'Hey, I loved the new component library!', time: '5m' },
|
||||
{ initials: 'JD', color: 'info', name: 'James Donovan', preview: 'Can you review my PR when you get a chance?', time: '22m' },
|
||||
{ initials: 'SR', color: 'success', name: 'Sofia Reyes', preview: 'The design tokens look great in production.', time: '1h' },
|
||||
{ initials: 'ML', color: 'warning', name: 'Marco Lin', preview: 'Pushed the icon fix — all shadows resolved.', time: '3h' },
|
||||
{ initials: 'AM', color: 'secondary', name: 'Ally Maria', preview: 'Updated the font preload entries in index.html.',time: '1d' },
|
||||
]
|
||||
|
||||
const projects = [
|
||||
{ name: 'MK Design System', role: 'Lead', gradient: 'primary', members: ['PC', 'JD', 'SR'] },
|
||||
{ name: 'Dashboard Rework', role: 'Contributor', gradient: 'info', members: ['ML', 'AM'] },
|
||||
{ name: 'Icon Library', role: 'Owner', gradient: 'success', members: ['OP', 'JD'] },
|
||||
{ name: 'Typography Scale', role: 'Reviewer', gradient: 'warning', members: ['SR', 'ML', 'PC'] },
|
||||
{ name: 'MK Design System', role: 'Lead', gradient: 'primary', members: ['PC', 'JD', 'SR'] },
|
||||
{ name: 'Dashboard Rework', role: 'Contributor', gradient: 'info', members: ['ML', 'AM'] },
|
||||
{ name: 'Icon Library', role: 'Owner', gradient: 'success', members: ['OP', 'JD'] },
|
||||
{ name: 'Typography Scale', role: 'Reviewer', gradient: 'warning', members: ['SR', 'ML', 'PC'] },
|
||||
]
|
||||
|
||||
const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', AM: 'secondary', OP: 'danger' }
|
||||
@@ -34,37 +91,131 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
|
||||
<!-- Cover + avatar -->
|
||||
<div class="relative mb-20">
|
||||
<div class="h-48 overflow-hidden rounded-2xl bg-gradient-dark shadow-dark">
|
||||
<!-- Decorative grid -->
|
||||
<svg class="h-full w-full opacity-10" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="pgrid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#pgrid)" />
|
||||
</svg>
|
||||
<!-- Hidden file inputs -->
|
||||
<input ref="coverInputRef" type="file" accept="image/*" class="hidden" @change="onCoverPicked" />
|
||||
<input ref="avatarInputRef" type="file" accept="image/*" class="hidden" @change="onAvatarPicked" />
|
||||
|
||||
<!-- ── Cover + avatar ── -->
|
||||
<div class="relative mb-16">
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="group relative h-48 overflow-hidden rounded-2xl bg-gradient-dark shadow-dark">
|
||||
|
||||
<!-- Background: applied image or default gradient+grid -->
|
||||
<template v-if="coverStagedUrl || coverAppliedUrl">
|
||||
<img
|
||||
:src="coverStagedUrl ?? coverAppliedUrl"
|
||||
class="h-full w-full object-cover"
|
||||
alt="Cover preview"
|
||||
/>
|
||||
<!-- dim overlay so controls stay readable -->
|
||||
<div class="absolute inset-0 bg-black/20" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg class="h-full w-full opacity-10" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="pgrid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="white" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#pgrid)" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- Change bg button — top-right, always accessible -->
|
||||
<button
|
||||
class="absolute right-3 top-3 flex items-center gap-1.5 rounded-lg bg-black/40 px-3 py-1.5 text-xs font-medium text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100"
|
||||
@click="coverInputRef.click()"
|
||||
>
|
||||
<span class="material-icons text-sm leading-none">image</span>
|
||||
Change bg
|
||||
</button>
|
||||
|
||||
<!-- Apply / Cancel strip — bottom of cover, only when staged -->
|
||||
<Transition name="strip">
|
||||
<div
|
||||
v-if="coverStagedUrl"
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-end gap-2 bg-black/50 px-4 py-2.5 backdrop-blur-sm"
|
||||
>
|
||||
<span class="mr-auto text-xs text-white/70">New background staged</span>
|
||||
<button
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium text-white/70 hover:text-white transition-colors"
|
||||
@click="cancelCover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gradient-primary px-4 py-1.5 text-xs font-medium text-white shadow-primary"
|
||||
@click="applyCover"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="absolute -bottom-14 left-8 flex h-28 w-28 items-center justify-center rounded-2xl bg-gradient-primary text-4xl font-bold text-white shadow-primary ring-4 ring-white">
|
||||
EC
|
||||
<div class="absolute -bottom-14 left-8">
|
||||
<div class="group/av relative h-28 w-28">
|
||||
|
||||
<!-- Avatar display: photo or initials -->
|
||||
<div
|
||||
class="h-28 w-28 overflow-hidden rounded-2xl ring-4 ring-white shadow-primary"
|
||||
:class="avatarAppliedUrl || avatarStagedUrl ? '' : 'bg-gradient-primary'"
|
||||
>
|
||||
<img
|
||||
v-if="avatarStagedUrl || avatarAppliedUrl"
|
||||
:src="avatarStagedUrl ?? avatarAppliedUrl"
|
||||
class="h-full w-full object-cover"
|
||||
alt="Avatar preview"
|
||||
/>
|
||||
<div v-else class="flex h-full w-full items-center justify-center text-4xl font-bold text-white">
|
||||
EC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change photo overlay — appears on avatar hover -->
|
||||
<button
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-2xl bg-black/50 opacity-0 transition-opacity group-hover/av:opacity-100"
|
||||
@click="avatarInputRef.click()"
|
||||
>
|
||||
<span class="material-icons text-xl text-white">photo_camera</span>
|
||||
<span class="text-xs font-medium text-white">Change</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar Apply / Cancel — sits just below the avatar -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="avatarStagedUrl" class="mb-4 flex items-center gap-2 pl-8">
|
||||
<span class="text-xs text-secondary">New photo staged</span>
|
||||
<button
|
||||
class="rounded-lg border border-gray-200 px-3 py-1 text-xs font-medium text-secondary hover:text-dark transition-colors"
|
||||
@click="cancelAvatar"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gradient-primary px-4 py-1 text-xs font-medium text-white shadow-primary"
|
||||
@click="applyAvatar"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="mb-4 h-7" />
|
||||
</Transition>
|
||||
|
||||
<!-- ── Main grid ── -->
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
|
||||
<!-- Left column -->
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Identity card -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<h4 class="text-xl font-bold text-dark">Esthera Carter</h4>
|
||||
<p class="mt-0.5 text-sm text-secondary">Lead Designer · San Francisco, CA</p>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mt-5 grid grid-cols-3 divide-x divide-gray-100 rounded-xl border border-gray-100">
|
||||
<div v-for="s in stats" :key="s.label" class="flex flex-col items-center py-3">
|
||||
<span class="text-lg font-bold text-dark">{{ s.value }}</span>
|
||||
@@ -72,13 +223,11 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bio -->
|
||||
<p class="mt-5 text-sm leading-relaxed text-secondary">
|
||||
Building design systems and component libraries. Obsessed with typography,
|
||||
spacing, and making sure every pixel is intentional.
|
||||
</p>
|
||||
|
||||
<!-- Socials -->
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<a
|
||||
v-for="s in socials"
|
||||
@@ -92,14 +241,13 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<h6 class="mb-4 text-sm font-bold text-dark">Conversations</h6>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="c in conversations"
|
||||
:key="c.name"
|
||||
class="flex items-start gap-3 cursor-pointer group"
|
||||
class="group flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-bold text-white"
|
||||
@@ -107,9 +255,9 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
>
|
||||
{{ c.initials }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-dark group-hover:text-primary transition-colors">{{ c.name }}</p>
|
||||
<p class="text-xs text-secondary truncate">{{ c.preview }}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-dark transition-colors group-hover:text-primary">{{ c.name }}</p>
|
||||
<p class="truncate text-xs text-secondary">{{ c.preview }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-secondary">{{ c.time }}</span>
|
||||
</div>
|
||||
@@ -121,7 +269,6 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
<!-- Right column -->
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
|
||||
<!-- Projects -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h6 class="text-sm font-bold text-dark">Projects</h6>
|
||||
@@ -136,9 +283,7 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
:key="p.name"
|
||||
class="group relative overflow-hidden rounded-xl border border-gray-100 p-4 transition-shadow hover:shadow-soft-md"
|
||||
>
|
||||
<!-- Gradient accent bar -->
|
||||
<div class="absolute left-0 top-0 h-full w-1 rounded-l-xl" :class="`bg-gradient-${p.gradient}`" />
|
||||
|
||||
<div class="pl-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -154,19 +299,15 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
<span class="material-icons text-base leading-none">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Member avatars -->
|
||||
<div class="mt-3 flex items-center">
|
||||
<div class="flex -space-x-2">
|
||||
<div
|
||||
v-for="m in p.members"
|
||||
:key="m"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold text-white ring-2 ring-white"
|
||||
:class="`bg-gradient-${memberColor[m]} shadow-${memberColor[m]}`"
|
||||
:title="m"
|
||||
>
|
||||
{{ m }}
|
||||
</div>
|
||||
<div class="mt-3 flex -space-x-2">
|
||||
<div
|
||||
v-for="m in p.members"
|
||||
:key="m"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold text-white ring-2 ring-white"
|
||||
:class="`bg-gradient-${memberColor[m]} shadow-${memberColor[m]}`"
|
||||
:title="m"
|
||||
>
|
||||
{{ m }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,7 +315,6 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<h6 class="mb-5 text-sm font-bold text-dark">Profile Settings</h6>
|
||||
<div class="space-y-4">
|
||||
@@ -182,50 +322,31 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-secondary">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value="Esthera"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
||||
/>
|
||||
<input type="text" value="Esthera" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-secondary">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value="Carter"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
||||
/>
|
||||
<input type="text" value="Carter" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-secondary">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value="esthera@mkdesign.dev"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
||||
/>
|
||||
<input type="email" value="esthera@mkdesign.dev" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-secondary">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value="San Francisco, CA"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors"
|
||||
/>
|
||||
<input type="text" value="San Francisco, CA" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-secondary">Bio</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors resize-none"
|
||||
>Building design systems and component libraries. Obsessed with typography, spacing, and making sure every pixel is intentional.</textarea>
|
||||
<textarea rows="3" class="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm text-dark outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary">Building design systems and component libraries. Obsessed with typography, spacing, and making sure every pixel is intentional.</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button class="rounded-lg bg-gradient-primary px-6 py-2.5 text-sm font-medium text-white shadow-primary hover:shadow-soft-md transition-shadow">
|
||||
<button class="rounded-lg bg-gradient-primary px-6 py-2.5 text-sm font-medium text-white shadow-primary hover:-translate-y-0.5 active:translate-y-0 transition-all">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
@@ -238,3 +359,30 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.strip-enter-active,
|
||||
.strip-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.strip-enter-from,
|
||||
.strip-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
.slide-down-enter-to,
|
||||
.slide-down-leave-from {
|
||||
max-height: 4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import DashboardLayout from '../layouts/DashboardLayout.vue'
|
||||
|
||||
// Core
|
||||
import MkButton from '../components/MkButton.vue'
|
||||
import MkBadge from '../components/MkBadge.vue'
|
||||
import MkAlert from '../components/MkAlert.vue'
|
||||
import MkAvatar from '../components/MkAvatar.vue'
|
||||
import MkProgress from '../components/MkProgress.vue'
|
||||
import MkCheckbox from '../components/MkCheckbox.vue'
|
||||
import MkSwitch from '../components/MkSwitch.vue'
|
||||
import MkInput from '../components/MkInput.vue'
|
||||
import MkTextArea from '../components/MkTextArea.vue'
|
||||
import MkPagination from '../components/MkPagination.vue'
|
||||
import MkPaginationItem from '../components/MkPaginationItem.vue'
|
||||
import MkTable from '../components/MkTable.vue'
|
||||
|
||||
// Cards
|
||||
import MkBackgroundBlogCard from '../components/cards/MkBackgroundBlogCard.vue'
|
||||
import MkCenteredBlogCard from '../components/cards/MkCenteredBlogCard.vue'
|
||||
import MkTransparentBlogCard from '../components/cards/MkTransparentBlogCard.vue'
|
||||
import MkDefaultInfoCard from '../components/cards/MkDefaultInfoCard.vue'
|
||||
import MkFilledInfoCard from '../components/cards/MkFilledInfoCard.vue'
|
||||
import MkDefaultCounterCard from '../components/cards/MkDefaultCounterCard.vue'
|
||||
import MkDefaultReviewCard from '../components/cards/MkDefaultReviewCard.vue'
|
||||
import MkHorizontalTeamCard from '../components/cards/MkHorizontalTeamCard.vue'
|
||||
import MkRotatingCard from '../components/cards/MkRotatingCard.vue'
|
||||
import MkRotatingCardFront from '../components/cards/MkRotatingCardFront.vue'
|
||||
import MkRotatingCardBack from '../components/cards/MkRotatingCardBack.vue'
|
||||
import MkMiniStatisticsCard from '../components/cards/MkMiniStatisticsCard.vue'
|
||||
import MkChartCard from '../components/cards/MkChartCard.vue'
|
||||
import MkTimelineList from '../components/cards/MkTimelineList.vue'
|
||||
import MkTimelineItem from '../components/cards/MkTimelineItem.vue'
|
||||
|
||||
// Layout
|
||||
import MkBreadcrumbs from '../components/layout/MkBreadcrumbs.vue'
|
||||
import MkNavbar from '../components/layout/MkNavbar.vue'
|
||||
import MkHeader from '../components/layout/MkHeader.vue'
|
||||
import MkFooterCentered from '../components/layout/MkFooterCentered.vue'
|
||||
|
||||
// ── Reactive state ────────────────────────────────────────────
|
||||
const checkbox1 = ref(true)
|
||||
const checkbox2 = ref(false)
|
||||
const switch1 = ref(true)
|
||||
const switch2 = ref(false)
|
||||
const inputVal = ref('')
|
||||
const textareaVal = ref('')
|
||||
const activePage = ref(3)
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────
|
||||
const colors = ['primary','secondary','success','warning','danger','info','dark']
|
||||
|
||||
const img = (seed, w = 600, h = 400) => `https://picsum.photos/seed/${seed}/${w}/${h}`
|
||||
|
||||
const tableRows = [
|
||||
{ initials:'EC', color:'primary', name:'Esthera Carter', email:'e.carter@mk.dev', position:['Lead Designer','Creative'], status:true, date:'23/04/18', action:{label:'Edit', route:'#'} },
|
||||
{ initials:'JD', color:'info', name:'James Donovan', email:'j.donovan@mk.dev',position:['Developer','Engineering'], status:true, date:'11/01/19', action:{label:'Edit', route:'#'} },
|
||||
{ initials:'SR', color:'success', name:'Sofia Reyes', email:'s.reyes@mk.dev', position:['Product Manager','Operations'], status:false, date:'19/09/20', action:{label:'Edit', route:'#'} },
|
||||
{ initials:'ML', color:'warning', name:'Marco Lin', email:'m.lin@mk.dev', position:['QA Engineer','Quality'], status:true, date:'24/12/21', action:{label:'Edit', route:'#'} },
|
||||
{ initials:'AM', color:'secondary', name:'Ally Maria', email:'a.maria@mk.dev', position:['UX Researcher','Design'], status:false, date:'04/10/22', action:{label:'Edit', route:'#'} },
|
||||
]
|
||||
|
||||
const navItems = [
|
||||
{ label:'Home', href:'#', icon:'home' },
|
||||
{ label:'Components', icon:'widgets', children:[
|
||||
{ label:'Buttons', href:'#', description:'Variants, sizes and states' },
|
||||
{ label:'Cards', href:'#', description:'Blog, info, counter, review' },
|
||||
{ label:'Forms', href:'#', description:'Inputs, selects, checkboxes' },
|
||||
]},
|
||||
{ label:'Docs', href:'#', icon:'article' },
|
||||
]
|
||||
|
||||
const breadcrumbRoutes = [
|
||||
{ route:'/', label:'Dashboard' },
|
||||
{ route:'/showcase', label:'Components' },
|
||||
{ route:'#', label:'Showcase' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout>
|
||||
<div class="space-y-12">
|
||||
|
||||
<!-- ── SECTION HELPER ──────────────────────────────────── -->
|
||||
<!-- Each section follows: title / subtitle / content card -->
|
||||
|
||||
<!-- ① Buttons ─────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Buttons</h2>
|
||||
<p class="text-sm text-secondary mb-4">variant × color × size</p>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md space-y-6">
|
||||
<!-- Gradient -->
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-secondary">Gradient</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MkButton v-for="c in colors" :key="c" variant="gradient" :color="c">{{ c }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contained -->
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-secondary">Contained</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MkButton v-for="c in colors" :key="c" variant="contained" :color="c">{{ c }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Outline -->
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-secondary">Outline</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MkButton v-for="c in colors" :key="c" variant="outline" :color="c">{{ c }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sizes + states -->
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-secondary">Sizes & states</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<MkButton variant="gradient" color="primary" size="sm">Small</MkButton>
|
||||
<MkButton variant="gradient" color="primary" size="md">Medium</MkButton>
|
||||
<MkButton variant="gradient" color="primary" size="lg">Large</MkButton>
|
||||
<MkButton variant="gradient" color="primary" :disabled="true">Disabled</MkButton>
|
||||
<MkButton variant="outline" color="primary" :full-width="true" class="max-w-xs">Full width</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② Badges ────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Badges</h2>
|
||||
<p class="text-sm text-secondary mb-4">fill · gradient · sizes · rounded</p>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<MkBadge v-for="c in colors" :key="c" variant="fill" :color="c">{{ c }}</MkBadge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<MkBadge v-for="c in colors" :key="c" variant="gradient" :color="c">{{ c }}</MkBadge>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<MkBadge color="primary" size="sm" :rounded="true">sm pill</MkBadge>
|
||||
<MkBadge color="primary" size="md" :rounded="true">md pill</MkBadge>
|
||||
<MkBadge color="primary" size="lg" :rounded="true">lg pill</MkBadge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ③ Alerts ─────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Alerts</h2>
|
||||
<p class="text-sm text-secondary mb-4">all colors · dismissible</p>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md space-y-3">
|
||||
<MkAlert color="success">Success — action completed.</MkAlert>
|
||||
<MkAlert color="info">Info — something you should know.</MkAlert>
|
||||
<MkAlert color="warning">Warning — check before proceeding.</MkAlert>
|
||||
<MkAlert color="danger" :dismissible="true">Danger (dismissible) — click × to close.</MkAlert>
|
||||
<MkAlert color="dark">Dark — a neutral message.</MkAlert>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ④ Avatars ────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Avatars</h2>
|
||||
<p class="text-sm text-secondary mb-4">xxs → xxl · borderRadius variants</p>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div v-for="s in ['xxs','xs','sm','md','lg','xl','xxl']" :key="s" class="flex flex-col items-center gap-2">
|
||||
<MkAvatar :image="img('av1',200,200)" alt="Avatar" :size="s" />
|
||||
<span class="text-xs text-secondary">{{ s }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<MkAvatar :image="img('av2',200,200)" alt="Circle" size="lg" border-radius="full" />
|
||||
<span class="text-xs text-secondary">circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑤ Progress ───────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Progress</h2>
|
||||
<p class="text-sm text-secondary mb-4">contained · gradient</p>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md space-y-3">
|
||||
<div v-for="c in colors" :key="c" class="flex items-center gap-4">
|
||||
<span class="w-20 shrink-0 text-xs text-secondary">{{ c }}</span>
|
||||
<div class="flex-1"><MkProgress variant="gradient" :color="c" :value="Math.round(30 + colors.indexOf(c) * 10)" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑥ Form Controls ──────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Form Controls</h2>
|
||||
<p class="text-sm text-secondary mb-4">input · textarea · checkbox · switch</p>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Inputs -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md space-y-4">
|
||||
<MkInput label="Default input" v-model="inputVal" placeholder="Type something…" />
|
||||
<MkInput label="With icon" icon="search" v-model="inputVal" placeholder="Search…" />
|
||||
<MkInput label="Error state" v-model="inputVal" :error="true" placeholder="Required field" />
|
||||
<MkInput label="Success state" v-model="inputVal" :success="true" placeholder="Looks good" />
|
||||
<MkInput label="Disabled" v-model="inputVal" :is-disabled="true" placeholder="Not editable" />
|
||||
<MkTextArea v-model="textareaVal" placeholder="Your message…" :rows="3">Message</MkTextArea>
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<p class="mb-4 text-xs font-medium uppercase tracking-wide text-secondary">Checkboxes</p>
|
||||
<div class="space-y-3 mb-6">
|
||||
<MkCheckbox id="c1" v-model="checkbox1" color="primary">Primary (checked)</MkCheckbox>
|
||||
<MkCheckbox id="c2" v-model="checkbox2" color="success">Success (unchecked)</MkCheckbox>
|
||||
<MkCheckbox id="c3" v-model="checkbox1" color="danger">Danger</MkCheckbox>
|
||||
</div>
|
||||
<p class="mb-4 text-xs font-medium uppercase tracking-wide text-secondary">Switches</p>
|
||||
<div class="space-y-3">
|
||||
<MkSwitch id="s1" v-model="switch1" color="primary">Primary (on)</MkSwitch>
|
||||
<MkSwitch id="s2" v-model="switch2" color="success">Success (off)</MkSwitch>
|
||||
<MkSwitch id="s3" v-model="switch1" color="info">Info</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑦ Pagination ─────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Pagination</h2>
|
||||
<p class="text-sm text-secondary mb-4">color variants · active state</p>
|
||||
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md space-y-4">
|
||||
<div v-for="c in ['primary','success','info','danger']" :key="c">
|
||||
<MkPagination :color="c">
|
||||
<MkPaginationItem :prev="true" @click="activePage > 1 && activePage--" />
|
||||
<MkPaginationItem v-for="n in 5" :key="n" :label="String(n)" :active="activePage === n" @click="activePage = n" />
|
||||
<MkPaginationItem :next="true" @click="activePage < 5 && activePage++" />
|
||||
</MkPagination>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑧ Blog Cards ─────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Blog Cards</h2>
|
||||
<p class="text-sm text-secondary mb-4">background · centered · transparent</p>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<MkBackgroundBlogCard
|
||||
:image="img('bg1')"
|
||||
title="Background Blog Card"
|
||||
description="Full-bleed image with gradient overlay. Content sits above the image."
|
||||
:action="{ route:'#', label:'Read more', color:'white' }"
|
||||
/>
|
||||
<MkCenteredBlogCard
|
||||
:image="img('cb1')"
|
||||
title="Centered Blog Card"
|
||||
description="Floating image header overlaps the card top edge for a layered effect."
|
||||
:action="{ color:'success', label:'Find Out More', route:'#' }"
|
||||
/>
|
||||
<MkTransparentBlogCard
|
||||
:image="img('tb1')"
|
||||
title="Transparent Blog Card"
|
||||
description="No card background — just the image with a soft shadow and plain text below."
|
||||
:action="{ route:'#', color:'primary', label:'Read more' }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑨ Info Cards ─────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Info Cards</h2>
|
||||
<p class="text-sm text-secondary mb-4">default · filled</p>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<div class="grid gap-6 sm:grid-cols-2">
|
||||
<MkDefaultInfoCard
|
||||
v-for="item in [
|
||||
{icon:{component:'bolt',color:'primary',size:'3xl'},title:'Fast Build',description:'Zero-config Vite setup with Tailwind v4.'},
|
||||
{icon:{component:'palette',color:'success',size:'3xl'},title:'Design Tokens',description:'All values live in @theme CSS custom properties.'},
|
||||
{icon:{component:'widgets',color:'info',size:'3xl'},title:'30+ Components',description:'Buttons, cards, forms, navbars and more.'},
|
||||
{icon:{component:'devices',color:'warning',size:'3xl'},title:'Responsive',description:'Mobile-first layouts using Tailwind breakpoints.'},
|
||||
]"
|
||||
:key="item.title"
|
||||
:icon="item.icon"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<MkFilledInfoCard
|
||||
:color="{ background:'primary', text:'white' }"
|
||||
:icon="{ component:'rocket_launch', color:'white' }"
|
||||
title="Ship faster"
|
||||
description="Pre-built components mean less time on UI and more time on features."
|
||||
:action="{ route:'#', label:{ text:'Learn More', color:'white' } }"
|
||||
/>
|
||||
<MkFilledInfoCard
|
||||
:color="{ background:'', text:'' }"
|
||||
:icon="{ component:'verified', color:'success' }"
|
||||
title="Design conformity"
|
||||
description="The conformity checklist helps keep every PR on-brand."
|
||||
:action="{ route:'#', label:{ text:'View Checklist', color:'success' } }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑩ Dashboard Cards ───────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Dashboard Cards</h2>
|
||||
<p class="text-sm text-secondary mb-4">mini statistics · chart holder · timeline</p>
|
||||
|
||||
<!-- Mini statistics row (matches Material Dashboard layout) -->
|
||||
<div class="grid gap-6 sm:grid-cols-2 xl:grid-cols-4 mb-8 mt-8">
|
||||
<MkMiniStatisticsCard
|
||||
:title="{ text: 'Today\'s Money', value: '$53k' }"
|
||||
detail="<span class='text-success font-medium'>+55%</span> than last week"
|
||||
:icon="{ name: 'weekend', color: 'white', background: 'primary' }"
|
||||
/>
|
||||
<MkMiniStatisticsCard
|
||||
:title="{ text: 'Today\'s Users', value: '2,300' }"
|
||||
detail="<span class='text-success font-medium'>+3%</span> than last month"
|
||||
:icon="{ name: 'leaderboard', color: 'white', background: 'info' }"
|
||||
/>
|
||||
<MkMiniStatisticsCard
|
||||
:title="{ text: 'New Clients', value: '3,462' }"
|
||||
detail="<span class='text-danger font-medium'>-2%</span> than yesterday"
|
||||
:icon="{ name: 'person', color: 'white', background: 'success' }"
|
||||
/>
|
||||
<MkMiniStatisticsCard
|
||||
:title="{ text: 'Sales', value: '$103k' }"
|
||||
detail="<span class='text-success font-medium'>+5%</span> just updated"
|
||||
:icon="{ name: 'store', color: 'white', background: 'warning' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chart cards row -->
|
||||
<div class="grid gap-6 md:grid-cols-3 mb-8 mt-12">
|
||||
<!-- Bar chart placeholder -->
|
||||
<MkChartCard
|
||||
title="Website Views"
|
||||
subtitle="Last campaign performance"
|
||||
update="campaign sent 2 days ago"
|
||||
color="primary"
|
||||
>
|
||||
<svg viewBox="0 0 140 60" class="w-full h-20" preserveAspectRatio="none">
|
||||
<rect x="8" y="30" width="12" height="30" rx="2" fill="white" opacity="0.7"/>
|
||||
<rect x="28" y="42" width="12" height="18" rx="2" fill="white" opacity="0.7"/>
|
||||
<rect x="48" y="20" width="12" height="40" rx="2" fill="white" opacity="0.7"/>
|
||||
<rect x="68" y="10" width="12" height="50" rx="2" fill="white" opacity="0.7"/>
|
||||
<rect x="88" y="35" width="12" height="25" rx="2" fill="white" opacity="0.7"/>
|
||||
<rect x="108" y="22" width="12" height="38" rx="2" fill="white" opacity="0.7"/>
|
||||
<rect x="128" y="15" width="12" height="45" rx="2" fill="white" opacity="0.7"/>
|
||||
</svg>
|
||||
</MkChartCard>
|
||||
|
||||
<!-- Line chart placeholder -->
|
||||
<MkChartCard
|
||||
title="Daily Sales"
|
||||
subtitle="<span class='font-bold'>+15%</span> increase in today's sales"
|
||||
update="updated 4 min ago"
|
||||
color="success"
|
||||
>
|
||||
<svg viewBox="0 0 140 60" class="w-full h-20" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points="0,50 18,38 35,42 52,20 70,25 88,12 105,18 122,8 140,14"
|
||||
fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"
|
||||
/>
|
||||
<polyline
|
||||
points="0,50 18,38 35,42 52,20 70,25 88,12 105,18 122,8 140,14 140,60 0,60"
|
||||
fill="white" opacity="0.15"
|
||||
/>
|
||||
</svg>
|
||||
</MkChartCard>
|
||||
|
||||
<!-- Line chart placeholder dark -->
|
||||
<MkChartCard
|
||||
title="Completed Tasks"
|
||||
subtitle="Last campaign performance"
|
||||
update="just updated"
|
||||
color="dark"
|
||||
>
|
||||
<svg viewBox="0 0 140 60" class="w-full h-20" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points="0,45 18,36 35,48 52,22 70,32 88,18 105,28 122,10 140,20"
|
||||
fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"
|
||||
/>
|
||||
<polyline
|
||||
points="0,45 18,36 35,48 52,22 70,32 88,18 105,28 122,10 140,20 140,60 0,60"
|
||||
fill="white" opacity="0.15"
|
||||
/>
|
||||
</svg>
|
||||
</MkChartCard>
|
||||
</div>
|
||||
|
||||
<!-- Timeline card -->
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<MkTimelineList
|
||||
title="Orders Overview"
|
||||
description="<span class='text-success font-medium'>+24%</span> this month"
|
||||
>
|
||||
<MkTimelineItem icon="notifications" color="success" title="$2,400 Design changes" date-time="22 DEC 7:20 PM" />
|
||||
<MkTimelineItem icon="code" color="danger" title="New order #1832412" date-time="21 DEC 11:00 PM" />
|
||||
<MkTimelineItem icon="shopping_cart" color="info" title="Server payments for April" date-time="21 DEC 9:34 PM" />
|
||||
<MkTimelineItem icon="credit_card" color="warning" title="New card added for order #4395133" date-time="20 DEC 2:20 AM" />
|
||||
<MkTimelineItem icon="vpn_key" color="primary" title="Unlock packages for development" date-time="18 DEC 4:54 AM" :last="true" />
|
||||
</MkTimelineList>
|
||||
|
||||
<!-- Dark variant -->
|
||||
<MkTimelineList
|
||||
title="Recent Activity"
|
||||
description="<span class='text-white font-medium'>12 events</span> this week"
|
||||
:dark="true"
|
||||
>
|
||||
<MkTimelineItem icon="check_circle" color="success" title="Design system shipped" date-time="Today 10:00 AM" />
|
||||
<MkTimelineItem icon="build" color="info" title="Phase 4 components merged" date-time="Yesterday 3:40 PM" />
|
||||
<MkTimelineItem icon="bug_report" color="danger" title="Alert transition bug fixed" date-time="2 days ago" />
|
||||
<MkTimelineItem icon="star" color="warning" title="v0.1.0 tagged" date-time="3 days ago" :last="true" />
|
||||
</MkTimelineList>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑪ Counter · Review · Team ───────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Counter, Review & Team Cards</h2>
|
||||
<p class="text-sm text-secondary mb-4">animated counters · star ratings · horizontal profile</p>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<!-- Counter cards -->
|
||||
<div class="rounded-2xl bg-white p-8 shadow-soft-md">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<MkDefaultCounterCard :count="240" suffix="+" color="primary" title="Projects" description="Completed this year" divider="horizontal" />
|
||||
<MkDefaultCounterCard :count="4500" suffix="+" color="success" title="Users" description="Active accounts" divider="horizontal" />
|
||||
<MkDefaultCounterCard :count="99" suffix="%" color="info" title="Uptime" description="Last 12 months" divider="horizontal" />
|
||||
<MkDefaultCounterCard :count="18" color="warning" title="Countries" description="Worldwide reach" divider="horizontal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review card -->
|
||||
<MkDefaultReviewCard
|
||||
:image="img('rev1',200,200)"
|
||||
name="Sofia Reyes"
|
||||
date="2 days ago"
|
||||
review="This design system saved us weeks of setup. The components are clean and the token system makes theming trivial."
|
||||
:rating="5"
|
||||
/>
|
||||
|
||||
<!-- Team card -->
|
||||
<MkHorizontalTeamCard
|
||||
:image="img('team1',400,500)"
|
||||
:profile="{ name:'James Donovan', link:'#' }"
|
||||
:position="{ label:'Lead Engineer', color:'primary' }"
|
||||
description="James leads the frontend platform team and maintains the component library. Open source contributor and design systems enthusiast."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑪ Rotating Card ─────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Rotating Card</h2>
|
||||
<p class="text-sm text-secondary mb-4">hover to flip — CSS 3D, no JS</p>
|
||||
|
||||
<div class="max-w-xs">
|
||||
<div style="height: 22rem">
|
||||
<MkRotatingCard color="primary" min-height="22rem">
|
||||
<MkRotatingCardFront
|
||||
:image="img('rot-front',600,800)"
|
||||
icon="layers"
|
||||
label="Design System"
|
||||
title="Material Kit 2"
|
||||
description="Vue 3 + Tailwind v4"
|
||||
/>
|
||||
<MkRotatingCardBack
|
||||
:image="img('rot-back',600,800)"
|
||||
title="Get Started"
|
||||
description="Build beautiful interfaces with a consistent, token-driven system."
|
||||
:action="[
|
||||
{ route:'#', label:'Docs', color:'white' },
|
||||
{ route:'#', label:'GitHub', color:'white' },
|
||||
]"
|
||||
/>
|
||||
</MkRotatingCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ⑫ Table ──────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Table</h2>
|
||||
<p class="text-sm text-secondary mb-4">avatar + status badge + action</p>
|
||||
|
||||
<MkTable :rows="tableRows">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h6 class="text-sm font-bold text-dark">Authors</h6>
|
||||
<MkBadge color="success" :rounded="true">5 active</MkBadge>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<MkPagination color="primary" size="sm">
|
||||
<MkPaginationItem :prev="true" />
|
||||
<MkPaginationItem label="1" :active="true" />
|
||||
<MkPaginationItem label="2" />
|
||||
<MkPaginationItem label="3" />
|
||||
<MkPaginationItem :next="true" />
|
||||
</MkPagination>
|
||||
</template>
|
||||
</MkTable>
|
||||
</section>
|
||||
|
||||
<!-- ⑬ Layout Components ──────────────────────────────────── -->
|
||||
<section>
|
||||
<h2 class="text-lg font-bold text-dark mb-0.5">Layout Components</h2>
|
||||
<p class="text-sm text-secondary mb-4">navbar · header · breadcrumbs · footer</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-soft-md">
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-secondary">MkBreadcrumbs</p>
|
||||
<MkBreadcrumbs :routes="breadcrumbRoutes" />
|
||||
</div>
|
||||
|
||||
<!-- Navbar preview -->
|
||||
<div class="rounded-2xl shadow-soft-md overflow-hidden">
|
||||
<p class="bg-gray-50 px-4 py-2 text-xs font-medium text-secondary border-b border-gray-100">MkNavbar — light mode (hover items for dropdown)</p>
|
||||
<MkNavbar
|
||||
:brand="{ name:'MK Design', route:'#' }"
|
||||
:nav-items="navItems"
|
||||
:action="{ label:'Get Started', href:'#', color:'primary' }"
|
||||
:sticky="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Header preview -->
|
||||
<div class="rounded-2xl shadow-soft-md overflow-hidden">
|
||||
<p class="bg-gray-50 px-4 py-2 text-xs font-medium text-secondary border-b border-gray-100">MkHeader — background image + gradient mask</p>
|
||||
<MkHeader
|
||||
:image="img('header1',1200,600)"
|
||||
:title="{ text:'Build Something Beautiful', variant:'h2' }"
|
||||
description="A design system for Vue 3 and Tailwind v4."
|
||||
mask="dark"
|
||||
:mask-opacity="0.6"
|
||||
:center="true"
|
||||
min-height="200px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer preview -->
|
||||
<div class="rounded-2xl shadow-soft-md overflow-hidden">
|
||||
<p class="bg-gray-50 px-4 py-2 text-xs font-medium text-secondary border-b border-gray-100">MkFooterCentered</p>
|
||||
<MkFooterCentered
|
||||
:links="[
|
||||
{ name:'Company', href:'#' },
|
||||
{ name:'About', href:'#' },
|
||||
{ name:'Blog', href:'#' },
|
||||
{ name:'License', href:'#' },
|
||||
]"
|
||||
:socials="[
|
||||
{ icon:'language', link:'#', label:'Website' },
|
||||
{ icon:'code', link:'#', label:'GitHub' },
|
||||
{ icon:'mail', link:'#', label:'Email' },
|
||||
]"
|
||||
:copyright="`© ${new Date().getFullYear()} MK Design System`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
@@ -17,7 +17,7 @@ const rows = [
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h6 class="text-sm font-bold text-dark">Authors Table</h6>
|
||||
<button class="rounded-lg bg-gradient-primary px-4 py-2 text-xs font-medium text-white shadow-primary hover:shadow-soft-md transition-shadow">
|
||||
<button class="rounded-lg bg-gradient-primary px-4 py-2 text-xs font-medium text-white shadow-primary hover:-translate-y-0.5 active:translate-y-0 transition-all">
|
||||
+ New Entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"meta": {
|
||||
"project": "mk-design-system",
|
||||
"sourceApproach": "tailwindcss-v4",
|
||||
"primaryFont": "Roboto",
|
||||
"fontType": "variable-woff2",
|
||||
"colorMode": "light",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"colors": {
|
||||
"primary": { "DEFAULT": "#e91e63", "gradientStart": "#EC407A", "gradientEnd": "#D81B60" },
|
||||
"secondary": { "DEFAULT": "#7b809a", "gradientStart": "#747b8a", "gradientEnd": "#495361" },
|
||||
"semantic": {
|
||||
"success": { "DEFAULT": "#4caf50", "gradientStart": "#66bb6a", "gradientEnd": "#43a047" },
|
||||
"warning": { "DEFAULT": "#fb8c00", "gradientStart": "#ffa726", "gradientEnd": "#fb8c00" },
|
||||
"danger": { "DEFAULT": "#f44335", "gradientStart": "#ef5350", "gradientEnd": "#e53935" },
|
||||
"info": { "DEFAULT": "#1a73e8", "gradientStart": "#49a3f1", "gradientEnd": "#1a73e8" }
|
||||
},
|
||||
"neutrals": {
|
||||
"white": "#ffffff",
|
||||
"black": "#000000",
|
||||
"light": "#f0f2f5",
|
||||
"dark": "#344767",
|
||||
"gray": {
|
||||
"100": "#f8f9fa",
|
||||
"200": "#f0f2f5",
|
||||
"300": "#dee2e6",
|
||||
"500": "#adb5bd",
|
||||
"600": "#6c757d",
|
||||
"900": "#212529"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"fontFamilies": {
|
||||
"sans": "Roboto, Helvetica, Arial, sans-serif",
|
||||
"serif": "Roboto Slab, serif",
|
||||
"mono": "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"
|
||||
},
|
||||
"fontFiles": {
|
||||
"roboto": "public/fonts/roboto/Roboto-VariableFont_wdth-wght.woff2",
|
||||
"robotoItalic":"public/fonts/roboto/Roboto-Italic-VariableFont_wdth-wght.woff2",
|
||||
"robotoSlab": "public/fonts/roboto-slab/RobotoSlab-VariableFont_wght.woff2",
|
||||
"materialIcons":"public/fonts/material-icons-round/material-icons-round-latin-400-normal.woff2"
|
||||
},
|
||||
"weightRange": "100–900 (variable font)",
|
||||
"sizes": {
|
||||
"xs": "0.75rem",
|
||||
"sm": "0.875rem",
|
||||
"base": "1rem",
|
||||
"lg": "1.125rem",
|
||||
"xl": "1.25rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "1.875rem",
|
||||
"4xl": "2.25rem",
|
||||
"5xl": "3rem"
|
||||
},
|
||||
"lineHeights": { "base": "1.5", "tight": "1.25", "relaxed": "1.75" }
|
||||
},
|
||||
"spacing": {
|
||||
"note": "Tailwind v4 default scale — rem-based, no custom overrides needed",
|
||||
"common": {
|
||||
"1": "0.25rem", "2": "0.5rem", "3": "0.75rem",
|
||||
"4": "1rem", "6": "1.5rem", "8": "2rem",
|
||||
"12": "3rem", "16": "4rem", "24": "6rem"
|
||||
}
|
||||
},
|
||||
"shadows": {
|
||||
"soft": {
|
||||
"xs": "0 2px 9px -5px rgba(0,0,0,0.15)",
|
||||
"sm": "0 0.3125rem 0.625rem 0 rgba(0,0,0,0.12)",
|
||||
"md": "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)",
|
||||
"lg": "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)"
|
||||
},
|
||||
"blur": "0 20px 27px rgba(0,0,0,0.05)",
|
||||
"colored": {
|
||||
"note": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba({r},{g},{b},0.4)",
|
||||
"primary": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba(233,30,99,0.4)",
|
||||
"secondary": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba(210,210,210,0.4)",
|
||||
"success": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba(76,175,80,0.4)",
|
||||
"warning": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba(251,140,0,0.4)",
|
||||
"danger": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba(244,67,54,0.4)",
|
||||
"info": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba(26,115,232,0.4)",
|
||||
"dark": "0 4px 20px 0 rgba(0,0,0,0.14), 0 7px 10px -5px rgba(64,64,64,0.4)"
|
||||
}
|
||||
},
|
||||
"borders": {
|
||||
"radius": {
|
||||
"xs": "0.1rem",
|
||||
"sm": "0.125rem",
|
||||
"md": "0.375rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"2xl": "1rem",
|
||||
"full":"9999px"
|
||||
},
|
||||
"width": { "default": "1px" }
|
||||
},
|
||||
"gradients": {
|
||||
"angle": "195deg",
|
||||
"note": "All gradients use the 195deg angle — do not change",
|
||||
"variants": {
|
||||
"primary": "linear-gradient(195deg, #EC407A, #D81B60)",
|
||||
"secondary": "linear-gradient(195deg, #747b8a, #495361)",
|
||||
"success": "linear-gradient(195deg, #66bb6a, #43a047)",
|
||||
"warning": "linear-gradient(195deg, #ffa726, #fb8c00)",
|
||||
"danger": "linear-gradient(195deg, #ef5350, #e53935)",
|
||||
"info": "linear-gradient(195deg, #49a3f1, #1a73e8)",
|
||||
"dark": "linear-gradient(195deg, #42424a, #191919)",
|
||||
"light": "linear-gradient(195deg, #ebeff4, #ced4da)"
|
||||
}
|
||||
},
|
||||
"breakpoints": {
|
||||
"note": "Tailwind v4 defaults",
|
||||
"sm": "640px",
|
||||
"md": "768px",
|
||||
"lg": "1024px",
|
||||
"xl": "1280px",
|
||||
"2xl": "1536px"
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -1,10 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const isLib = process.env.BUILD_TARGET === 'lib'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
// Tailwind only for the app build — consuming projects bring their own
|
||||
!isLib && tailwindcss(),
|
||||
vue(),
|
||||
],
|
||||
].filter(Boolean),
|
||||
|
||||
build: isLib
|
||||
? {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.js'),
|
||||
name: 'MkDesignSystem',
|
||||
fileName: format => `mk-design-system.${format}.js`,
|
||||
formats: ['es', 'cjs'],
|
||||
},
|
||||
rollupOptions: {
|
||||
// Vue must be provided by the consuming project
|
||||
external: ['vue'],
|
||||
output: { globals: { vue: 'Vue' } },
|
||||
},
|
||||
// Emit a separate CSS file (design-system.css) consumers can import
|
||||
cssCodeSplit: false,
|
||||
}
|
||||
: {},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user