Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,96 @@ body {
transform: translateX(18px);
}

/* ── Issue #497: Channel search ────────────────────────────────────────────── */
.channel-search {
display: flex;
align-items: center;
gap: 10px;
margin-top: 20px;
margin-bottom: 4px;
}

.channel-search__label {
flex-shrink: 0;
font-size: 0.9rem;
color: #9aa0a6;
font-weight: 500;
}

.channel-search__input {
flex: 1;
max-width: 280px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 9px 14px;
background: #12151c;
color: inherit;
font-family: inherit;
font-size: 0.92rem;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.channel-search__input:focus-visible {
outline: none;
border-color: rgba(91, 125, 255, 0.6);
box-shadow: 0 0 0 3px rgba(91, 125, 255, 0.15);
}

.channel-search__clear {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #9aa0a6;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 6px 10px;
transition: background 0.15s ease, color 0.15s ease;
}

.channel-search__clear:hover {
background: rgba(255, 255, 255, 0.06);
color: #e8eaed;
}

.notification-preferences__no-results {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px 20px;
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 14px;
text-align: center;
color: #9aa0a6;
margin-top: 4px;
}

.notification-preferences__no-results p {
margin: 0;
font-size: 0.97rem;
}

.notification-preferences__no-results strong {
color: #c2c8d3;
}

.channel-search__clear-inline {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #5b7dff;
cursor: pointer;
font-size: 0.88rem;
padding: 6px 14px;
transition: background 0.15s ease;
}

.channel-search__clear-inline:hover {
background: rgba(91, 125, 255, 0.1);
}

.notification-preferences__control-panel {
display: grid;
gap: 18px;
Expand Down
75 changes: 70 additions & 5 deletions dashboard/src/pages/NotificationPreferencesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useState, useRef } from 'react';

type ChannelKey = 'inApp' | 'email' | 'discord' | 'telegram';
type CategoryKey = 'security' | 'governance' | 'system' | 'custom';
Expand Down Expand Up @@ -46,6 +46,17 @@ export function NotificationPreferencesPage() {
const [simulateFailure, setSimulateFailure] = useState(false);
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle');

// ── Issue #497: Channel search ────────────────────────────────────────────
const [channelSearch, setChannelSearch] = useState('');
const channelSearchRef = useRef<HTMLInputElement>(null);

/** Channels filtered by the keyword search (case-insensitive). */
const filteredChannels = useMemo(() => {
const q = channelSearch.trim().toLowerCase();
if (!q) return channelDefinitions;
return channelDefinitions.filter((ch) => ch.label.toLowerCase().includes(q));
}, [channelSearch]);

const errorCategories = useMemo(
() =>
categoryDefinitions
Expand Down Expand Up @@ -210,21 +221,75 @@ export function NotificationPreferencesPage() {
</p>
</div>

<div className="notification-preferences__matrix" role="table" aria-label="Notification preference matrix">
{/* Issue #497: Channel keyword search */}
<div className="channel-search">
<label htmlFor="channel-search-input" className="channel-search__label">
Search channels
</label>
<input
id="channel-search-input"
ref={channelSearchRef}
type="search"
className="channel-search__input"
placeholder="e.g. email, discord…"
value={channelSearch}
onChange={(e) => setChannelSearch(e.target.value)}
aria-label="Search available channels"
aria-controls="notification-matrix"
/>
{channelSearch && (
<button
type="button"
className="channel-search__clear"
onClick={() => {
setChannelSearch('');
channelSearchRef.current?.focus();
}}
aria-label="Clear channel search"
>
</button>
)}
</div>

<div
id="notification-matrix"
className="notification-preferences__matrix"
role="table"
aria-label="Notification preference matrix"
style={{ gridTemplateColumns: `minmax(180px, 1fr) repeat(${filteredChannels.length}, 88px)` }}
>
<div className="matrix-cell matrix-cell--corner">Categories / Channels</div>
{channelDefinitions.map((channel) => (
{filteredChannels.map((channel) => (
<div key={channel.key} className="matrix-cell matrix-cell--header">
{channel.label}
</div>
))}

{categoryDefinitions.map((category) => (
{filteredChannels.length === 0 && (
<div
className="notification-preferences__no-results"
role="status"
aria-live="polite"
>
<p>No channels match <strong>&ldquo;{channelSearch}&rdquo;</strong>.</p>
<button
type="button"
className="channel-search__clear-inline"
onClick={() => setChannelSearch('')}
>
Clear search
</button>
</div>
)}

{filteredChannels.length > 0 && categoryDefinitions.map((category) => (
<Fragment key={category.key}>
<div className="matrix-cell matrix-cell--category">
<div>{category.label}</div>
<p>{category.description}</p>
</div>
{channelDefinitions.map((channel) => {
{filteredChannels.map((channel) => {
const fieldId = `pref-${category.key}-${channel.key}`;
return (
<label key={fieldId} className="matrix-cell matrix-cell--toggle" htmlFor={fieldId}>
Expand Down