1
0
forked from wrenn/wrenn
Files
wrenn-releases/frontend/src/routes/dashboard/team/+page.svelte
pptx704 bf494f73fc Fix team name blink on navigation by lifting teams into a singleton store
Teams list was fetched on every Sidebar mount (each page navigation),
causing a flash from '…' to the real name on every tab switch. Move teams
into a module-level reactive store (teams.svelte.ts) that fetches once per
session and is shared between Sidebar and the team page.
2026-03-24 14:44:09 +06:00

1231 lines
40 KiB
Svelte

<script lang="ts">
import Sidebar from '$lib/components/Sidebar.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/auth.svelte';
import { toast } from '$lib/toast.svelte';
import {
getTeam,
listTeams,
updateTeam,
addMember,
removeMember,
updateMemberRole,
deleteTeam,
leaveTeam,
switchTeam,
searchUsers,
type TeamInfo,
type TeamMember,
type UserSearchResult
} from '$lib/api/team';
import { teams as teamsStore } from '$lib/teams.svelte';
let collapsed = $state(
typeof window !== 'undefined'
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
: false
);
// Page data
let team = $state<TeamInfo | null>(null);
let members = $state<TeamMember[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// True when this is the user's only team — deleting/leaving would leave them teamless
let isLastTeam = $derived(teamsStore.list.length <= 1);
// Current user's role — derived from members list
let myRole = $derived(members.find((m) => m.user_id === auth.userId)?.role ?? 'member');
let canManage = $derived(myRole === 'owner' || myRole === 'admin');
// Inline name edit
let editingName = $state(false);
let editName = $state('');
let savingName = $state(false);
let nameError = $state<string | null>(null);
let nameInputEl = $state<HTMLInputElement | null>(null);
// Copy state
let copiedSlug = $state(false);
let copiedId = $state(false);
// Add member dialog
let showAddMember = $state(false);
let addEmail = $state('');
let searchResults = $state<UserSearchResult[]>([]);
let searchLoading = $state(false);
let showResults = $state(false);
let adding = $state(false);
let addError = $state<string | null>(null);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Split button dropdown (members table)
let openDropdownId = $state<string | null>(null);
let dropdownPos = $state<{ top: number; left: number }>({ top: 0, left: 0 });
// Role update (inline, no confirmation needed — reversible)
let updatingRoleId = $state<string | null>(null);
// Remove member confirmation
let removeTarget = $state<TeamMember | null>(null);
let removing = $state(false);
let removeError = $state<string | null>(null);
// Danger zone
let showDangerConfirm = $state(false);
let dangerLoading = $state(false);
let dangerError = $state<string | null>(null);
async function fetchTeam() {
loading = true;
error = null;
if (!auth.teamId) {
loading = false;
return;
}
const [teamResult] = await Promise.all([
getTeam(auth.teamId),
teamsStore.fetch()
]);
if (teamResult.ok) {
team = teamResult.data.team;
members = teamResult.data.members;
} else {
error = teamResult.error;
}
loading = false;
}
function startEditName() {
if (!team || !canManage) return;
editName = team.name;
editingName = true;
nameError = null;
setTimeout(() => nameInputEl?.focus(), 0);
}
function cancelEditName() {
editingName = false;
nameError = null;
}
async function saveEditName() {
if (!team) return;
const trimmed = editName.trim();
if (!trimmed || trimmed === team.name) {
cancelEditName();
return;
}
savingName = true;
nameError = null;
const result = await updateTeam(team.id, trimmed);
if (result.ok) {
team = { ...team, name: trimmed };
editingName = false;
toast.success('Team name updated');
} else {
nameError = result.error;
}
savingName = false;
}
async function copyToClipboard(text: string, which: 'slug' | 'id') {
try {
await navigator.clipboard.writeText(text);
if (which === 'slug') {
copiedSlug = true;
setTimeout(() => (copiedSlug = false), 2000);
} else {
copiedId = true;
setTimeout(() => (copiedId = false), 2000);
}
} catch {
toast.error('Copy failed — select the text and copy manually.');
}
}
function handleSearchInput() {
const val = addEmail.trim();
showResults = false;
if (searchTimeout) clearTimeout(searchTimeout);
if (val.length < 3) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
searchLoading = true;
const result = await searchUsers(val);
if (result.ok) {
searchResults = result.data;
showResults = result.data.length > 0;
}
searchLoading = false;
}, 300);
}
function selectSearchResult(user: UserSearchResult) {
addEmail = user.email;
showResults = false;
}
async function handleAddMember() {
if (!team || !addEmail.trim()) return;
adding = true;
addError = null;
const result = await addMember(team.id, addEmail.trim().toLowerCase());
if (result.ok) {
members = [...members, result.data];
showAddMember = false;
addEmail = '';
searchResults = [];
showResults = false;
toast.success('Member added');
} else {
addError = result.error;
}
adding = false;
}
async function handleUpdateRole(member: TeamMember, newRole: 'admin' | 'member') {
if (!team) return;
updatingRoleId = member.user_id;
openDropdownId = null;
const result = await updateMemberRole(team.id, member.user_id, newRole);
if (result.ok) {
members = members.map((m) =>
m.user_id === member.user_id ? { ...m, role: newRole } : m
);
toast.success(
newRole === 'admin'
? `${member.email} is now an admin`
: `${member.email} is now a member`
);
} else {
toast.error(result.error);
}
updatingRoleId = null;
}
async function handleRemoveMember() {
if (!team || !removeTarget) return;
removing = true;
removeError = null;
const uid = removeTarget.user_id;
const result = await removeMember(team.id, uid);
if (result.ok) {
members = members.filter((m) => m.user_id !== uid);
removeTarget = null;
toast.success('Member removed');
} else {
removeError = result.error;
}
removing = false;
}
async function handleDangerAction() {
if (!team) return;
dangerLoading = true;
dangerError = null;
const result = myRole === 'owner' ? await deleteTeam(team.id) : await leaveTeam(team.id);
if (result.ok) {
// Fetch remaining teams and switch to the first available one
const teamsResult = await listTeams();
const remaining = teamsResult.ok ? teamsResult.data : [];
if (remaining.length > 0) {
const switchResult = await switchTeam(remaining[0].id);
if (switchResult.ok) {
auth.login(switchResult.data);
window.location.reload();
return;
}
}
// No teams left — prompt user to create one
dangerLoading = false;
showDangerConfirm = false;
toast.error('No teams remaining. Use the sidebar to create a new team.');
} else {
dangerError = result.error;
dangerLoading = false;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function roleLabel(role: string): string {
if (role === 'owner') return 'Owner';
if (role === 'admin') return 'Admin';
return 'Member';
}
// Whether to show actions for a given member row
function canActOn(member: TeamMember): boolean {
if (!canManage) return false;
if (member.user_id === auth.userId) return false; // can't act on self
if (member.role === 'owner') return false; // can't act on owner
return true;
}
onMount(fetchTeam);
</script>
<svelte:head>
<title>Wrenn - Team</title>
</svelte:head>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') {
if (openDropdownId) {
openDropdownId = null;
return;
}
if (editingName && !savingName) {
cancelEditName();
return;
}
if (removing || adding || dangerLoading) return;
removeTarget = null;
if (!adding) showAddMember = false;
showDangerConfirm = false;
}
}}
onclick={(e) => {
if (openDropdownId && !(e.target as Element)?.closest('.split-btn-container')) {
openDropdownId = null;
}
if (showResults && !(e.target as Element)?.closest('.search-container')) {
showResults = false;
}
}}
/>
<div class="flex h-screen overflow-hidden">
<Sidebar bind:collapsed />
<div class="flex flex-1 flex-col overflow-hidden">
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
<!-- Header -->
<div class="px-7 pt-8">
<h1 class="font-serif text-page tracking-[-0.02em] text-[var(--color-text-bright)]">
Team
</h1>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Manage your team, its members, and account settings.
</p>
<div class="mt-6 border-b border-[var(--color-border)]"></div>
</div>
<!-- Content -->
<div class="p-8" style="animation: fadeUp 0.35s ease both">
{#if error}
<div
class="mb-6 flex items-center justify-between gap-4 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]"
>
<span>{error}</span>
<button
onclick={fetchTeam}
class="shrink-0 font-semibold underline-offset-2 hover:underline"
>
Try again
</button>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-24">
<div class="flex items-center gap-3 text-ui text-[var(--color-text-secondary)]">
<svg
class="animate-spin"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading team...
</div>
</div>
{:else if !auth.teamId}
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="mb-5 flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<p class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">No team yet</p>
<p class="mt-1.5 max-w-xs text-center text-ui text-[var(--color-text-tertiary)]">
Use the team switcher in the sidebar to create your first team.
</p>
</div>
{:else if team}
<!-- ── Team Info ── -->
<section class="mb-8">
<div
class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)]"
>
<!-- Name row -->
<div class="flex items-center gap-4 border-b border-[var(--color-border)] px-5 py-4">
<div class="min-w-0 flex-1">
<div
class="mb-1.5 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Team name
</div>
{#if editingName}
<div class="flex items-center gap-2">
<input
bind:this={nameInputEl}
bind:value={editName}
onkeydown={(e) => {
if (e.key === 'Enter' && !savingName) saveEditName();
if (e.key === 'Escape' && !savingName) cancelEditName();
}}
disabled={savingName}
class="min-w-0 flex-1 rounded-[var(--radius-input)] border border-[var(--color-accent)] bg-[var(--color-bg-4)] px-3 py-1.5 text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 disabled:opacity-60"
/>
<!-- Save button -->
<button
onclick={saveEditName}
disabled={savingName || !editName.trim()}
title="Save"
class="flex h-7 w-7 items-center justify-center rounded-[var(--radius-button)] border border-[var(--color-accent)]/40 bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-bright)] transition-colors duration-150 hover:bg-[var(--color-accent-glow)] disabled:opacity-40"
>
{#if savingName}
<svg
class="animate-spin"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{:else}
<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>
{/if}
</button>
<!-- Cancel button -->
<button
onclick={cancelEditName}
disabled={savingName}
title="Cancel"
class="flex h-7 w-7 items-center justify-center rounded-[var(--radius-button)] border border-[var(--color-border-mid)] text-[var(--color-text-tertiary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-secondary)] disabled:opacity-40"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{#if nameError}
<p class="mt-1.5 text-meta text-[var(--color-red)]">{nameError}</p>
{/if}
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="group flex items-center gap-2"
onclick={canManage ? startEditName : undefined}
role={canManage ? 'button' : undefined}
tabindex={canManage ? 0 : undefined}
onkeydown={canManage
? (e) => {
if (e.key === 'Enter' || e.key === ' ') startEditName();
}
: undefined}
class:cursor-pointer={canManage}
title={canManage ? 'Click to edit' : undefined}
>
<span
class="text-ui font-medium text-[var(--color-text-bright)] {canManage ? 'group-hover:text-[var(--color-text-bright)]' : ''}"
>
{team.name}
</span>
{#if canManage}
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 text-[var(--color-text-tertiary)]"
>
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
{/if}
</div>
{/if}
</div>
</div>
<!-- Slug row -->
<div class="flex items-center gap-4 border-b border-[var(--color-border)] px-5 py-4">
<div class="min-w-0 flex-1">
<div
class="mb-1.5 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Slug
</div>
<span class="font-mono text-ui text-[var(--color-text-secondary)]"
>{team.slug}</span
>
</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 -->
<div class="flex items-center gap-4 px-5 py-4">
<div class="min-w-0 flex-1">
<div
class="mb-1.5 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Team ID
</div>
<span class="font-mono text-ui text-[var(--color-text-secondary)]"
>{team.id}</span
>
</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>
</section>
<!-- ── Members ── -->
<section class="mb-8">
<div class="mb-4 flex items-center justify-between">
<div>
<h2
class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]"
>
Members
</h2>
<p class="mt-0.5 text-meta text-[var(--color-text-tertiary)]">
{members.length}
{members.length === 1 ? 'member' : 'members'}
</p>
</div>
{#if canManage}
<button
onclick={() => {
showAddMember = true;
addEmail = '';
searchResults = [];
showResults = false;
addError = null;
}}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Member
</button>
{/if}
</div>
<div
class="overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)]"
>
<!-- Table header -->
<div
class="grid grid-cols-[1fr_120px_140px_120px] border-b border-[var(--color-border)] bg-[var(--color-bg-3)]"
>
<div
class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Member
</div>
<div
class="px-4 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Role
</div>
<div
class="px-4 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
>
Joined
</div>
<div
class="px-4 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]"
></div>
</div>
{#each members as member, i (member.user_id)}
<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"
style="animation: fadeUp 0.35s ease both; animation-delay: {i * 40}ms"
>
<!-- Email -->
<div class="min-w-0 px-5 py-4">
<span class="block 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}
</div>
<!-- Role badge -->
<div class="px-4 py-4">
{#if updatingRoleId === member.user_id}
<svg
class="animate-spin text-[var(--color-text-tertiary)]"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{:else}
<span
class="inline-flex items-center rounded-[3px] px-2 py-0.5 text-badge font-semibold uppercase tracking-[0.06em]
{member.role === 'owner'
? 'bg-[var(--color-accent-glow-mid)] text-[var(--color-accent-mid)]'
: member.role === 'admin'
? 'bg-[var(--color-bg-5)] text-[var(--color-text-secondary)]'
: 'bg-[var(--color-bg-4)] text-[var(--color-text-muted)]'}"
>
{roleLabel(member.role)}
</span>
{/if}
</div>
<!-- Joined date -->
<div class="px-4 py-4">
<span class="text-ui text-[var(--color-text-secondary)]"
>{formatDate(member.joined_at)}</span
>
</div>
<!-- Actions: split button -->
<div class="flex items-center justify-end px-3 py-3">
{#if canActOn(member)}
<div
class="split-btn-container relative flex items-stretch overflow-hidden rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]"
>
<!-- Primary: Remove -->
<button
onclick={() => {
removeTarget = member;
removeError = null;
}}
class="flex items-center px-3 py-1.5 text-meta font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-red)]"
>
Remove
</button>
<!-- Divider -->
<div class="w-px shrink-0 bg-[var(--color-border-mid)]"></div>
<!-- Chevron: Make Admin / Make Member -->
<button
onclick={(e) => {
e.stopPropagation();
if (openDropdownId === member.user_id) {
openDropdownId = null;
} else {
const rect = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
dropdownPos = {
top: rect.bottom + 4,
left: rect.right - 140
};
openDropdownId = member.user_id;
}
}}
class="flex items-center px-2 py-1.5 text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-bright)]"
>
<svg
class="transition-transform duration-150 {openDropdownId === member.user_id
? 'rotate-180'
: ''}"
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="6 9 12 15 18 9" />
</svg>
</button>
</div>
{/if}
</div>
</div>
{/each}
</div>
</section>
<!-- ── Danger Zone ── -->
<section>
<div
class="rounded-[var(--radius-card)] border border-[var(--color-red)]/25 bg-[var(--color-red)]/[0.03]"
>
<div class="border-b border-[var(--color-red)]/15 px-5 py-4">
<h2
class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]"
>
Danger Zone
</h2>
</div>
<div class="flex items-center justify-between gap-6 px-5 py-4">
{#if myRole === 'owner'}
<div>
<p class="text-ui font-medium text-[var(--color-text-primary)]">
Delete this team
</p>
<p class="mt-0.5 text-meta text-[var(--color-text-tertiary)]">
{#if isLastTeam}Create another team before deleting this one.{:else}Permanently deletes the team and destroys all running capsules. This cannot be undone.{/if}
</p>
</div>
<button
onclick={() => { showDangerConfirm = true; dangerError = null; }}
disabled={isLastTeam}
class="shrink-0 rounded-[var(--radius-button)] border px-4 py-2 text-ui font-semibold transition-all duration-150 {isLastTeam ? 'cursor-not-allowed border-[var(--color-border)] text-[var(--color-text-muted)] opacity-50' : 'border-[var(--color-red)]/40 text-[var(--color-red)] hover:bg-[var(--color-red)]/10 hover:border-[var(--color-red)]/60'}"
>
Delete Team
</button>
{:else}
<div>
<p class="text-ui font-medium text-[var(--color-text-primary)]">
Leave this team
</p>
<p class="mt-0.5 text-meta text-[var(--color-text-tertiary)]">
{#if isLastTeam}Create another team before leaving this one.{:else}You will lose access to all capsules and resources belonging to this team.{/if}
</p>
</div>
<button
onclick={() => { showDangerConfirm = true; dangerError = null; }}
disabled={isLastTeam}
class="shrink-0 rounded-[var(--radius-button)] border px-4 py-2 text-ui font-semibold transition-all duration-150 {isLastTeam ? 'cursor-not-allowed border-[var(--color-border)] text-[var(--color-text-muted)] opacity-50' : 'border-[var(--color-red)]/40 text-[var(--color-red)] hover:bg-[var(--color-red)]/10 hover:border-[var(--color-red)]/60'}"
>
Leave Team
</button>
{/if}
</div>
</div>
</section>
{/if}
</div>
</main>
<footer class="h-px shrink-0 bg-[var(--color-border)]"></footer>
</div>
</div>
<!-- Split button dropdown -->
{#if openDropdownId}
{@const dropdownMember = members.find((m) => m.user_id === openDropdownId)}
{#if dropdownMember}
<div
class="fixed z-50 w-36 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] py-1"
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px; animation: fadeUp 0.15s ease both"
>
{#if dropdownMember.role === 'member'}
<button
onclick={(e) => {
e.stopPropagation();
handleUpdateRole(dropdownMember, 'admin');
}}
class="flex w-full items-center gap-2 px-3 py-2 text-meta text-[var(--color-text-primary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)]"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 text-[var(--color-text-secondary)]"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Make Admin
</button>
{:else if dropdownMember.role === 'admin'}
<button
onclick={(e) => {
e.stopPropagation();
handleUpdateRole(dropdownMember, 'member');
}}
class="flex w-full items-center gap-2 px-3 py-2 text-meta text-[var(--color-text-primary)] transition-colors duration-150 hover:bg-[var(--color-bg-3)]"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 text-[var(--color-text-secondary)]"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Make Member
</button>
{/if}
</div>
{/if}
{/if}
<!-- Add Member Dialog -->
{#if showAddMember}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={() => {
if (!adding) {
showAddMember = false;
}
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !adding) showAddMember = false;
}}
></div>
<div
class="relative w-full max-w-[400px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
style="animation: fadeUp 0.2s ease both"
>
<h2
class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]"
>
Add Member
</h2>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
Add an existing Wrenn user to your team by email.
</p>
{#if addError}
<div
class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]"
>
{addError}
</div>
{/if}
<div class="mt-5">
<label
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
for="add-email"
>
Email address
</label>
<div class="search-container relative">
<div class="relative">
<input
id="add-email"
type="email"
placeholder="colleague@example.com"
bind:value={addEmail}
oninput={handleSearchInput}
onkeydown={(e) => {
if (e.key === 'Enter' && !adding) handleAddMember();
}}
disabled={adding}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
{#if searchLoading}
<div class="absolute right-2.5 top-1/2 -translate-y-1/2">
<svg
class="animate-spin text-[var(--color-text-tertiary)]"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</div>
{/if}
</div>
<!-- Typeahead results -->
{#if showResults && searchResults.length > 0}
<div
class="absolute left-0 right-0 top-full z-10 mt-1 overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] py-1"
style="animation: fadeUp 0.15s ease both"
>
{#each searchResults as result (result.user_id)}
<button
onclick={() => selectSearchResult(result)}
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
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)]"
>
{result.email[0]}
</div>
<span class="min-w-0 truncate text-[var(--color-text-primary)]"
>{result.email}</span
>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => {
showAddMember = false;
}}
disabled={adding}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleAddMember}
disabled={adding || !addEmail.trim()}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if adding}
<svg
class="animate-spin"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Adding...
{:else}
Add Member
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Remove Member Confirmation Dialog -->
{#if removeTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={() => {
if (!removing) removeTarget = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !removing) removeTarget = null;
}}
></div>
<div
class="relative w-full max-w-[380px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
style="animation: fadeUp 0.2s ease both"
>
<h2
class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]"
>
Remove Member
</h2>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
Remove <span class="font-medium text-[var(--color-text-secondary)]"
>{removeTarget.email}</span
> from the team? They will lose access immediately.
</p>
{#if removeError}
<div
class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]"
>
{removeError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => {
removeTarget = null;
}}
disabled={removing}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleRemoveMember}
disabled={removing}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if removing}
<svg
class="animate-spin"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Removing...
{:else}
Remove
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Danger Zone Confirmation Dialog -->
{#if showDangerConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60"
onclick={() => {
if (!dangerLoading) showDangerConfirm = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !dangerLoading) showDangerConfirm = false;
}}
></div>
<div
class="relative w-full max-w-[400px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
style="animation: fadeUp 0.2s ease both"
>
{#if myRole === 'owner'}
<h2
class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]"
>
Delete Team
</h2>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
This will permanently delete <span class="font-medium text-[var(--color-text-secondary)]"
>{team?.name}</span
> and destroy all running capsules. This action cannot be undone.
</p>
{:else}
<h2
class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]"
>
Leave Team
</h2>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
You will lose access to all capsules and resources in <span
class="font-medium text-[var(--color-text-secondary)]">{team?.name}</span
>.
</p>
{/if}
<!-- Amber warning -->
<div
class="mt-4 flex items-start gap-2 rounded-[var(--radius-input)] border border-[var(--color-amber)]/20 bg-[var(--color-amber)]/5 px-3 py-2.5"
>
<svg
class="mt-0.5 shrink-0"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-amber)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p class="text-meta leading-relaxed text-[var(--color-amber)]">
{#if myRole === 'owner'}
All team data, API keys, and running capsules will be permanently destroyed.
{:else}
You'll need a new invitation to rejoin this team.
{/if}
</p>
</div>
{#if dangerError}
<div
class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]"
>
{dangerError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => {
showDangerConfirm = false;
}}
disabled={dangerLoading}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDangerAction}
disabled={dangerLoading}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if dangerLoading}
<svg
class="animate-spin"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{/if}
{myRole === 'owner' ? 'Delete Team' : 'Leave Team'}
</button>
</div>
</div>
</div>
{/if}