scaffolded avatar and bg image uploaders
This commit is contained in:
+196
-48
@@ -1,6 +1,63 @@
|
||||
<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' },
|
||||
@@ -34,10 +91,27 @@ 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 -->
|
||||
<!-- 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">
|
||||
@@ -46,25 +120,102 @@ const memberColor = { PC: 'primary', JD: 'info', SR: 'success', ML: 'warning', A
|
||||
</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">
|
||||
<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,10 +299,7 @@ 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 class="mt-3 flex -space-x-2">
|
||||
<div
|
||||
v-for="m in p.members"
|
||||
:key="m"
|
||||
@@ -172,9 +314,7 @@ 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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user