f087551454
Adds 7 reusable primitives in src/lib/components/ui/ and migrates ~70 hand-rolled call sites across forms, admin panels, and routes. Tokens unchanged — same Cozy Home palette, just consistently applied. Primitives - Switch: pill toggle, role=switch, terracotta track, cubic-bezier knob - Button: 5 variants × 4 sizes, press-squash, loading spinner, buttonClass() helper for <a> link-as-CTA cases - Checkbox: rounded square with animated check-draw + indeterminate - Select: native <select> with Cozy chevron + matched radius - Slider: gradient track, terracotta-bordered knob, aria-valuetext - Input + Field: documented in CLAUDE.md for future use - 9 buttonClass unit tests Migrations - 23 <input type=checkbox> → <Switch> (boolean settings) - 5 multi-select checkboxes → <Checkbox> (DiscoveryPanel, sys-stats metrics) - ~28 <select> → <Select> - 17 <input type=range> → <Slider> (ThemeCustomizer's hue picker kept custom) - ~25 hand-rolled buttons → <Button> / buttonClass() Surface polish - Admin section wrappers: rounded-lg → rounded-[1.4rem] + shadow-soft (resolves the Phase-5 tradeoff from the Cozy migration memo) - BoardPropertiesPanel: live theme preview swatch showing computed hsl() on a sample button; hue/sat use Slider; bg/cardSize use Select - AppHealthBadge: role=status + aria-live=polite; .status-degraded (slow amber breathing) and .status-offline (single attention flash) now applied - AppForm collapse triggers: rotating chevron + aria-expanded - Empty states for /boards and /apps: inline SVGs using --room-* tokens (peach/sky/sage/butter) instead of generic Lucide icons - Login Remember Me: showcase Switch (first-impression surface) Motion (src/app.css) - New cozy-rise / cozy-rise-stagger for staggered grid reveals (/boards, /apps) - New cozy-expand for accordion sections (healthcheck, integration, wallpaper) - All motion respects prefers-reduced-motion CLAUDE.md - New project guide with a mandatory Frontend reuse table — every primitive documented with "never use raw <input type=checkbox>/<select>/<range>" and "do not repeat rounded-xl bg-primary px-4 py-2 ..." rules Verification - npm run check: 0 errors, 0 warnings, 5831 files - npm test: 301 passing - npm run lint: 0 errors (19 pre-existing warnings unchanged) - npm run build: ✔ done Branch is feat/cozy-polish, ready to PR against master.
447 lines
14 KiB
Svelte
447 lines
14 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';
|
|
import Switch from '$lib/components/ui/Switch.svelte';
|
|
import Button from '$lib/components/ui/Button.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(() => {});
|
|
});
|
|
interface IntegrationField {
|
|
name: string;
|
|
type: 'string' | 'number' | 'boolean';
|
|
required: boolean;
|
|
label: string;
|
|
description?: string;
|
|
}
|
|
let availableIntegrations = $state<
|
|
Array<{
|
|
id: string;
|
|
name: string;
|
|
icon: string;
|
|
authConfigFields: IntegrationField[];
|
|
extraConfigFields: IntegrationField[];
|
|
}>
|
|
>([]);
|
|
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-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
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-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
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-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
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-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
/>
|
|
</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-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
/>
|
|
</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)}
|
|
aria-expanded={showAdvanced}
|
|
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<svg
|
|
class="h-3.5 w-3.5 transition-transform duration-200 {showAdvanced ? 'rotate-90' : ''}"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.75"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<polyline points="6 4 10 8 6 12" />
|
|
</svg>
|
|
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
|
|
</button>
|
|
|
|
{#if showAdvanced}
|
|
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
|
|
<div class="flex items-center gap-3">
|
|
<Switch
|
|
id="healthcheckEnabled"
|
|
name="healthcheckEnabled"
|
|
bind:checked={$form.healthcheckEnabled}
|
|
ariaLabelledby="healthcheckEnabledLabel"
|
|
/>
|
|
<label id="healthcheckEnabledLabel" for="healthcheckEnabled" class="cursor-pointer 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>
|
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
<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-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
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-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
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-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
min="30"
|
|
max="86400"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Integration Section -->
|
|
<button
|
|
type="button"
|
|
onclick={() => (showIntegration = !showIntegration)}
|
|
aria-expanded={showIntegration}
|
|
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<svg
|
|
class="h-3.5 w-3.5 transition-transform duration-200 {showIntegration ? 'rotate-90' : ''}"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.75"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<polyline points="6 4 10 8 6 12" />
|
|
</svg>
|
|
{showIntegration ? 'Hide' : 'Show'} Integration Settings
|
|
</button>
|
|
|
|
{#if showIntegration}
|
|
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
|
|
<div class="flex items-center gap-3">
|
|
<Switch
|
|
id="integrationEnabled"
|
|
name="integrationEnabled"
|
|
bind:checked={$form.integrationEnabled}
|
|
ariaLabelledby="integrationEnabledLabel"
|
|
/>
|
|
<label id="integrationEnabledLabel" for="integrationEnabled" class="cursor-pointer 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-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
>
|
|
<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-status-online-ink' : '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" size="lg" disabled={$submitting} loading={$submitting}>
|
|
{#if $submitting}
|
|
{$t('app.saving')}
|
|
{:else}
|
|
{mode === 'edit' ? $t('app.update') : $t('app.save')}
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
</form>
|