Files
Seasoned/Seasoned.Frontend/app/pages/gallery.vue
2026-03-19 02:35:49 +00:00

457 lines
15 KiB
Vue

<template>
<v-container>
<v-card class="recipe-card pa-10 mx-auto mt-10" max-width="1200" elevation="1">
<header class="text-center mb-10">
<v-img
src="/images/seasoned-logo.png"
width="180"
class="mx-auto"
contain
></v-img>
<p class="collection-title">Your Recipe Collection</p>
</header>
<v-row justify="center" class="mb-6">
<v-col cols="12" md="8" lg="6">
<v-text-field
v-model="searchQuery"
placeholder="Search for 'comfort food' or 'smoothie recipe'"
variant="outlined"
class="search-bar"
hide-details
clearable
@click:clear="fetchRecipes"
:loading="isSearching"
>
<template v-slot:prepend-inner>
<v-icon :color="isSearching ? '#556b2f' : '#2e1e0a'">
{{ isSearching ? 'mdi-auto-fix' : 'mdi-magnify' }}
</v-icon>
</template>
</v-text-field>
</v-col>
</v-row>
<v-divider class="mb-10 separator"></v-divider>
<v-row v-if="loading" justify="center" class="py-16">
<v-col cols="12" class="d-flex flex-column align-center">
<v-progress-circular indeterminate color="#556b2f" size="64" width="3"></v-progress-circular>
<p class="brand-subtitle mt-4">Opening Collection...</p>
</v-col>
</v-row>
<v-row v-else-if="recipes?.length">
<v-col v-for="recipe in sortedRecipes" :key="recipe.id" cols="12" sm="6" md="4">
<v-card class="gallery-item-card pa-4">
<v-sheet
height="200"
color="#f8f5f0"
class="rounded-sm mb-4 d-flex align-center justify-center"
style="border: 1px solid #e8e2d6;"
>
<v-img
v-if="recipe.imageUrl"
:src="recipe.imageUrl"
cover
class="recipe-thumbnail"
></v-img>
<v-icon
v-else
icon="mdi-camera-outline"
size="80"
color="#d1c7b7"
></v-icon>
</v-sheet>
<h3 class="gallery-item-title text-center">{{ recipe.title }}</h3>
<p class="gallery-item-date text-center">
Added {{ new Date(recipe.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) }}
</p>
<v-card-actions class="justify-center">
<v-card-actions class="justify-center">
<v-btn
variant="text"
color="#556b2f"
class="save-btn"
@click="openRecipe(recipe)"
>
Open
</v-btn>
<v-btn
variant="text"
color="#8c4a32"
class="cancel-btn"
@click="editRecipe(recipe)"
>
Edit
</v-btn>
</v-card-actions>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-row v-else justify="center" class="py-10 text-center">
<v-col cols="12">
<p class="brand-subtitle mb-4">Your collection is empty.</p>
<v-btn to="/" variant="text" color="#556b2f">Return Home to add some</v-btn>
</v-col>
</v-row>
</v-card>
<v-dialog v-model="showDetails" max-width="950" persistent>
<v-card v-if="selectedRecipe" class="recipe-card pa-8">
<v-btn
v-if="!isEditing"
icon="mdi-close"
variant="text"
position="absolute"
style="top: 10px; right: 10px; z-index: 10;"
color="#5d4037"
@click="closeDetails"
></v-btn>
<v-row align="start" class="mb-6">
<v-col cols="12" md="4" class="d-flex flex-column align-center">
<v-hover v-slot="{ isHovering, props }">
<v-card
v-bind="props"
width="160"
height="160"
:class="[
'rounded-lg d-flex align-center justify-center position-relative overflow-hidden',
{
'image-drop-zone': isEditing,
'cursor-pointer': isEditing
}
]"
:style="{ pointerEvents: isEditing ? 'auto' : 'none' }"
@click="isEditing ? $refs.fileInput.click() : null"
:elevation="isHovering && isEditing ? 4 : 1"
>
<v-img
v-if="selectedRecipe.imageUrl"
:src="selectedRecipe.imageUrl"
cover
class="rounded-lg fill-height"
></v-img>
<div
v-if="isEditing && (isHovering || !selectedRecipe.imageUrl)"
class="d-flex flex-column align-center justify-center position-absolute"
style="background: rgba(226,215,186,0.4); inset: 0;"
>
<v-icon icon="mdi-camera-plus" color="#556b2f" size="large"></v-icon>
<span class="brand-subtitle" style="font-size: 0.7rem; color: #556b2f;">Update Photo</span>
</div>
</v-card>
</v-hover>
<input type="file" ref="fileInput" accept="image/*" style="display: none" @change="handleImageUpload" />
</v-col>
<v-col cols="12" md="8">
<v-text-field
v-if="isEditing"
v-model="selectedRecipe.title"
label="Recipe Title"
variant="underlined"
class="recipe-title-edit"
hide-details
></v-text-field>
<h2 v-else class="recipe-title" style="font-size: 2.5rem;">{{ selectedRecipe.title }}</h2>
</v-col>
</v-row>
<v-divider class="mb-8 separator"></v-divider>
<v-row class="mt-10" density="compact">
<v-col cols="12" md="5" class="pe-md-10">
<h3 class="section-header justify-center mb-6">
<v-icon icon="mdi-spoon-sugar" class="mr-2" size="small"></v-icon>
Ingredients
</h3>
<v-textarea
v-if="isEditing"
v-model="selectedRecipe.ingredients"
variant="outlined"
auto-grow
rows="10"
density="comfortable"
class="auth-input recipe-textarea"
bg-color="transparent"
:persistent-placeholder="true"
></v-textarea>
<div v-else class="ingredients-container">
<div
v-for="(ing, index) in (Array.isArray(selectedRecipe.ingredients) ? selectedRecipe.ingredients : selectedRecipe.ingredients?.split('\n') || [])"
:key="index"
class="ingredient-item"
>
{{ ing }}
</div>
</div>
</v-col>
<v-col cols="12" md="7" class="ps-md-10">
<h3 class="section-header justify-center mb-6">
<v-icon icon="mdi-pot-steam-outline" class="mr-2" size="small"></v-icon>
Instructions
</h3>
<v-textarea
v-if="isEditing"
v-model="selectedRecipe.instructions"
variant="outlined"
auto-grow
rows="10"
density="comfortable"
class="auth-input recipe-textarea"
bg-color="transparent"
:persistent-placeholder="true"
></v-textarea>
<div v-else
v-for="(step, index) in (Array.isArray(selectedRecipe.instructions) ? selectedRecipe.instructions : selectedRecipe.instructions?.split('\n') || [])"
:key="index"
class="instruction-step mb-8"
>
<span class="step-number">{{ index + 1 }}.</span>
<p class="step-text">{{ step }}</p>
</div>
</v-col>
</v-row>
<v-card-actions v-if="isEditing" class="justify-center mt-8">
<v-btn variant="text" @click="saveChanges" class="save-btn px-8">Save Changes</v-btn>
<v-btn variant="text" @click="isEditing = false" class="cancel-btn px-8">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import '@/assets/css/gallery.css'
import '@/assets/css/app-theme.css'
/*const mockRecipes = [
{
id: 1,
title: "Miso-Glazed Smashed Burger",
imageUrl: "https://picsum.photos/id/42/800/600",
ingredients: ["1/2 lb Ground Beef", "1 tbsp White Miso", "Brioche Bun", "Pickled Ginger"],
instructions: ["Mix miso into beef.", "Smash thin on high heat.", "Sear until crispy."],
createdAt: "2026-03-18T10:00:00Z"
},
{
id: 2,
title: "Zesty Yuzu Raspberry Bowl",
imageUrl: "https://picsum.photos/id/429/800/600",
ingredients: ["1 cup Oats", "2 tbsp Yuzu Juice", "Fresh Raspberries", "Honey"],
instructions: ["Soak oats overnight.", "Stir in yuzu.", "Top with berries."],
createdAt: "2026-03-17T08:30:00Z"
},
{
id: 3,
title: "Caribbean Curry Chickpeas",
imageUrl: "https://picsum.photos/id/493/800/600",
ingredients: ["1 can Chickpeas", "Coconut Milk", "Curry Powder", "Sweet Potato"],
instructions: ["Sauté potatoes.", "Add chickpeas and spices.", "Simmer in coconut milk."],
createdAt: "2026-03-16T12:45:00Z"
},
{
id: 4,
title: "Tallow-Crisped Truffle Fries",
imageUrl: "https://picsum.photos/id/517/800/600",
ingredients: ["Russet Potatoes", "Beef Tallow", "Truffle Salt", "Parsley"],
instructions: ["Double fry in tallow.", "Toss with truffle salt.", "Garnish with parsley."],
createdAt: "2026-03-15T16:20:00Z"
},
{
id: 5,
title: "Smoked Gouda & Broccolini Soup",
imageUrl: "https://picsum.photos/id/488/800/600",
ingredients: ["Broccolini", "Smoked Gouda", "Heavy Cream", "Vegetable Broth"],
instructions: ["Simmer broccolini in broth.", "Blend until smooth.", "Melt in cheese."],
createdAt: "2026-03-14T11:10:00Z"
},
{
id: 6,
title: "Heirloom Tomato Galette",
imageUrl: "https://picsum.photos/id/447/800/600",
ingredients: ["Puff Pastry", "Heirloom Tomatoes", "Ricotta", "Thyme"],
instructions: ["Spread ricotta on pastry.", "Layer tomatoes.", "Bake at 200°C until golden."],
createdAt: "2026-03-13T09:00:00Z"
},
{
id: 7,
title: "Thai Basil Pesto Pasta",
imageUrl: "https://picsum.photos/id/102/800/600",
ingredients: ["Linguine", "Thai Basil", "Cashews", "Garlic", "Chili Flakes"],
instructions: ["Blend basil, cashews, and garlic.", "Toss with hot pasta.", "Add chili for heat."],
createdAt: "2026-03-12T19:30:00Z"
},
{
id: 8,
title: "Whipped Feta & Hot Honey Toast",
imageUrl: "https://picsum.photos/id/311/800/600",
ingredients: ["Sourdough", "Feta Cheese", "Greek Yogurt", "Hot Honey"],
instructions: ["Whip feta and yogurt.", "Toast sourdough.", "Spread and drizzle honey."],
createdAt: "2026-03-11T07:45:00Z"
}
]
*/
const recipes = ref([])
//const recipes = ref(mockRecipes)
//const loading = ref(false)
const loading = ref(true)
const showDetails = ref(false)
const selectedRecipe = ref(null)
const isEditing = ref(false)
const originalRecipe = ref(null)
const config = useRuntimeConfig()
const searchQuery = ref('')
const isSearching = ref(false)
let debounceTimeout = null
onMounted(async () => {
await fetchRecipes()
//loading.value = false
})
const fetchRecipes = async () => {
try {
loading.value = true
const data = await $fetch(`${config.public.apiBase}api/recipe/my-collection`, {
credentials: 'include'
})
recipes.value = data
} catch (err) {
console.error("Failed to load collection:", err)
if (err.status === 401) navigateTo('/login')
} finally {
loading.value = false
}
}
const handleImageUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
selectedRecipe.value.imageUrl = e.target.result;
};
reader.readAsDataURL(file);
};
const openRecipe = (recipe) => {
selectedRecipe.value = { ...recipe }
isEditing.value = false
showDetails.value = true
}
const editRecipe = (recipe) => {
const editableRecipe = JSON.parse(JSON.stringify(recipe));
if (Array.isArray(editableRecipe.ingredients)) {
editableRecipe.ingredients = editableRecipe.ingredients.join('\n');
}
if (Array.isArray(editableRecipe.instructions)) {
editableRecipe.instructions = editableRecipe.instructions.join('\n');
}
originalRecipe.value = JSON.parse(JSON.stringify(recipe));
selectedRecipe.value = editableRecipe;
isEditing.value = true;
showDetails.value = true;
}
const closeDetails = () => {
if (isEditing.value && originalRecipe.value) {
const index = recipes.value.findIndex(r => r.id === originalRecipe.value.id)
if (index !== -1) {
recipes.value[index] = originalRecipe.value
}
}
showDetails.value = false
isEditing.value = false
originalRecipe.value = null
}
const saveChanges = async () => {
try {
const { embedding, user, ...recipeData } = selectedRecipe.value;
const payload = {
title: recipeData.title,
imageUrl: recipeData.imageUrl,
ingredients: typeof recipeData.ingredients === 'string'
? recipeData.ingredients.split('\n').filter(i => i.trim())
: recipeData.ingredients,
instructions: typeof recipeData.instructions === 'string'
? recipeData.instructions.split('\n').filter(i => i.trim())
: recipeData.instructions
};
await $fetch(`${config.public.apiBase}api/recipe/update/${selectedRecipe.value.id}`, {
method: 'PUT',
body: payload,
credentials: 'include'
});
const index = recipes.value.findIndex(r => r.id === selectedRecipe.value.id);
if (index !== -1) {
recipes.value[index] = { ...recipes.value[index], ...payload };
}
closeDetails();
} catch (e) {
console.error("The kitchen ledger could not be updated:", e);
}
}
const sortedRecipes = computed(() => {
return [...recipes.value].sort((a, b) => {
return new Date(b.createdAt) - new Date(a.createdAt)
})
})
const performSearch = async () => {
if (!searchQuery.value) {
await fetchRecipes()
return
}
try {
isSearching.value = true
const data = await $fetch(`${config.public.apiBase}api/recipe/search`, {
params: { query: searchQuery.value },
credentials: 'include'
})
recipes.value = data
} catch (err) {
console.error("The Chef couldn't find those flavors:", err)
} finally {
isSearching.value = false
}
}
watch(searchQuery, (newVal) => {
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
performSearch()
}, 600)
})
</script>