Compare commits
35 Commits
91d8e1602a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a6a2ff5f1 | |||
| 29db0060a5 | |||
| 7a74873ffa | |||
| dc732429ff | |||
| 30b5ff8cdd | |||
| 115c6fb78f | |||
| ac46e4106f | |||
| 3f6a5e7c1e | |||
| c114bc84e8 | |||
| 8668c297cc | |||
| 4df0675b81 | |||
| 00cb853ece | |||
| 0463275ba5 | |||
| bc25fabd40 | |||
| a71fe1f5d5 | |||
| 13fd652d1f | |||
| 41173aa97e | |||
| a9079b77ef | |||
| 039c65f8b5 | |||
| 2374574220 | |||
| 5271343a25 | |||
| 494a55ec7b | |||
| 971130a3cf | |||
| 0b557567d2 | |||
| 5c057f2fed | |||
| bea7dc91c5 | |||
| b5b1999fa2 | |||
| 1964c0ae70 | |||
| ffbe559f32 | |||
| 0b82abbf48 | |||
| 726949e921 | |||
| 0c9dfab8ff | |||
| 7a331bd494 | |||
| bbdfda5c3d | |||
| 4fb02cecae |
19
Jenkinsfile
vendored
19
Jenkinsfile
vendored
@@ -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
|
||||
git fetch origin
|
||||
git reset --hard origin/${env.DEPLOY_BRANCH}
|
||||
else
|
||||
git clone ${env.GIT_REPO_URL} .
|
||||
fi
|
||||
if [ -f .env ]; then cp .env /tmp/.seasoned_env_bak; fi
|
||||
|
||||
git fetch origin
|
||||
git reset --hard origin/${env.DEPLOY_BRANCH}
|
||||
|
||||
if [ -f /tmp/.seasoned_env_bak ]; then mv /tmp/.seasoned_env_bak .env; fi
|
||||
|
||||
# Set the image tag for this deployment
|
||||
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
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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." });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -70,24 +70,28 @@
|
||||
</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-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-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>
|
||||
@@ -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) {
|
||||
isLoggedIn.value = true
|
||||
navigateTo('/')
|
||||
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
|
||||
|
||||
64
Seasoned.Frontend/app/pages/recipe/[id].vue
Normal file
64
Seasoned.Frontend/app/pages/recipe/[id].vue
Normal 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>
|
||||
@@ -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)
|
||||
router.push('/login')
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
BIN
Seasoned.Frontend/public/images/image-preview.png
Normal file
BIN
Seasoned.Frontend/public/images/image-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user