From 0a65701e0e9a18d2742a7e985795ef317ac799bb Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 19 Jan 2026 01:12:44 +0100 Subject: [PATCH] =?UTF-8?q?i=C3=BCpdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/assets/js/ui-list.js | 114 ++++++++++++++++++++++++++++--- public/assets/js/ui-user.js | 1 + schema.sql | 2 + src/ApiKernel.php | 129 ++++++++++++++++++++++++++++++++++-- 4 files changed, 230 insertions(+), 16 deletions(-) diff --git a/public/assets/js/ui-list.js b/public/assets/js/ui-list.js index d5031cd..9575061 100644 --- a/public/assets/js/ui-list.js +++ b/public/assets/js/ui-list.js @@ -1,4 +1,4 @@ -import { apiList, apiGet, apiDelete, apiUpdate, toast } from './api.js'; +import { apiList, apiGet, apiDelete, apiUpdate, apiAction, toast } from './api.js'; function formatUsage(usage){ if (!usage || !usage.total) return ''; @@ -109,18 +109,72 @@ async function openTemplateEditor(id){ export async function loadList(resource){ const el=document.getElementById(`view-${resource}`); if(!el) return; + const label = resource.charAt(0).toUpperCase()+resource.slice(1); el.innerHTML=`
-
${resource.charAt(0).toUpperCase()+resource.slice(1)}
+
+ ${label} +
+
+ + +
+ +
+
Lade …
`; const data=await apiList(resource); const list=el.querySelector(`#list-${resource}`); + const filterInput=el.querySelector(`#filter-${resource}`); + const filterReset=el.querySelector(`#filter-${resource}-reset`); + const sortSelect=el.querySelector(`#sort-${resource}`); if(!Array.isArray(data)||data.length===0){ list.innerHTML=`
Keine Einträge
`; return; } + const sortDefault = (window.__listSortDefault || 'created_asc'); + if (sortSelect) sortSelect.value = sortDefault; + + function compareByName(a, b) { + const av = String(a?.name || ''); + const bv = String(b?.name || ''); + return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }); + } + + function parseDate(value) { + const t = Date.parse(value || ''); + return Number.isFinite(t) ? t : 0; + } + + function sortItems(items, key) { + const listCopy = items.slice(); + if (key === 'name_asc') { + return listCopy.sort(compareByName); + } + if (key === 'name_desc') { + return listCopy.sort((a,b)=>compareByName(b,a)); + } + if (key === 'updated_desc') { + return listCopy.sort((a,b)=>parseDate(b.updated_at) - parseDate(a.updated_at)); + } + return listCopy.sort((a,b)=>parseDate(a.created_at) - parseDate(b.created_at)); + } + + function matchesQuery(item, query) { + if (!query) return true; + const q = query.toLowerCase(); + const name = String(item?.name || '').toLowerCase(); + const api = resource === 'templates' ? String(item?.api_name || '').toLowerCase() : ''; + return name.includes(q) || (api && api.includes(q)); + } + function parentBadge(r,it){ if(r==='sections'&&it.template_id) return ` Template #${it.template_id}${it.template_name ? ' · '+esc(it.template_name) : ''}`; if(r==='blocks'&&it.section_id) return ` Section #${it.section_id}${it.section_name ? ' · '+esc(it.section_name) : ''}`; @@ -128,7 +182,8 @@ export async function loadList(resource){ return ' frei'; } - list.innerHTML=data.map(item=>{ + function render(items){ + list.innerHTML=items.map(item=>{ const name = esc(item.name||''); const apiName = resource==='templates' ? esc(item.api_name||'') : ''; const apiLine = (resource==='templates' && apiName) ? `
API: ${apiName}
` : ''; @@ -159,8 +214,49 @@ export async function loadList(resource){ `; }).join(''); + bindListHandlers(list, resource); + } + + function applyFilter(){ + const query = (filterInput?.value || '').trim(); + const sortKey = sortSelect?.value || sortDefault; + const filtered = data.filter(item => matchesQuery(item, query)); + const sorted = sortItems(filtered, sortKey); + render(sorted); + } + + async function persistSort(nextValue){ + window.__listSortDefault = nextValue; + try { + await apiAction('account.settings.update', { method: 'POST', data: { list_sort: nextValue } }); + } catch {} + } + + if (filterInput) { + filterInput.addEventListener('input', applyFilter); + } + if (filterReset) { + filterReset.addEventListener('click', () => { + if (filterInput) { + filterInput.value = ''; + filterInput.focus(); + } + applyFilter(); + }); + } + if (sortSelect) { + sortSelect.addEventListener('change', () => { + const next = sortSelect.value || sortDefault; + persistSort(next); + applyFilter(); + }); + } + + applyFilter(); + // --- Editor öffnen (ANPASSUNG) ----------------------------------------- - list.querySelectorAll('[data-open]').forEach(b=>b.addEventListener('click', async ()=>{ + function bindListHandlers(scope, resName){ + scope.querySelectorAll('[data-open]').forEach(b=>b.addEventListener('click', async ()=>{ const [res,id]=b.dataset.open.split(':'); // Detail laden, um Name + aktuellen HTML/Content zu haben @@ -185,7 +281,7 @@ export async function loadList(resource){ // ----------------------------------------------------------------------- // edit snippet - list.querySelectorAll('[data-edit]').forEach(b=>b.addEventListener('click', async ()=>{ + scope.querySelectorAll('[data-edit]').forEach(b=>b.addEventListener('click', async ()=>{ const [res, id] = b.dataset.edit.split(':'); if (res === 'snippets') await openSnippetEditor(id); if (res === 'templates') await openTemplateEditor(id); @@ -193,7 +289,7 @@ export async function loadList(resource){ // preview const prevDlg=document.getElementById('previewDialog'), prevFrame=document.getElementById('previewFrame'); - list.querySelectorAll('[data-preview]').forEach(b=>b.addEventListener('click', async ()=>{ + scope.querySelectorAll('[data-preview]').forEach(b=>b.addEventListener('click', async ()=>{ const [res,id]=b.dataset.preview.split(':'); const obj=await apiGet(res,id); const html=(obj?.html||obj?.content||'(leer)b.addEventListener('click', ()=>{ + scope.querySelectorAll('[data-test]').forEach(b=>b.addEventListener('click', ()=>{ const id = Number(b.dataset.test || '0'); const nm = b.dataset.name || ''; if (!id) { @@ -225,7 +321,7 @@ export async function loadList(resource){ let pending=null; delCancel && (delCancel.onclick=()=>{pending=null;delDlg.close();}); - list.querySelectorAll('[data-del]').forEach(b=>b.addEventListener('click', async ()=>{ + scope.querySelectorAll('[data-del]').forEach(b=>b.addEventListener('click', async ()=>{ const [res,id]=b.dataset.del.split(':'); const nm=b.dataset.name||''; let usage = null; try { @@ -237,7 +333,7 @@ export async function loadList(resource){ delText && (delText.innerHTML=`Soll ${nm || '(ohne Name)'} #${id} aus ${res} wirklich gelöscht werden?
Achtung: Kinder-Elemente werden nicht automatisch mit gelöscht.${usageWarn}`); delDlg.showModal(); })); - + } delForm && (delForm.onsubmit=async(e)=>{ e.preventDefault(); if(!pending) return delDlg.close(); diff --git a/public/assets/js/ui-user.js b/public/assets/js/ui-user.js index dd1d24a..aa6481d 100644 --- a/public/assets/js/ui-user.js +++ b/public/assets/js/ui-user.js @@ -276,6 +276,7 @@ function fillSettingsForm(settings) { settingsForm.editor_default.value = settings.editor_default || 'grapesjs'; } window.__editorDefault = settings.editor_default || 'grapesjs'; + window.__listSortDefault = settings.list_sort || 'created_asc'; state.rotate = { bridge: false, sender: false, external: false }; refreshAdminTables(settings.bridge_setup?.tables || [], settings.bridge_tables || []); } diff --git a/schema.sql b/schema.sql index 67ba2da..62c7817 100644 --- a/schema.sql +++ b/schema.sql @@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS `customer_users` ( `password_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, `role` varchar(30) NOT NULL DEFAULT 'editor', `is_active` tinyint(1) NOT NULL DEFAULT 1, + `list_sort` varchar(32) DEFAULT NULL, `created_at` timestamp NULL DEFAULT current_timestamp(), `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), @@ -138,6 +139,7 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_customer_users` ( `password_hash` varchar(255) NOT NULL, `role` varchar(30) NOT NULL DEFAULT 'editor', `is_active` tinyint(1) NOT NULL DEFAULT 1, + `list_sort` varchar(32) DEFAULT NULL, `created_at` timestamp NULL DEFAULT current_timestamp(), `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), diff --git a/src/ApiKernel.php b/src/ApiKernel.php index c81b07e..5b13e8b 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -415,6 +415,8 @@ class ApiKernel if ($descCol && isset($r[$descCol])) $item['desc'] = $r[$descCol]; if ($catCol && isset($r[$catCol])) $item['category'] = $r[$catCol]; if ($updCol && isset($r[$updCol])) $item['updated_at'] = $r[$updCol]; + $createdCol = $this->firstExisting($allCols, ['created_at', 'created', 'createdAt']); + if ($createdCol && isset($r[$createdCol])) $item['created_at'] = $r[$createdCol]; // Lade HTML und JSON aus den korrekten Spalten $htmlCol = $this->firstExisting($allCols, ['html', 'body', 'markup', 'content']); @@ -1749,6 +1751,8 @@ class ApiKernel $this->ensureRole($user, ['owner', 'admin']); $customerId = (int)($user['customer_id'] ?? 0); $settings = $this->ensureSettingsTokens($customerId, $this->getCustomerSettings($customerId)); + $this->ensureAuthUserListSortColumn(); + $settings['list_sort'] = $this->resolveUserListSort($user, $customerId); $this->respond(['ok' => true, 'settings' => $settings]); } @@ -1759,21 +1763,30 @@ class ApiKernel $customerId = (int)($user['customer_id'] ?? 0); if ($customerId <= 0) $this->fail('Customer context missing', null, 500); - $bridgeUrl = trim((string)($this->in['bridge_url'] ?? '')); - $bridgeToken = trim((string)($this->in['bridge_token'] ?? '')); - $senderToken = trim((string)($this->in['sender_token'] ?? '')); - $externalToken = trim((string)($this->in['external_api_token'] ?? '')); - $editorDefault = strtolower(trim((string)($this->in['editor_default'] ?? ''))); + $settings = $this->getCustomerSettings($customerId); + $hasBridgeUrl = array_key_exists('bridge_url', $this->in); + $hasBridgeToken = array_key_exists('bridge_token', $this->in); + $hasSenderToken = array_key_exists('sender_token', $this->in); + $hasExternalToken = array_key_exists('external_api_token', $this->in); + $hasEditorDefault = array_key_exists('editor_default', $this->in); + $hasListSort = array_key_exists('list_sort', $this->in); + $hasBridgeTables = array_key_exists('bridge_tables', $this->in); + + $bridgeUrl = $hasBridgeUrl ? trim((string)($this->in['bridge_url'] ?? '')) : (string)($settings['bridge_url'] ?? ''); + $bridgeToken = $hasBridgeToken ? trim((string)($this->in['bridge_token'] ?? '')) : (string)($settings['bridge_token'] ?? ''); + $senderToken = $hasSenderToken ? trim((string)($this->in['sender_token'] ?? '')) : (string)($settings['sender_token'] ?? ''); + $externalToken = $hasExternalToken ? trim((string)($this->in['external_api_token'] ?? '')) : (string)($settings['external_api_token'] ?? ''); + $editorDefault = $hasEditorDefault ? strtolower(trim((string)($this->in['editor_default'] ?? ''))) : strtolower((string)($settings['editor_default'] ?? '')); + $listSort = $hasListSort ? strtolower(trim((string)($this->in['list_sort'] ?? ''))) : ''; $rotateBridge = !empty($this->in['rotate_bridge_token']); $rotateSender = !empty($this->in['rotate_sender_token']); $rotateExternal = !empty($this->in['rotate_external_token']); - $bridgeTables = $this->normalizeBridgeTables($this->in['bridge_tables'] ?? []); + $bridgeTables = $hasBridgeTables ? $this->normalizeBridgeTables($this->in['bridge_tables'] ?? []) : ($settings['bridge_tables'] ?? []); if ($bridgeUrl && !filter_var($bridgeUrl, FILTER_VALIDATE_URL)) { $this->fail('Ungültige Bridge-URL', null, 422); } - $settings = $this->getCustomerSettings($customerId); if ($rotateBridge || $bridgeToken === '') $bridgeToken = $this->generateToken(); if ($rotateSender || $senderToken === '') $senderToken = $this->generateToken(); if ($rotateExternal || $externalToken === '') $externalToken = $this->generateToken(); @@ -1781,6 +1794,9 @@ class ApiKernel if ($editorDefault !== '' && !in_array($editorDefault, ['grapesjs', 'craftjs'], true)) { $this->fail('Ungültiger Editor-Typ', null, 422); } + if ($listSort !== '' && !in_array($listSort, ['created_asc', 'name_asc', 'name_desc', 'updated_desc'], true)) { + $this->fail('Ungültige Sortierung', null, 422); + } $settings = $this->saveCustomerSettings($customerId, [ 'bridge_url' => $bridgeUrl, @@ -1790,6 +1806,11 @@ class ApiKernel 'editor_default' => $editorDefault ?: null, 'bridge_tables' => $bridgeTables, ]); + if ($hasListSort) { + $this->ensureAuthUserListSortColumn(); + $this->updateUserListSort($user, $customerId, $listSort ?: null); + } + $settings['list_sort'] = $this->resolveUserListSort($user, $customerId, $listSort); $this->respond(['ok' => true, 'settings' => $settings]); } @@ -2734,6 +2755,100 @@ SQL; ]; } + private function ensureAuthUserListSortColumn(): void + { + if (!$this->pdo) { + return; + } + $cols = $this->authUserColumns(); + $table = $cols['table']; + try { + $columns = $this->tableColumns($table); + } catch (Throwable $e) { + $this->fail('User-Tabelle konnte nicht gelesen werden', $e->getMessage(), 500); + return; + } + if (in_array('list_sort', $columns, true)) { + return; + } + try { + $sql = 'ALTER TABLE `' . $table . '` ADD COLUMN `list_sort` varchar(32) DEFAULT NULL'; + $this->pdo->exec($sql); + } catch (Throwable $e) { + $this->fail('User-Tabelle konnte nicht aktualisiert werden', $e->getMessage(), 500); + } + } + + private function getUserListSort(array $user, int $customerId): ?string + { + if (!$this->pdo) { + return null; + } + $userId = (int)($user['id'] ?? 0); + if ($userId <= 0) { + return null; + } + $cols = $this->authUserColumns(); + $table = $cols['table']; + $dbCols = $this->tableColumns($table); + if (!$this->columnExists($dbCols, 'list_sort')) { + return null; + } + $where = sprintf('`%s` = :id', $cols['col_id']); + $params = [':id' => $userId]; + if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) { + $where .= sprintf(' AND `%s` = :cid', $cols['col_customer']); + $params[':cid'] = $customerId; + } + $sql = sprintf('SELECT `list_sort` FROM `%s` WHERE %s LIMIT 1', $table, $where); + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + return $row['list_sort'] !== null ? (string)$row['list_sort'] : null; + } + + private function updateUserListSort(array $user, int $customerId, ?string $value): void + { + if (!$this->pdo) { + return; + } + $userId = (int)($user['id'] ?? 0); + if ($userId <= 0) { + return; + } + $cols = $this->authUserColumns(); + $table = $cols['table']; + $dbCols = $this->tableColumns($table); + if (!$this->columnExists($dbCols, 'list_sort')) { + return; + } + $where = sprintf('`%s` = :id', $cols['col_id']); + $params = [ + ':id' => $userId, + ':value' => $value, + ]; + if ($customerId > 0 && $this->columnExists($dbCols, $cols['col_customer'])) { + $where .= sprintf(' AND `%s` = :cid', $cols['col_customer']); + $params[':cid'] = $customerId; + } + $sql = sprintf('UPDATE `%s` SET `list_sort` = :value WHERE %s LIMIT 1', $table, $where); + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + } + + private function resolveUserListSort(array $user, int $customerId, string $fallback = ''): string + { + $value = $fallback !== '' ? $fallback : (string)($this->getUserListSort($user, $customerId) ?? ''); + $allowed = ['created_asc', 'name_asc', 'name_desc', 'updated_desc']; + if ($value === '' || !in_array($value, $allowed, true)) { + return 'created_asc'; + } + return $value; + } + private function ensureAuthUserHydrated(array $user): array { $role = (string)($user['role'] ?? '');