forked from wrenn/wrenn
Polish team page: delight micro-interactions and layout improvements
- Slug + Team ID rows collapsed into a 2-column grid for better density - "you" badge moved inline with email instead of stacked below it - Copy checkmark draws itself via SVG stroke-dashoffset animation - New member row flashes accent-green on entry - Removed member row slides out smoothly (fly transition) - Member rows use staggered fly-in on page load - Team name briefly highlights accent color after a successful rename - Search result avatars get colorized initials based on email character
This commit is contained in:
@ -2,6 +2,8 @@
|
|||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { auth } from '$lib/auth.svelte';
|
import { auth } from '$lib/auth.svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import {
|
import {
|
||||||
@ -73,6 +75,10 @@
|
|||||||
let removing = $state(false);
|
let removing = $state(false);
|
||||||
let removeError = $state<string | null>(null);
|
let removeError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Delight: new member row flash, name saved flash
|
||||||
|
let recentlyAddedId = $state<string | null>(null);
|
||||||
|
let nameSavedFlash = $state(false);
|
||||||
|
|
||||||
// Danger zone
|
// Danger zone
|
||||||
let showDangerConfirm = $state(false);
|
let showDangerConfirm = $state(false);
|
||||||
let dangerLoading = $state(false);
|
let dangerLoading = $state(false);
|
||||||
@ -124,6 +130,8 @@
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
team = { ...team, name: trimmed };
|
team = { ...team, name: trimmed };
|
||||||
editingName = false;
|
editingName = false;
|
||||||
|
nameSavedFlash = true;
|
||||||
|
setTimeout(() => (nameSavedFlash = false), 900);
|
||||||
toast.success('Team name updated');
|
toast.success('Team name updated');
|
||||||
} else {
|
} else {
|
||||||
nameError = result.error;
|
nameError = result.error;
|
||||||
@ -177,6 +185,8 @@
|
|||||||
const result = await addMember(team.id, addEmail.trim().toLowerCase());
|
const result = await addMember(team.id, addEmail.trim().toLowerCase());
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
members = [...members, result.data];
|
members = [...members, result.data];
|
||||||
|
recentlyAddedId = result.data.user_id;
|
||||||
|
setTimeout(() => (recentlyAddedId = null), 1200);
|
||||||
showAddMember = false;
|
showAddMember = false;
|
||||||
addEmail = '';
|
addEmail = '';
|
||||||
searchResults = [];
|
searchResults = [];
|
||||||
@ -251,6 +261,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function avatarColor(email: string): string {
|
||||||
|
const palette = ['#5e8c58', '#5a9fd4', '#d4a73c', '#a07ab0', '#cf8172'];
|
||||||
|
return palette[email.charCodeAt(0) % palette.length];
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('en-US', {
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@ -471,7 +486,7 @@
|
|||||||
title={canManage ? 'Click to edit' : undefined}
|
title={canManage ? 'Click to edit' : undefined}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="text-ui font-medium text-[var(--color-text-bright)] {canManage ? 'group-hover:text-[var(--color-text-bright)]' : ''}"
|
class="text-ui font-medium transition-colors duration-300 {nameSavedFlash ? 'text-[var(--color-accent-mid)]' : 'text-[var(--color-text-bright)]'} {canManage ? 'group-hover:text-[var(--color-text-bright)]' : ''}"
|
||||||
>
|
>
|
||||||
{team.name}
|
{team.name}
|
||||||
</span>
|
</span>
|
||||||
@ -498,108 +513,115 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slug row -->
|
<!-- Slug + ID row (2-col grid) -->
|
||||||
<div class="flex items-center gap-4 border-b border-[var(--color-border)] px-5 py-4">
|
<div class="grid grid-cols-2 divide-x divide-[var(--color-border)]">
|
||||||
<div class="min-w-0 flex-1">
|
<!-- Slug -->
|
||||||
<div
|
<div class="flex items-center gap-3 px-5 py-4">
|
||||||
class="mb-1.5 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
|
<div class="min-w-0 flex-1">
|
||||||
>
|
<div
|
||||||
Slug
|
class="mb-1 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
|
||||||
|
>
|
||||||
|
Slug
|
||||||
|
</div>
|
||||||
|
<span class="block truncate font-mono text-ui text-[var(--color-text-secondary)]"
|
||||||
|
>{team.slug}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-ui text-[var(--color-text-secondary)]"
|
<button
|
||||||
>{team.slug}</span
|
onclick={() => copyToClipboard(team!.slug, 'slug')}
|
||||||
|
title="Copy slug"
|
||||||
|
class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150
|
||||||
|
{copiedSlug
|
||||||
|
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]'
|
||||||
|
: 'border-[var(--color-border-mid)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
||||||
>
|
>
|
||||||
|
{#if copiedSlug}
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="checkmark-draw"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
Copied
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
Copy
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onclick={() => copyToClipboard(team!.slug, 'slug')}
|
|
||||||
class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150
|
|
||||||
{copiedSlug
|
|
||||||
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]'
|
|
||||||
: 'border-[var(--color-border-mid)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
|
||||||
>
|
|
||||||
{#if copiedSlug}
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
Copied
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
Copy
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Team ID row -->
|
<!-- Team ID -->
|
||||||
<div class="flex items-center gap-4 px-5 py-4">
|
<div class="flex items-center gap-3 px-5 py-4">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
class="mb-1.5 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
|
class="mb-1 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
|
||||||
>
|
>
|
||||||
Team ID
|
Team ID
|
||||||
|
</div>
|
||||||
|
<span class="block truncate font-mono text-ui text-[var(--color-text-secondary)]"
|
||||||
|
>{team.id}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-mono text-ui text-[var(--color-text-secondary)]"
|
<button
|
||||||
>{team.id}</span
|
onclick={() => copyToClipboard(team!.id, 'id')}
|
||||||
|
title="Copy team ID"
|
||||||
|
class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150
|
||||||
|
{copiedId
|
||||||
|
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]'
|
||||||
|
: 'border-[var(--color-border-mid)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
||||||
>
|
>
|
||||||
|
{#if copiedId}
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="checkmark-draw"
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
Copied
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
Copy
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onclick={() => copyToClipboard(team!.id, 'id')}
|
|
||||||
class="flex shrink-0 items-center gap-1.5 rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-semibold transition-all duration-150
|
|
||||||
{copiedId
|
|
||||||
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]'
|
|
||||||
: 'border-[var(--color-border-mid)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
|
||||||
>
|
|
||||||
{#if copiedId}
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
Copied
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
Copy
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -676,17 +698,20 @@
|
|||||||
|
|
||||||
{#each members as member, i (member.user_id)}
|
{#each members as member, i (member.user_id)}
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-[1fr_120px_140px_120px] items-center border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0"
|
class="grid grid-cols-[1fr_120px_140px_120px] items-center border-b border-[var(--color-border)] transition-colors duration-150 hover:bg-[var(--color-bg-3)] last:border-b-0 {recentlyAddedId === member.user_id ? 'member-flash' : ''}"
|
||||||
style="animation: fadeUp 0.35s ease both; animation-delay: {i * 40}ms"
|
in:fly={{ y: 6, duration: 200, delay: i * 30, easing: cubicOut }}
|
||||||
|
out:fly={{ x: -16, duration: 180, easing: cubicOut }}
|
||||||
>
|
>
|
||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
<div class="min-w-0 px-5 py-4">
|
<div class="min-w-0 px-5 py-4">
|
||||||
<span class="block truncate text-ui text-[var(--color-text-bright)]"
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
>{member.email}</span
|
<span class="truncate text-ui text-[var(--color-text-bright)]"
|
||||||
>
|
>{member.email}</span
|
||||||
{#if member.user_id === auth.userId}
|
>
|
||||||
<span class="text-meta text-[var(--color-text-muted)]">you</span>
|
{#if member.user_id === auth.userId}
|
||||||
{/if}
|
<span class="shrink-0 rounded-[2px] bg-[var(--color-bg-4)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">you</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role badge -->
|
<!-- Role badge -->
|
||||||
@ -989,7 +1014,8 @@
|
|||||||
class="flex w-full items-center gap-2.5 px-3 py-2 text-ui transition-colors duration-150 hover:bg-[var(--color-bg-3)]"
|
class="flex w-full items-center gap-2.5 px-3 py-2 text-ui transition-colors duration-150 hover:bg-[var(--color-bg-3)]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--color-bg-5)] text-badge font-bold uppercase text-[var(--color-text-secondary)]"
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-badge font-bold uppercase"
|
||||||
|
style="background: {avatarColor(result.email)}22; color: {avatarColor(result.email)}"
|
||||||
>
|
>
|
||||||
{result.email[0]}
|
{result.email[0]}
|
||||||
</div>
|
</div>
|
||||||
@ -1228,3 +1254,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Checkmark SVG path draw animation for copy buttons */
|
||||||
|
.checkmark-draw polyline {
|
||||||
|
stroke-dasharray: 24;
|
||||||
|
stroke-dashoffset: 24;
|
||||||
|
animation: draw-check 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes draw-check {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New member row entrance flash */
|
||||||
|
.member-flash {
|
||||||
|
animation: member-added 1.2s ease forwards;
|
||||||
|
}
|
||||||
|
@keyframes member-added {
|
||||||
|
0% { background-color: transparent; }
|
||||||
|
15% { background-color: var(--color-accent-glow-mid); }
|
||||||
|
100% { background-color: transparent; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user