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 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user