scaffolded avatar and bg image uploaders

This commit is contained in:
2026-06-01 17:01:29 -06:00
parent b08d347727
commit 79a8720fb0
+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>