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:
2026-04-03 00:24:08 +03:00
parent a6b09aae9c
commit c5f5f84c79
10 changed files with 405 additions and 28 deletions
+48 -11
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@plugin '@tailwindcss/typography';
@custom-variant dark (&:is(.dark *));
+23 -6
View File
@@ -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>
+31 -10
View File
@@ -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>
<input
type="text"
value={iconValue}
oninput={handleValueChange}
placeholder={iconType === 'lucide'
? $t('app.icon_lucide_placeholder')
: iconType === 'simple'
{#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 === '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"
/>
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>
+146
View File
@@ -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}"
>&times;</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>
+21
View File
@@ -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 }));
};