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 { 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 container: HTMLElement | undefined = $state();
|
||||
|
||||
@@ -53,6 +57,7 @@
|
||||
<button
|
||||
class="trigger"
|
||||
class:filtered={searchFilterStore.isFiltered}
|
||||
class:inline
|
||||
type="button"
|
||||
aria-label="Suchfilter"
|
||||
aria-haspopup="menu"
|
||||
@@ -136,6 +141,22 @@
|
||||
background: #eaf4ed;
|
||||
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 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -44,3 +44,35 @@ export function setDomainFavicon(
|
||||
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>
|
||||
<p class="tagline" aria-live="polite">{quote || '\u00a0'}</p>
|
||||
<form class="search-form" onsubmit={submit}>
|
||||
<SearchFilter />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder="Rezept suchen…"
|
||||
autocomplete="off"
|
||||
inputmode="search"
|
||||
aria-label="Suchbegriff"
|
||||
/>
|
||||
<div class="search-box">
|
||||
<SearchFilter inline />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder="Rezept suchen…"
|
||||
autocomplete="off"
|
||||
inputmode="search"
|
||||
aria-label="Suchbegriff"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -444,20 +446,31 @@
|
||||
}
|
||||
form {
|
||||
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'] {
|
||||
flex: 1;
|
||||
padding: 0.9rem 1rem;
|
||||
font-size: 1.1rem;
|
||||
border: 1px solid #cfd9d1;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
input[type='search']:focus {
|
||||
outline: 2px solid #2b6a3d;
|
||||
outline-offset: 1px;
|
||||
outline: none;
|
||||
}
|
||||
.results,
|
||||
.listing {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Pencil, Check, X, Globe } from 'lucide-svelte';
|
||||
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 loading = $state(true);
|
||||
@@ -10,6 +11,11 @@
|
||||
let adding = $state(false);
|
||||
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() {
|
||||
const res = await fetch('/api/domains');
|
||||
domains = await res.json();
|
||||
@@ -39,6 +45,45 @@
|
||||
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) {
|
||||
const ok = await confirmAction({
|
||||
title: 'Domain entfernen?',
|
||||
@@ -83,11 +128,59 @@
|
||||
<ul class="list">
|
||||
{#each domains as d (d.id)}
|
||||
<li>
|
||||
<div>
|
||||
<div class="dom">{d.domain}</div>
|
||||
{#if d.display_name}<div class="label">{d.display_name}</div>{/if}
|
||||
</div>
|
||||
<button class="btn danger" onclick={() => remove(d)}>Löschen</button>
|
||||
{#if d.favicon_path}
|
||||
<img class="fav" src={`/images/${d.favicon_path}`} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<span class="fav fallback" aria-hidden="true"><Globe size={18} strokeWidth={1.8} /></span>
|
||||
{/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>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -150,11 +243,15 @@
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #e4eae7;
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dom {
|
||||
font-weight: 600;
|
||||
@@ -163,6 +260,48 @@
|
||||
color: #888;
|
||||
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 {
|
||||
color: #c53030;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
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 }) => {
|
||||
const id = Number(params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) error(400, { message: 'Invalid id' });
|
||||
const id = parseId(params.id!);
|
||||
removeDomain(getDb(), id);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
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';
|
||||
|
||||
describe('allowed domains', () => {
|
||||
@@ -32,4 +39,35 @@ describe('allowed domains', () => {
|
||||
removeDomain(db, d.id);
|
||||
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