refactor(client): requireProfile() + asyncFetch wrapper

requireProfile():
- src/lib/client/profile.svelte.ts: neuer Helper, returnt das aktive
  Profile oder null nach standardisiertem alertAction
- 5x in recipes/[id]/+page.svelte: setRating, toggleFavorite, logCooked,
  addComment, toggleWishlist verlieren je 7 Zeilen Guard-Klausel
- profile-Variable im Closure macht den ! am profileStore.active obsolet

asyncFetch():
- src/lib/client/api-fetch-wrapper.ts: returnt Response auf 2xx, null
  nach alertAction auf Fehler
- 4 Call-Sites umgestellt: saveRecipe + saveTitle (recipes/[id]),
  saveEdit (admin/domains), rename (admin/profiles)
- admin/domains add() bewusst nicht migriert — inline-Error-UX statt Modal

Findings aus REVIEW-2026-04-18.md (Quick-Win 5) und redundancy.md
This commit is contained in:
hsiegeln
2026-04-18 22:22:19 +02:00
parent ff293e9db8
commit 30a447a3ea
5 changed files with 114 additions and 110 deletions

View File

@@ -0,0 +1,25 @@
import { alertAction } from '$lib/client/confirm.svelte';
/**
* Fetch wrapper for actions where a non-OK response should pop a modal
* via alertAction(). Returns the Response on 2xx, or null after showing
* the alert. Caller should `if (!res) return;` after the call.
*
* Use this for *interactive* actions (rename, delete, save). For form
* submissions where the error should appear inline next to the field
* (e.g. admin/domains add()), keep manual handling.
*/
export async function asyncFetch(
url: string,
init: RequestInit | undefined,
errorTitle: string
): Promise<Response | null> {
const res = await fetch(url, init);
if (res.ok) return res;
const body = (await res.json().catch(() => null)) as { message?: string } | null;
await alertAction({
title: errorTitle,
message: body?.message ?? `HTTP ${res.status}`
});
return null;
}

View File

@@ -1,4 +1,5 @@
import type { Profile } from '$lib/types'; import type { Profile } from '$lib/types';
import { alertAction } from '$lib/client/confirm.svelte';
const STORAGE_KEY = 'kochwas.activeProfileId'; const STORAGE_KEY = 'kochwas.activeProfileId';
@@ -60,3 +61,17 @@ class ProfileStore {
} }
export const profileStore = new ProfileStore(); export const profileStore = new ProfileStore();
/**
* Returns the active profile, or null after showing the standard
* "kein Profil gewählt" dialog. Use as the first line of any per-profile
* action so we don't repeat the guard at every call-site.
*/
export async function requireProfile(): Promise<Profile | null> {
if (profileStore.active) return profileStore.active;
await alertAction({
title: 'Kein Profil gewählt',
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
});
return null;
}

View File

@@ -2,7 +2,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Pencil, Check, X, Globe } from 'lucide-svelte'; import { Pencil, Check, X, Globe } from 'lucide-svelte';
import type { AllowedDomain } from '$lib/types'; import type { AllowedDomain } from '$lib/types';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online'; import { requireOnline } from '$lib/client/require-online';
let domains = $state<AllowedDomain[]>([]); let domains = $state<AllowedDomain[]>([]);
@@ -64,22 +65,19 @@
if (!requireOnline('Das Speichern')) return; if (!requireOnline('Das Speichern')) return;
saving = true; saving = true;
try { try {
const res = await fetch(`/api/domains/${d.id}`, { const res = await asyncFetch(
method: 'PATCH', `/api/domains/${d.id}`,
headers: { 'content-type': 'application/json' }, {
body: JSON.stringify({ method: 'PATCH',
domain: editDomain.trim(), headers: { 'content-type': 'application/json' },
display_name: editLabel.trim() || null body: JSON.stringify({
}) domain: editDomain.trim(),
}); display_name: editLabel.trim() || null
if (!res.ok) { })
const body = await res.json().catch(() => ({})); },
await alertAction({ 'Speichern fehlgeschlagen'
title: 'Speichern fehlgeschlagen', );
message: body.message ?? `HTTP ${res.status}` if (!res) return;
});
return;
}
cancelEdit(); cancelEdit();
await load(); await load();
} finally { } finally {

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { profileStore } from '$lib/client/profile.svelte'; import { profileStore } from '$lib/client/profile.svelte';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online'; import { requireOnline } from '$lib/client/require-online';
let newName = $state(''); let newName = $state('');
@@ -27,19 +28,16 @@
const next = prompt('Neuer Name:', currentName); const next = prompt('Neuer Name:', currentName);
if (!next || next === currentName) return; if (!next || next === currentName) return;
if (!requireOnline('Das Umbenennen')) return; if (!requireOnline('Das Umbenennen')) return;
const res = await fetch(`/api/profiles/${id}`, { const res = await asyncFetch(
method: 'PATCH', `/api/profiles/${id}`,
headers: { 'content-type': 'application/json' }, {
body: JSON.stringify({ name: next.trim() }) method: 'PATCH',
}); headers: { 'content-type': 'application/json' },
if (!res.ok) { body: JSON.stringify({ name: next.trim() })
const body = await res.json().catch(() => ({})); },
await alertAction({ 'Umbenennen fehlgeschlagen'
title: 'Umbenennen fehlgeschlagen', );
message: body.message ?? `HTTP ${res.status}` if (!res) return;
});
return;
}
await profileStore.load(); await profileStore.load();
} }

