From c5f5f84c79d19031e728adab8b6a9f9c562baadc Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 3 Apr 2026 00:24:08 +0300 Subject: [PATCH] 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 --- package-lock.json | 59 +++++-- package.json | 1 + src/app.css | 1 + src/lib/components/app/AppForm.svelte | 29 +++- src/lib/components/app/AppIconPicker.svelte | 41 +++-- .../components/ui/AutocompleteInput.svelte | 109 +++++++++++++ src/lib/components/ui/TagsInput.svelte | 146 ++++++++++++++++++ src/lib/server/services/appService.ts | 21 +++ .../utils/__tests__/widgetValidators.test.ts | 10 +- src/routes/api/apps/suggestions/+server.ts | 16 ++ 10 files changed, 405 insertions(+), 28 deletions(-) create mode 100644 src/lib/components/ui/AutocompleteInput.svelte create mode 100644 src/lib/components/ui/TagsInput.svelte create mode 100644 src/routes/api/apps/suggestions/+server.ts diff --git a/package-lock.json b/package-lock.json index 7fb7eeb..aa6be20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7168172..d98fe3c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.css b/src/app.css index 35c5f58..96983bd 100644 --- a/src/app.css +++ b/src/app.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@plugin '@tailwindcss/typography'; @custom-variant dark (&:is(.dark *)); diff --git a/src/lib/components/app/AppForm.svelte b/src/lib/components/app/AppForm.svelte index 275478f..42930df 100644 --- a/src/lib/components/app/AppForm.svelte +++ b/src/lib/components/app/AppForm.svelte @@ -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([]); + let tagSuggestions = $state([]); + + // 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>([]); let integrationConfig = $state>({}); let testingConnection = $state(false); @@ -148,13 +165,13 @@ - @@ -162,13 +179,13 @@ - diff --git a/src/lib/components/app/AppIconPicker.svelte b/src/lib/components/app/AppIconPicker.svelte index 664af2a..2200cf5 100644 --- a/src/lib/components/app/AppIconPicker.svelte +++ b/src/lib/components/app/AppIconPicker.svelte @@ -1,6 +1,8 @@
@@ -44,22 +51,36 @@ />
- + + + {:else} + + 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} - {#if iconType === 'emoji' && iconValue} + + {#if iconType === 'lucide' && iconValue} +
+ + {iconValue} +
+ {:else if iconType === 'emoji' && iconValue}
{iconValue}
{:else if iconType === 'url' && iconValue} {$t('app.icon_preview')} diff --git a/src/lib/components/ui/AutocompleteInput.svelte b/src/lib/components/ui/AutocompleteInput.svelte new file mode 100644 index 0000000..27c327d --- /dev/null +++ b/src/lib/components/ui/AutocompleteInput.svelte @@ -0,0 +1,109 @@ + + + + +
+ + + {#if open && filtered.length > 0} +
+ {#each filtered as item, i} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/ui/TagsInput.svelte b/src/lib/components/ui/TagsInput.svelte new file mode 100644 index 0000000..a388636 --- /dev/null +++ b/src/lib/components/ui/TagsInput.svelte @@ -0,0 +1,146 @@ + + + + +
+ + {#if tags.length > 0} +
+ {#each tags as tag} + + {tag} + + + {/each} +
+ {/if} + + + + {#if open && filtered.length > 0} +
+ {#each filtered as item, i} + + {/each} +
+ {/if} +
diff --git a/src/lib/server/services/appService.ts b/src/lib/server/services/appService.ts index 2269de5..c9f6857 100644 --- a/src/lib/server/services/appService.ts +++ b/src/lib/server/services/appService.ts @@ -306,3 +306,24 @@ export async function getCategories() { }); return apps.map((a) => a.category).filter(Boolean) as string[]; } + +export async function getAllTags(): Promise { + // 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(); + 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(); +} diff --git a/src/lib/utils/__tests__/widgetValidators.test.ts b/src/lib/utils/__tests__/widgetValidators.test.ts index 89cb91e..157a709 100644 --- a/src/lib/utils/__tests__/widgetValidators.test.ts +++ b/src/lib/utils/__tests__/widgetValidators.test.ts @@ -112,10 +112,18 @@ describe('Widget Config Validators', () => { } }); + it('accepts html format', () => { + const result = noteWidgetConfigSchema.safeParse({ + content: '

Hello

', + 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); }); diff --git a/src/routes/api/apps/suggestions/+server.ts b/src/routes/api/apps/suggestions/+server.ts new file mode 100644 index 0000000..87f67ea --- /dev/null +++ b/src/routes/api/apps/suggestions/+server.ts @@ -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 })); +};