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:
25
src/lib/client/api-fetch-wrapper.ts
Normal file
25
src/lib/client/api-fetch-wrapper.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`, {
|
||||
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;
|
||||
}
|
||||
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
|
||||
})
|
||||
},
|
||||
'Speichern fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
cancelEdit();
|
||||
await load();
|
||||
} finally {
|
||||
|
||||
@@ -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}`, {
|
||||
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;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/profiles/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: next.trim() })
|
||||
},
|
||||
'Umbenennen fehlgeschlagen'
|
||||
);
|
||||
if (!res) return;
|
||||
await profileStore.load();
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`, {
|
||||
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;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
},
|
||||
'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}`, {
|
||||
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;
|
||||
}
|
||||
const res = await asyncFetch(
|
||||
`/api/recipes/${data.recipe.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: next })
|
||||
},
|
||||
'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');
|
||||
|
||||
Reference in New Issue
Block a user