View File

@@ -17,9 +17,10 @@
import RecipeView from '$lib/components/RecipeView.svelte'; import RecipeView from '$lib/components/RecipeView.svelte';
import RecipeEditor from '$lib/components/RecipeEditor.svelte'; import RecipeEditor from '$lib/components/RecipeEditor.svelte';
import StarRating from '$lib/components/StarRating.svelte'; import StarRating from '$lib/components/StarRating.svelte';
import { profileStore } from '$lib/client/profile.svelte'; import { profileStore, requireProfile } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte'; import { wishlistStore } from '$lib/client/wishlist.svelte';
import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online'; import { requireOnline } from '$lib/client/require-online';
import type { CommentRow } from '$lib/server/recipes/actions'; import type { CommentRow } from '$lib/server/recipes/actions';
@@ -73,19 +74,16 @@
if (!requireOnline('Das Speichern')) return; if (!requireOnline('Das Speichern')) return;
saving = true; saving = true;
try { try {
const res = await fetch(`/api/recipes/${data.recipe.id}`, { const res = await asyncFetch(
method: 'PATCH', `/api/recipes/${data.recipe.id}`,
headers: { 'content-type': 'application/json' }, {
body: JSON.stringify(patch) method: 'PATCH',
}); headers: { 'content-type': 'application/json' },
if (!res.ok) { body: JSON.stringify(patch)
const body = await res.json().catch(() => ({})); },
await alertAction({ 'Speichern fehlgeschlagen'
title: 'Speichern fehlgeschlagen', );
message: body.message ?? `HTTP ${res.status}` if (!res) return;
});
return;
}
const body = await res.json(); const body = await res.json();
if (body.recipe) { if (body.recipe) {
recipeState = body.recipe; recipeState = body.recipe;
@@ -122,60 +120,44 @@
); );
async function setRating(stars: number) { async function setRating(stars: number) {
if (!profileStore.active) { const profile = await requireProfile();
await alertAction({ if (!profile) return;
title: 'Kein Profil gewählt',
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
});
return;
}
if (!requireOnline('Das Rating')) return; if (!requireOnline('Das Rating')) return;
await fetch(`/api/recipes/${data.recipe.id}/rating`, { await fetch(`/api/recipes/${data.recipe.id}/rating`, {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileStore.active.id, stars }) body: JSON.stringify({ profile_id: profile.id, stars })
}); });
const existing = ratings.find((r) => r.profile_id === profileStore.active!.id); const existing = ratings.find((r) => r.profile_id === profile.id);
if (existing) existing.stars = stars; if (existing) existing.stars = stars;
else ratings = [...ratings, { profile_id: profileStore.active.id, stars }]; else ratings = [...ratings, { profile_id: profile.id, stars }];
} }
async function toggleFavorite() { async function toggleFavorite() {
if (!profileStore.active) { const profile = await requireProfile();
await alertAction({ if (!profile) return;
title: 'Kein Profil gewählt',
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
});
return;
}
if (!requireOnline('Das Favorit-Setzen')) return; if (!requireOnline('Das Favorit-Setzen')) return;
const profileId = profileStore.active.id;
const wasFav = isFav; const wasFav = isFav;
const method = wasFav ? 'DELETE' : 'PUT'; const method = wasFav ? 'DELETE' : 'PUT';
await fetch(`/api/recipes/${data.recipe.id}/favorite`, { await fetch(`/api/recipes/${data.recipe.id}/favorite`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileId }) body: JSON.stringify({ profile_id: profile.id })
}); });
favoriteProfileIds = wasFav favoriteProfileIds = wasFav
? favoriteProfileIds.filter((id) => id !== profileId) ? favoriteProfileIds.filter((id) => id !== profile.id)
: [...favoriteProfileIds, profileId]; : [...favoriteProfileIds, profile.id];
if (!wasFav) void firePulse('fav'); if (!wasFav) void firePulse('fav');
} }
async function logCooked() { async function logCooked() {
if (!profileStore.active) { const profile = await requireProfile();
await alertAction({ if (!profile) return;
title: 'Kein Profil gewählt',
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
});
return;
}
if (!requireOnline('Der Kochjournal-Eintrag')) return; if (!requireOnline('Der Kochjournal-Eintrag')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, { const res = await fetch(`/api/recipes/${data.recipe.id}/cooked`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileStore.active.id }) body: JSON.stringify({ profile_id: profile.id })
}); });
const entry = await res.json(); const entry = await res.json();
cookingLog = [entry, ...cookingLog]; cookingLog = [entry, ...cookingLog];
@@ -186,20 +168,15 @@
} }
async function addComment() { async function addComment() {
if (!profileStore.active) { const profile = await requireProfile();
await alertAction({ if (!profile) return;
title: 'Kein Profil gewählt',
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
});
return;
}
if (!requireOnline('Das Speichern des Kommentars')) return; if (!requireOnline('Das Speichern des Kommentars')) return;
const text = newComment.trim(); const text = newComment.trim();
if (!text) return; if (!text) return;
const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, { const res = await fetch(`/api/recipes/${data.recipe.id}/comments`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ profile_id: profileStore.active.id, text }) body: JSON.stringify({ profile_id: profile.id, text })
}); });
if (res.ok) { if (res.ok) {
const body = await res.json(); const body = await res.json();
@@ -207,10 +184,10 @@
...comments, ...comments,
{ {
id: body.id, id: body.id,
profile_id: profileStore.active.id, profile_id: profile.id,
text, text,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
author: profileStore.active.name author: profile.name
} }
]; ];
newComment = ''; newComment = '';
@@ -250,19 +227,16 @@
return; return;
} }
if (!requireOnline('Das Umbenennen')) return; if (!requireOnline('Das Umbenennen')) return;
const res = await fetch(`/api/recipes/${data.recipe.id}`, { const res = await asyncFetch(
method: 'PATCH', `/api/recipes/${data.recipe.id}`,
headers: { 'content-type': 'application/json' }, {
body: JSON.stringify({ title: next }) method: 'PATCH',
}); headers: { 'content-type': 'application/json' },
if (!res.ok) { body: JSON.stringify({ title: next })
const body = await res.json().catch(() => ({})); },
await alertAction({ 'Umbenennen fehlgeschlagen'
title: 'Umbenennen fehlgeschlagen', );
message: body.message ?? `HTTP ${res.status}` if (!res) return;
});
return;
}
title = next; title = next;
editingTitle = false; editingTitle = false;
} }
@@ -278,28 +252,22 @@
} }
async function toggleWishlist() { async function toggleWishlist() {
if (!profileStore.active) { const profile = await requireProfile();
await alertAction({ if (!profile) return;
title: 'Kein Profil gewählt',
message: 'Tippe oben rechts auf „Profil wählen", dann klappt die Aktion.'
});
return;
}
if (!requireOnline('Das Wunschlisten-Setzen')) return; if (!requireOnline('Das Wunschlisten-Setzen')) return;
const profileId = profileStore.active.id;
const wasOn = onMyWishlist; const wasOn = onMyWishlist;
if (wasOn) { if (wasOn) {
await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profileId}`, { await fetch(`/api/wishlist/${data.recipe.id}?profile_id=${profile.id}`, {
method: 'DELETE' method: 'DELETE'
}); });
wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profileId); wishlistProfileIds = wishlistProfileIds.filter((id) => id !== profile.id);
} else { } else {
await fetch('/api/wishlist', { await fetch('/api/wishlist', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profileId }) body: JSON.stringify({ recipe_id: data.recipe.id, profile_id: profile.id })
}); });
wishlistProfileIds = [...wishlistProfileIds, profileId]; wishlistProfileIds = [...wishlistProfileIds, profile.id];
} }
void wishlistStore.refresh(); void wishlistStore.refresh();
if (!wasOn) void firePulse('wish'); if (!wasOn) void firePulse('wish');