Compare commits

...

35 Commits

Author SHA1 Message Date
7a6a2ff5f1 removed word in footer 2026-03-23 01:54:26 +00:00
29db0060a5 added footer 2026-03-23 01:48:48 +00:00
7a74873ffa fix login pathing 2026-03-21 00:29:06 +00:00
dc732429ff fix image preview 2026-03-21 00:04:17 +00:00
30b5ff8cdd embeddable link with preview rough 2026-03-20 23:19:46 +00:00
115c6fb78f rough image preview for sharing 2026-03-20 23:03:17 +00:00
ac46e4106f fix text leaking out drop zone on mobile 2026-03-20 22:28:57 +00:00
3f6a5e7c1e layout fix 2026-03-20 21:55:46 +00:00
c114bc84e8 fixing shared recipe layout for desktop 2026-03-20 21:44:45 +00:00
8668c297cc fix desktop recipe display for sharing 2026-03-20 21:33:55 +00:00
4df0675b81 UI update 2026-03-20 20:50:00 +00:00
00cb853ece UI updates 2026-03-20 20:42:21 +00:00
0463275ba5 updated link sharing 2026-03-20 20:31:38 +00:00
bc25fabd40 update program 2026-03-20 20:02:17 +00:00
a71fe1f5d5 update program 2026-03-20 19:56:08 +00:00
13fd652d1f update jenkinsfile 2026-03-20 19:38:23 +00:00
41173aa97e update jenkins 2026-03-20 19:25:40 +00:00
a9079b77ef update compose 2026-03-20 19:13:07 +00:00
039c65f8b5 update the docker file 2026-03-20 19:05:34 +00:00
2374574220 Jwt rough setup 2026-03-20 18:54:27 +00:00
5271343a25 cleaned up readme 2026-03-20 02:17:58 +00:00
494a55ec7b fix 2026-03-19 21:51:38 +00:00
971130a3cf fix 2026-03-19 21:48:38 +00:00
0b557567d2 program update 2026-03-19 21:43:09 +00:00
5c057f2fed updated program 2026-03-19 21:33:40 +00:00
bea7dc91c5 updated test 2026-03-19 21:23:18 +00:00
b5b1999fa2 session timeout fix 2026-03-19 21:18:36 +00:00
1964c0ae70 update images 2026-03-19 21:08:44 +00:00
ffbe559f32 added delete button 2026-03-19 20:45:46 +00:00
0b82abbf48 error fix 2026-03-19 19:51:20 +00:00
726949e921 fixed errors 2026-03-19 19:38:21 +00:00
0c9dfab8ff noramlize recipe output 2026-03-19 18:23:17 +00:00
7a331bd494 save update 2026-03-19 17:38:32 +00:00
bbdfda5c3d save functions updated 2026-03-19 16:46:47 +00:00
4fb02cecae social media linking rough 2026-03-19 16:33:40 +00:00
19 changed files with 452 additions and 154 deletions

15
Jenkinsfile vendored
View File

