feat: add IconGrid, EntityPicker controls and enhance search panel
Port icon grid and entity picker patterns from wled-screen-controller. IconGrid replaces plain <select> elements with visual icon grids for known item sets (widget type, icon type, healthcheck method, permission level). EntityPicker replaces search dropdowns with a command-palette style overlay with keyboard navigation and filtering. Enhance SearchDialog with keyboard navigation (arrow keys, Enter, Escape), grouped results with section headers, active highlight, and a footer with shortcut hints.
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants.js';
|
import { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants.js';
|
||||||
|
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
|
||||||
|
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.svelte';
|
||||||
|
|
||||||
interface PermissionRecord {
|
interface PermissionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,34 +55,44 @@
|
|||||||
let selectedTargetType = $state<string>(TargetType.USER);
|
let selectedTargetType = $state<string>(TargetType.USER);
|
||||||
let selectedTargetId = $state('');
|
let selectedTargetId = $state('');
|
||||||
let selectedLevel = $state<string>(PermissionLevel.VIEW);
|
let selectedLevel = $state<string>(PermissionLevel.VIEW);
|
||||||
let entitySearchQuery = $state('');
|
|
||||||
let targetSearchQuery = $state('');
|
|
||||||
let showEntityDropdown = $state(false);
|
|
||||||
let showTargetDropdown = $state(false);
|
|
||||||
|
|
||||||
let entityOptions = $derived(
|
const entityTypeItems: IconGridItem[] = [
|
||||||
selectedEntityType === EntityType.APP ? apps : boards
|
{ value: EntityType.BOARD, icon: '📋', label: $t('admin.perm_board') ?? 'Board' },
|
||||||
|
{ value: EntityType.APP, icon: '🌐', label: $t('admin.perm_app') ?? 'App' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetTypeItems: IconGridItem[] = [
|
||||||
|
{ value: TargetType.USER, icon: '👤', label: $t('admin.perm_user') ?? 'User' },
|
||||||
|
{ value: TargetType.GROUP, icon: '👥', label: $t('admin.perm_group') ?? 'Group' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const levelItems: IconGridItem[] = [
|
||||||
|
{ value: PermissionLevel.VIEW, icon: '👁', label: $t('admin.perm_view') ?? 'View' },
|
||||||
|
{ value: PermissionLevel.EDIT, icon: '✏️', label: $t('admin.perm_edit') ?? 'Edit' },
|
||||||
|
{ value: PermissionLevel.ADMIN, icon: '🔑', label: $t('admin.perm_admin') ?? 'Admin' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const entityPickerItems: EntityPickerItem[] = $derived(
|
||||||
|
(selectedEntityType === EntityType.APP ? apps : boards).map((opt) => ({
|
||||||
|
value: opt.id,
|
||||||
|
label: opt.name
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
let targetOptions = $derived(
|
const targetPickerItems: EntityPickerItem[] = $derived(
|
||||||
selectedTargetType === TargetType.USER ? users : groups
|
(selectedTargetType === TargetType.USER ? users : groups).map((opt) => ({
|
||||||
|
value: opt.id,
|
||||||
|
label: opt.name
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
let filteredEntityOptions = $derived(
|
function handleEntityTypeChange() {
|
||||||
entitySearchQuery.length > 0
|
selectedEntityId = '';
|
||||||
? entityOptions.filter((opt) =>
|
}
|
||||||
opt.name.toLowerCase().includes(entitySearchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
: entityOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
let filteredTargetOptions = $derived(
|
function handleTargetTypeChange() {
|
||||||
targetSearchQuery.length > 0
|
selectedTargetId = '';
|
||||||
? targetOptions.filter((opt) =>
|
}
|
||||||
opt.name.toLowerCase().includes(targetSearchQuery.toLowerCase())
|
|
||||||
)
|
|
||||||
: targetOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleGrant() {
|
function handleGrant() {
|
||||||
if (!selectedEntityId || !selectedTargetId) return;
|
if (!selectedEntityId || !selectedTargetId) return;
|
||||||
@@ -91,8 +105,6 @@
|
|||||||
});
|
});
|
||||||
selectedEntityId = '';
|
selectedEntityId = '';
|
||||||
selectedTargetId = '';
|
selectedTargetId = '';
|
||||||
entitySearchQuery = '';
|
|
||||||
targetSearchQuery = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRevoke(perm: PermissionRecord) {
|
function handleRevoke(perm: PermissionRecord) {
|
||||||
@@ -104,18 +116,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEntity(option: SelectOption) {
|
|
||||||
selectedEntityId = option.id;
|
|
||||||
entitySearchQuery = option.name;
|
|
||||||
showEntityDropdown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectTarget(option: SelectOption) {
|
|
||||||
selectedTargetId = option.id;
|
|
||||||
targetSearchQuery = option.name;
|
|
||||||
showTargetDropdown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntityName(entityType: string, entityId: string): string {
|
function getEntityName(entityType: string, entityId: string): string {
|
||||||
const list = entityType === EntityType.APP ? apps : boards;
|
const list = entityType === EntityType.APP ? apps : boards;
|
||||||
return list.find((e) => e.id === entityId)?.name ?? entityId;
|
return list.find((e) => e.id === entityId)?.name ?? entityId;
|
||||||
@@ -133,100 +133,56 @@
|
|||||||
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('admin.perm_title')}</h3>
|
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('admin.perm_title')}</h3>
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-entity-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity_type')}</label>
|
<label class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity_type')}</label>
|
||||||
<select
|
<IconGrid
|
||||||
id="perm-entity-type"
|
items={entityTypeItems}
|
||||||
bind:value={selectedEntityType}
|
bind:value={selectedEntityType}
|
||||||
onchange={() => { selectedEntityId = ''; entitySearchQuery = ''; }}
|
onchange={handleEntityTypeChange}
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
columns={2}
|
||||||
>
|
/>
|
||||||
<option value={EntityType.BOARD}>{$t('admin.perm_board')}</option>
|
|
||||||
<option value={EntityType.APP}>{$t('admin.perm_app')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-entity-search" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity')}</label>
|
<label class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_entity')}</label>
|
||||||
<div class="relative">
|
<EntityPicker
|
||||||
<input
|
items={entityPickerItems}
|
||||||
id="perm-entity-search"
|
bind:value={selectedEntityId}
|
||||||
type="text"
|
placeholder={$t('admin.perm_select')}
|
||||||
bind:value={entitySearchQuery}
|
searchPlaceholder={$t('admin.perm_search_placeholder')}
|
||||||
onfocus={() => { showEntityDropdown = true; }}
|
/>
|
||||||
onblur={() => { setTimeout(() => { showEntityDropdown = false; }, 200); }}
|
|
||||||
placeholder={$t('admin.perm_search_placeholder')}
|
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
|
||||||
/>
|
|
||||||
{#if showEntityDropdown && filteredEntityOptions.length > 0}
|
|
||||||
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
|
||||||
{#each filteredEntityOptions as option (option.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-3 py-1.5 text-left text-sm text-foreground hover:bg-accent"
|
|
||||||
onmousedown={() => selectEntity(option)}
|
|
||||||
>
|
|
||||||
{option.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-target-type" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target_type')}</label>
|
<label class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target_type')}</label>
|
||||||
<select
|
<IconGrid
|
||||||
id="perm-target-type"
|
items={targetTypeItems}
|
||||||
bind:value={selectedTargetType}
|
bind:value={selectedTargetType}
|
||||||
onchange={() => { selectedTargetId = ''; targetSearchQuery = ''; }}
|
onchange={handleTargetTypeChange}
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
columns={2}
|
||||||
>
|
/>
|
||||||
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
|
||||||
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-target-search" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
|
<label class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_target')}</label>
|
||||||
<div class="relative">
|
<EntityPicker
|
||||||
<input
|
items={targetPickerItems}
|
||||||
id="perm-target-search"
|
bind:value={selectedTargetId}
|
||||||
type="text"
|
placeholder={$t('admin.perm_select')}
|
||||||
bind:value={targetSearchQuery}
|
searchPlaceholder={$t('admin.perm_search_placeholder')}
|
||||||
onfocus={() => { showTargetDropdown = true; }}
|
/>
|
||||||
onblur={() => { setTimeout(() => { showTargetDropdown = false; }, 200); }}
|
|
||||||
placeholder={$t('admin.perm_search_placeholder')}
|
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
|
||||||
/>
|
|
||||||
{#if showTargetDropdown && filteredTargetOptions.length > 0}
|
|
||||||
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
|
||||||
{#each filteredTargetOptions as option (option.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full px-3 py-1.5 text-left text-sm text-foreground hover:bg-accent"
|
|
||||||
onmousedown={() => selectTarget(option)}
|
|
||||||
>
|
|
||||||
{option.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="perm-level" class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_level')}</label>
|
<label class="mb-1 block text-xs text-muted-foreground">{$t('admin.perm_level')}</label>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<select
|
<div class="flex-1">
|
||||||
id="perm-level"
|
<IconGrid
|
||||||
bind:value={selectedLevel}
|
items={levelItems}
|
||||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
bind:value={selectedLevel}
|
||||||
>
|
columns={3}
|
||||||
<option value={PermissionLevel.VIEW}>{$t('admin.perm_view')}</option>
|
/>
|
||||||
<option value={PermissionLevel.EDIT}>{$t('admin.perm_edit')}</option>
|
</div>
|
||||||
<option value={PermissionLevel.ADMIN}>{$t('admin.perm_admin')}</option>
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleGrant}
|
onclick={handleGrant}
|
||||||
disabled={!selectedEntityId || !selectedTargetId}
|
disabled={!selectedEntityId || !selectedTargetId}
|
||||||
class="shrink-0 rounded bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
class="shrink-0 self-start rounded bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{$t('admin.perm_grant')}
|
{$t('admin.perm_grant')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import type { createAppSchema } from '$lib/utils/validators.js';
|
import type { createAppSchema } from '$lib/utils/validators.js';
|
||||||
import AppIconPicker from './AppIconPicker.svelte';
|
import AppIconPicker from './AppIconPicker.svelte';
|
||||||
|
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
|
||||||
type AppSchema = z.infer<typeof createAppSchema>;
|
type AppSchema = z.infer<typeof createAppSchema>;
|
||||||
|
|
||||||
@@ -19,6 +21,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let showAdvanced = $state(false);
|
let showAdvanced = $state(false);
|
||||||
|
|
||||||
|
const healthcheckMethodItems: IconGridItem[] = [
|
||||||
|
{ value: 'GET', icon: '🔍', label: 'GET', desc: 'Full response' },
|
||||||
|
{ value: 'HEAD', icon: '📋', label: 'HEAD', desc: 'Headers only' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form method="POST" {action} use:enhance class="space-y-4">
|
<form method="POST" {action} use:enhance class="space-y-4">
|
||||||
@@ -140,20 +147,17 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="healthcheckMethod"
|
|
||||||
class="mb-1 block text-sm font-medium text-card-foreground"
|
class="mb-1 block text-sm font-medium text-card-foreground"
|
||||||
>
|
>
|
||||||
{$t('app.healthcheck_method')}
|
{$t('app.healthcheck_method')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<IconGrid
|
||||||
id="healthcheckMethod"
|
items={healthcheckMethodItems}
|
||||||
|
value={$form.healthcheckMethod ?? 'GET'}
|
||||||
|
onchange={(v) => ($form.healthcheckMethod = v as 'GET' | 'HEAD')}
|
||||||
name="healthcheckMethod"
|
name="healthcheckMethod"
|
||||||
bind:value={$form.healthcheckMethod}
|
columns={2}
|
||||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
/>
|
||||||
>
|
|
||||||
<option value="GET">GET</option>
|
|
||||||
<option value="HEAD">HEAD</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
iconType: string;
|
iconType: string;
|
||||||
@@ -9,9 +11,15 @@
|
|||||||
|
|
||||||
let { iconType = $bindable('lucide'), iconValue = $bindable(''), onchange }: Props = $props();
|
let { iconType = $bindable('lucide'), iconValue = $bindable(''), onchange }: Props = $props();
|
||||||
|
|
||||||
function handleTypeChange(e: Event) {
|
const iconTypeItems: IconGridItem[] = [
|
||||||
const target = e.target as HTMLSelectElement;
|
{ value: 'lucide', icon: '◇', label: $t('app.icon_lucide') ?? 'Lucide' },
|
||||||
iconType = target.value;
|
{ value: 'simple', icon: '◆', label: $t('app.icon_simple') ?? 'Simple Icons' },
|
||||||
|
{ value: 'url', icon: '🔗', label: $t('app.icon_url') ?? 'Image URL' },
|
||||||
|
{ value: 'emoji', icon: '😀', label: $t('app.icon_emoji') ?? 'Emoji' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleTypeChange(value: string) {
|
||||||
|
iconType = value;
|
||||||
iconValue = '';
|
iconValue = '';
|
||||||
onchange?.(iconType, iconValue);
|
onchange?.(iconType, iconValue);
|
||||||
}
|
}
|
||||||
@@ -27,16 +35,14 @@
|
|||||||
<label class="block text-sm font-medium text-card-foreground">{$t('app.icon')}</label>
|
<label class="block text-sm font-medium text-card-foreground">{$t('app.icon')}</label>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<select
|
<div class="w-44 shrink-0">
|
||||||
value={iconType}
|
<IconGrid
|
||||||
onchange={handleTypeChange}
|
items={iconTypeItems}
|
||||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
value={iconType}
|
||||||
>
|
onchange={handleTypeChange}
|
||||||
<option value="lucide">{$t('app.icon_lucide')}</option>
|
columns={2}
|
||||||
<option value="simple">{$t('app.icon_simple')}</option>
|
/>
|
||||||
<option value="url">{$t('app.icon_url')}</option>
|
</div>
|
||||||
<option value="emoji">{$t('app.icon_emoji')}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { search } from '$lib/stores/search.svelte.js';
|
import { search } from '$lib/stores/search.svelte.js';
|
||||||
import SearchResult from './SearchResult.svelte';
|
import SearchResult from './SearchResult.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
let inputEl: HTMLInputElement;
|
let inputEl: HTMLInputElement;
|
||||||
|
let resultsEl: HTMLDivElement;
|
||||||
const appResults = $derived(search.results.filter((r) => r.type === 'app'));
|
|
||||||
const boardResults = $derived(search.results.filter((r) => r.type === 'board'));
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (search.open && inputEl) {
|
if (search.open && inputEl) {
|
||||||
// Focus input when dialog opens
|
|
||||||
requestAnimationFrame(() => inputEl?.focus());
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -20,19 +18,66 @@
|
|||||||
search.close();
|
search.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
search.moveSelection(1);
|
||||||
|
scrollActiveIntoView();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
search.moveSelection(-1);
|
||||||
|
scrollActiveIntoView();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const item = search.selectCurrent();
|
||||||
|
if (item) {
|
||||||
|
if (item.action) {
|
||||||
|
item.action();
|
||||||
|
} else if (item.type === 'app') {
|
||||||
|
window.open(item.url, '_blank', 'noopener,noreferrer');
|
||||||
|
} else {
|
||||||
|
goto(item.url);
|
||||||
|
}
|
||||||
|
search.close();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollActiveIntoView() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = resultsEl?.querySelector('.search-active');
|
||||||
|
el?.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track flat index offset per group for keyboard highlight
|
||||||
|
function getFlatIndexOffset(groupIdx: number): number {
|
||||||
|
let offset = 0;
|
||||||
|
for (let i = 0; i < groupIdx; i++) {
|
||||||
|
offset += search.grouped[i].items.length;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMac = $derived(
|
||||||
|
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('mac')
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if search.open}
|
{#if search.open}
|
||||||
<!-- Backdrop -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
|
||||||
onclick={handleBackdropClick}
|
onclick={handleBackdropClick}
|
||||||
onkeydown={(e) => e.key === 'Escape' && search.close()}
|
style="animation: searchFadeIn 0.15s ease-out"
|
||||||
>
|
>
|
||||||
<!-- Dialog -->
|
<!-- Dialog -->
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-lg rounded-lg border border-border bg-popover shadow-2xl"
|
class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-popover shadow-2xl"
|
||||||
|
style="max-height: 60vh; animation: searchSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={$t('search.placeholder')}
|
aria-label={$t('search.placeholder')}
|
||||||
>
|
>
|
||||||
@@ -57,6 +102,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder={$t('search.placeholder')}
|
placeholder={$t('search.placeholder')}
|
||||||
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
/>
|
/>
|
||||||
<kbd
|
<kbd
|
||||||
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
|
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
|
||||||
@@ -66,7 +112,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div class="max-h-[50vh] overflow-y-auto p-2">
|
<div class="overflow-y-auto p-2" bind:this={resultsEl}>
|
||||||
{#if search.loading}
|
{#if search.loading}
|
||||||
<div class="flex items-center justify-center py-8">
|
<div class="flex items-center justify-center py-8">
|
||||||
<div
|
<div
|
||||||
@@ -84,29 +130,57 @@
|
|||||||
{$t('search.no_results', { values: { query: search.query } })}
|
{$t('search.no_results', { values: { query: search.query } })}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#if appResults.length > 0}
|
{#each search.grouped as group, groupIdx (group.key)}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
<p
|
||||||
{$t('search.apps')}
|
class="mb-1 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||||
|
>
|
||||||
|
{$t(`search.${group.label}s`) ?? group.label}
|
||||||
</p>
|
</p>
|
||||||
{#each appResults as result (result.id)}
|
{#each group.items as result, itemIdx (result.id)}
|
||||||
<SearchResult {result} onselect={() => search.close()} />
|
{@const flatIdx = getFlatIndexOffset(groupIdx) + itemIdx}
|
||||||
|
<SearchResult
|
||||||
|
{result}
|
||||||
|
active={flatIdx === search.selectedIdx}
|
||||||
|
onselect={() => search.close()}
|
||||||
|
onhover={() => (search.selectedIdx = flatIdx)}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
|
|
||||||
{#if boardResults.length > 0}
|
|
||||||
<div>
|
|
||||||
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
||||||
{$t('search.boards')}
|
|
||||||
</p>
|
|
||||||
{#each boardResults as result (result.id)}
|
|
||||||
<SearchResult {result} onselect={() => search.close()} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<span class="inline-flex items-center gap-3">
|
||||||
|
<span>↑↓ {$t('search.nav_hint') ?? 'navigate'}</span>
|
||||||
|
<span>↵ {$t('search.select_hint') ?? 'select'}</span>
|
||||||
|
<span>esc {$t('search.close_hint') ?? 'close'}</span>
|
||||||
|
<span class="ml-auto">{isMac ? '⌘' : 'Ctrl'}+K</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes searchFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes searchSlideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-12px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: SearchResultItem;
|
result: SearchResultItem;
|
||||||
|
active?: boolean;
|
||||||
onselect: () => void;
|
onselect: () => void;
|
||||||
|
onhover?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { result, onselect }: Props = $props();
|
let { result, active = false, onselect, onhover }: Props = $props();
|
||||||
|
|
||||||
const href = $derived(result.type === 'app' ? result.url : `/boards/${result.id}`);
|
const href = $derived(result.type === 'app' ? result.url : `/boards/${result.id}`);
|
||||||
const isExternal = $derived(result.type === 'app');
|
const isExternal = $derived(result.type === 'app');
|
||||||
@@ -17,11 +19,14 @@
|
|||||||
target={isExternal ? '_blank' : undefined}
|
target={isExternal ? '_blank' : undefined}
|
||||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||||
onclick={onselect}
|
onclick={onselect}
|
||||||
class="flex items-center gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-accent"
|
onmouseenter={onhover}
|
||||||
|
class="flex items-center gap-3 rounded-md px-3 py-2.5 transition-colors
|
||||||
|
{active ? 'search-active bg-primary/15 text-foreground' : 'hover:bg-accent'}"
|
||||||
>
|
>
|
||||||
<!-- Icon -->
|
<!-- Icon -->
|
||||||
<div
|
<div
|
||||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md
|
||||||
|
{active ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'}"
|
||||||
>
|
>
|
||||||
{#if result.icon}
|
{#if result.icon}
|
||||||
<span class="text-lg">{result.icon}</span>
|
<span class="text-lg">{result.icon}</span>
|
||||||
@@ -76,4 +81,11 @@
|
|||||||
>
|
>
|
||||||
{result.type}
|
{result.type}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<!-- Enter hint when active -->
|
||||||
|
{#if active}
|
||||||
|
<kbd class="shrink-0 rounded border border-border bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
↵
|
||||||
|
</kbd>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { Search } from 'lucide-svelte';
|
||||||
|
|
||||||
|
export interface EntityPickerItem {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string | null;
|
||||||
|
desc?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: EntityPickerItem[];
|
||||||
|
value: string;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
allowNone?: boolean;
|
||||||
|
noneLabel?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
value = $bindable(),
|
||||||
|
onchange,
|
||||||
|
placeholder = '',
|
||||||
|
searchPlaceholder = '',
|
||||||
|
allowNone = false,
|
||||||
|
noneLabel = '—',
|
||||||
|
name
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let query = $state('');
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
let listEl: HTMLDivElement;
|
||||||
|
let inputEl: HTMLInputElement;
|
||||||
|
|
||||||
|
const selectedItem = $derived(items.find((i) => i.value === value));
|
||||||
|
|
||||||
|
const allItems = $derived.by(() => {
|
||||||
|
const result: (EntityPickerItem & { _isNone?: boolean })[] = [];
|
||||||
|
if (allowNone) {
|
||||||
|
result.push({ value: '', label: noneLabel, icon: null, desc: null, _isNone: true });
|
||||||
|
}
|
||||||
|
result.push(...items);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
if (!query.trim()) return allItems;
|
||||||
|
const q = query.toLowerCase().trim();
|
||||||
|
return allItems.filter(
|
||||||
|
(i) =>
|
||||||
|
i.label.toLowerCase().includes(q) || (i.desc && i.desc.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Reset highlight to current value when opened
|
||||||
|
const idx = filtered.findIndex((i) => i.value === value);
|
||||||
|
highlightIdx = idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
query = '';
|
||||||
|
highlightIdx = 0;
|
||||||
|
open = true;
|
||||||
|
requestAnimationFrame(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(item: EntityPickerItem) {
|
||||||
|
value = item.value;
|
||||||
|
closePicker();
|
||||||
|
onchange?.(item.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.min(highlightIdx + 1, filtered.length - 1);
|
||||||
|
scrollHighlightIntoView();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
scrollHighlightIntoView();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filtered[highlightIdx]) {
|
||||||
|
selectItem(filtered[highlightIdx]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePicker();
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollHighlightIntoView() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = listEl?.querySelector('.ep-highlight');
|
||||||
|
el?.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset highlight when query changes
|
||||||
|
$effect(() => {
|
||||||
|
query; // track
|
||||||
|
highlightIdx = 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" {name} {value} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
onclick={openPicker}
|
||||||
|
>
|
||||||
|
{#if selectedItem}
|
||||||
|
{#if selectedItem.icon}
|
||||||
|
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
<span>{selectedItem.icon}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="flex-1 truncate text-left">{selectedItem.label}</span>
|
||||||
|
{:else if allowNone && !value}
|
||||||
|
<span class="flex-1 text-left text-muted-foreground">{noneLabel}</span>
|
||||||
|
{:else if placeholder}
|
||||||
|
<span class="flex-1 text-left text-muted-foreground">{placeholder}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
|
||||||
|
onclick={handleBackdropClick}
|
||||||
|
onkeydown={() => {}}
|
||||||
|
style="animation: epFadeIn 0.15s ease-out"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-popover shadow-2xl"
|
||||||
|
style="max-height: 60vh; animation: epSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={searchPlaceholder || 'Select entity'}
|
||||||
|
>
|
||||||
|
<!-- Search row -->
|
||||||
|
<div class="flex items-center gap-2 border-b border-border px-3 py-2.5">
|
||||||
|
<Search size={16} class="shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
type="text"
|
||||||
|
placeholder={searchPlaceholder || $t('admin.perm_search_placeholder') || 'Search...'}
|
||||||
|
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="overflow-y-auto py-1" bind:this={listEl}>
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<div class="py-6 text-center text-sm text-muted-foreground">—</div>
|
||||||
|
{:else}
|
||||||
|
{#each filtered as item, i (item.value + (item._isNone ? '_none' : ''))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors
|
||||||
|
{i === highlightIdx ? 'ep-highlight bg-accent' : 'hover:bg-accent/50'}
|
||||||
|
{item.value === value && !item._isNone ? 'border-l-[3px] border-l-primary' : 'border-l-[3px] border-l-transparent'}"
|
||||||
|
onclick={() => selectItem(item)}
|
||||||
|
onmouseenter={() => (highlightIdx = i)}
|
||||||
|
>
|
||||||
|
{#if item.icon}
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>{item.icon}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="flex-1 truncate text-foreground">{item.label}</span>
|
||||||
|
{#if item.desc}
|
||||||
|
<span class="shrink-0 truncate text-xs text-muted-foreground" style="max-width: 40%">
|
||||||
|
{item.desc}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div
|
||||||
|
class="border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
↑↓ navigate · Enter select · Esc close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes epFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes epSlideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-12px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
export interface IconGridItem {
|
||||||
|
value: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
desc?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: IconGridItem[];
|
||||||
|
value: string;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
columns?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, value = $bindable(), onchange, columns = 2, placeholder = '', name }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl: HTMLButtonElement;
|
||||||
|
let popupEl: HTMLDivElement;
|
||||||
|
let filterQuery = $state('');
|
||||||
|
|
||||||
|
const selectedItem = $derived(items.find((i) => i.value === value));
|
||||||
|
const showFilter = $derived(items.length > 9);
|
||||||
|
const filteredItems = $derived(
|
||||||
|
filterQuery
|
||||||
|
? items.filter((i) => {
|
||||||
|
const text = `${i.label} ${i.desc || ''}`.toLowerCase();
|
||||||
|
return text.includes(filterQuery.toLowerCase());
|
||||||
|
})
|
||||||
|
: items
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (open) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
openPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPopup() {
|
||||||
|
open = true;
|
||||||
|
filterQuery = '';
|
||||||
|
requestAnimationFrame(() => positionPopup());
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
filterQuery = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(item: IconGridItem) {
|
||||||
|
value = item.value;
|
||||||
|
close();
|
||||||
|
onchange?.(item.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPopup() {
|
||||||
|
if (!triggerEl || !popupEl) return;
|
||||||
|
const rect = triggerEl.getBoundingClientRect();
|
||||||
|
const gap = 6;
|
||||||
|
const pad = 8;
|
||||||
|
const popupW = Math.max(rect.width, 220);
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom - gap - pad;
|
||||||
|
const spaceAbove = rect.top - gap - pad;
|
||||||
|
const openUp = spaceBelow < 200 && spaceAbove > spaceBelow;
|
||||||
|
const available = openUp ? spaceAbove : spaceBelow;
|
||||||
|
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + popupW > window.innerWidth - pad) left = window.innerWidth - pad - popupW;
|
||||||
|
if (left < pad) left = pad;
|
||||||
|
|
||||||
|
popupEl.style.left = `${left}px`;
|
||||||
|
popupEl.style.width = `${popupW}px`;
|
||||||
|
popupEl.style.maxHeight = `${available}px`;
|
||||||
|
|
||||||
|
if (openUp) {
|
||||||
|
popupEl.style.top = '';
|
||||||
|
popupEl.style.bottom = `${window.innerHeight - rect.top + gap}px`;
|
||||||
|
} else {
|
||||||
|
popupEl.style.top = `${rect.bottom + gap}px`;
|
||||||
|
popupEl.style.bottom = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (open && !target.closest('.icon-grid-popup') && !target.closest('.icon-grid-trigger')) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" {name} {value} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-grid-trigger flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
bind:this={triggerEl}
|
||||||
|
onclick={toggle}
|
||||||
|
>
|
||||||
|
{#if selectedItem}
|
||||||
|
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||||
|
<span class="text-base">{selectedItem.icon}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 text-left">{selectedItem.label}</span>
|
||||||
|
{:else if placeholder}
|
||||||
|
<span class="flex-1 text-left text-muted-foreground">{placeholder}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="icon-grid-popup fixed z-50 overflow-y-auto rounded-lg border border-border bg-popover shadow-xl"
|
||||||
|
bind:this={popupEl}
|
||||||
|
style="animation: iconGridSlideIn 0.15s ease-out"
|
||||||
|
>
|
||||||
|
{#if showFilter}
|
||||||
|
<div class="border-b border-border px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={filterQuery}
|
||||||
|
placeholder={$t('common.search_filter') ?? 'Filter...'}
|
||||||
|
class="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="grid gap-1 p-1.5"
|
||||||
|
style:grid-template-columns="repeat({columns}, 1fr)"
|
||||||
|
>
|
||||||
|
{#each filteredItems as item (item.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center gap-1 rounded-md px-2 py-2.5 text-center transition-colors
|
||||||
|
{item.value === value
|
||||||
|
? 'border-2 border-primary bg-primary/10 text-foreground'
|
||||||
|
: 'border-2 border-transparent hover:bg-accent text-foreground'}"
|
||||||
|
onclick={() => select(item)}
|
||||||
|
>
|
||||||
|
<span class="flex h-6 w-6 items-center justify-center">
|
||||||
|
<span class="text-lg">{item.icon}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-medium leading-tight">{item.label}</span>
|
||||||
|
{#if item.desc}
|
||||||
|
<span class="text-[10px] leading-tight text-muted-foreground">{item.desc}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if filteredItems.length === 0}
|
||||||
|
<div class="col-span-full py-4 text-center text-xs text-muted-foreground">
|
||||||
|
{$t('search.no_results', { values: { query: filterQuery } })}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes iconGridSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
|
||||||
|
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||||
|
import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: string;
|
sectionId: string;
|
||||||
@@ -31,6 +35,26 @@
|
|||||||
let statusLabel = $state('');
|
let statusLabel = $state('');
|
||||||
let statusAppIds = $state<string[]>([]);
|
let statusAppIds = $state<string[]>([]);
|
||||||
|
|
||||||
|
const widgetTypeItems: IconGridItem[] = [
|
||||||
|
{ value: 'app', icon: '🖥️', label: 'App' },
|
||||||
|
{ value: 'bookmark', icon: '🔖', label: 'Bookmark' },
|
||||||
|
{ value: 'note', icon: '📝', label: 'Note' },
|
||||||
|
{ value: 'embed', icon: '🧩', label: 'Embed' },
|
||||||
|
{ value: 'status', icon: '📊', label: 'Status' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const noteFormatItems: IconGridItem[] = [
|
||||||
|
{ value: 'markdown', icon: '📝', label: 'Markdown' },
|
||||||
|
{ value: 'text', icon: '📄', label: 'Plain Text' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const appPickerItems: EntityPickerItem[] = $derived(
|
||||||
|
apps.map((app) => ({
|
||||||
|
value: app.id,
|
||||||
|
label: app.name
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
selectedWidgetType = 'app';
|
selectedWidgetType = 'app';
|
||||||
selectedAppId = '';
|
selectedAppId = '';
|
||||||
@@ -94,40 +118,30 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
||||||
<!-- Widget Type Selector -->
|
<!-- Widget Type Selector (Icon Grid) -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="widget-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
<label class="mb-1 block text-sm font-medium text-foreground">
|
||||||
Widget Type
|
Widget Type
|
||||||
</label>
|
</label>
|
||||||
<select
|
<IconGrid
|
||||||
id="widget-type-{sectionId}"
|
items={widgetTypeItems}
|
||||||
bind:value={selectedWidgetType}
|
bind:value={selectedWidgetType}
|
||||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
columns={5}
|
||||||
>
|
/>
|
||||||
<option value="app">App</option>
|
|
||||||
<option value="bookmark">Bookmark</option>
|
|
||||||
<option value="note">Note</option>
|
|
||||||
<option value="embed">Embed</option>
|
|
||||||
<option value="status">Status</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Type-specific config forms -->
|
<!-- Type-specific config forms -->
|
||||||
{#if selectedWidgetType === 'app'}
|
{#if selectedWidgetType === 'app'}
|
||||||
<div>
|
<div>
|
||||||
<label for="widget-app-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
<label class="mb-1 block text-sm font-medium text-foreground">
|
||||||
{$t('widget.select_app')}
|
{$t('widget.select_app')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<EntityPicker
|
||||||
id="widget-app-{sectionId}"
|
items={appPickerItems}
|
||||||
bind:value={selectedAppId}
|
bind:value={selectedAppId}
|
||||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
placeholder={$t('widget.choose_app')}
|
||||||
>
|
searchPlaceholder={$t('widget.choose_app')}
|
||||||
<option value="">{$t('widget.choose_app')}</option>
|
/>
|
||||||
{#each apps as app (app.id)}
|
|
||||||
<option value={app.id}>{app.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if selectedWidgetType === 'bookmark'}
|
{:else if selectedWidgetType === 'bookmark'}
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -177,15 +191,12 @@
|
|||||||
{:else if selectedWidgetType === 'note'}
|
{:else if selectedWidgetType === 'note'}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label for="note-format-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Format</label>
|
<label class="mb-1 block text-sm font-medium text-foreground">Format</label>
|
||||||
<select
|
<IconGrid
|
||||||
id="note-format-{sectionId}"
|
items={noteFormatItems}
|
||||||
bind:value={noteFormat}
|
bind:value={noteFormat}
|
||||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
columns={2}
|
||||||
>
|
/>
|
||||||
<option value="markdown">Markdown</option>
|
|
||||||
<option value="text">Plain Text</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="note-content-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Content</label>
|
<label for="note-content-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Content</label>
|
||||||
|
|||||||
@@ -258,6 +258,11 @@
|
|||||||
"search.no_results": "No results for \"{query}\"",
|
"search.no_results": "No results for \"{query}\"",
|
||||||
"search.apps": "Apps",
|
"search.apps": "Apps",
|
||||||
"search.boards": "Boards",
|
"search.boards": "Boards",
|
||||||
|
"search.nav_hint": "navigate",
|
||||||
|
"search.select_hint": "select",
|
||||||
|
"search.close_hint": "close",
|
||||||
|
|
||||||
|
"common.search_filter": "Filter...",
|
||||||
|
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
|
|||||||
@@ -246,6 +246,11 @@
|
|||||||
"search.no_results": "Ничего не найдено по запросу «{query}»",
|
"search.no_results": "Ничего не найдено по запросу «{query}»",
|
||||||
"search.apps": "Приложения",
|
"search.apps": "Приложения",
|
||||||
"search.boards": "Доски",
|
"search.boards": "Доски",
|
||||||
|
"search.nav_hint": "навигация",
|
||||||
|
"search.select_hint": "выбрать",
|
||||||
|
"search.close_hint": "закрыть",
|
||||||
|
|
||||||
|
"common.search_filter": "Фильтр...",
|
||||||
"common.save": "Сохранить",
|
"common.save": "Сохранить",
|
||||||
"common.cancel": "Отмена",
|
"common.cancel": "Отмена",
|
||||||
"common.delete": "Удалить",
|
"common.delete": "Удалить",
|
||||||
|
|||||||
@@ -5,17 +5,49 @@ export interface SearchResultItem {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
url: string;
|
url: string;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
/** Optional action (e.g. "open in new tab"). If present, item acts as a command. */
|
||||||
|
action?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
items: SearchResultItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_ORDER: Record<string, number> = { app: 0, board: 1 };
|
||||||
|
|
||||||
class SearchStore {
|
class SearchStore {
|
||||||
open = $state(false);
|
open = $state(false);
|
||||||
query = $state('');
|
query = $state('');
|
||||||
results = $state<SearchResultItem[]>([]);
|
results = $state<SearchResultItem[]>([]);
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
|
selectedIdx = $state(0);
|
||||||
|
|
||||||
#debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
#debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Grouped results for rendering, preserving group order. */
|
||||||
|
get grouped(): SearchGroup[] {
|
||||||
|
const map = new Map<string, SearchResultItem[]>();
|
||||||
|
for (const item of this.results) {
|
||||||
|
const existing = map.get(item.type);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(item);
|
||||||
|
} else {
|
||||||
|
map.set(item.type, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.sort(([a], [b]) => (GROUP_ORDER[a] ?? 99) - (GROUP_ORDER[b] ?? 99))
|
||||||
|
.map(([key, items]) => ({ key, label: key, items }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat list of items in render order (for keyboard navigation). */
|
||||||
|
get flatItems(): SearchResultItem[] {
|
||||||
|
return this.grouped.flatMap((g) => g.items);
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -97,6 +129,7 @@ class SearchStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.results = items;
|
this.results = items;
|
||||||
|
this.selectedIdx = 0;
|
||||||
} catch {
|
} catch {
|
||||||
this.error = 'Search failed';
|
this.error = 'Search failed';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
@@ -105,12 +138,25 @@ class SearchStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moveSelection(delta: number) {
|
||||||
|
const flat = this.flatItems;
|
||||||
|
if (flat.length === 0) return;
|
||||||
|
this.selectedIdx = Math.max(0, Math.min(this.selectedIdx + delta, flat.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
selectCurrent(): SearchResultItem | null {
|
||||||
|
const flat = this.flatItems;
|
||||||
|
if (this.selectedIdx < 0 || this.selectedIdx >= flat.length) return null;
|
||||||
|
return flat[this.selectedIdx];
|
||||||
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.open = !this.open;
|
this.open = !this.open;
|
||||||
if (!this.open) {
|
if (!this.open) {
|
||||||
this.query = '';
|
this.query = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.selectedIdx = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +165,7 @@ class SearchStore {
|
|||||||
this.query = '';
|
this.query = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
this.selectedIdx = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user