2025-05-22 22:02:11 +00:00
|
|
|
<script lang="ts">
|
2025-09-01 15:11:16 +00:00
|
|
|
import Switch from '$lib/components/common/Switch.svelte';
|
|
|
|
|
import Textarea from '$lib/components/common/Textarea.svelte';
|
|
|
|
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
|
|
|
|
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
|
|
|
|
|
import XMark from '$lib/components/icons/XMark.svelte';
|
|
|
|
|
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
|
|
|
|
|
import Sortable from 'sortablejs';
|
|
|
|
|
import { getContext } from 'svelte';
|
|
|
|
|
import { config } from '$lib/stores';
|
|
|
|
|
|
|
|
|
|
const i18n = getContext('i18n');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export let banners: any[] = [];
|
|
|
|
|
|
|
|
|
|
let sortable: any = null;
|
|
|
|
|
let bannerListElement: HTMLDivElement | null = null;
|
|
|
|
|
|
|
|
|
|
// dynamic languages from config
|
|
|
|
|
$: LANGS = Array.isArray($config.features.translation_languages)
|
|
|
|
|
? $config.features.translation_languages
|
|
|
|
|
: ['de']; // fallback if missing
|
|
|
|
|
|
|
|
|
|
// reactive UI language code
|
|
|
|
|
$: langCode = $i18n?.language?.split('-')[0] || 'de';
|
|
|
|
|
|
|
|
|
|
// contentObjs stores parsed content objects keyed by banner.id
|
|
|
|
|
let contentObjs: Record<string, Record<string, string>> = {};
|
|
|
|
|
|
|
|
|
|
// Initialize/Sync contentObjs with banners
|
|
|
|
|
$: if (banners) {
|
|
|
|
|
for (const b of banners) {
|
|
|
|
|
if (!b) continue;
|
|
|
|
|
const id = b.id ?? (b.id = crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
|
|
|
|
|
if (!contentObjs[id]) {
|
|
|
|
|
contentObjs[id] = parseContentToObj(b.content);
|
|
|
|
|
} else {
|
|
|
|
|
const currentString = safeStringify(contentObjs[id]);
|
|
|
|
|
const incomingString = typeof b.content === 'string'
|
|
|
|
|
? b.content
|
|
|
|
|
: safeStringify(parseContentToObj(b.content));
|
|
|
|
|
if (incomingString !== currentString) {
|
|
|
|
|
contentObjs[id] = parseContentToObj(b.content);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// remove entries for banners that no longer exist
|
|
|
|
|
const ids = new Set(banners.map((b) => b.id));
|
|
|
|
|
for (const key of Object.keys(contentObjs)) {
|
|
|
|
|
if (!ids.has(key)) delete contentObjs[key];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initSortable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseContentToObj(content: any) {
|
|
|
|
|
let parsed: Record<string, string> = {};
|
|
|
|
|
try {
|
|
|
|
|
parsed = typeof content === 'string' ? JSON.parse(content) : { ...content };
|
|
|
|
|
} catch {
|
|
|
|
|
parsed = { de: content || '' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ensure all languages from config exist
|
|
|
|
|
for (const lang of LANGS) {
|
|
|
|
|
if (parsed[lang] == null) parsed[lang] = '';
|
|
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeStringify(obj: any) {
|
|
|
|
|
try { return JSON.stringify(obj); }
|
|
|
|
|
catch { return '{}'; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initSortable() {
|
|
|
|
|
if (sortable) { try { sortable.destroy(); } catch {} sortable = null; }
|
|
|
|
|
if (bannerListElement) {
|
|
|
|
|
sortable = Sortable.create(bannerListElement, {
|
|
|
|
|
animation: 150,
|
|
|
|
|
handle: '.item-handle',
|
|
|
|
|
onUpdate: () => {
|
|
|
|
|
const order = Array.from(bannerListElement!.children)
|
|
|
|
|
.map(ch => (ch as HTMLElement).id.replace('banner-item-', ''));
|
|
|
|
|
banners = order.map(id => banners.find(b => b.id === id));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleInlineChange(e: any, idx: number, id: string) {
|
|
|
|
|
const val = e?.detail ?? e?.target?.value ?? '';
|
|
|
|
|
if (!contentObjs[id]) contentObjs[id] = Object.fromEntries(LANGS.map(l => [l, '']));
|
|
|
|
|
contentObjs[id][langCode] = val;
|
|
|
|
|
contentObjs = { ...contentObjs };
|
|
|
|
|
banners[idx].content = safeStringify(contentObjs[id]);
|
|
|
|
|
banners = [...banners];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let showBannerModal = false;
|
|
|
|
|
let editingBannerIndex: number | null = null;
|
|
|
|
|
let newBanner: { id: string; content: Record<string, string> } = { id: '', content: {} };
|
|
|
|
|
|
|
|
|
|
function openEditModal(idx: number) {
|
|
|
|
|
editingBannerIndex = idx;
|
|
|
|
|
const b = banners[idx];
|
|
|
|
|
if (!b) return;
|
|
|
|
|
const id = b.id;
|
|
|
|
|
if (!contentObjs[id]) contentObjs[id] = parseContentToObj(b.content);
|
|
|
|
|
newBanner = { id, content: { ...contentObjs[id] } };
|
|
|
|
|
showBannerModal = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeModal() {
|
|
|
|
|
showBannerModal = false;
|
|
|
|
|
editingBannerIndex = null;
|
|
|
|
|
newBanner = { id: '', content: Object.fromEntries(LANGS.map(l => [l, ''])) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveModal() {
|
|
|
|
|
const any = Object.values(newBanner.content).some(v => v && v.trim() !== '');
|
|
|
|
|
if (!any) {
|
|
|
|
|
alert('At least one translation is required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!newBanner.content.de?.trim()) {
|
|
|
|
|
const first = Object.values(newBanner.content).find(v => v.trim() !== '');
|
|
|
|
|
if (first) newBanner.content.de = first;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (editingBannerIndex != null) {
|
|
|
|
|
banners[editingBannerIndex].content = safeStringify(newBanner.content);
|
|
|
|
|
contentObjs[newBanner.id] = { ...newBanner.content };
|
|
|
|
|
contentObjs = { ...contentObjs };
|
|
|
|
|
banners = [...banners];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closeModal();
|
|
|
|
|
}
|
2025-05-22 22:02:11 +00:00
|
|
|
</script>
|
|
|
|
|
|
2025-09-01 15:11:16 +00:00
|
|
|
<!-- Draggable banners -->
|
|
|
|
|
<div class="flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
|
|
|
|
|
{#each banners as banner, bannerIdx (banner.id)}
|
|
|
|
|
<div class="flex justify-between items-start -ml-1" id={"banner-item-" + banner.id}>
|
|
|
|
|
<EllipsisVertical className="size-4 cursor-move item-handle" />
|
|
|
|
|
|
|
|
|
|
<div class="flex flex-row flex-1 gap-2 items-start">
|
|
|
|
|
<select
|
|
|
|
|
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
|
|
|
|
|
bind:value={banner.type}
|
|
|
|
|
required
|
|
|
|
|
>
|
|
|
|
|
{#if banner.type == ''}
|
|
|
|
|
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
|
|
|
|
|
{/if}
|
|
|
|
|
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
|
|
|
|
|
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
|
|
|
|
|
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
|
|
|
|
|
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
<Textarea
|
|
|
|
|
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
|
|
|
|
|
placeholder={$i18n.t('Content')}
|
|
|
|
|
value={(contentObjs[banner.id]?.[langCode]) ?? ''}
|
|
|
|
|
on:input={(e) => handleInlineChange(e, bannerIdx, banner.id)}
|
|
|
|
|
maxSize={100}
|
|
|
|
|
readonly
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div class="relative -left-2">
|
|
|
|
|
<Tooltip content={$i18n.t('Remember Dismissal')} class="flex h-fit items-center">
|
|
|
|
|
<Switch bind:state={banner.dismissible} />
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button class="p-1 text-gray-500 hover:text-yellow-600" type="button" on:click={() => openEditModal(bannerIdx)} title={$i18n.t('Edit')}>
|
|
|
|
|
<PencilSolid />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button class="pr-3" type="button" on:click={() => { banners.splice(bannerIdx, 1); banners = banners; }}>
|
|
|
|
|
<XMark className={'size-4'} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
2025-05-22 22:02:11 +00:00
|
|
|
</div>
|
2025-09-01 15:11:16 +00:00
|
|
|
|
|
|
|
|
<!-- Modal -->
|
|
|
|
|
{#if showBannerModal}
|
|
|
|
|
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
|
|
|
|
|
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
|
|
|
|
|
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
|
|
|
|
|
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Translations')}</h2>
|
|
|
|
|
<button class="text-xs px-2 py-1" on:click={closeModal}>
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
|
|
|
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#each LANGS as lang}
|
|
|
|
|
<div class="mb-2">
|
|
|
|
|
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
|
|
|
|
|
<Textarea
|
|
|
|
|
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
|
|
|
|
|
bind:value={newBanner.content[lang]}
|
|
|
|
|
placeholder={`Enter ${lang.toUpperCase()} content`}
|
|
|
|
|
maxSize={200}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
|
|
|
<div class="flex justify-end space-x-2 mt-3">
|
|
|
|
|
<button
|
|
|
|
|
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
|
|
|
|
on:click={saveModal}
|
|
|
|
|
>
|
|
|
|
|
{$i18n.t('Save')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|