feat(domains): Inline-Edit + Favicon in Settings + Filter IN Suchmaske
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m17s
Domain-Admin-Seite bekommt jetzt ein Favicon-Icon vor jedem Eintrag, einen Pencil-Button zum Inline-Editieren von Domain und Anzeigename, und Save/Cancel-Buttons. Beim Ändern des Domain-Namens wird das Favicon zurückgesetzt und beim Speichern frisch nachgeladen (den Filter-Dropdown- Icons reicht der neue favicon_path automatisch zu). Der Filter-Button auf der Hauptseite sitzt jetzt IM weißen Suchfeld- Container (neuer .search-box-Wrapper mit Border) statt daneben, analog zum Referenz-Screenshot von rezeptwelt.de. Neue inline-Prop an SearchFilter schaltet eigenen Border/Background ab und setzt stattdessen einen vertikalen Divider nach rechts. - Neuer PATCH /api/domains/[id] mit zod-Schema. - Repository: updateDomain(id, patch) + getDomainById(id). domain-Change nullt favicon_path → Caller lädt neu. - Tests für updateDomain-Fälle und getDomainById. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
import { SlidersHorizontal, Check, ChevronDown } from 'lucide-svelte';
|
import { SlidersHorizontal, Check, ChevronDown } from 'lucide-svelte';
|
||||||
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||||
|
|
||||||
|
// inline: Button wird transparent und ohne eigenen Border gestylt,
|
||||||
|
// damit er sich in einen umgebenden Such-Container einpassen lässt.
|
||||||
|
let { inline = false }: { inline?: boolean } = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let container: HTMLElement | undefined = $state();
|
let container: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
<button
|
<button
|
||||||
class="trigger"
|
class="trigger"
|
||||||
class:filtered={searchFilterStore.isFiltered}
|
class:filtered={searchFilterStore.isFiltered}
|
||||||
|
class:inline
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Suchfilter"
|
aria-label="Suchfilter"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
@@ -136,6 +141,22 @@
|
|||||||
background: #eaf4ed;
|
background: #eaf4ed;
|
||||||
border-color: #2b6a3d;
|
border-color: #2b6a3d;
|
||||||
}
|
}
|
||||||
|
/* In der Suchmaske: kein eigener Rahmen/Hintergrund, der Container drumherum
|
||||||
|
trägt die visuelle Form. */
|
||||||
|
.trigger.inline {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid #e4eae7;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.5rem 0.85rem 0.5rem 0.65rem;
|
||||||
|
}
|
||||||
|
.trigger.inline.filtered {
|
||||||
|
background: transparent;
|
||||||
|
color: #2b6a3d;
|
||||||
|
}
|
||||||
|
.trigger.inline:hover {
|
||||||
|
background: rgba(43, 106, 61, 0.06);
|
||||||
|
}
|
||||||
.badge {
|
.badge {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,35 @@ export function setDomainFavicon(
|
|||||||
id
|
id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDomainById(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number
|
||||||
|
): AllowedDomain | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, domain, display_name, favicon_path FROM allowed_domain WHERE id = ?'
|
||||||
|
)
|
||||||
|
.get(id) as AllowedDomain | undefined;
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDomain(
|
||||||
|
db: Database.Database,
|
||||||
|
id: number,
|
||||||
|
patch: { domain?: string; display_name?: string | null }
|
||||||
|
): AllowedDomain | null {
|
||||||
|
const current = getDomainById(db, id);
|
||||||
|
if (!current) return null;
|
||||||
|
const nextDomain =
|
||||||
|
patch.domain !== undefined ? normalizeDomain(patch.domain) : current.domain;
|
||||||
|
const nextLabel =
|
||||||
|
patch.display_name !== undefined ? patch.display_name : current.display_name;
|
||||||
|
// Wenn sich die Domain ändert: favicon_path zurücksetzen, damit der Caller
|
||||||
|
// es neu laden kann. Sonst zeigen wir fälschlich das alte Icon.
|
||||||
|
const nextFavicon = nextDomain !== current.domain ? null : current.favicon_path;
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE allowed_domain SET domain = ?, display_name = ?, favicon_path = ? WHERE id = ?'
|
||||||
|
).run(nextDomain, nextLabel, nextFavicon, id);
|
||||||
|
return getDomainById(db, id);
|
||||||
|
}
|
||||||
|
|||||||
@@ -281,15 +281,17 @@
|
|||||||
<h1>Kochwas</h1>
|
<h1>Kochwas</h1>
|
||||||
<p class="tagline" aria-live="polite">{quote || '\u00a0'}</p>
|
<p class="tagline" aria-live="polite">{quote || '\u00a0'}</p>
|
||||||
<form class="search-form" onsubmit={submit}>
|
<form class="search-form" onsubmit={submit}>
|
||||||
<SearchFilter />
|
<div class="search-box">
|
||||||
<input
|
<SearchFilter inline />
|
||||||
type="search"
|
<input
|
||||||
bind:value={query}
|
type="search"
|
||||||
placeholder="Rezept suchen…"
|
bind:value={query}
|
||||||
autocomplete="off"
|
placeholder="Rezept suchen…"
|
||||||
inputmode="search"
|
autocomplete="off"
|
||||||
aria-label="Suchbegriff"
|
inputmode="search"
|
||||||
/>
|
aria-label="Suchbegriff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -444,20 +446,31 @@
|
|||||||
}
|
}
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
}
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
.search-box:focus-within {
|
||||||
|
outline: 2px solid #2b6a3d;
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
border: 1px solid #cfd9d1;
|
border: 0;
|
||||||
border-radius: 10px;
|
background: transparent;
|
||||||
background: white;
|
min-width: 0;
|
||||||
min-height: 48px;
|
|
||||||
}
|
}
|
||||||
input[type='search']:focus {
|
input[type='search']:focus {
|
||||||
outline: 2px solid #2b6a3d;
|
outline: none;
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
}
|
||||||
.results,
|
.results,
|
||||||
.listing {
|
.listing {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
||||||
import type { AllowedDomain } from '$lib/types';
|
import type { AllowedDomain } from '$lib/types';
|
||||||
import { confirmAction } from '$lib/client/confirm.svelte';
|
import { confirmAction, alertAction } from '$lib/client/confirm.svelte';
|
||||||
|
|
||||||
let domains = $state<AllowedDomain[]>([]);
|
let domains = $state<AllowedDomain[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -10,6 +11,11 @@
|
|||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
let errored = $state<string | null>(null);
|
let errored = $state<string | null>(null);
|
||||||
|
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let editDomain = $state('');
|
||||||
|
let editLabel = $state('');
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await fetch('/api/domains');
|
const res = await fetch('/api/domains');
|
||||||
domains = await res.json();
|
domains = await res.json();
|
||||||
@@ -39,6 +45,45 @@
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEdit(d: AllowedDomain) {
|
||||||
|
editingId = d.id;
|
||||||
|
editDomain = d.domain;
|
||||||
|
editLabel = d.display_name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editDomain = '';
|
||||||
|
editLabel = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(d: AllowedDomain) {
|
||||||
|
if (!editDomain.trim()) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/domains/${d.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: editDomain.trim(),
|
||||||
|
display_name: editLabel.trim() || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
await alertAction({
|
||||||
|
title: 'Speichern fehlgeschlagen',
|
||||||
|
message: body.message ?? `HTTP ${res.status}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelEdit();
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(d: AllowedDomain) {
|
async function remove(d: AllowedDomain) {
|
||||||
const ok = await confirmAction({
|
const ok = await confirmAction({
|
||||||
title: 'Domain entfernen?',
|
title: 'Domain entfernen?',
|
||||||
@@ -83,11 +128,59 @@
|
|||||||
<ul class="list">
|
<ul class="list">
|
||||||
{#each domains as d (d.id)}
|
{#each domains as d (d.id)}
|
||||||
<li>
|
<li>
|
||||||
<div>
|
{#if d.favicon_path}
|
||||||
<div class="dom">{d.domain}</div>
|
<img class="fav" src={`/images/${d.favicon_path}`} alt="" loading="lazy" />
|
||||||
{#if d.display_name}<div class="label">{d.display_name}</div>{/if}
|
{:else}
|
||||||
</div>
|
<span class="fav fallback" aria-hidden="true"><Globe size={18} strokeWidth={1.8} /></span>
|
||||||
<button class="btn danger" onclick={() => remove(d)}>Löschen</button>
|
{/if}
|
||||||
|
{#if editingId === d.id}
|
||||||
|
<div class="edit-fields">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editDomain}
|
||||||
|
placeholder="chefkoch.de"
|
||||||
|
aria-label="Domain"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editLabel}
|
||||||
|
placeholder="Anzeigename (optional)"
|
||||||
|
aria-label="Anzeigename"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
class="btn primary icon-btn"
|
||||||
|
aria-label="Speichern"
|
||||||
|
disabled={saving}
|
||||||
|
onclick={() => saveEdit(d)}
|
||||||
|
>
|
||||||
|
<Check size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn icon-btn"
|
||||||
|
aria-label="Abbrechen"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="info">
|
||||||
|
<div class="dom">{d.domain}</div>
|
||||||
|
{#if d.display_name}<div class="label">{d.display_name}</div>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
class="btn icon-btn"
|
||||||
|
aria-label="Bearbeiten"
|
||||||
|
onclick={() => startEdit(d)}
|
||||||
|
>
|
||||||
|
<Pencil size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button class="btn danger" onclick={() => remove(d)}>Löschen</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -150,11 +243,15 @@
|
|||||||
.list li {
|
.list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.75rem;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #e4eae7;
|
border: 1px solid #e4eae7;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.7rem 0.85rem;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.dom {
|
.dom {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -163,6 +260,48 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.fav {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.fav.fallback {
|
||||||
|
background: #eef3ef;
|
||||||
|
color: #8fb097;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.edit-fields {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.edit-fields input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px solid #cfd9d1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
.error {
|
.error {
|
||||||
color: #c53030;
|
color: #c53030;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { z } from 'zod';
|
||||||
import { getDb } from '$lib/server/db';
|
import { getDb } from '$lib/server/db';
|
||||||
import { removeDomain } from '$lib/server/domains/repository';
|
import {
|
||||||
|
removeDomain,
|
||||||
|
updateDomain,
|
||||||
|
setDomainFavicon
|
||||||
|
} from '$lib/server/domains/repository';
|
||||||
|
import { fetchAndStoreFavicon } from '$lib/server/domains/favicons';
|
||||||
|
|
||||||
|
const IMAGE_DIR = process.env.IMAGE_DIR ?? './data/images';
|
||||||
|
|
||||||
|
const UpdateSchema = z.object({
|
||||||
|
domain: z.string().min(3).max(253).optional(),
|
||||||
|
display_name: z.string().max(100).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseId(raw: string): number {
|
||||||
|
const id = Number(raw);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATCH: RequestHandler = async ({ params, request }) => {
|
||||||
|
const id = parseId(params.id!);
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = UpdateSchema.safeParse(body);
|
||||||
|
if (!parsed.success) error(400, { message: 'Invalid body' });
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const updated = updateDomain(db, id, parsed.data);
|
||||||
|
if (!updated) error(404, { message: 'Not found' });
|
||||||
|
// Wenn updateDomain favicon_path genullt hat (Domain geändert), frisch laden.
|
||||||
|
if (updated.favicon_path === null) {
|
||||||
|
const path = await fetchAndStoreFavicon(updated.domain, IMAGE_DIR);
|
||||||
|
if (path) {
|
||||||
|
setDomainFavicon(db, updated.id, path);
|
||||||
|
updated.favicon_path = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(updated);
|
||||||
|
} catch (e) {
|
||||||
|
error(409, { message: (e as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params }) => {
|
export const DELETE: RequestHandler = async ({ params }) => {
|
||||||
const id = Number(params.id);
|
const id = parseId(params.id!);
|
||||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
|
||||||
removeDomain(getDb(), id);
|
removeDomain(getDb(), id);
|
||||||
return json({ ok: true });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||||
import { addDomain, listDomains, removeDomain } from '../../src/lib/server/domains/repository';
|
import {
|
||||||
|
addDomain,
|
||||||
|
listDomains,
|
||||||
|
removeDomain,
|
||||||
|
setDomainFavicon,
|
||||||
|
updateDomain,
|
||||||
|
getDomainById
|
||||||
|
} from '../../src/lib/server/domains/repository';
|
||||||
import { isDomainAllowed } from '../../src/lib/server/domains/whitelist';
|
import { isDomainAllowed } from '../../src/lib/server/domains/whitelist';
|
||||||
|
|
||||||
describe('allowed domains', () => {
|
describe('allowed domains', () => {
|
||||||
@@ -32,4 +39,35 @@ describe('allowed domains', () => {
|
|||||||
removeDomain(db, d.id);
|
removeDomain(db, d.id);
|
||||||
expect(listDomains(db)).toEqual([]);
|
expect(listDomains(db)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updateDomain changes label without touching favicon', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const d = addDomain(db, 'chefkoch.de', 'Chefkoch');
|
||||||
|
setDomainFavicon(db, d.id, 'favicon-abc.png');
|
||||||
|
const updated = updateDomain(db, d.id, { display_name: 'Chefkoch.de' });
|
||||||
|
expect(updated?.domain).toBe('chefkoch.de');
|
||||||
|
expect(updated?.display_name).toBe('Chefkoch.de');
|
||||||
|
expect(updated?.favicon_path).toBe('favicon-abc.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateDomain resets favicon when the domain itself changes', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const d = addDomain(db, 'chefkoch.de');
|
||||||
|
setDomainFavicon(db, d.id, 'favicon-abc.png');
|
||||||
|
const updated = updateDomain(db, d.id, { domain: 'rezeptwelt.de' });
|
||||||
|
expect(updated?.domain).toBe('rezeptwelt.de');
|
||||||
|
expect(updated?.favicon_path).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateDomain returns null for missing id', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
expect(updateDomain(db, 999, { domain: 'x.com' })).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDomainById fetches single row', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const d = addDomain(db, 'chefkoch.de');
|
||||||
|
expect(getDomainById(db, d.id)?.domain).toBe('chefkoch.de');
|
||||||
|
expect(getDomainById(db, 999)).toBe(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user