c5f5f84c79
- 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
409 lines
13 KiB
Svelte
409 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { t } from 'svelte-i18n';
|
|
import { superForm, type SuperValidated } from 'sveltekit-superforms';
|
|
import type { z } from 'zod';
|
|
import type { createAppSchema } from '$lib/utils/validators.js';
|
|
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';
|
|
|
|
type AppSchema = z.infer<typeof createAppSchema>;
|
|
|
|
interface Props {
|
|
form: SuperValidated<AppSchema>;
|
|
action?: string;
|
|
mode?: 'create' | 'edit';
|
|
}
|
|
|
|
let { form: formData, action = '?/create', mode = 'create' }: Props = $props();
|
|
|
|
const { form, errors, enhance, submitting } = superForm(formData, {
|
|
resetForm: mode === 'create'
|
|
});
|
|
|
|
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);
|
|
let testResult = $state<{ success: boolean; message: string } | null>(null);
|
|
|
|
$effect(() => {
|
|
fetch('/api/integrations')
|
|
.then((r) => r.json())
|
|
.then((json) => {
|
|
if (json.success) availableIntegrations = json.data ?? [];
|
|
})
|
|
.catch(() => {});
|
|
});
|
|
|
|
const selectedIntegration = $derived(
|
|
availableIntegrations.find((i) => i.id === ($form.integrationType ?? ''))
|
|
);
|
|
|
|
async function handleTestConnection() {
|
|
if (!$form.integrationType || !$form.url) return;
|
|
testingConnection = true;
|
|
testResult = null;
|
|
try {
|
|
const res = await fetch('/api/integrations/test', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
integrationType: $form.integrationType,
|
|
appUrl: $form.url,
|
|
config: integrationConfig
|
|
})
|
|
});
|
|
const json = await res.json();
|
|
testResult = json.data ?? { success: false, message: json.error ?? 'Unknown error' };
|
|
} catch {
|
|
testResult = { success: false, message: 'Network error' };
|
|
} finally {
|
|
testingConnection = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if ($form.integrationType && Object.keys(integrationConfig).length > 0) {
|
|
$form.integrationConfig = JSON.stringify(integrationConfig);
|
|
}
|
|
});
|
|
|
|
const healthcheckMethodItems: IconGridItem[] = [
|
|
{ value: 'GET', icon: '🔍', label: 'GET', desc: 'Full response' },
|
|
{ value: 'HEAD', icon: '📋', label: 'HEAD', desc: 'Headers only' }
|
|
];
|
|
</script>
|
|
|
|
<form method="POST" {action} use:enhance class="space-y-4">
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label for="name" class="mb-1 block text-sm font-medium text-card-foreground">
|
|
{$t('app.name')} <span class="text-destructive">{$t('common.required')}</span>
|
|
</label>
|
|
<input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
bind:value={$form.name}
|
|
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"
|
|
placeholder={$t('app.name_placeholder')}
|
|
/>
|
|
{#if $errors.name}
|
|
<p class="mt-1 text-sm text-destructive">{$errors.name[0]}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div>
|
|
<label for="url" class="mb-1 block text-sm font-medium text-card-foreground">
|
|
{$t('app.url')} <span class="text-destructive">{$t('common.required')}</span>
|
|
</label>
|
|
<input
|
|
id="url"
|
|
name="url"
|
|
type="url"
|
|
bind:value={$form.url}
|
|
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"
|
|
placeholder={$t('app.url_placeholder')}
|
|
/>
|
|
{#if $errors.url}
|
|
<p class="mt-1 text-sm text-destructive">{$errors.url[0]}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- URL Preview / Test Connection -->
|
|
<AppUrlPreview
|
|
url={$form.url ?? ''}
|
|
currentIcon={$form.icon ?? ''}
|
|
currentName={$form.name ?? ''}
|
|
onApplyFavicon={(favicon) => {
|
|
$form.icon = favicon;
|
|
$form.iconType = 'url';
|
|
}}
|
|
onApplyTitle={(title) => {
|
|
$form.name = title;
|
|
}}
|
|
/>
|
|
|
|
<div>
|
|
<label for="description" class="mb-1 block text-sm font-medium text-card-foreground">
|
|
{$t('app.description')}
|
|
</label>
|
|
<input
|
|
id="description"
|
|
name="description"
|
|
type="text"
|
|
bind:value={$form.description}
|
|
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"
|
|
placeholder={$t('app.description_placeholder')}
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label for="category" class="mb-1 block text-sm font-medium text-card-foreground">
|
|
{$t('app.category')}
|
|
</label>
|
|
<AutocompleteInput
|
|
id="category"
|
|
name="category"
|
|
bind:value={$form.category}
|
|
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>
|
|
|
|
<div>
|
|
<label for="tags" class="mb-1 block text-sm font-medium text-card-foreground">
|
|
{$t('app.tags')}
|
|
</label>
|
|
<TagsInput
|
|
id="tags"
|
|
name="tags"
|
|
bind:value={$form.tags}
|
|
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>
|
|
|
|
<AppIconPicker
|
|
iconType={$form.iconType ?? 'lucide'}
|
|
iconValue={$form.icon ?? ''}
|
|
onchange={(type, value) => {
|
|
$form.iconType = type as typeof $form.iconType;
|
|
$form.icon = value;
|
|
}}
|
|
/>
|
|
<input type="hidden" name="icon" value={$form.icon ?? ''} />
|
|
<input type="hidden" name="iconType" value={$form.iconType ?? 'lucide'} />
|
|
|
|
<button
|
|
type="button"
|
|
onclick={() => (showAdvanced = !showAdvanced)}
|
|
class="text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
|
|
</button>
|
|
|
|
{#if showAdvanced}
|
|
<div class="space-y-4 rounded-md border border-border p-4">
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
id="healthcheckEnabled"
|
|
name="healthcheckEnabled"
|
|
type="checkbox"
|
|
bind:checked={$form.healthcheckEnabled}
|
|
class="rounded border-input"
|
|
/>
|
|
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
|
|
{$t('app.healthcheck_enabled')}
|
|
</label>
|
|
</div>
|
|
|
|
{#if $form.healthcheckEnabled}
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<div>
|
|
<label
|
|
class="mb-1 block text-sm font-medium text-card-foreground"
|
|
>
|
|
{$t('app.healthcheck_method')}
|
|
</label>
|
|
<IconGrid
|
|
items={healthcheckMethodItems}
|
|
value={$form.healthcheckMethod ?? 'GET'}
|
|
onchange={(v) => ($form.healthcheckMethod = v as 'GET' | 'HEAD')}
|
|
name="healthcheckMethod"
|
|
columns={2}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="healthcheckExpectedStatus"
|
|
class="mb-1 block text-sm font-medium text-card-foreground"
|
|
>
|
|
{$t('app.healthcheck_expected_status')}
|
|
</label>
|
|
<input
|
|
id="healthcheckExpectedStatus"
|
|
name="healthcheckExpectedStatus"
|
|
type="number"
|
|
bind:value={$form.healthcheckExpectedStatus}
|
|
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"
|
|
min="100"
|
|
max="599"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="healthcheckTimeout"
|
|
class="mb-1 block text-sm font-medium text-card-foreground"
|
|
>
|
|
{$t('app.healthcheck_timeout')}
|
|
</label>
|
|
<input
|
|
id="healthcheckTimeout"
|
|
name="healthcheckTimeout"
|
|
type="number"
|
|
bind:value={$form.healthcheckTimeout}
|
|
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"
|
|
min="1000"
|
|
max="30000"
|
|
step="1000"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
for="healthcheckInterval"
|
|
class="mb-1 block text-sm font-medium text-card-foreground"
|
|
>
|
|
{$t('app.healthcheck_interval')}
|
|
</label>
|
|
<input
|
|
id="healthcheckInterval"
|
|
name="healthcheckInterval"
|
|
type="number"
|
|
bind:value={$form.healthcheckInterval}
|
|
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"
|
|
min="30"
|
|
max="86400"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Integration Section -->
|
|
<button
|
|
type="button"
|
|
onclick={() => (showIntegration = !showIntegration)}
|
|
class="text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showIntegration ? 'Hide' : 'Show'} Integration Settings
|
|
</button>
|
|
|
|
{#if showIntegration}
|
|
<div class="space-y-4 rounded-md border border-border p-4">
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
id="integrationEnabled"
|
|
name="integrationEnabled"
|
|
type="checkbox"
|
|
bind:checked={$form.integrationEnabled}
|
|
class="rounded border-input"
|
|
/>
|
|
<label for="integrationEnabled" class="text-sm text-card-foreground">
|
|
Enable Integration
|
|
</label>
|
|
</div>
|
|
|
|
{#if $form.integrationEnabled}
|
|
<div>
|
|
<label for="integrationType" class="mb-1 block text-sm font-medium text-card-foreground">
|
|
Integration Type
|
|
</label>
|
|
<select
|
|
id="integrationType"
|
|
name="integrationType"
|
|
bind:value={$form.integrationType}
|
|
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="">None</option>
|
|
{#each availableIntegrations as integration (integration.id)}
|
|
<option value={integration.id}>{integration.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
{#if selectedIntegration}
|
|
<div>
|
|
<h4 class="mb-2 text-sm font-medium text-card-foreground">Authentication</h4>
|
|
<IntegrationConfigFields
|
|
fields={selectedIntegration.authConfigFields}
|
|
values={integrationConfig}
|
|
onchange={(name, value) => {
|
|
integrationConfig = { ...integrationConfig, [name]: value };
|
|
}}
|
|
idPrefix="int-auth"
|
|
/>
|
|
</div>
|
|
|
|
{#if selectedIntegration.extraConfigFields.length > 0}
|
|
<div>
|
|
<h4 class="mb-2 text-sm font-medium text-card-foreground">Extra Configuration</h4>
|
|
<IntegrationConfigFields
|
|
fields={selectedIntegration.extraConfigFields}
|
|
values={integrationConfig}
|
|
onchange={(name, value) => {
|
|
integrationConfig = { ...integrationConfig, [name]: value };
|
|
}}
|
|
idPrefix="int-extra"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onclick={handleTestConnection}
|
|
disabled={testingConnection}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-foreground hover:bg-accent disabled:opacity-50"
|
|
>
|
|
{testingConnection ? 'Testing...' : 'Test Connection'}
|
|
</button>
|
|
{#if testResult}
|
|
<span class="text-sm {testResult.success ? 'text-green-500' : 'text-destructive'}">
|
|
{testResult.message}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<input type="hidden" name="integrationConfig" value={$form.integrationConfig ?? ''} />
|
|
<input type="hidden" name="integrationType" value={$form.integrationType ?? ''} />
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={$submitting}
|
|
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{#if $submitting}
|
|
{$t('app.saving')}
|
|
{:else}
|
|
{mode === 'edit' ? $t('app.update') : $t('app.save')}
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</form>
|