@@ -58,22 +58,19 @@ pipeline {
set -e
cd ${env.DEPLOY_PATH} || mkdir -p ${env.DEPLOY_PATH} && cd ${env.DEPLOY_PATH}
# Clone or pull the repo (contains docker-compose.yml)
if [ -d .git ]; then
if [ -f .env ]; then cp .env /tmp/.seasoned_env_bak; fi
git fetch origin
git reset --hard origin/${env.DEPLOY_BRANCH}
else
git clone ${env.GIT_REPO_URL} .
fi
# Set the image tag for this deployment
if [ -f /tmp/.seasoned_env_bak ]; then mv /tmp/.seasoned_env_bak .env; fi
export IMAGE_TAG=${env.IMAGE_TAG}
export DOCKER_REGISTRY=${env.DOCKER_REGISTRY}
export DOCKER_IMAGE=${env.DOCKER_IMAGE}
# Pull latest image and deploy
docker compose pull
docker compose up -d
docker compose --env-file .env pull
docker compose --env-file .env up -d --force-recreate
DEPLOY_EOF
"""
}

View File

@@ -31,7 +31,7 @@ Technical Requirements:
1. AI & Multimodal Intelligence
Multimodal Extraction: Use Gemini 3.1 Flash Lite to accept image/jpeg inputs and return a strictly validated JSON Schema containing title, ingredients, and steps.
Multimodal Extraction: Uses Gemini 3.1 Flash Lite to accept image/jpeg inputs and return a strictly validated JSON Schema containing title, ingredients, and steps.
Semantic Search: Implement pgvector in the local database. Recipes will be converted into "embeddings" (via Gemini) to allow users to search for "Comfort food for a rainy day" instead of just keyword matches.
@@ -39,8 +39,6 @@ Technical Requirements:
Directory Structure: Adherence to the new app/ directory standard for better IDE performance and separation of concerns.
Serverless-Style Routes: Use Nitro server routes to keep the Gemini API Key hidden from the client-side.
Responsive Design: A UI that adapts perfectly to a tablet propped up on a kitchen counter.
3. Data & Storage
@@ -49,8 +47,6 @@ Technical Requirements:
Private Media Pipeline: A custom upload handler that saves images to a local Docker volume, served via a secured static asset route.
Sharing Permissions: A relational join-table logic that allows one user to "push" a recipe to another user's library.
Use Cases:
Photo-to-Recipe: User snaps a picture of a magazine page; Gemini extracts the text; the user saves it to their Postgres DB.
@@ -58,5 +54,3 @@ Use Cases:
Semantic Discovery: User searches for "High protein dinner with lime" and the app uses vector similarity to find the best match.
Ad-Free Web Scraping: User pastes a blog URL; the server fetches the content, and Gemini strips out the ads and life stories.
Collaborative Boxes: One user "seasons" a recipe (rates/tags it) and shares it with someone who also uses the instance.

View File

@@ -63,7 +63,23 @@ public class RecipeController : ControllerBase
_context.Recipes.Add(recipe);
await _context.SaveChangesAsync();
return Ok(new { message = "Recipe saved to your collection!" });
return Ok(new {id = recipe.Id, message = "Recipe saved to your collection!" });
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<ActionResult<Recipe>> GetRecipe(int id)
{
var recipe = await _context.Recipes
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == id);
if (recipe == null)
{
return NotFound("That recipe seems to have vanished from the archives.");
}
return Ok(recipe);
}
[HttpPut("update/{id}")]
@@ -139,4 +155,23 @@ public class RecipeController : ControllerBase
return Ok(results);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteRecipe(int id)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var recipe = await _context.Recipes
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId);
if (recipe == null)
{
return NotFound("Recipe not found or you don't have permission to delete it.");
}
_context.Recipes.Remove(recipe);
await _context.SaveChangesAsync();
return Ok(new { message = "Recipe deleted from your archives." });
}
}

View File

@@ -1,19 +1,33 @@
using Seasoned.Backend.Services;
using Microsoft.AspNetCore.HttpOverrides;
using System.Text.Json;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Seasoned.Backend.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using DotNetEnv;
Env.Load("../.env");
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
var jwtKey = builder.Configuration.GetValue<string>("Jwt:Key");
if (string.IsNullOrWhiteSpace(jwtKey))
{
throw new InvalidOperationException("CRITICAL: JWT Key is missing or empty! Check your .env/Docker environment mapping.");
}
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SeasonedAPI";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SeasonedFrontend";
builder.Services.AddScoped<IRecipeService, RecipeService>();
builder.Services.AddIdentityApiEndpoints<IdentityUser>( options => {
builder.Services.AddIdentityApiEndpoints<IdentityUser>(options => {
options.Password.RequireDigit = false;
options.Password.RequiredLength = 6;
options.Password.RequireNonAlphanumeric = false;
@@ -21,22 +35,7 @@ builder.Services.AddIdentityApiEndpoints<IdentityUser>( options => {
options.Password.RequireLowercase = false;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = "Seasoned.Session";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
});
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddAuthorization();
@@ -93,18 +92,24 @@ using (var scope = app.Services.CreateScope())
}
}
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("SeasonedOriginPolicy");
app.UseAuthentication();
app.UseAuthorization();
app.MapGroup("/api/auth").MapIdentityApi<IdentityUser>();
app.MapControllers();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapGroup("/api/auth").MapIdentityApi<IdentityUser>();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();

View File

@@ -10,8 +10,10 @@
<ItemGroup>
<PackageReference Include="dotenv.net" Version="4.0.1" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.13" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageReference Include="Mscc.GenerativeAI" Version="2.2.8" />
<PackageReference Include="pgvector" Version="0.3.2" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />

View File

@@ -43,6 +43,18 @@
<NuxtPage />
<SessionTimeout />
</v-main>
<v-footer
class="d-flex flex-column py-4"
color="transparent"
flat
elevation="0"
style="border: none; box-shadow: none;"
>
<v-divider class="w-100 mb-4" color="#dccca7"></v-divider>
<div class="text-center w-100 menu-text" style="font-size: 0.9rem; opacity: 0.8;">
Built and maintained by Chloe Stanton
</div>
</v-footer>
</v-app>
</template>
@@ -50,17 +62,23 @@
import { onMounted } from 'vue'
import '@/assets/css/app-theme.css'
import SessionTimeout from './components/SessionTimeout.vue'
const authCookie = useCookie('.AspNetCore.Identity.Application')
const isLoggedIn = useState('isLoggedIn', () => false)
onMounted(() => {
if (authCookie.value) isLoggedIn.value = true
if (import.meta.client) {
const token = localStorage.getItem('auth_token')
if (token) {
isLoggedIn.value = true
}
}
})
const logout = () => {
authCookie.value = null
isLoggedIn.value = false
if (import.meta.client) localStorage.removeItem('token')
if (import.meta.client) {
localStorage.removeItem('auth_token')
localStorage.removeItem('token')
}
navigateTo('/login')
}
</script>

View File

@@ -9,6 +9,10 @@ html, body {
background-image: url("https://www.transparenttextures.com/patterns/tileable-wood-colored.png") !important;
background-size: 500px;
background-attachment: fixed;
background-repeat: repeat !important;
min-height: 100vh !important;
width: 100% !important;
display: block !important;
}
.landing-wrapper {
@@ -117,6 +121,10 @@ html, body {
color: #5d4a36 !important;
font-size: 1.1rem;
text-align: center;
inline-size: 100%;
overflow-wrap: break-word !important;
word-break: break-all !important;
padding: 0 10px !important;
}
.drop-text span {
@@ -287,6 +295,25 @@ html, body {
border-radius: 8px !important;
}
.public-cta-container {
background: rgba(255, 255, 255, 0.3);
border-radius: 16px;
border: 1px dashed #dccca7;
}
.cta-title {
font-family: 'Libre Baskerville', serif !important;
color: #2e1e0a;
font-size: 1.8rem;
}
.cta-text {
font-family: 'Libre Baskerville', serif !important;
color: #5d4a36;
font-size: 1.1rem;
line-height: 1.6;
}
@media print {
@page {
margin: 0 !important;
@@ -308,7 +335,7 @@ html, body {
.chat-container, .v-app-bar, .no-print, .separator, .v-divider,
.recipe-description, button, .v-btn, .drop-zone, .v-card-actions,
.v-btn--variant-text, .v-btn--variant-elevated,
footer, .v-footer, .recipe-actions-row, .share-btn {
footer, .v-footer, .recipe-actions-row, .share-btn, .public-cta-container {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
@@ -646,6 +673,30 @@ html, body {
border: 1px solid #e8dec5 !important;
}
.save-prompt-paper {
background-color: #f4e4bc !important;
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png") !important;
border: 1px solid #dccca7 !important;
border-left: 6px solid #8c4a32 !important;
border-radius: 4px !important;
}
.prompt-title {
font-family: 'Libre Baskerville', serif !important;
font-weight: 700;
font-size: 1.1rem;
color: #1e1408;
text-transform: uppercase;
letter-spacing: 1px;
}
.prompt-text {
font-family: 'Libre Baskerville', serif !important;
color: #5d4a36;
font-size: 1rem;
line-height: 1.4;
}
/* Mobile Experience: Full-screen Parchment */
@media (max-width: 959px) {
.landing-wrapper {
@@ -659,14 +710,12 @@ html, body {
overflow: visible !important;
}
/* 2. Kill the Wood Background and apply Parchment to the whole page */
.recipe-bg, .landing-page, .v-application {
background-color: #f4e4bc !important;
background-image: none !important;
min-height: 100vh !important;
}
/* 3. Make the Card fill the width and remove the 'floating' shadow */
.v-container {
padding: 0 !important;
margin: 0 !important;
@@ -682,11 +731,9 @@ html, body {
border-radius: 0 !important;
box-shadow: none !important;
min-height: 100vh;
/* Keeps the subtle paper texture without the wood peeking through */
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png") !important;
}
/* 4. Force columns to stack so they don't squash */
.recipe-content .v-row {
display: flex !important;
flex-direction: column !important;
@@ -709,21 +756,18 @@ html, body {
text-shadow: none;
}
/* 2. Adjust the hover state for mobile (touch) */
.nav-auth-btn:hover,
.nav-home-btn:hover,
.nav-btn:hover {
background-color: rgba(46, 30, 10, 0.1) !important; /* Subtle dark tint */
color: #8c4a32 !important; /* Use your rust-orange accent on hover */
background-color: rgba(46, 30, 10, 0.1) !important;
color: #8c4a32 !important;
}
/* 3. If you have an App Bar, ensure it doesn't stay transparent with white text */
.v-app-bar {
background-color: #f4e4bc !important;
border-bottom: 1px solid #dccca7 !important;
}
/* 4. Fix the icon colors in the nav if they are white */
.v-app-bar .v-icon {
color: #2e1e0a !important;
}

View File

@@ -1,6 +1,6 @@
<template>
<transition name="fade">
<div v-if="recipe" class="recipe-content mx-auto" style="max-width: 900px;">
<div v-if="recipe" class="recipe-content">
<v-divider class="mb-10 separator"></v-divider>
<h2 class="recipe-title text-center mb-4">{{ recipe.title }}</h2>
@@ -39,9 +39,39 @@
</v-col>
</v-row>
<v-expand-transition>
<v-card
v-if="showSavePrompt"
class="mx-auto mb-8 save-prompt-paper"
max-width="600"
elevation="4"
>
<div class="d-flex align-center pa-5">
<v-icon color="#8c4a32" class="mr-4" size="large">mdi-feather</v-icon>
<div class="text-left">
<p class="prompt-title mb-1">A Note from the Chef</p>
<p class="prompt-text mb-0">
To share this creation with others, please <strong>Save to Collection</strong> first.
</p>
</div>
<v-spacer></v-spacer>
<v-btn
icon="mdi-close"
variant="text"
size="small"
color="#5d4a36"
@click="showSavePrompt = false"
></v-btn>
</div>
</v-card>
</v-expand-transition>
<v-row justify="center" class="mt-12 pb-10">
<v-btn
class="print-btn px-12"
class="px-8 print-btn"
size="large"
elevation="0"
@click="printRecipe"
@@ -51,7 +81,8 @@
</v-btn>
<v-btn
class="px-12 transition-swing"
v-if="!isPublicView"
class="px-8 transition-swing"
size="large"
elevation="0"
:loading="isSaving"
@@ -72,7 +103,7 @@
</v-btn>
<v-btn
class="px-12 transition-swing"
class="px-8 transition-swing"
size="large"
elevation="0"
:color="hasShared ? '#556b2f' : '#5d4a36'"
@@ -90,6 +121,24 @@
</template>
</v-btn>
</v-row>
<v-fade-transition>
<div v-if="isPublicView" class="public-cta-container mt-16 text-center pa-8">
<v-divider class="mb-8 separator"></v-divider>
<h3 class="cta-title mb-4">Enjoyed this recipe?</h3>
<p class="cta-text mb-6">
Join <strong>Seasoned</strong> to scan your own family recipes,
consult with the Chef, and build your digital archive.
</p>
<v-btn
to="/login"
class="auth-btn px-10"
size="large"
elevation="4"
>
Start Your Collection
</v-btn>
</div>
</v-fade-transition>
</div>
</transition>
</template>
@@ -98,11 +147,13 @@
import { ref } from 'vue'
import '@/assets/css/app-theme.css'
const hasShared =ref(false)
const showSavePrompt = ref(false)
const props = defineProps({
recipe: { type: Object, default: null },
isSaving: { type: Boolean, default: false },
hasSaved: { type: Boolean, default: false }
hasSaved: { type: Boolean, default: false },
isPublicView: { type: Boolean, default: false }
})
defineEmits(['save'])
@@ -112,22 +163,32 @@ const printRecipe = () => {
}
const shareRecipe = async () => {
if (!props.recipe?.id) {
showSavePrompt.value = true;
setTimeout(() => {
showSavePrompt.value = false;
}, 8000);
return;
}
const shareUrl = `${window.location.origin}/recipe/${props.recipe.id}`;
const shareData = {
title: props.recipe.title,
text: `Check out this delicious ${props.recipe.title} recipe!`,
url: window.location.href
text: `Check out this delicious ${props.recipe.title} recipe on Seasoned!`,
url: shareUrl
}
try {
if (navigator.share) {
await navigator.share(shareData)
triggerShareSuccess()
await navigator.share(shareData);
} else {
await navigator.clipboard.writeText(window.location.href)
triggerShareSuccess()
await navigator.clipboard.writeText(shareUrl);
}
triggerShareSuccess();
} catch (err) {
console.error('Error sharing:', err)
if (err.name !== 'AbortError') console.error('Error sharing:', err);
}
}

View File

@@ -102,12 +102,16 @@ const saveToCollection = async () => {
saving.value = true
try {
await $fetch(`${config.public.apiBase}api/recipe/save`, {
const response = await $fetch(`${config.public.apiBase}api/recipe/save`, {
method: 'POST',
credentials: 'include',
body: recipe.value
})
if (response && response.id) {
recipe.value.id = response.id
}
hasSaved.value = true
} catch (error) {
console.error("Save failed:", error)

View File

@@ -69,7 +69,6 @@
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"
@@ -87,7 +86,12 @@
>
Edit
</v-btn>
</v-card-actions>
<v-btn
variant="text"
color="#8c4a32"
icon="mdi-trash-can-outline"
@click="deleteRecipe(recipe.id)"
></v-btn>
</v-card-actions>
</v-card>
</v-col>
@@ -250,6 +254,46 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteConfirmVisible" max-width="400" persistent>
<v-card
class="recipe-card elevation-5"
style="height: auto !important; min-height: unset !important; display: block !important;"
>
<div class="pa-8 text-center">
<v-icon
icon="mdi-alert-rhombus-outline"
color="#8c4a32"
size="48"
class="mb-4"
></v-icon>
<h3 class="recipe-title mb-10" style="font-size: 1.5rem; line-height: 1;">
Remove from Archive?
</h3>
<div class="d-flex justify-center align-center mt-6">
<v-btn
variant="text"
class="cancel-btn px-4 mr-4"
@click="deleteConfirmVisible = false"
>
Keep it
</v-btn>
<v-btn
color="#8c4a32"
class="save-recipe-btn px-6 text-white"
elevation="1"
:loading="isDeleting"
@click="confirmDelete"
>
Yes, Delete
</v-btn>
</div>
</div>
</v-card>
</v-dialog>
</v-container>
</template>
@@ -269,6 +313,9 @@ const config = useRuntimeConfig()
const searchQuery = ref('')
const isSearching = ref(false)
let debounceTimeout = null
const deleteConfirmVisible = ref(false)
const recipeToDelete = ref(null)
const isDeleting = ref(false)
onMounted(async () => {
await fetchRecipes()
@@ -281,9 +328,15 @@ const fetchRecipes = async () => {
credentials: 'include'
})
recipes.value = data
const isLoggedIn = useState('isLoggedIn')
isLoggedIn.value = true
} catch (err) {
console.error("Failed to load collection:", err)
if (err.status === 401) navigateTo('/login')
if (err.status === 401) {
const isLoggedIn = useState('isLoggedIn')
isLoggedIn.value = false
navigateTo('/login')
}
} finally {
loading.value = false
}
@@ -359,7 +412,8 @@ const saveChanges = async () => {
const index = recipes.value.findIndex(r => r.id === selectedRecipe.value.id);
if (index !== -1) {
recipes.value[index] = { ...recipes.value[index], ...payload };
const updatedRecipe = { ...recipes.value[index], ...payload };
recipes.value.splice(index, 1, updatedRecipe);
}
closeDetails();
@@ -404,5 +458,31 @@ watch(searchQuery, (newVal) => {
}, 600)
})
const deleteRecipe = (id) => {
recipeToDelete.value = id
deleteConfirmVisible.value = true
}
const confirmDelete = async () => {
if (!recipeToDelete.value) return;
try {
isDeleting.value = true
await $fetch(`${config.public.apiBase}api/recipe/${recipeToDelete.value}`, {
method: 'DELETE',
credentials: 'include'
});
recipes.value = recipes.value.filter(r => r.id !== recipeToDelete.value);
deleteConfirmVisible.value = false;
recipeToDelete.value = null;
} catch (err) {
console.error("The archive could not be cleared:", err);
} finally {
isDeleting.value = false
}
}
</script>

View File

@@ -143,25 +143,29 @@ const handleAuth = async () => {
}
authLoading.value = true
const endpoint = isLogin.value ? 'api/auth/login' : 'api/auth/register'
const endpoint = isLogin.value ? '/api/auth/login' : '/api/auth/register'
const url = isLogin.value
? `${config.public.apiBase}${endpoint}?useCookies=true&useSessionCookies=false`
: `${config.public.apiBase}${endpoint}`
const url = `${config.public.apiBase}${endpoint}`
try {
const response = await $fetch(url, {
method: 'POST',
body: {
email: email.value,
password: password.value
},
credentials: 'include'
password: password.value,
useCookies: false,
useSessionCookies: false
}
})
if (isLogin.value) {
if (response.accessToken) {
localStorage.setItem('auth_token', response.accessToken)
isLoggedIn.value = true
navigateTo('/')
} else {
throw new Error('Token not received')
}
} else {
isLogin.value = true
authLoading.value = false

View File

@@ -0,0 +1,64 @@
<template>
<div class="recipe-bg">
<v-container class="py-10 d-flex justify-center"> <RecipeDisplay
v-if="normalizedRecipe"
:recipe="normalizedRecipe"
:is-public-view="true"
class="recipe-card"
style="width: 100%; max-width: 1000px;"
/>
<v-progress-linear v-else indeterminate color="#8c4a32" />
</v-container>
</div>
</template>
<script setup>
import { computed } from 'vue'
definePageMeta({
auth: false
})
const route = useRoute();
const config = useRuntimeConfig();
const imageUrl = normalizedRecipe.value.imageUrl;
const absoluteImageUrl = computed(() => {
const img = normalizedRecipe.value?.imageUrl;
if (!img) return `${config.public.apiBase}/images/seasoned-logo.png`;
return img.startsWith('http') ? img : `${config.public.apiBase}${img}`;
});
const { data: rawRecipe, error } = await useAsyncData(`recipe-${route.params.id}`, () => {
const baseUrl = config.public.apiBase.endsWith('/')
? config.public.apiBase
: `${config.public.apiBase}/`;
return $fetch(`${baseUrl}api/recipe/${route.params.id}`);
});
const normalizedRecipe = computed(() => {
if (!rawRecipe.value) return null;
const r = rawRecipe.value;
return {
id: r.id || r.Id,
title: r.title || r.Title || 'Untitled Recipe',
ingredients: r.ingredients || r.Ingredients || [],
instructions: r.instructions || r.Instructions || [],
imageUrl: r.imageUrl || r.ImageUrl || null
};
});
if (error.value || !normalizedRecipe.value) {
throw createError({ statusCode: 404, statusMessage: 'Recipe not found' })
}
useSeoMeta({
title: `${normalizedRecipe.value.title} | Seasoned`,
ogTitle: `Chef's Choice: ${normalizedRecipe.value.title}`,
description: `Check out this delicious recipe for ${normalizedRecipe.value.title} on Seasoned.`,
ogImage: absoluteImageUrl.value,
twitterCard: 'summary_large_image',
ogType: 'article',
})
</script>

View File

@@ -80,14 +80,6 @@ onMounted(() => {
if (savedRecipe) {
recipe.value = JSON.parse(savedRecipe)
localStorage.removeItem('pending_recipe')
snackbar.value = {
show: true,
message: 'Restored your analyzed recipe.',
color: '#f4ede1',
icon: 'mdi-history',
iconColor: '#556b2f',
textColor: '#5d4037'
}
}
})
@@ -146,62 +138,30 @@ const saveToCollection = async () => {
if (!isAuth) {
saving.value = false;
localStorage.setItem('pending_recipe', JSON.stringify(recipe.value))
snackbar.value = {
show: true,
message: 'Please sign in to preserve this recipe in your archives.',
color: '#efe5e3',
icon: 'mdi-account-key',
iconColor: '#8c4a32',
textColor: '#5d4037'
};
setTimeout(() => {
router.push('/login')
}, 2000)
return;
}
try {
await $fetch(`${config.public.apiBase}api/recipe/save`, {
const response = await $fetch(`${config.public.apiBase}api/recipe/save`, {
method: 'POST',
credentials: 'include',
body: recipe.value
});
if (response && response.id) {
recipe.value.id = response.id
}
hasSaved.value = true;
snackbar.value = {
show: true,
message: 'Recipe added to your collection.',
color: '#f4ede1',
icon: 'mdi-check-decagram',
iconColor: '#556b2f',
textColor: '#5d4037'
};
} catch (error) {
console.error("Save failed:", error);
snackbar.value = {
show: true,
message: 'Failure to save recipe.',
color: '#f8d7da',
icon: 'mdi-alert-rhombus',
iconColor: '#8c4a32',
textColor: '#5d4037'
};
} finally {
saving.value = false;
}
}
const snackbar = ref({
show: false,
message: '',
color: '#f4ede1',
icon: 'mdi-check-decagram',
iconColor: '#556b2f',
textColor: '#5d4037'
})
const clearAll = () => {
files.value = null
recipe.value = null

View File

@@ -3,18 +3,33 @@ export default defineNuxtPlugin((nuxtApp) => {
const isLoggedIn = useState('isLoggedIn');
nuxtApp.hook('app:created', () => {
const originalFetch = globalThis.$fetch;
globalThis.$fetch = originalFetch.create({
onResponseError({ response }) {
onRequest({ options }) {
if (import.meta.client) {
const token = localStorage.getItem('auth_token');
if (token) {
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${token}`);
options.headers = headers;
}
}
},
onResponseError({ response }) {
if (response.status === 401) {
console.warn("Session Interceptor: Caught 401 Unauthorized.");
const route = useRoute();
if (route.path !== '/login') {
isLoggedIn.value = false;
if (import.meta.client) {
localStorage.removeItem('auth_token');
}
showTimeout.value = true;
}
}

View File

@@ -20,6 +20,14 @@ export default defineNuxtConfig({
buildAssetsDir: '_nuxt',
head: {
title: 'Seasoned',
meta: [
{ property: 'og:site_name', content: 'Seasoned' },
{ property: 'og:title', content: 'AI powered recipe app' },
{ property: 'og:description', content: 'Discover and preserve tradtions in one place.' },
{ property: 'og:image', content: 'https://seasoned.ddns.net/images/image-preview.png' },
{ property: 'og:type', content: 'website' },
{ name: 'twitter:card', content: 'summary_large_image' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { ref } from 'vue'
import { createVuetify } from 'vuetify'
import { flushPromises } from '@vue/test-utils'
import * as components from 'vuetify/components'
@@ -36,6 +37,11 @@ vi.stubGlobal('$fetch', mockFetch)
const mockNavigate = vi.fn()
vi.stubGlobal('navigateTo', mockNavigate)
vi.stubGlobal('useState', (key, init) => {
const state = ref(init ? init() : null)
return state
})
describe('GalleryPage.vue', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -21,8 +21,8 @@ vi.stubGlobal('$fetch', mockFetch)
const mockNavigate = vi.fn()
vi.stubGlobal('navigateTo', mockNavigate)
vi.stubGlobal('localStorage', { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn() })
// Mock Nuxt's useState
vi.stubGlobal('useState', () => ({ value: false }))
describe('LoginPage.vue', () => {
@@ -37,15 +37,12 @@ describe('LoginPage.vue', () => {
it('switches between Login and Register modes', async () => {
const wrapper = mount(LoginPage, mountOptions)
// Default is Login
expect(wrapper.find('h1').text()).toBe('Sign In')
expect(wrapper.find('input[label="Confirm Password"]').exists()).toBe(false)
// Click toggle
await wrapper.find('.auth-toggle-btn').trigger('click')
expect(wrapper.find('h1').text()).toBe('Join Us')
// V-expand-transition might need a tick or we check the v-if logic
expect(wrapper.vm.isLogin).toBe(false)
})
@@ -65,7 +62,7 @@ describe('LoginPage.vue', () => {
})
it('calls login API and redirects on success', async () => {
mockFetch.mockResolvedValueOnce({ token: 'fake-token' })
mockFetch.mockResolvedValueOnce({ accessToken: 'fake-token' })
const wrapper = mount(LoginPage, mountOptions)
const vm = wrapper.vm as any

View File

@@ -20,11 +20,15 @@ services:
build: .
container_name: seasoned-main
restart: always
env_file: .env
ports:
- "5000:5000"
environment:
- GEMINI_API_KEY=${GEMINI_API_KEY}
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE}
- Jwt__Key=${JWT_KEY}
- Jwt__Issuer=${JWT_ISSUER}
- Jwt__Audience=${JWT_AUDIENCE}
- ConnectionStrings__DefaultConnection=${ConnectionStrings__DefaultConnection}
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_HTTP_PORTS=8080