feat(app-form): icon picker, tag/category autocomplete, typography
- Replace AppIconPicker text input with visual IconPickerButton for lucide icons (grid with search) - Add AutocompleteInput component for category field with existing category suggestions - Add TagsInput component for tags field with tag pills, autocomplete from existing tags, and keyboard navigation - Add GET /api/apps/suggestions endpoint returning all categories/tags - Add getAllTags() to appService (merges Tag model + comma-separated) - Install @tailwindcss/typography plugin to fix prose rendering (headings, lists, blockquotes now render in Note/Markdown widgets) - Fix note widget validator test for new html format
This commit is contained in:
Generated
+48
-11
@@ -11,6 +11,7 @@
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bits-ui": "^1.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -1890,6 +1891,29 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
||||
@@ -2956,7 +2980,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
@@ -6140,8 +6163,7 @@
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -6879,8 +6901,7 @@
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
@@ -8290,6 +8311,25 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"requires": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"requires": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tailwindcss/vite": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
|
||||
@@ -9056,8 +9096,7 @@
|
||||
"cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
|
||||
},
|
||||
"d": {
|
||||
"version": "1.0.2",
|
||||
@@ -11045,8 +11084,7 @@
|
||||
"tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||
},
|
||||
"tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -11456,8 +11494,7 @@
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bits-ui": "^1.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import AppIconPicker from './AppIconPicker.svelte';
|
||||
import IntegrationConfigFields from './IntegrationConfigFields.svelte';
|
||||
import AppUrlPreview from './AppUrlPreview.svelte';
|
||||
import AutocompleteInput from '$lib/components/ui/AutocompleteInput.svelte';
|
||||
import TagsInput from '$lib/components/ui/TagsInput.svelte';
|
||||
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||
|
||||
@@ -25,6 +27,21 @@
|
||||
|
||||
let showAdvanced = $state(false);
|
||||
let showIntegration = $state(false);
|
||||
let categorySuggestions = $state<string[]>([]);
|
||||
let tagSuggestions = $state<string[]>([]);
|
||||
|
||||
// Fetch autocomplete suggestions
|
||||
$effect(() => {
|
||||
fetch('/api/apps/suggestions')
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.success) {
|
||||
categorySuggestions = json.data?.categories ?? [];
|
||||
tagSuggestions = json.data?.tags ?? [];
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
|
||||
let integrationConfig = $state<Record<string, unknown>>({});
|
||||
let testingConnection = $state(false);
|
||||
@@ -148,13 +165,13 @@
|
||||
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
{$t('app.category')}
|
||||
</label>
|
||||
<input
|
||||
<AutocompleteInput
|
||||
id="category"
|
||||
name="category"
|
||||
type="text"
|
||||
bind:value={$form.category}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
suggestions={categorySuggestions}
|
||||
placeholder={$t('app.category_placeholder')}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,13 +179,13 @@
|
||||
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||
{$t('app.tags')}
|
||||
</label>
|
||||
<input
|
||||
<TagsInput
|
||||
id="tags"
|
||||
name="tags"
|
||||
type="text"
|
||||
bind:value={$form.tags}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
suggestions={tagSuggestions}
|
||||
placeholder={$t('app.tags_placeholder')}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +31,11 @@
|
||||
iconValue = target.value;
|
||||
onchange?.(iconType, iconValue);
|
||||
}
|
||||
|
||||
function handleIconPickerChange(value: string) {
|
||||
iconValue = value;
|
||||
onchange?.(iconType, iconValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
@@ -44,22 +51,36 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if iconType === 'lucide'}
|
||||
<div class="flex flex-1 items-start">
|
||||
<IconPickerButton
|
||||
value={iconValue}
|
||||
onchange={handleIconPickerChange}
|
||||
placeholder={$t('app.icon_lucide_placeholder') ?? 'Select icon'}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
value={iconValue}
|
||||
oninput={handleValueChange}
|
||||
placeholder={iconType === 'lucide'
|
||||
? $t('app.icon_lucide_placeholder')
|
||||
: iconType === 'simple'
|
||||
placeholder={iconType === 'simple'
|
||||
? $t('app.icon_simple_placeholder')
|
||||
: iconType === 'url'
|
||||
? $t('app.icon_url_placeholder')
|
||||
: $t('app.icon_emoji_placeholder')}
|
||||
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if iconType === 'emoji' && iconValue}
|
||||
<!-- Preview -->
|
||||
{#if iconType === 'lucide' && iconValue}
|
||||
<div class="flex items-center gap-2">
|
||||
<DynamicIcon name={iconValue} size={24} />
|
||||
<span class="text-xs text-muted-foreground">{iconValue}</span>
|
||||
</div>
|
||||
{:else if iconType === 'emoji' && iconValue}
|
||||
<div class="text-2xl">{iconValue}</div>
|
||||
{:else if iconType === 'url' && iconValue}
|
||||
<img src={iconValue} alt={$t('app.icon_preview')} class="h-8 w-8 rounded object-contain" />
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value: string;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
value = $bindable(),
|
||||
suggestions,
|
||||
placeholder = '',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let highlightIdx = $state(-1);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = value.trim().toLowerCase();
|
||||
if (!q) return suggestions;
|
||||
return suggestions.filter((s) => s.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function handleInput() {
|
||||
open = true;
|
||||
highlightIdx = -1;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
open = true;
|
||||
}
|
||||
|
||||
function selectItem(item: string) {
|
||||
value = item;
|
||||
open = false;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open || filtered.length === 0) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
open = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = (highlightIdx + 1) % filtered.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = highlightIdx <= 0 ? filtered.length - 1 : highlightIdx - 1;
|
||||
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
||||
e.preventDefault();
|
||||
selectItem(filtered[highlightIdx]);
|
||||
} else if (e.key === 'Escape') {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerEl && !containerEl.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<div class="relative" bind:this={containerEl}>
|
||||
<input
|
||||
{id}
|
||||
{name}
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
onkeydown={handleKeydown}
|
||||
{placeholder}
|
||||
class={className}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
{#if open && filtered.length > 0}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
|
||||
{#each filtered as item, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectItem(item)}
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-sm text-foreground transition-colors
|
||||
{i === highlightIdx ? 'bg-accent' : 'hover:bg-accent/50'}"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value: string;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
value = $bindable(),
|
||||
suggestions,
|
||||
placeholder = '',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let highlightIdx = $state(-1);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Parse comma-separated tags
|
||||
const tags = $derived(value.split(',').map((t) => t.trim()).filter(Boolean));
|
||||
|
||||
// Get the current partial tag being typed (after last comma)
|
||||
const currentPartial = $derived.by(() => {
|
||||
const parts = value.split(',');
|
||||
return parts[parts.length - 1]?.trim() ?? '';
|
||||
});
|
||||
|
||||
// Filter suggestions: exclude already-used tags, match partial
|
||||
const filtered = $derived.by(() => {
|
||||
const used = new Set(tags.map((t) => t.toLowerCase()));
|
||||
const q = currentPartial.toLowerCase();
|
||||
return suggestions
|
||||
.filter((s) => !used.has(s.toLowerCase()))
|
||||
.filter((s) => !q || s.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function handleInput() {
|
||||
open = true;
|
||||
highlightIdx = -1;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
open = true;
|
||||
}
|
||||
|
||||
function selectItem(item: string) {
|
||||
// Replace the current partial with the selected tag
|
||||
const parts = value.split(',').map((p) => p.trim());
|
||||
parts[parts.length - 1] = item;
|
||||
value = parts.join(',') + ',';
|
||||
open = false;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
const newTags = tags.filter((t) => t !== tag);
|
||||
value = newTags.join(',');
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!open || filtered.length === 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
open = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightIdx = (highlightIdx + 1) % filtered.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightIdx = highlightIdx <= 0 ? filtered.length - 1 : highlightIdx - 1;
|
||||
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
||||
e.preventDefault();
|
||||
selectItem(filtered[highlightIdx]);
|
||||
} else if (e.key === 'Escape') {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerEl && !containerEl.contains(e.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<div class="relative" bind:this={containerEl}>
|
||||
<!-- Tag pills -->
|
||||
{#if tags.length > 0}
|
||||
<div class="mb-1.5 flex flex-wrap gap-1">
|
||||
{#each tags as tag}
|
||||
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(tag)}
|
||||
class="text-primary/60 hover:text-primary"
|
||||
aria-label="Remove {tag}"
|
||||
>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
{id}
|
||||
{name}
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
onkeydown={handleKeydown}
|
||||
{placeholder}
|
||||
class={className}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
{#if open && filtered.length > 0}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
|
||||
{#each filtered as item, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectItem(item)}
|
||||
class="flex w-full items-center px-3 py-1.5 text-left text-sm text-foreground transition-colors
|
||||
{i === highlightIdx ? 'bg-accent' : 'hover:bg-accent/50'}"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -306,3 +306,24 @@ export async function getCategories() {
|
||||
});
|
||||
return apps.map((a) => a.category).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export async function getAllTags(): Promise<string[]> {
|
||||
// Collect from both the Tag model and the comma-separated tags field
|
||||
const [tagModels, apps] = await Promise.all([
|
||||
prisma.tag.findMany({ select: { name: true }, orderBy: { name: 'asc' } }),
|
||||
prisma.app.findMany({
|
||||
where: { tags: { not: '' } },
|
||||
select: { tags: true }
|
||||
})
|
||||
]);
|
||||
|
||||
const tagSet = new Set<string>();
|
||||
for (const t of tagModels) tagSet.add(t.name);
|
||||
for (const a of apps) {
|
||||
for (const tag of a.tags.split(',')) {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed) tagSet.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(tagSet).sort();
|
||||
}
|
||||
|
||||
@@ -112,10 +112,18 @@ describe('Widget Config Validators', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts html format', () => {
|
||||
const result = noteWidgetConfigSchema.safeParse({
|
||||
content: '<h1>Hello</h1>',
|
||||
format: 'html'
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid format', () => {
|
||||
const result = noteWidgetConfigSchema.safeParse({
|
||||
content: 'Some content',
|
||||
format: 'html'
|
||||
format: 'invalid'
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import * as appService from '$lib/server/services/appService.js';
|
||||
import { success } from '$lib/server/utils/response.js';
|
||||
|
||||
/**
|
||||
* GET /api/apps/suggestions — Get categories and tags for autocomplete.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
const [categories, tags] = await Promise.all([
|
||||
appService.getCategories(),
|
||||
appService.getAllTags()
|
||||
]);
|
||||
|
||||
return json(success({ categories, tags }));
|
||||
};
|
||||
Reference in New Issue
Block a user