Compare commits

...

2 Commits

Author SHA1 Message Date
brian 0b1ab9261e adding components 2026-06-01 17:09:26 -06:00
brian 79a8720fb0 scaffolded avatar and bg image uploaders 2026-06-01 17:01:29 -06:00
13 changed files with 907 additions and 78 deletions
+79
View File
@@ -0,0 +1,79 @@
<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;
}
.alert-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
padding: 0;
margin: 0;
}
</style>
+40
View File
@@ -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>
+67
View File
@@ -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>
+89
View File
@@ -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>
+40
View File
@@ -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>
+89
View File
@@ -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>
+17
View File
@@ -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>
+51
View File
@@ -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>
+64
View File
@@ -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>
+45
View File
@@ -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>
+51
View File
@@ -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>
+49
View File
@@ -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>
+226 -78
View File
@@ -1,31 +1,88 @@
<script setup> <script setup>
import { ref, onUnmounted } from 'vue'
import DashboardLayout from '../layouts/DashboardLayout.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 = [ const stats = [
{ label: 'Followers', value: '4,812' }, { label: 'Followers', value: '4,812' },
{ label: 'Following', value: '294' }, { label: 'Following', value: '294' },
{ label: 'Projects', value: '32' }, { label: 'Projects', value: '32' },
] ]
const socials = [ const socials = [
{ icon: 'facebook', color: 'text-blue-600', label: 'Facebook' }, { icon: 'facebook', color: 'text-blue-600', label: 'Facebook' },
{ icon: 'twitter', color: 'text-sky-500', label: 'Twitter' }, { icon: 'twitter', color: 'text-sky-500', label: 'Twitter' },
{ icon: 'instagram', color: 'text-pink-500', label: 'Instagram' }, { icon: 'instagram', color: 'text-pink-500', label: 'Instagram' },
] ]
const conversations = [ const conversations = [
{ initials: 'OP', color: 'primary', name: 'Olivia Park', preview: 'Hey, I loved the new component library!', time: '5m' }, { 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: '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: '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: '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: 'AM', color: 'secondary', name: 'Ally Maria', preview: 'Updated the font preload entries in index.html.',time: '1d' },
] ]
const projects = [ const projects = [
{ name: 'MK Design System', role: 'Lead', gradient: 'primary', members: ['PC', 'JD', 'SR'] }, { name: 'MK Design System', role: 'Lead', gradient: 'primary', members: ['PC', 'JD', 'SR'] },
{ name: 'Dashboard Rework', role: 'Contributor', gradient: 'info', members: ['ML', 'AM'] }, { name: 'Dashboard Rework', role: 'Contributor', gradient: 'info', members: ['ML', 'AM'] },
{ name: 'Icon Library', role: 'Owner', gradient: 'success', members: ['OP', 'JD'] }, { name: 'Icon Library', role: 'Owner', gradient: 'success', members: ['OP', 'JD'] },
{ name: 'Typography Scale', role: 'Reviewer', gradient: 'warning', members: ['SR', 'ML', 'PC'] }, { 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' } 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> <template>
<DashboardLayout> <DashboardLayout>
<!-- Cover + avatar --> <!-- Hidden file inputs -->
<div class="relative mb-20"> <input ref="coverInputRef" type="file" accept="image/*" class="hidden" @change="onCoverPicked" />
<div class="h-48 overflow-hidden rounded-2xl bg-gradient-dark shadow-dark"> <input ref="avatarInputRef" type="file" accept="image/*" class="hidden" @change="onAvatarPicked" />
<!-- Decorative grid -->
<svg class="h-full w-full opacity-10" xmlns="http://www.w3.org/2000/svg"> <!-- Cover + avatar -->
<defs> <div class="relative mb-16">
<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"/> <!-- Cover -->
</pattern> <div class="group relative h-48 overflow-hidden rounded-2xl bg-gradient-dark shadow-dark">
</defs>
<rect width="100%" height="100%" fill="url(#pgrid)" /> <!-- Background: applied image or default gradient+grid -->
</svg> <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> </div>
<!-- Avatar --> <!-- 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"> <div class="absolute -bottom-14 left-8">
EC <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>
</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"> <div class="grid gap-6 lg:grid-cols-3">
<!-- Left column --> <!-- Left column -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Identity card -->
<div class="rounded-2xl bg-white p-6 shadow-soft-md"> <div class="rounded-2xl bg-white p-6 shadow-soft-md">
<h4 class="text-xl font-bold text-dark">Esthera Carter</h4> <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> <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 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"> <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> <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>
</div> </div>
<!-- Bio -->
<p class="mt-5 text-sm leading-relaxed text-secondary"> <p class="mt-5 text-sm leading-relaxed text-secondary">
Building design systems and component libraries. Obsessed with typography, Building design systems and component libraries. Obsessed with typography,
spacing, and making sure every pixel is intentional. spacing, and making sure every pixel is intentional.
</p> </p>
<!-- Socials -->
<div class="mt-5 flex items-center gap-3"> <div class="mt-5 flex items-center gap-3">
<a <a
v-for="s in socials" v-for="s in socials"
@@ -92,14 +241,13 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
</div> </div>
</div> </div>
<!-- Conversations -->
<div class="rounded-2xl bg-white p-6 shadow-soft-md"> <div class="rounded-2xl bg-white p-6 shadow-soft-md">
<h6 class="mb-4 text-sm font-bold text-dark">Conversations</h6> <h6 class="mb-4 text-sm font-bold text-dark">Conversations</h6>
<div class="space-y-4"> <div class="space-y-4">
<div <div
v-for="c in conversations" v-for="c in conversations"
:key="c.name" :key="c.name"
class="flex items-start gap-3 cursor-pointer group" class="group flex cursor-pointer items-start gap-3"
> >
<div <div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-bold text-white" 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 }} {{ c.initials }}
</div> </div>
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium text-dark group-hover:text-primary transition-colors">{{ c.name }}</p> <p class="text-sm font-medium text-dark transition-colors group-hover:text-primary">{{ c.name }}</p>
<p class="text-xs text-secondary truncate">{{ c.preview }}</p> <p class="truncate text-xs text-secondary">{{ c.preview }}</p>
</div> </div>
<span class="shrink-0 text-xs text-secondary">{{ c.time }}</span> <span class="shrink-0 text-xs text-secondary">{{ c.time }}</span>
</div> </div>
@@ -121,7 +269,6 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
<!-- Right column --> <!-- Right column -->
<div class="space-y-6 lg:col-span-2"> <div class="space-y-6 lg:col-span-2">
<!-- Projects -->
<div class="rounded-2xl bg-white p-6 shadow-soft-md"> <div class="rounded-2xl bg-white p-6 shadow-soft-md">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h6 class="text-sm font-bold text-dark">Projects</h6> <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" :key="p.name"
class="group relative overflow-hidden rounded-xl border border-gray-100 p-4 transition-shadow hover:shadow-soft-md" 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="absolute left-0 top-0 h-full w-1 rounded-l-xl" :class="`bg-gradient-${p.gradient}`" />
<div class="pl-3"> <div class="pl-3">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <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> <span class="material-icons text-base leading-none">more_vert</span>
</button> </button>
</div> </div>
<div class="mt-3 flex -space-x-2">
<!-- Member avatars --> <div
<div class="mt-3 flex items-center"> v-for="m in p.members"
<div class="flex -space-x-2"> :key="m"
<div class="flex h-7 w-7 items-center justify-center rounded-lg text-xs font-bold text-white ring-2 ring-white"
v-for="m in p.members" :class="`bg-gradient-${memberColor[m]} shadow-${memberColor[m]}`"
:key="m" :title="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]}`" {{ m }}
:title="m"
>
{{ m }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -174,7 +315,6 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
</div> </div>
</div> </div>
<!-- Settings -->
<div class="rounded-2xl bg-white p-6 shadow-soft-md"> <div class="rounded-2xl bg-white p-6 shadow-soft-md">
<h6 class="mb-5 text-sm font-bold text-dark">Profile Settings</h6> <h6 class="mb-5 text-sm font-bold text-dark">Profile Settings</h6>
<div class="space-y-4"> <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 class="grid gap-4 sm:grid-cols-2">
<div> <div>
<label class="mb-1 block text-xs font-medium text-secondary">First Name</label> <label class="mb-1 block text-xs font-medium text-secondary">First Name</label>
<input <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" />
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"
/>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-secondary">Last Name</label> <label class="mb-1 block text-xs font-medium text-secondary">Last Name</label>
<input <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" />
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"
/>
</div> </div>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-secondary">Email Address</label> <label class="mb-1 block text-xs font-medium text-secondary">Email Address</label>
<input <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" />
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"
/>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-secondary">Location</label> <label class="mb-1 block text-xs font-medium text-secondary">Location</label>
<input <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" />
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"
/>
</div> </div>
<div> <div>
<label class="mb-1 block text-xs font-medium text-secondary">Bio</label> <label class="mb-1 block text-xs font-medium text-secondary">Bio</label>
<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>
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>
</div> </div>
<div class="flex justify-end"> <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 transition-shadow hover:shadow-soft-md">
Save Changes Save Changes
</button> </button>
</div> </div>
@@ -238,3 +359,30 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
</DashboardLayout> </DashboardLayout>
</template> </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>