feat(members): align grid UI to spec — avatar colors, badges, join date, invite panel
- MemberCard: white bg, 1px border + shadow-card, centered column layout,
avatar color by role (green-dark/blue), role badge with role-specific colors,
join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons
and divider, inline role-control with Abbrechen, blue editing border #B5D4F4
- InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle,
label "Mitglied einladen", full hover state (green border/bg/icon/label)
- InvitePanel: white bg, title "Einladelink teilen", description, mono link
box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren"
- RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text
- +page.server.ts: expose householdName from locals.haushalt
- +page.svelte: subtitle "{n} Mitglieder · {householdName}"
- Tests: add join date format test, Abbrechen test, InvitePanel title test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
|
|||||||
return {
|
return {
|
||||||
members: membersRes.data ?? [],
|
members: membersRes.data ?? [],
|
||||||
currentUserId: locals.benutzer!.id,
|
currentUserId: locals.benutzer!.id,
|
||||||
activeInvite: inviteRes.data?.data ?? null
|
activeInvite: inviteRes.data?.data ?? null,
|
||||||
|
householdName: locals.haushalt?.name ?? ''
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,7 +70,8 @@
|
|||||||
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
|
<svelte:head><title>Mitglieder — Mealprep</title></svelte:head>
|
||||||
|
|
||||||
<div class="p-[16px_20px] md:p-[40px_56px]">
|
<div class="p-[16px_20px] md:p-[40px_56px]">
|
||||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-8 text-[var(--color-text)]">Mitglieder</h1>
|
<h1 class="font-[var(--font-display)] text-[28px] font-medium tracking-[-0.02em] mb-1 text-[var(--color-text)]">Mitglieder</h1>
|
||||||
|
<p class="text-[13px] text-[var(--color-text-muted)] mb-8">{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}</p>
|
||||||
|
|
||||||
<MemberGrid
|
<MemberGrid
|
||||||
{members}
|
{members}
|
||||||
|
|||||||
@@ -10,8 +10,75 @@
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="invite-card"
|
data-testid="invite-card"
|
||||||
{onclick}
|
{onclick}
|
||||||
style="background: var(--color-surface); border-radius: var(--radius-xl); border: 2px dashed var(--color-border); padding: 20px; cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; width: 100%;"
|
class="invite-card"
|
||||||
>
|
>
|
||||||
<span style="font-size: 2rem; line-height: 1;" aria-hidden="true">+</span>
|
<div class="invite-plus">+</div>
|
||||||
<span>Einladen</span>
|
<div class="invite-label">Mitglied einladen</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.invite-card {
|
||||||
|
background: white;
|
||||||
|
border: 1.5px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 180px;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card:hover {
|
||||||
|
border-color: var(--green-light);
|
||||||
|
background: var(--green-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-plus {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card:hover .invite-plus {
|
||||||
|
background: var(--green-light);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-card:hover .invite-label {
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.invite-card {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-plus {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -23,28 +23,117 @@
|
|||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isExpiringSoon = $derived(
|
||||||
|
new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="invite-panel">
|
||||||
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 20px; margin-top: 16px; border: 1px solid var(--color-border);"
|
<div class="invite-panel-title">Einladelink teilen</div>
|
||||||
>
|
<div class="invite-panel-desc">Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.</div>
|
||||||
<p
|
|
||||||
style="font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 0 0 12px;"
|
|
||||||
>
|
|
||||||
{invite.shareUrl || invite.inviteCode}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;">
|
<div class="invite-link-row">
|
||||||
<button type="button" data-testid="copy-btn" onclick={copy}>
|
<div class="invite-link-box">{invite.shareUrl || invite.inviteCode}</div>
|
||||||
|
<button type="button" data-testid="copy-btn" class="btn-copy" onclick={copy}>
|
||||||
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
{copied ? 'Kopiert ✓' : 'Kopieren'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" data-testid="regenerate-btn" onclick={onregenerate}>
|
|
||||||
Neu generieren
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin: 0; font-size: 0.875rem;">
|
<div class="invite-expiry">
|
||||||
Läuft ab: {formatExpiry(invite.expiresAt)}
|
Läuft ab: <span class:yellow={isExpiringSoon}>{formatExpiry(invite.expiresAt)}</span>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<button type="button" data-testid="regenerate-btn" class="btn-regen" onclick={onregenerate}>
|
||||||
|
Neuen Link generieren
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.invite-panel {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-panel-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-panel-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-box {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expiry {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expiry span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-expiry span.yellow {
|
||||||
|
background: var(--yellow-tint);
|
||||||
|
color: var(--yellow-text);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regen {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regen:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -31,4 +31,9 @@ describe('InvitePanel', () => {
|
|||||||
await userEvent.click(screen.getByTestId('regenerate-btn'));
|
await userEvent.click(screen.getByTestId('regenerate-btn'));
|
||||||
expect(onregenerate).toHaveBeenCalledOnce();
|
expect(onregenerate).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the panel title', () => {
|
||||||
|
render(InvitePanel, { props: { invite, onregenerate: vi.fn() } });
|
||||||
|
expect(screen.getByText('Einladelink teilen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
|
||||||
|
|
||||||
type Member = {
|
type Member = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -26,13 +25,18 @@
|
|||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let editingRole = $state(false);
|
let editingRole = $state(false);
|
||||||
|
|
||||||
const roleOptions = [
|
|
||||||
{ value: 'planner', label: 'Planer' },
|
|
||||||
{ value: 'member', label: 'Mitglied' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
|
const initials = $derived(member.displayName.slice(0, 2).toUpperCase());
|
||||||
|
|
||||||
|
const avatarBg = $derived(member.role === 'planner' ? 'var(--green-dark)' : 'var(--blue)');
|
||||||
|
|
||||||
|
const joinDateFormatted = $derived(
|
||||||
|
new Date(member.joinedAt).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
let cardEl: HTMLElement | undefined = $state(undefined);
|
let cardEl: HTMLElement | undefined = $state(undefined);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -65,151 +69,318 @@
|
|||||||
<article
|
<article
|
||||||
bind:this={cardEl}
|
bind:this={cardEl}
|
||||||
data-testid="member-card"
|
data-testid="member-card"
|
||||||
style="
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
{isCurrentUser ? 'border: 2px solid var(--color-success);' : ''}
|
|
||||||
"
|
|
||||||
class="member-card"
|
class="member-card"
|
||||||
|
class:own={isCurrentUser}
|
||||||
|
class:editing={editingRole}
|
||||||
>
|
>
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div
|
<div class="avatar" style="background: {avatarBg};">
|
||||||
style="
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-success-dark);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Name row -->
|
<!-- Name -->
|
||||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;">
|
<div class="member-name">{member.displayName}</div>
|
||||||
<span style="font-weight: 500;">{member.displayName}</span>
|
|
||||||
{#if isCurrentUser}
|
|
||||||
<span
|
|
||||||
style="
|
|
||||||
background: var(--color-success-tint);
|
|
||||||
color: var(--color-success-dark);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
"
|
|
||||||
>Du</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Role badge or SegmentedControl -->
|
<!-- Role badge or inline role control -->
|
||||||
{#if editingRole}
|
{#if editingRole}
|
||||||
<SegmentedControl
|
<div role="group" class="role-control">
|
||||||
options={roleOptions}
|
<button
|
||||||
value={member.role}
|
type="button"
|
||||||
onchange={(newValue) => {
|
class="role-control-btn"
|
||||||
onrolechange(member, newValue);
|
class:active={member.role === 'planner'}
|
||||||
|
onclick={() => {
|
||||||
|
if (member.role !== 'planner') {
|
||||||
|
onrolechange(member, 'planner');
|
||||||
editingRole = false;
|
editingRole = false;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
>Planer</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="role-control-btn"
|
||||||
|
class:active={member.role === 'member'}
|
||||||
|
onclick={() => {
|
||||||
|
if (member.role !== 'member') {
|
||||||
|
onrolechange(member, 'member');
|
||||||
|
editingRole = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Mitglied</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span style="font-size: 13px; color: var(--color-text-muted);">
|
<span class="role-badge" class:planer={member.role === 'planner'} class:mitglied={member.role === 'member'}>
|
||||||
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
|
{member.role === 'planner' ? 'Planer' : 'Mitglied'}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Join date -->
|
||||||
|
<div class="join-date">seit {joinDateFormatted}</div>
|
||||||
|
|
||||||
|
<!-- Du badge (own card) or Abbrechen (when editing role) -->
|
||||||
|
{#if isCurrentUser}
|
||||||
|
<div class="self-badge-wrap">
|
||||||
|
<span class="self-badge">Du</span>
|
||||||
|
</div>
|
||||||
|
{:else if editingRole}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cancel-btn"
|
||||||
|
onclick={() => { editingRole = false; }}
|
||||||
|
>Abbrechen</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Kebab button -->
|
<!-- Kebab button -->
|
||||||
{#if isPlanner && !isCurrentUser}
|
{#if isPlanner && !isCurrentUser && !editingRole}
|
||||||
<button
|
<button
|
||||||
data-testid="kebab-btn"
|
data-testid="kebab-btn"
|
||||||
type="button"
|
type="button"
|
||||||
class="kebab-btn"
|
class="kebab-btn"
|
||||||
onclick={() => { menuOpen = true; }}
|
onclick={() => { menuOpen = true; }}
|
||||||
aria-label="Optionen"
|
aria-label="Optionen"
|
||||||
style="
|
>⋯</button>
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
line-height: 1;
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
"
|
|
||||||
>⋮</button>
|
|
||||||
|
|
||||||
<!-- Dropdown -->
|
<!-- Dropdown -->
|
||||||
{#if menuOpen}
|
{#if menuOpen}
|
||||||
<div
|
<div class="dropdown">
|
||||||
style="
|
|
||||||
position: absolute;
|
|
||||||
top: 40px;
|
|
||||||
right: 12px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-raised);
|
|
||||||
z-index: 10;
|
|
||||||
min-width: 140px;
|
|
||||||
overflow: hidden;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style="
|
class="dropdown-item"
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 16px;
|
|
||||||
text-align: left;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
"
|
|
||||||
onclick={() => { menuOpen = false; editingRole = true; }}
|
onclick={() => { menuOpen = false; editingRole = true; }}
|
||||||
>Rolle ändern</button>
|
><span class="dropdown-icon">🔄</span>Rolle ändern</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style="
|
class="dropdown-item danger"
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 16px;
|
|
||||||
text-align: left;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--color-danger, #e53e3e);
|
|
||||||
"
|
|
||||||
onclick={() => { menuOpen = false; onremove(member); }}
|
onclick={() => { menuOpen = false; onremove(member); }}
|
||||||
>Entfernen</button>
|
><span class="dropdown-icon">✕</span>Entfernen</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.member-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card.own {
|
||||||
|
border-color: var(--green-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-card.editing {
|
||||||
|
border-color: #B5D4F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.planer {
|
||||||
|
background: var(--green-tint);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.mitglied {
|
||||||
|
background: var(--blue-tint);
|
||||||
|
color: var(--blue-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-badge-wrap {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--green-tint);
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.kebab-btn {
|
.kebab-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kebab-btn:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.kebab-btn {
|
.kebab-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-card:hover .kebab-btn {
|
.member-card:hover .kebab-btn,
|
||||||
|
.member-card:focus-within .kebab-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 44px;
|
||||||
|
right: 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 10;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--color-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.danger {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.danger:hover {
|
||||||
|
background: var(--error-tint, #FDECEA);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn:first-child {
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-control-btn.active {
|
||||||
|
background: var(--green-dark);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.member-card {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -144,4 +144,32 @@ describe('MemberCard', () => {
|
|||||||
await userEvent.keyboard('{Escape}');
|
await userEvent.keyboard('{Escape}');
|
||||||
expect(screen.queryByText('Entfernen')).toBeNull();
|
expect(screen.queryByText('Entfernen')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows formatted join date', () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: plannerMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: false,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/seit 01\.01\.2024/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Abbrechen button when editing role', async () => {
|
||||||
|
render(MemberCard, {
|
||||||
|
props: {
|
||||||
|
member: regularMember,
|
||||||
|
isCurrentUser: false,
|
||||||
|
isPlanner: true,
|
||||||
|
onremove: vi.fn(),
|
||||||
|
onrolechange: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await userEvent.click(screen.getByTestId('kebab-btn'));
|
||||||
|
await userEvent.click(screen.getByText('Rolle ändern'));
|
||||||
|
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,16 +19,16 @@
|
|||||||
{#if show}
|
{#if show}
|
||||||
{#if isMobile()}
|
{#if isMobile()}
|
||||||
<BottomSheet open={show} onclose={oncancel}>
|
<BottomSheet open={show} onclose={oncancel}>
|
||||||
<div data-testid="remove-dialog" style="padding: 24px;">
|
<div data-testid="remove-dialog" style="padding: 24px 24px 32px;">
|
||||||
<h2 style="margin: 0 0 12px;">Mitglied entfernen</h2>
|
<h2 style="margin: 0 0 8px; font-size: 15px; font-weight: 500;">Mitglied entfernen?</h2>
|
||||||
<p>
|
<p style="font-size: 12px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
|
||||||
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
|
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt.
|
||||||
</p>
|
</p>
|
||||||
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={oncancel}
|
onclick={oncancel}
|
||||||
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 12px; font-weight: 500; cursor: pointer;"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="confirm-remove-btn"
|
data-testid="confirm-remove-btn"
|
||||||
onclick={onconfirm}
|
onclick={onconfirm}
|
||||||
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 12px; font-weight: 500; cursor: pointer;"
|
||||||
>
|
>
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<div
|
<div
|
||||||
data-testid="dialog-backdrop"
|
data-testid="dialog-backdrop"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,0.4); display: flex; align-items: center; justify-content: center;"
|
style="position: fixed; inset: 0; z-index: 100; background: rgba(28,28,24,.45); display: flex; align-items: center; justify-content: center;"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="remove-dialog"
|
data-testid="remove-dialog"
|
||||||
@@ -55,19 +55,19 @@
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="remove-dialog-title"
|
aria-labelledby="remove-dialog-title"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
style="background: var(--color-surface); border-radius: var(--radius-xl); padding: 24px; max-width: 400px; width: 90%;"
|
style="background: white; border-radius: var(--radius-xl); padding: 28px 32px; max-width: 380px; width: 100%; box-shadow: var(--shadow-raised);"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 id="remove-dialog-title" style="margin: 0 0 12px;">Mitglied entfernen</h2>
|
<h2 id="remove-dialog-title" style="font-size: 16px; font-weight: 500; margin: 0 0 8px;">Mitglied entfernen?</h2>
|
||||||
<p>
|
<p style="font-size: 13px; color: var(--color-text-muted); line-height: 1.6; margin-bottom: 24px;">
|
||||||
Soll <strong>{member.displayName}</strong> wirklich entfernt werden?
|
<strong style="color: var(--color-text); font-weight: 500;">{member.displayName}</strong> wird aus dem Haushalt entfernt und verliert sofort den Zugang zu allen Plänen und Rezepten.
|
||||||
</p>
|
</p>
|
||||||
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px;">
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={oncancel}
|
onclick={oncancel}
|
||||||
style="background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: 1px solid var(--color-border); background: white; font-size: 13px; font-weight: 500; cursor: pointer;"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="confirm-remove-btn"
|
data-testid="confirm-remove-btn"
|
||||||
onclick={onconfirm}
|
onclick={onconfirm}
|
||||||
style="background: var(--color-error); color: white; border: none; border-radius: var(--radius-md); padding: 10px 20px; cursor: pointer;"
|
style="padding: 9px 18px; border-radius: var(--radius-md); border: none; background: var(--color-error); color: white; font-size: 13px; font-weight: 500; cursor: pointer;"
|
||||||
>
|
>
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user