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 { alertAction } from '$lib/client/confirm.svelte';
const STORAGE_KEY = 'kochwas.activeProfileId';
@@ -60,3 +61,17 @@ class 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 { Pencil, Check, X, Globe } from 'lucide-svelte';
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';
let domains = $state<AllowedDomain[]>([]);
@@ -64,22 +65,19 @@
if (!requireOnline('Das Speichern')) return;
saving = true;
try {
const res = await fetch(`/api/domains/${d.id}`, {
const res = await asyncFetch(
`/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;
}
},
'Speichern fehlgeschlagen'
);
if (!res) return;
cancelEdit();
await load();
} finally {

View File

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

View File

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