This commit is contained in:
97
src/components/admin/search/Search.svelte
Normal file
97
src/components/admin/search/Search.svelte
Normal file
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
// types
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
mustMatch?: boolean;
|
||||
|
||||
requestSuggestions: (query: string, limit: number) => Promise<string[]>;
|
||||
|
||||
onSubmit?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
// html bindings
|
||||
let container: HTMLDivElement;
|
||||
|
||||
// inputs
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, requestSuggestions, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let inputValue = $state(value);
|
||||
let suggestions = $state<string[]>([]);
|
||||
let matched = $state(false);
|
||||
|
||||
// callbacks
|
||||
async function onBodyMouseDown(e: MouseEvent) {
|
||||
if (!container.contains(e.target as Node)) suggestions = [];
|
||||
}
|
||||
|
||||
async function onSearchInput() {
|
||||
if (readonly) return;
|
||||
|
||||
suggestions = await requestSuggestions(inputValue ?? '', 5);
|
||||
|
||||
let suggestion = suggestions.find((s) => s === inputValue);
|
||||
if (suggestion != null) {
|
||||
inputValue = value = suggestion;
|
||||
matched = true;
|
||||
onSubmit?.(value);
|
||||
} else if (!mustMatch) {
|
||||
value = inputValue;
|
||||
matched = false;
|
||||
} else {
|
||||
value = null;
|
||||
matched = false;
|
||||
onSubmit?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onSuggestionClick(suggestion: string) {
|
||||
inputValue = value = suggestion;
|
||||
suggestions = [];
|
||||
onSubmit?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:body onmousedown={onBodyMouseDown} />
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">
|
||||
<span>
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-red-700">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</legend>
|
||||
<div class="relative" bind:this={container}>
|
||||
<input
|
||||
{id}
|
||||
{readonly}
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
bind:value={inputValue}
|
||||
oninput={() => onSearchInput()}
|
||||
onfocusin={() => onSearchInput()}
|
||||
pattern={mustMatch && matched ? `^(${suggestions.join('|')})$` : undefined}
|
||||
/>
|
||||
{#if suggestions.length > 0}
|
||||
<ul class="absolute bg-base-200 w-full z-20 menu menu-sm rounded-box">
|
||||
{#each suggestions as suggestion (suggestion)}
|
||||
<li class="w-full text-left">
|
||||
<button
|
||||
class="block w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title={suggestion}
|
||||
onclick={() => onSuggestionClick(suggestion)}>{suggestion}</button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="fieldset-label"></p>
|
||||
</fieldset>
|
||||
61
src/components/admin/search/TeamSearch.svelte
Normal file
61
src/components/admin/search/TeamSearch.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import Search from '@components/admin/search/Search.svelte';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
// types
|
||||
type Teams = Exclude<ActionReturnType<typeof actions.team.teams>['data'], undefined>['teams'];
|
||||
type Team = Teams[0];
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
mustMatch?: boolean;
|
||||
|
||||
onSubmit?: (team: Team | null) => void;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let teamSuggestionCache = $state<Teams>([]);
|
||||
|
||||
// functions
|
||||
async function getSuggestions(query: string, limit: number) {
|
||||
const { data, error } = await actions.team.teams({
|
||||
name: query,
|
||||
limit: limit
|
||||
});
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
teamSuggestionCache = data.teams;
|
||||
return teamSuggestionCache.map((team) => team.name);
|
||||
}
|
||||
|
||||
async function getTeamByTeamName(teamName: string) {
|
||||
let team = teamSuggestionCache.find((team) => team.name === teamName);
|
||||
if (!team) {
|
||||
await getSuggestions(teamName, 5);
|
||||
return await getTeamByTeamName(teamName);
|
||||
}
|
||||
return team;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Search
|
||||
{id}
|
||||
bind:value
|
||||
{label}
|
||||
{readonly}
|
||||
{required}
|
||||
{mustMatch}
|
||||
requestSuggestions={async (teamName) => getSuggestions(teamName, 5)}
|
||||
onSubmit={async (teamName) => onSubmit?.(teamName != null ? await getTeamByTeamName(teamName) : null)}
|
||||
/>
|
||||
61
src/components/admin/search/UserSearch.svelte
Normal file
61
src/components/admin/search/UserSearch.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { type ActionReturnType, actions } from 'astro:actions';
|
||||
import Search from '@components/admin/search/Search.svelte';
|
||||
import { actionErrorPopup } from '@util/action.ts';
|
||||
|
||||
// types
|
||||
type Users = Exclude<ActionReturnType<typeof actions.user.users>['data'], undefined>['users'];
|
||||
type User = Users[0];
|
||||
interface Props {
|
||||
id?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
mustMatch?: boolean;
|
||||
|
||||
onSubmit?: (user: User | null) => void;
|
||||
}
|
||||
|
||||
// inputs
|
||||
let { id, value = $bindable(), label, readonly, required, mustMatch, onSubmit }: Props = $props();
|
||||
|
||||
// states
|
||||
let userSuggestionCache = $state<Users>([]);
|
||||
|
||||
// functions
|
||||
async function getSuggestions(query: string, limit: number) {
|
||||
const { data, error } = await actions.user.users({
|
||||
username: query,
|
||||
limit: limit
|
||||
});
|
||||
|
||||
if (error) {
|
||||
actionErrorPopup(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
userSuggestionCache = data.users;
|
||||
return userSuggestionCache.map((user) => user.username);
|
||||
}
|
||||
|
||||
async function getUserByUsername(username: string) {
|
||||
let user = userSuggestionCache.find((user) => user.username === username);
|
||||
if (!user) {
|
||||
await getSuggestions(username, 5);
|
||||
return await getUserByUsername(username);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Search
|
||||
{id}
|
||||
bind:value
|
||||
{label}
|
||||
{readonly}
|
||||
{required}
|
||||
{mustMatch}
|
||||
requestSuggestions={async (username) => getSuggestions(username, 5)}
|
||||
onSubmit={async (username) => onSubmit?.(username != null ? await getUserByUsername(username) : null)}
|
||||
/>
|
||||
48
src/components/admin/table/SortableTh.svelte
Normal file
48
src/components/admin/table/SortableTh.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
key?: string;
|
||||
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
interface SortableHeaderContext {
|
||||
headerKey: Writable<string>;
|
||||
onSort: (key: string, order: 'asc' | 'desc') => void;
|
||||
}
|
||||
|
||||
// inputs
|
||||
const { key, children, ...restProps }: Props & Record<string, any> = $props();
|
||||
|
||||
let { headerKey, onSort }: SortableHeaderContext = getContext('sortableHeader');
|
||||
|
||||
let asc = $state(false);
|
||||
|
||||
// callbacks
|
||||
function onButtonClick() {
|
||||
if (key == undefined) return;
|
||||
|
||||
$headerKey = key;
|
||||
asc = !asc;
|
||||
onSort(key, asc ? 'asc' : 'desc');
|
||||
}
|
||||
</script>
|
||||
|
||||
<th {...restProps}>
|
||||
{#if key}
|
||||
<button class="flex items-center gap-1" onclick={() => onButtonClick()}>
|
||||
<span>{@render children?.()}</span>
|
||||
{#if $headerKey === key && asc}
|
||||
<Icon icon="heroicons:chevron-up-16-solid" />
|
||||
{:else}
|
||||
<Icon icon="heroicons:chevron-down-16-solid" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</th>
|
||||
57
src/components/admin/table/SortableTr.svelte
Normal file
57
src/components/admin/table/SortableTr.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { setContext, type Snippet } from 'svelte';
|
||||
import { type Writable, writable } from 'svelte/store';
|
||||
|
||||
// types
|
||||
interface Props {
|
||||
data: Writable<{ [key: string]: any }[]>;
|
||||
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
// inputs
|
||||
const { data, children, ...restProps }: Props & Record<string, any> = $props();
|
||||
|
||||
setContext('sortableHeader', {
|
||||
headerKey: writable(null),
|
||||
onSort: onSort
|
||||
});
|
||||
|
||||
// functions
|
||||
function onSort(key: string, order: 'asc' | 'desc') {
|
||||
data.update((old) => {
|
||||
old.sort((a, b) => {
|
||||
let entryA = getDataEntryByKey(key, a);
|
||||
let entryB = getDataEntryByKey(key, b);
|
||||
|
||||
if (entryA === undefined || entryB === undefined) return 0;
|
||||
|
||||
if (typeof entryA === 'string') entryA = entryA.toLowerCase();
|
||||
if (typeof entryB === 'string') entryB = entryB.toLowerCase();
|
||||
|
||||
if (order === 'asc') {
|
||||
return entryA < entryB ? -1 : 1;
|
||||
} else if (order === 'desc') {
|
||||
return entryA > entryB ? -1 : 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
function getDataEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||
let entry = data;
|
||||
for (const part of key.split('.')) {
|
||||
if ((entry = entry[part]) === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
</script>
|
||||
|
||||
<tr {...restProps}>
|
||||
{@render children()}
|
||||
</tr>
|
||||
Reference in New Issue
Block a user