Last Used implementiert
This commit is contained in:
@@ -633,6 +633,42 @@
|
||||
padding: 40px;
|
||||
}
|
||||
}
|
||||
/* --- Cook Date Modal (appended) --- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background: #fff;
|
||||
padding: 24px 28px;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 10px 30px -5px rgba(0,0,0,0.35);
|
||||
animation: fadeIn 0.18s ease-out;
|
||||
}
|
||||
|
||||
.modal-dialog h3 { margin: 0 0 12px; font-size: 1.25rem; }
|
||||
.modal-field-group { margin-bottom: 16px; }
|
||||
.modal-field-group label { display:block; font-size: 0.75rem; letter-spacing: .5px; font-weight:600; margin-bottom:4px; color:#555; text-transform: uppercase; }
|
||||
.modal-field-group input { width: 100%; padding: 8px 10px; border: 1px solid #cbd5e0; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; }
|
||||
.modal-field-inline { display: flex; gap: 10px; }
|
||||
.modal-field-inline .modal-field-group { flex: 1; margin-bottom: 0; }
|
||||
|
||||
.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top: 18px; }
|
||||
.modal-actions button { padding:8px 14px; border:none; border-radius:6px; cursor:pointer; font-weight:500; font-size: 0.9rem; }
|
||||
.modal-actions .primary { background:#2f855a; color:#fff; }
|
||||
.modal-actions .primary:hover { background:#276749; }
|
||||
.modal-actions .secondary { background:#e2e8f0; }
|
||||
.modal-actions .secondary:hover { background:#cbd5e0; }
|
||||
.modal-actions .danger { background:#dc3545; color:#fff; }
|
||||
.modal-actions .danger:hover { background:#c82333; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recipe-detail {
|
||||
|
||||
@@ -36,6 +36,9 @@ const RecipeDetail: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingImages, setEditingImages] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [markingCooked, setMarkingCooked] = useState(false);
|
||||
const [showCookModal, setShowCookModal] = useState(false);
|
||||
const [cookDate, setCookDate] = useState<string>(''); // YYYY-MM-DD
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecipe = async () => {
|
||||
@@ -124,6 +127,43 @@ const RecipeDetail: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openCookModal = () => {
|
||||
if (!recipe) return;
|
||||
const now = new Date();
|
||||
const isoDate = now.toISOString().slice(0,10); // YYYY-MM-DD
|
||||
setCookDate(isoDate);
|
||||
setShowCookModal(true);
|
||||
};
|
||||
|
||||
const submitCooked = async () => {
|
||||
if (!recipe) return;
|
||||
if (cookDate) {
|
||||
const d = new Date(`${cookDate}T00:00:00`);
|
||||
if (d.getTime() > Date.now() + 60_000) {
|
||||
setError('Datum liegt in der Zukunft');
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
setMarkingCooked(true);
|
||||
let iso: string | undefined = undefined;
|
||||
if (cookDate) {
|
||||
const combined = new Date(`${cookDate}T00:00:00`);
|
||||
if (!isNaN(combined.getTime())) iso = combined.toISOString();
|
||||
}
|
||||
const resp = await recipeApi.markRecipeCooked(recipe.id, iso);
|
||||
if (resp.success) {
|
||||
setRecipe({ ...recipe, lastUsed: resp.data.lastUsed || undefined });
|
||||
setShowCookModal(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Mark cooked failed', e);
|
||||
setError('Fehler beim Speichern des Datums');
|
||||
} finally {
|
||||
setMarkingCooked(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="recipe-detail">
|
||||
@@ -192,6 +232,14 @@ const RecipeDetail: React.FC = () => {
|
||||
>
|
||||
📸 {editingImages ? 'Fertig' : 'Bilder verwalten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={openCookModal}
|
||||
className="edit-button"
|
||||
title="Datum der letzten Zubereitung setzen"
|
||||
style={{ marginRight: '8px', background: '#2f855a' }}
|
||||
>
|
||||
🍳 {recipe.lastUsed ? 'Zubereitet ändern' : 'Zubereitet'}
|
||||
</button>
|
||||
<Link to={`/recipes/${recipe.id}/edit`} className="edit-button">
|
||||
✏️ Bearbeiten
|
||||
</Link>
|
||||
@@ -200,6 +248,24 @@ const RecipeDetail: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showCookModal && (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div className="modal-dialog">
|
||||
<h3>Datum der Zubereitung</h3>
|
||||
<div className="modal-field-group">
|
||||
<label htmlFor="cook-date">Datum</label>
|
||||
<input id="cook-date" type="date" value={cookDate} onChange={e => setCookDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
{recipe.lastUsed && (
|
||||
<button className="danger" disabled={markingCooked} onClick={async () => { try { setMarkingCooked(true); const resp = await recipeApi.markRecipeCooked(recipe.id, undefined); if (resp.success) { setRecipe({ ...recipe, lastUsed: resp.data.lastUsed || undefined }); } } finally { setMarkingCooked(false); setShowCookModal(false);} }}>Jetzt setzen</button>
|
||||
)}
|
||||
<button className="secondary" onClick={() => setShowCookModal(false)} disabled={markingCooked}>Abbrechen</button>
|
||||
<button className="primary" onClick={submitCooked} disabled={markingCooked}>{markingCooked ? 'Speichere...' : 'Speichern'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="recipe-content">
|
||||
@@ -249,6 +315,14 @@ const RecipeDetail: React.FC = () => {
|
||||
<span className="meta-label">Portionen:</span>
|
||||
<span className="meta-value">👥 {recipe.servings}</span>
|
||||
</div>
|
||||
{recipe.lastUsed && (
|
||||
<div className="meta-item">
|
||||
<span className="meta-label">Zuletzt zubereitet:</span>
|
||||
<span className="meta-value">
|
||||
{new Date(recipe.lastUsed).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -198,6 +198,29 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.recipe-lastused {
|
||||
background: #2f855a;
|
||||
color: #fff;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.recipe-never {
|
||||
background: #e53e3e;
|
||||
color: #fff;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -29,6 +29,8 @@ const RecipeList: React.FC = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<'title' | 'lastUsed'>('title');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
@@ -50,8 +52,8 @@ const RecipeList: React.FC = () => {
|
||||
category: category || undefined,
|
||||
page,
|
||||
limit: 12,
|
||||
sortBy: 'title',
|
||||
sortOrder: 'asc',
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
@@ -71,7 +73,7 @@ const RecipeList: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
loadRecipes();
|
||||
}, [debouncedSearch, category, page]);
|
||||
}, [debouncedSearch, category, page, sortBy, sortOrder]);
|
||||
|
||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -140,6 +142,24 @@ const RecipeList: React.FC = () => {
|
||||
<option value="Fisch">Fisch</option>
|
||||
<option value="Dessert">Dessert</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => { setSortBy(e.target.value as any); setPage(1); }}
|
||||
className="category-select"
|
||||
style={{ marginLeft: '8px' }}
|
||||
>
|
||||
<option value="title">Titel</option>
|
||||
<option value="lastUsed">Zuletzt zubereitet</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setPage(1); }}
|
||||
className="search-button"
|
||||
style={{ marginLeft: '4px' }}
|
||||
title="Sortierreihenfolge umschalten"
|
||||
>
|
||||
{sortOrder === 'asc' ? '⬆️' : '⬇️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,6 +208,13 @@ const RecipeList: React.FC = () => {
|
||||
<span className="recipe-category">{recipe.category}</span>
|
||||
)}
|
||||
<span className="recipe-servings">👥 {recipe.servings} Portionen</span>
|
||||
{recipe.lastUsed ? (
|
||||
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
|
||||
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="recipe-never" title="Noch nie zubereitet">🆕 Nie</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="recipe-actions">
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface Recipe {
|
||||
ingredients?: string;
|
||||
instructions?: string;
|
||||
comment?: string;
|
||||
lastUsed?: string; // ISO Datum vom Backend
|
||||
images?: RecipeImage[];
|
||||
ingredientsList?: Ingredient[];
|
||||
}
|
||||
@@ -106,6 +107,12 @@ export const recipeApi = {
|
||||
const response = await api.delete(`/recipes/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Mark recipe as cooked (updates lastUsed)
|
||||
markRecipeCooked: async (id: number, lastUsed?: string): Promise<ApiResponse<{ id: number; lastUsed: string | null }>> => {
|
||||
const response = await api.post(`/recipes/${id}/cooked`, lastUsed ? { lastUsed } : {});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Ingredient API methods
|
||||
|
||||
Reference in New Issue
Block a user