1
0
forked from wrenn/wrenn

Harden channels page: deduplicate dropdowns, add missing provider logos

Consolidate three identical click-outside $effect blocks into a reusable
useClickOutside helper. Extract duplicated events checkbox list into an
eventsDropdownItems snippet shared by create and edit dialogs. Add brand
SVG icons for Teams, Google Chat, and Matrix providers.
This commit is contained in:
2026-04-11 06:18:36 +06:00
parent 2bad843069
commit dbad418093

View File

@ -286,39 +286,22 @@
return `${events.length} events`;
}
// Click-outside handlers for dropdowns
// Click-outside handler — single listener covers all dropdowns
function useClickOutside(open: () => boolean, el: () => HTMLElement | null, close: () => void) {
$effect(() => {
if (!providerDropdownOpen) return;
function handleMouseDown(e: MouseEvent) {
if (providerDropdownEl && !providerDropdownEl.contains(e.target as Node)) {
providerDropdownOpen = false;
if (!open()) return;
function onMouseDown(e: MouseEvent) {
const container = el();
if (container && !container.contains(e.target as Node)) close();
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
document.addEventListener('mousedown', onMouseDown);
return () => document.removeEventListener('mousedown', onMouseDown);
});
}
$effect(() => {
if (!eventsDropdownOpen) return;
function handleMouseDown(e: MouseEvent) {
if (eventsDropdownEl && !eventsDropdownEl.contains(e.target as Node)) {
eventsDropdownOpen = false;
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
});
$effect(() => {
if (!editEventsDropdownOpen) return;
function handleMouseDown(e: MouseEvent) {
if (editEventsDropdownEl && !editEventsDropdownEl.contains(e.target as Node)) {
editEventsDropdownOpen = false;
}
}
document.addEventListener('mousedown', handleMouseDown);
return () => document.removeEventListener('mousedown', handleMouseDown);
});
useClickOutside(() => providerDropdownOpen, () => providerDropdownEl, () => { providerDropdownOpen = false; });
useClickOutside(() => eventsDropdownOpen, () => eventsDropdownEl, () => { eventsDropdownOpen = false; });
useClickOutside(() => editEventsDropdownOpen, () => editEventsDropdownEl, () => { editEventsDropdownOpen = false; });
onMount(fetchChannels);
</script>
@ -848,29 +831,7 @@
class="absolute left-0 top-full z-20 mt-1.5 w-full overflow-y-auto rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] py-1.5 shadow-xl"
style="max-height: 280px; animation: fadeUp 0.12s ease both"
>
{#each Object.entries(groupedEvents) as [group, events], gi}
<div class="px-3 py-1.5 text-badge font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">{group}</div>
{#each events as et}
{@const checked = createEvents.includes(et.value)}
<label class="flex cursor-pointer items-center gap-2.5 px-3 py-2 transition-colors duration-100 hover:bg-[var(--color-bg-3)]">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border transition-colors duration-100
{checked ? 'border-[var(--color-accent)] bg-[var(--color-accent)]' : 'border-[var(--color-border-mid)] bg-[var(--color-bg-4)]'}">
{#if checked}
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</span>
<input type="checkbox" class="sr-only" {checked} onchange={() => { createEvents = toggleEvent(createEvents, et.value); }} />
<span class="font-mono text-meta {checked ? 'text-[var(--color-text-bright)]' : 'text-[var(--color-text-secondary)]'}">{et.value}</span>
</label>
{/each}
{#if gi < Object.entries(groupedEvents).length - 1}
<div class="mx-3 my-1 border-t border-[var(--color-border)]"></div>
{/if}
{/each}
{@render eventsDropdownItems(createEvents, (v) => { createEvents = toggleEvent(createEvents, v); })}
</div>
{/if}
</div>
@ -1091,29 +1052,7 @@
class="absolute left-0 top-full z-20 mt-1.5 w-full overflow-y-auto rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] py-1.5 shadow-xl"
style="max-height: 280px; animation: fadeUp 0.12s ease both"
>
{#each Object.entries(groupedEvents) as [group, events], gi}
<div class="px-3 py-1.5 text-badge font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">{group}</div>
{#each events as et}
{@const checked = editEvents.includes(et.value)}
<label class="flex cursor-pointer items-center gap-2.5 px-3 py-2 transition-colors duration-100 hover:bg-[var(--color-bg-3)]">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border transition-colors duration-100
{checked ? 'border-[var(--color-accent)] bg-[var(--color-accent)]' : 'border-[var(--color-border-mid)] bg-[var(--color-bg-4)]'}">
{#if checked}
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</span>
<input type="checkbox" class="sr-only" {checked} onchange={() => { editEvents = toggleEvent(editEvents, et.value); }} />
<span class="font-mono text-meta {checked ? 'text-[var(--color-text-bright)]' : 'text-[var(--color-text-secondary)]'}">{et.value}</span>
</label>
{/each}
{#if gi < Object.entries(groupedEvents).length - 1}
<div class="mx-3 my-1 border-t border-[var(--color-border)]"></div>
{/if}
{/each}
{@render eventsDropdownItems(editEvents, (v) => { editEvents = toggleEvent(editEvents, v); })}
</div>
{/if}
</div>
@ -1296,13 +1235,45 @@
</div>
{/if}
{#snippet eventsDropdownItems(selected: string[], toggle: (value: string) => void)}
{#each Object.entries(groupedEvents) as [group, events], gi}
<div class="px-3 py-1.5 text-badge font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">{group}</div>
{#each events as et}
{@const checked = selected.includes(et.value)}
<label class="flex cursor-pointer items-center gap-2.5 px-3 py-2 transition-colors duration-100 hover:bg-[var(--color-bg-3)]">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm border transition-colors duration-100
{checked ? 'border-[var(--color-accent)] bg-[var(--color-accent)]' : 'border-[var(--color-border-mid)] bg-[var(--color-bg-4)]'}">
{#if checked}
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</span>
<input type="checkbox" class="sr-only" {checked} onchange={() => toggle(et.value)} />
<span class="font-mono text-meta {checked ? 'text-[var(--color-text-bright)]' : 'text-[var(--color-text-secondary)]'}">{et.value}</span>
</label>
{/each}
{#if gi < Object.entries(groupedEvents).length - 1}
<div class="mx-3 my-1 border-t border-[var(--color-border)]"></div>
{/if}
{/each}
{/snippet}
{#snippet providerIcon(provider: string)}
{#if provider === 'discord'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" /></svg>
{:else if provider === 'slack'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" /></svg>
{:else if provider === 'teams'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M20.625 8.5h-3.25V6.25a1.75 1.75 0 0 1 1.75-1.75h.002a1.75 1.75 0 1 1 0 3.5h-.002a1.7 1.7 0 0 1-.5-.074V8.5zM22.25 10h-4.375a.875.875 0 0 0-.875.875v5.25a3.375 3.375 0 0 0 3.016 3.355A3.5 3.5 0 0 0 24 16v-2.5A3.5 3.5 0 0 0 22.25 10zM9.5 7a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm5.25 3H4.25A2.25 2.25 0 0 0 2 12.25V18a5.5 5.5 0 0 0 11 0v-5.75A2.25 2.25 0 0 0 14.75 10zM16 8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z" /></svg>
{:else if provider === 'googlechat'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.372 0 0 5.042 0 11.264c0 2.026.564 3.94 1.544 5.612L.05 21.932a.75.75 0 0 0 .96.932l5.112-1.7A12.4 12.4 0 0 0 12 22.528c6.628 0 12-5.042 12-11.264S18.628 0 12 0zm4.5 14.25h-9a.75.75 0 0 1 0-1.5h9a.75.75 0 0 1 0 1.5zm0-3h-9a.75.75 0 0 1 0-1.5h9a.75.75 0 0 1 0 1.5zm0-3h-9a.75.75 0 0 1 0-1.5h9a.75.75 0 0 1 0 1.5z" /></svg>
{:else if provider === 'telegram'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /></svg>
{:else if provider === 'matrix'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.434-.24.905-.36 1.416-.36.54 0 1.033.107 1.48.32.448.214.773.553.974 1.02.309-.4.694-.727 1.154-.98a3.1 3.1 0 0 1 1.49-.36c.434 0 .839.058 1.213.172.375.115.694.303.957.565.264.262.467.6.61 1.014.144.414.215.907.215 1.478V17.3h-2.36v-5.18c0-.312-.013-.603-.04-.873a1.84 1.84 0 0 0-.195-.685 1.08 1.08 0 0 0-.432-.45c-.187-.108-.438-.162-.754-.162-.316 0-.573.058-.77.172a1.27 1.27 0 0 0-.472.46 1.98 1.98 0 0 0-.24.672 4.4 4.4 0 0 0-.065.746V17.3H9.36v-5.07c0-.282-.006-.558-.02-.826a2.15 2.15 0 0 0-.148-.72 1.04 1.04 0 0 0-.403-.498c-.182-.126-.44-.19-.773-.19a1.55 1.55 0 0 0-.416.068c-.158.052-.31.147-.458.286a1.62 1.62 0 0 0-.36.566c-.1.238-.15.545-.15.924V17.3H4.28V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z" /></svg>
{:else if provider === 'webhook'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
{:else}