feat(inline-edit): add WYSIWYG inline dashboard editing mode
Replace the disconnected board edit page with inline editing directly on the board view. Toggle with Ctrl+E or the Edit button. Features: - Edit mode store with changeset accumulation and batch save - Floating toolbar (save, discard, add section, board settings, exit) - Widget hover overlays with edit/delete/drag controls - Type-specific widget config panels for all 14 widget types - Section inline editing (title, icon picker, delete) - "+" buttons for adding widgets and sections inline - Section-level drag-and-drop reordering via svelte-dnd-action - Batch save API endpoint (single Prisma transaction) - Board properties side panel with live theme/wallpaper preview - Modal widget type picker with search filtering - Icon picker component with visual grid and search - Confirmation dialog modal for all destructive actions - HTML format support for Note widget (in addition to markdown/text) - Full i18n support (en + ru) for all new UI strings - Legacy edit page banner linking to new inline mode
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
interface NoteConfig {
|
||||
content: string;
|
||||
format: 'markdown' | 'text';
|
||||
format: 'markdown' | 'text' | 'html';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -28,6 +28,9 @@
|
||||
.replace(/\n/g, '<br>')
|
||||
);
|
||||
}
|
||||
if (config.format === 'html') {
|
||||
return DOMPurify.sanitize(config.content);
|
||||
}
|
||||
const raw = marked.parse(config.content, { async: false }) as string;
|
||||
return DOMPurify.sanitize(raw);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
widgetType: string;
|
||||
initialConfig?: Record<string, unknown>;
|
||||
apps?: Array<{ id: string; name: string }>;
|
||||
mode: 'create' | 'edit';
|
||||
onSave: (config: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { widgetType, initialConfig = {}, apps = [], mode, onSave, onCancel }: Props = $props();
|
||||
|
||||
// -- Form fields initialised from config --
|
||||
// App
|
||||
let appId = $state((initialConfig.appId as string) ?? '');
|
||||
|
||||
// Bookmark
|
||||
let bookmarkUrl = $state((initialConfig.url as string) ?? '');
|
||||
let bookmarkLabel = $state((initialConfig.label as string) ?? '');
|
||||
let bookmarkIcon = $state((initialConfig.icon as string) ?? '');
|
||||
let bookmarkDescription = $state((initialConfig.description as string) ?? '');
|
||||
|
||||
// Note
|
||||
let noteContent = $state((initialConfig.content as string) ?? '');
|
||||
let noteFormat = $state<'markdown' | 'text' | 'html'>((initialConfig.format as 'markdown' | 'text' | 'html') ?? 'markdown');
|
||||
|
||||
// Embed
|
||||
let embedUrl = $state((initialConfig.url as string) ?? '');
|
||||
let embedHeight = $state((initialConfig.height as number) ?? 300);
|
||||
let embedSandbox = $state((initialConfig.sandbox as string) ?? '');
|
||||
|
||||
// Status
|
||||
let statusLabel = $state((initialConfig.label as string) ?? '');
|
||||
let statusAppIds = $state<string[]>((initialConfig.appIds as string[]) ?? []);
|
||||
|
||||
// Clock
|
||||
let clockTimezone = $state((initialConfig.timezone as string) ?? '');
|
||||
let clockStyle = $state<string>((initialConfig.clockStyle as string) ?? 'digital');
|
||||
let clockShowWeather = $state((initialConfig.showWeather as boolean) ?? false);
|
||||
let clockLatitude = $state(String(initialConfig.latitude ?? ''));
|
||||
let clockLongitude = $state(String(initialConfig.longitude ?? ''));
|
||||
|
||||
// System Stats
|
||||
let sysStatsSourceUrl = $state((initialConfig.sourceUrl as string) ?? '');
|
||||
let sysStatsSourceType = $state<string>((initialConfig.sourceType as string) ?? 'custom');
|
||||
let sysStatsRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 30);
|
||||
|
||||
// RSS
|
||||
let rssFeedUrl = $state((initialConfig.feedUrl as string) ?? '');
|
||||
let rssMaxItems = $state((initialConfig.maxItems as number) ?? 10);
|
||||
let rssShowSummary = $state((initialConfig.showSummary as boolean) ?? true);
|
||||
|
||||
// Calendar
|
||||
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#6366f1', label: '' }]);
|
||||
let calendarDaysAhead = $state((initialConfig.daysAhead as number) ?? 7);
|
||||
|
||||
// Markdown
|
||||
let markdownContent = $state((initialConfig.content as string) ?? '');
|
||||
|
||||
// Metric
|
||||
let metricLabel = $state((initialConfig.label as string) ?? '');
|
||||
let metricSource = $state<string>((initialConfig.source as string) ?? 'static');
|
||||
let metricValue = $state(String(initialConfig.value ?? ''));
|
||||
let metricUrl = $state((initialConfig.url as string) ?? '');
|
||||
let metricJsonPath = $state((initialConfig.jsonPath as string) ?? '');
|
||||
let metricQuery = $state((initialConfig.query as string) ?? '');
|
||||
let metricUnit = $state((initialConfig.unit as string) ?? '');
|
||||
let metricRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 60);
|
||||
|
||||
// Link Group
|
||||
let linkGroupLinks = $state<Array<{ label: string; url: string; icon: string }>>((initialConfig.links as Array<{ label: string; url: string; icon: string }>) ?? [{ label: '', url: '', icon: '' }]);
|
||||
let linkGroupCollapsible = $state((initialConfig.collapsible as boolean) ?? false);
|
||||
|
||||
// Camera
|
||||
let cameraStreamUrl = $state((initialConfig.streamUrl as string) ?? '');
|
||||
let cameraType = $state<string>((initialConfig.type as string) ?? 'image');
|
||||
let cameraRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 10);
|
||||
let cameraAspectRatio = $state((initialConfig.aspectRatio as string) ?? '16/9');
|
||||
|
||||
// Integration
|
||||
let integrationAppId = $state((initialConfig.appId as string) ?? '');
|
||||
let integrationEndpointId = $state((initialConfig.endpointId as string) ?? '');
|
||||
let integrationRefreshInterval = $state((initialConfig.refreshInterval as number) ?? 60);
|
||||
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
switch (widgetType) {
|
||||
case 'app':
|
||||
return { appId };
|
||||
case 'bookmark':
|
||||
return { url: bookmarkUrl, label: bookmarkLabel, icon: bookmarkIcon || undefined, description: bookmarkDescription || undefined };
|
||||
case 'note':
|
||||
return { content: noteContent, format: noteFormat };
|
||||
case 'embed':
|
||||
return { url: embedUrl, height: embedHeight, sandbox: embedSandbox || undefined };
|
||||
case 'status':
|
||||
return { appIds: statusAppIds, label: statusLabel || undefined };
|
||||
case 'clock':
|
||||
return { timezone: clockTimezone || undefined, clockStyle, showWeather: clockShowWeather, latitude: clockLatitude ? Number(clockLatitude) : undefined, longitude: clockLongitude ? Number(clockLongitude) : undefined };
|
||||
case 'system_stats':
|
||||
return { sourceUrl: sysStatsSourceUrl, sourceType: sysStatsSourceType, metrics: ['cpu', 'ram', 'disk'], refreshInterval: sysStatsRefreshInterval };
|
||||
case 'rss':
|
||||
return { feedUrl: rssFeedUrl, maxItems: rssMaxItems, showSummary: rssShowSummary };
|
||||
case 'calendar':
|
||||
return { icalUrls: calendarUrlsRaw.filter((u) => u.url.trim()), daysAhead: calendarDaysAhead };
|
||||
case 'markdown':
|
||||
return { content: markdownContent };
|
||||
case 'metric':
|
||||
return { label: metricLabel, source: metricSource, value: metricValue || undefined, url: metricUrl || undefined, jsonPath: metricJsonPath || undefined, query: metricQuery || undefined, unit: metricUnit || undefined, refreshInterval: metricRefreshInterval };
|
||||
case 'link_group':
|
||||
return { links: linkGroupLinks.filter((l) => l.url.trim()), collapsible: linkGroupCollapsible };
|
||||
case 'camera':
|
||||
return { streamUrl: cameraStreamUrl, type: cameraType, refreshInterval: cameraRefreshInterval, aspectRatio: cameraAspectRatio };
|
||||
case 'integration':
|
||||
return { appId: integrationAppId, endpointId: integrationEndpointId, refreshInterval: integrationRefreshInterval };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onSave(buildConfig());
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
|
||||
function addLinkGroupLink() {
|
||||
linkGroupLinks = [...linkGroupLinks, { label: '', url: '', icon: '' }];
|
||||
}
|
||||
|
||||
function removeLinkGroupLink(index: number) {
|
||||
linkGroupLinks = linkGroupLinks.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function addCalendarUrl() {
|
||||
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#6366f1', label: '' }];
|
||||
}
|
||||
|
||||
function removeCalendarUrl(index: number) {
|
||||
calendarUrlsRaw = calendarUrlsRaw.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
// Helper for input styling
|
||||
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
|
||||
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
|
||||
|
||||
let firstInput: HTMLElement | undefined = $state();
|
||||
$effect(() => { tick().then(() => firstInput?.focus()); });
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-border bg-card p-4 shadow-lg"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{mode === 'create' ? ($t('widget.add_widget') ?? 'Add Widget') : ($t('widget.edit_widget') ?? 'Edit Widget')}
|
||||
<span class="ml-1 text-xs font-normal text-muted-foreground">({widgetType})</span>
|
||||
</h3>
|
||||
<button type="button" onclick={onCancel} class="rounded p-0.5 text-muted-foreground hover:text-foreground">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 space-y-3 overflow-y-auto">
|
||||
{#if widgetType === 'app'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||
<select bind:value={appId} class={inputClass} bind:this={firstInput}>
|
||||
<option value="">Select an app...</option>
|
||||
{#each apps as app}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'bookmark'}
|
||||
<div>
|
||||
<label class={labelClass}>URL</label>
|
||||
<input type="url" bind:value={bookmarkUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
||||
<input type="text" bind:value={bookmarkLabel} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('app.icon') ?? 'Icon'}</label>
|
||||
<input type="text" bind:value={bookmarkIcon} placeholder="e.g. globe" class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('common.description') ?? 'Description'}</label>
|
||||
<input type="text" bind:value={bookmarkDescription} class={inputClass} />
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'note'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
|
||||
<textarea bind:value={noteContent} rows="4" class={inputClass} bind:this={firstInput}></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.format') ?? 'Format'}</label>
|
||||
<select bind:value={noteFormat} class={inputClass}>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'embed'}
|
||||
<div>
|
||||
<label class={labelClass}>URL</label>
|
||||
<input type="url" bind:value={embedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</label>
|
||||
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Sandbox</label>
|
||||
<input type="text" bind:value={embedSandbox} placeholder="allow-scripts allow-same-origin" class={inputClass} />
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'status'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
||||
<input type="text" bind:value={statusLabel} class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
|
||||
<div class="space-y-1 rounded-lg border border-input bg-background p-2">
|
||||
{#each apps as app}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statusAppIds.includes(app.id)}
|
||||
onchange={() => {
|
||||
if (statusAppIds.includes(app.id)) {
|
||||
statusAppIds = statusAppIds.filter((id) => id !== app.id);
|
||||
} else {
|
||||
statusAppIds = [...statusAppIds, app.id];
|
||||
}
|
||||
}}
|
||||
class="h-3.5 w-3.5 rounded border-input accent-primary"
|
||||
/>
|
||||
{app.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'clock'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.timezone') ?? 'Timezone'}</label>
|
||||
<input type="text" bind:value={clockTimezone} placeholder="America/New_York" class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.style') ?? 'Style'}</label>
|
||||
<select bind:value={clockStyle} class={inputClass}>
|
||||
<option value="digital">Digital</option>
|
||||
<option value="analog">Analog</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||
{$t('widget.show_weather') ?? 'Show Weather'}
|
||||
</label>
|
||||
{#if clockShowWeather}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class={labelClass}>Latitude</label>
|
||||
<input type="text" bind:value={clockLatitude} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Longitude</label>
|
||||
<input type="text" bind:value={clockLongitude} class={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if widgetType === 'system_stats'}
|
||||
<div>
|
||||
<label class={labelClass}>Source URL</label>
|
||||
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Source Type</label>
|
||||
<select bind:value={sysStatsSourceType} class={inputClass}>
|
||||
<option value="glances">Glances</option>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</label>
|
||||
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'rss'}
|
||||
<div>
|
||||
<label class={labelClass}>Feed URL</label>
|
||||
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Max Items ({rssMaxItems})</label>
|
||||
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||
Show Summary
|
||||
</label>
|
||||
|
||||
{:else if widgetType === 'calendar'}
|
||||
<div>
|
||||
<label class={labelClass}>iCal URLs</label>
|
||||
{#each calendarUrlsRaw as cal, i}
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<input type="url" bind:value={cal.url} placeholder="https://..." class="{inputClass} flex-1" />
|
||||
<input type="text" bind:value={cal.label} placeholder="Label" class="{inputClass} w-20" />
|
||||
<input type="color" bind:value={cal.color} class="h-8 w-8 cursor-pointer rounded border-0" />
|
||||
{#if calendarUrlsRaw.length > 1}
|
||||
<button type="button" onclick={() => removeCalendarUrl(i)} class="text-destructive hover:text-destructive/80">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Days Ahead ({calendarDaysAhead})</label>
|
||||
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'markdown'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.content') ?? 'Content'}</label>
|
||||
<textarea bind:value={markdownContent} rows="6" class="{inputClass} font-mono text-xs" bind:this={firstInput}></textarea>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'metric'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('common.label') ?? 'Label'}</label>
|
||||
<input type="text" bind:value={metricLabel} class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Source</label>
|
||||
<select bind:value={metricSource} class={inputClass}>
|
||||
<option value="static">Static</option>
|
||||
<option value="json">JSON Endpoint</option>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if metricSource === 'static'}
|
||||
<div>
|
||||
<label class={labelClass}>Value</label>
|
||||
<input type="text" bind:value={metricValue} class={inputClass} />
|
||||
</div>
|
||||
{:else if metricSource === 'json'}
|
||||
<div>
|
||||
<label class={labelClass}>URL</label>
|
||||
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>JSON Path</label>
|
||||
<input type="text" bind:value={metricJsonPath} placeholder="$.data.value" class={inputClass} />
|
||||
</div>
|
||||
{:else if metricSource === 'prometheus'}
|
||||
<div>
|
||||
<label class={labelClass}>URL</label>
|
||||
<input type="url" bind:value={metricUrl} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>PromQL Query</label>
|
||||
<input type="text" bind:value={metricQuery} class={inputClass} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class={labelClass}>Unit</label>
|
||||
<input type="text" bind:value={metricUnit} placeholder="%" class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({metricRefreshInterval}s)</label>
|
||||
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'link_group'}
|
||||
<div>
|
||||
<label class={labelClass}>Links</label>
|
||||
{#each linkGroupLinks as link, i}
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<input type="text" bind:value={link.label} placeholder="Label" class="{inputClass} w-24" />
|
||||
<input type="url" bind:value={link.url} placeholder="URL" class="{inputClass} flex-1" />
|
||||
<input type="text" bind:value={link.icon} placeholder="Icon" class="{inputClass} w-16" />
|
||||
{#if linkGroupLinks.length > 1}
|
||||
<button type="button" onclick={() => removeLinkGroupLink(i)} class="text-destructive hover:text-destructive/80">×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||
Collapsible
|
||||
</label>
|
||||
|
||||
{:else if widgetType === 'camera'}
|
||||
<div>
|
||||
<label class={labelClass}>Stream URL</label>
|
||||
<input type="url" bind:value={cameraStreamUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Type</label>
|
||||
<select bind:value={cameraType} class={inputClass}>
|
||||
<option value="image">Image</option>
|
||||
<option value="mjpeg">MJPEG</option>
|
||||
<option value="hls">HLS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)</label>
|
||||
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Aspect Ratio</label>
|
||||
<select bind:value={cameraAspectRatio} class={inputClass}>
|
||||
<option value="16/9">16:9</option>
|
||||
<option value="4/3">4:3</option>
|
||||
<option value="1/1">1:1</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'integration'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
|
||||
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
|
||||
<option value="">Select app...</option>
|
||||
{#each apps as app}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Endpoint ID</label>
|
||||
<input type="text" bind:value={integrationEndpointId} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)</label>
|
||||
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-3 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<button type="button" onclick={onCancel}
|
||||
class="rounded-lg border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent">
|
||||
{$t('common.cancel') ?? 'Cancel'}
|
||||
</button>
|
||||
<button type="button" onclick={handleSave}
|
||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
{mode === 'create' ? ($t('common.add') ?? 'Add') : ($t('common.save') ?? 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +110,8 @@
|
||||
|
||||
const noteFormatItems: IconGridItem[] = [
|
||||
{ value: 'markdown', icon: '📝', label: 'Markdown' },
|
||||
{ value: 'text', icon: '📄', label: 'Plain Text' }
|
||||
{ value: 'text', icon: '📄', label: 'Plain Text' },
|
||||
{ value: 'html', icon: '🌐', label: 'HTML' }
|
||||
];
|
||||
|
||||
const clockStyleItems: IconGridItem[] = [
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
widgetId: string;
|
||||
onEdit: (widgetId: string) => void;
|
||||
onDelete: (widgetId: string) => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { widgetId, onEdit, onDelete, children }: Props = $props();
|
||||
|
||||
let showDeleteConfirm = $state(false);
|
||||
let hovered = $state(false);
|
||||
|
||||
function handleDelete() {
|
||||
onDelete(widgetId);
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative"
|
||||
role="group"
|
||||
onmouseenter={() => { hovered = true; }}
|
||||
onmouseleave={() => { hovered = false; }}
|
||||
>
|
||||
{@render children()}
|
||||
|
||||
<!-- Overlay controls -->
|
||||
{#if hovered}
|
||||
<div class="absolute inset-0 z-10 rounded-xl bg-black/5 transition-opacity">
|
||||
<!-- Top-left: drag handle -->
|
||||
<div class="absolute left-1.5 top-1.5">
|
||||
<div class="cursor-grab rounded-md bg-card/90 p-1 text-muted-foreground shadow-sm backdrop-blur-sm" title="Drag to reorder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="5" r="1" /><circle cx="15" cy="5" r="1" />
|
||||
<circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" />
|
||||
<circle cx="9" cy="19" r="1" /><circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-right: edit + delete -->
|
||||
<div class="absolute right-1.5 top-1.5 flex items-center gap-1">
|
||||
<!-- Edit button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEdit(widgetId)}
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
title={$t('common.edit') ?? 'Edit'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showDeleteConfirm = true; }}
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
title={$t('common.delete') ?? 'Delete'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDeleteConfirm}
|
||||
<ConfirmDialog
|
||||
title={$t('widget.delete_title') ?? 'Delete Widget'}
|
||||
message={$t('widget.delete_confirm') ?? 'Are you sure you want to delete this widget? This action will take effect when you save.'}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { showDeleteConfirm = false; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -2,6 +2,10 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import WidgetRenderer from './WidgetRenderer.svelte';
|
||||
import WidgetContainer from './WidgetContainer.svelte';
|
||||
import WidgetEditOverlay from './WidgetEditOverlay.svelte';
|
||||
import WidgetConfigPanel from './WidgetConfigPanel.svelte';
|
||||
import WidgetTypePicker from './WidgetTypePicker.svelte';
|
||||
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||
import type { CardSize } from '$lib/utils/constants.js';
|
||||
|
||||
interface AppData {
|
||||
@@ -25,11 +29,17 @@
|
||||
|
||||
interface Props {
|
||||
widgets: WidgetData[];
|
||||
sectionId?: string;
|
||||
allApps?: AppData[];
|
||||
cardSize?: CardSize;
|
||||
}
|
||||
|
||||
let { widgets, allApps = [], cardSize = 'medium' }: Props = $props();
|
||||
let { widgets, sectionId, allApps = [], cardSize = 'medium' }: Props = $props();
|
||||
|
||||
// Edit mode state
|
||||
let editingWidgetId = $state<string | null>(null);
|
||||
let showTypePicker = $state(false);
|
||||
let addingWidgetType = $state<string | null>(null);
|
||||
|
||||
// Widgets that should span full width
|
||||
const fullWidthTypes = new Set(['note', 'embed', 'status', 'system_stats', 'rss', 'calendar', 'markdown', 'camera']);
|
||||
@@ -56,19 +66,123 @@
|
||||
return 'col-span-2 sm:col-span-3 lg:col-span-4';
|
||||
}
|
||||
});
|
||||
|
||||
function handleEditWidget(widgetId: string) {
|
||||
editingWidgetId = widgetId;
|
||||
}
|
||||
|
||||
function handleDeleteWidget(widgetId: string) {
|
||||
editMode.deleteWidget(widgetId);
|
||||
}
|
||||
|
||||
function handleSaveWidgetConfig(widgetId: string, config: Record<string, unknown>) {
|
||||
editMode.updateWidget(widgetId, config);
|
||||
editingWidgetId = null;
|
||||
}
|
||||
|
||||
function handleTypeSelected(type: string) {
|
||||
showTypePicker = false;
|
||||
addingWidgetType = type;
|
||||
}
|
||||
|
||||
function handleNewWidgetSave(config: Record<string, unknown>) {
|
||||
if (!sectionId || !addingWidgetType) return;
|
||||
const tempId = `temp-widget-${crypto.randomUUID()}`;
|
||||
|
||||
editMode.addWidget({
|
||||
tempId,
|
||||
sectionId,
|
||||
type: addingWidgetType,
|
||||
config: JSON.stringify(config),
|
||||
order: widgets.length,
|
||||
appId: addingWidgetType === 'app' ? (config.appId as string) : undefined
|
||||
});
|
||||
|
||||
addingWidgetType = null;
|
||||
}
|
||||
|
||||
function getWidgetConfig(widget: WidgetData): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(widget.config || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const appsForPicker = $derived(allApps.map((a) => ({ id: a.id, name: a.name })));
|
||||
</script>
|
||||
|
||||
{#if widgets.length === 0}
|
||||
{#if widgets.length === 0 && !editMode.active}
|
||||
<p class="text-sm text-muted-foreground">{$t('widget.no_widgets')}</p>
|
||||
{:else}
|
||||
<div class={gridClass}>
|
||||
{#each widgets as widget (widget.id)}
|
||||
{@const isFullWidth = fullWidthTypes.has(widget.type)}
|
||||
<div class={isFullWidth ? fullWidthClass : ''}>
|
||||
<WidgetContainer>
|
||||
<WidgetRenderer {widget} {allApps} {cardSize} />
|
||||
</WidgetContainer>
|
||||
{#if editMode.active}
|
||||
{#if editingWidgetId === widget.id}
|
||||
<!-- Inline config editor -->
|
||||
<WidgetConfigPanel
|
||||
widgetType={widget.type}
|
||||
initialConfig={getWidgetConfig(widget)}
|
||||
apps={appsForPicker}
|
||||
mode="edit"
|
||||
onSave={(config) => handleSaveWidgetConfig(widget.id, config)}
|
||||
onCancel={() => { editingWidgetId = null; }}
|
||||
/>
|
||||
{:else}
|
||||
<WidgetEditOverlay
|
||||
widgetId={widget.id}
|
||||
onEdit={handleEditWidget}
|
||||
onDelete={handleDeleteWidget}
|
||||
>
|
||||
<WidgetContainer>
|
||||
<WidgetRenderer {widget} {allApps} {cardSize} />
|
||||
</WidgetContainer>
|
||||
</WidgetEditOverlay>
|
||||
{/if}
|
||||
{:else}
|
||||
<WidgetContainer>
|
||||
<WidgetRenderer {widget} {allApps} {cardSize} />
|
||||
</WidgetContainer>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add Widget button (edit mode only) -->
|
||||
{#if editMode.active}
|
||||
{#if addingWidgetType}
|
||||
<div class={fullWidthClass}>
|
||||
<WidgetConfigPanel
|
||||
widgetType={addingWidgetType}
|
||||
apps={appsForPicker}
|
||||
mode="create"
|
||||
onSave={handleNewWidgetSave}
|
||||
onCancel={() => { addingWidgetType = null; }}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showTypePicker = true; }}
|
||||
class="flex h-full min-h-[80px] w-full items-center justify-center rounded-xl border-2 border-dashed border-border bg-card/30 text-muted-foreground transition-all hover:border-primary hover:bg-primary/5 hover:text-primary"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
<span class="text-xs">{$t('widget.add_widget') ?? 'Add Widget'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Type picker modal (rendered outside grid, fixed position) -->
|
||||
{#if showTypePicker}
|
||||
<WidgetTypePicker
|
||||
onSelect={handleTypeSelected}
|
||||
onClose={() => { showTypePicker = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onSelect: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onSelect, onClose }: Props = $props();
|
||||
|
||||
let filterQuery = $state('');
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => { tick().then(() => searchInput?.focus()); });
|
||||
|
||||
const widgetTypes = [
|
||||
{ value: 'app', label: 'App', description: 'Link to a registered application' },
|
||||
{ value: 'bookmark', label: 'Bookmark', description: 'Quick link with icon and description' },
|
||||
{ value: 'note', label: 'Note', description: 'Text or markdown content' },
|
||||
{ value: 'embed', label: 'Embed', description: 'Embedded iframe content' },
|
||||
{ value: 'status', label: 'Status', description: 'Monitor multiple app statuses' },
|
||||
{ value: 'clock', label: 'Clock', description: 'Clock with optional weather' },
|
||||
{ value: 'system_stats', label: 'System Stats', description: 'CPU, RAM, disk usage' },
|
||||
{ value: 'rss', label: 'RSS Feed', description: 'RSS/Atom feed reader' },
|
||||
{ value: 'calendar', label: 'Calendar', description: 'iCal calendar events' },
|
||||
{ value: 'markdown', label: 'Markdown', description: 'Rich markdown document' },
|
||||
{ value: 'metric', label: 'Metric', description: 'Single value from API or Prometheus' },
|
||||
{ value: 'link_group', label: 'Link Group', description: 'Grouped collection of links' },
|
||||
{ value: 'camera', label: 'Camera', description: 'Image, MJPEG, or HLS stream' },
|
||||
{ value: 'integration', label: 'Integration', description: 'Custom app integration endpoint' }
|
||||
];
|
||||
|
||||
const filteredTypes = $derived(
|
||||
filterQuery.trim()
|
||||
? widgetTypes.filter((wt) =>
|
||||
wt.label.toLowerCase().includes(filterQuery.toLowerCase()) ||
|
||||
wt.description.toLowerCase().includes(filterQuery.toLowerCase()) ||
|
||||
wt.value.toLowerCase().includes(filterQuery.toLowerCase())
|
||||
)
|
||||
: widgetTypes
|
||||
);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
|
||||
function iconFor(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
app: 'M2 3h20v14H2zM8 21h8M12 17v4',
|
||||
bookmark: 'm19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z',
|
||||
note: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|14 2 14 8 20 8|16 13 8 13|16 17 8 17|10 9 9 9 8 9',
|
||||
embed: '16 18 22 12 16 6|8 6 2 12 8 18',
|
||||
status: '22 12 18 12 15 21 9 3 6 12 2 12',
|
||||
clock: 'M12 12m-10 0a10 10 0 1 0 20 0a10 10 0 1 0 -20 0|12 6 12 12 16 14',
|
||||
system_stats: 'M4 4h16v16H4z|9 9h6v6H9z',
|
||||
rss: 'M4 11a9 9 0 0 1 9 9|M4 4a16 16 0 0 1 16 16|M5 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0',
|
||||
calendar: 'M3 4h18v18H3z|16 2v4|8 2v4|3 10h18',
|
||||
markdown: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|14 2 14 8 20 8',
|
||||
metric: '22 7 13.5 15.5 8.5 10.5 2 17|16 7 22 7 22 13',
|
||||
link_group: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71|M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
||||
camera: 'M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z|M12 13m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0',
|
||||
integration: 'M12 2v10|M18.4 6.6 14.5 10.5|M22 12h-10|M18.4 17.4 14.5 13.5|M12 22v-10|M5.6 17.4 9.5 13.5|M2 12h10|M5.6 6.6 9.5 10.5'
|
||||
};
|
||||
return map[type] ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Enter' && onClose()}
|
||||
transition:fade={{ duration: 120 }}
|
||||
>
|
||||
<!-- Modal -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl lg:max-w-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
transition:scale={{ start: 0.95, duration: 150 }}
|
||||
>
|
||||
<!-- Header + Search -->
|
||||
<div class="border-b border-border px-5 pb-3 pt-5">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-foreground">{$t('widget.add_widget') ?? 'Add Widget'}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
type="text"
|
||||
bind:value={filterQuery}
|
||||
placeholder={$t('widget.search_type') ?? 'Search widget types...'}
|
||||
class="w-full rounded-lg border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="max-h-80 overflow-y-auto p-3">
|
||||
{#if filteredTypes.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">{$t('common.no_results') ?? 'No matching widget types'}</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
{#each filteredTypes as wt}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSelect(wt.value)}
|
||||
class="flex items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div class="mt-0.5 shrink-0 rounded-lg bg-primary/10 p-2 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
{#each iconFor(wt.value).split('|') as segment}
|
||||
{#if segment.includes('m') || segment.includes('M') || segment.includes('a') || segment.includes('z') || segment.includes('A') || segment.includes('c') || segment.includes('l') || segment.includes('v') || segment.includes('h') || segment.includes('V') || segment.includes('H')}
|
||||
<path d={segment} />
|
||||
{:else}
|
||||
<polyline points={segment} />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-foreground">{wt.label}</div>
|
||||
<div class="text-xs leading-snug text-muted-foreground">{wt.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user