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>
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 transition-shadow hover:shadow-soft-md">
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>