Compare commits
37 Commits
dd0aa5084b
...
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 | |||
| 91d8e1602a | |||
| 1a09734a9b |
15
Jenkinsfile
vendored
15
Jenkinsfile
vendored
@@ -58,22 +58,19 @@ pipeline {
|
|||||||
set -e
|
set -e
|
||||||
cd ${env.DEPLOY_PATH} || mkdir -p ${env.DEPLOY_PATH} && cd ${env.DEPLOY_PATH}
|
cd ${env.DEPLOY_PATH} || mkdir -p ${env.DEPLOY_PATH} && cd ${env.DEPLOY_PATH}
|
||||||
|
|
||||||
# Clone or pull the repo (contains docker-compose.yml)
|
if [ -f .env ]; then cp .env /tmp/.seasoned_env_bak; fi
|
||||||
if [ -d .git ]; then
|
|
||||||
git fetch origin
|
git fetch origin
|
||||||
git reset --hard origin/${env.DEPLOY_BRANCH}
|
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 IMAGE_TAG=${env.IMAGE_TAG}
|
||||||
export DOCKER_REGISTRY=${env.DOCKER_REGISTRY}
|
export DOCKER_REGISTRY=${env.DOCKER_REGISTRY}
|
||||||
export DOCKER_IMAGE=${env.DOCKER_IMAGE}
|
export DOCKER_IMAGE=${env.DOCKER_IMAGE}
|
||||||
|
|
||||||
# Pull latest image and deploy
|
docker compose --env-file .env pull
|
||||||
docker compose pull
|
docker compose --env-file .env up -d --force-recreate
|
||||||
docker compose up -d
|
|
||||||
DEPLOY_EOF
|
DEPLOY_EOF
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Technical Requirements:
|
|||||||
|
|
||||||
1. AI & Multimodal Intelligence
|
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.
|
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.
|
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.
|
Responsive Design: A UI that adapts perfectly to a tablet propped up on a kitchen counter.
|
||||||
|
|
||||||
3. Data & Storage
|
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.
|
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:
|
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.
|
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.
|
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.
|
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);
|
_context.Recipes.Add(recipe);
|
||||||
await _context.SaveChangesAsync();
|
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}")]
|
[HttpPut("update/{id}")]
|
||||||
@@ -139,4 +155,23 @@ public class RecipeController : ControllerBase
|
|||||||
|
|
||||||
return Ok(results);
|
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,15 +1,29 @@
|
|||||||
using Seasoned.Backend.Services;
|
using Seasoned.Backend.Services;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Seasoned.Backend.Data;
|
using Seasoned.Backend.Data;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using DotNetEnv;
|
using DotNetEnv;
|
||||||
|
|
||||||
Env.Load("../.env");
|
Env.Load("../.env");
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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.AddScoped<IRecipeService, RecipeService>();
|
||||||
|
|
||||||
@@ -23,22 +37,6 @@ builder.Services.AddIdentityApiEndpoints<IdentityUser>( options => {
|
|||||||
})
|
})
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>();
|
.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;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
@@ -94,18 +92,24 @@ using (var scope = app.Services.CreateScope())
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||||
|
{
|
||||||
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||||
|
});
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
app.UseRouting();
|
||||||
app.UseCors("SeasonedOriginPolicy");
|
app.UseCors("SeasonedOriginPolicy");
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.MapGroup("/api/auth").MapIdentityApi<IdentityUser>();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.MapGroup("/api/auth").MapIdentityApi<IdentityUser>();
|
|
||||||
app.MapControllers();
|
|
||||||
app.MapFallbackToFile("index.html");
|
app.MapFallbackToFile("index.html");
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -10,8 +10,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="dotenv.net" Version="4.0.1" />
|
<PackageReference Include="dotenv.net" Version="4.0.1" />
|
||||||
<PackageReference Include="DotNetEnv" Version="3.1.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.Identity.EntityFrameworkCore" Version="9.0.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.13" />
|
<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="Mscc.GenerativeAI" Version="2.2.8" />
|
||||||
<PackageReference Include="pgvector" Version="0.3.2" />
|
<PackageReference Include="pgvector" Version="0.3.2" />
|
||||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||||
|
|||||||
@@ -43,6 +43,18 @@
|
|||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
<SessionTimeout />
|
<SessionTimeout />
|
||||||
</v-main>
|
</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>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -50,17 +62,23 @@
|
|||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import '@/assets/css/app-theme.css'
|
import '@/assets/css/app-theme.css'
|
||||||
import SessionTimeout from './components/SessionTimeout.vue'
|
import SessionTimeout from './components/SessionTimeout.vue'
|
||||||
const authCookie = useCookie('.AspNetCore.Identity.Application')
|
|
||||||
const isLoggedIn = useState('isLoggedIn', () => false)
|
const isLoggedIn = useState('isLoggedIn', () => false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (authCookie.value) isLoggedIn.value = true
|
if (import.meta.client) {
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
|
if (token) {
|
||||||
|
isLoggedIn.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
authCookie.value = null
|
|
||||||
isLoggedIn.value = false
|
isLoggedIn.value = false
|
||||||
if (import.meta.client) localStorage.removeItem('token')
|
if (import.meta.client) {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
navigateTo('/login')
|
navigateTo('/login')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -9,6 +9,10 @@ html, body {
|
|||||||
background-image: url("https://www.transparenttextures.com/patterns/tileable-wood-colored.png") !important;
|
background-image: url("https://www.transparenttextures.com/patterns/tileable-wood-colored.png") !important;
|
||||||
background-size: 500px;
|
background-size: 500px;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
|
background-repeat: repeat !important;
|
||||||
|
min-height: 100vh !important;
|
||||||
|
width: 100% !important;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.landing-wrapper {
|
.landing-wrapper {
|
||||||
@@ -117,6 +121,10 @@ html, body {
|
|||||||
color: #5d4a36 !important;
|
color: #5d4a36 !important;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
inline-size: 100%;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
word-break: break-all !important;
|
||||||
|
padding: 0 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-text span {
|
.drop-text span {
|
||||||
@@ -227,7 +235,7 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-recipe-btn:hover {
|
.save-recipe-btn:hover {
|
||||||
background-color: #3b4e1e !important;
|
background-color: #8c4a32 !important;
|
||||||
color: #f4e4bc !important;
|
color: #f4e4bc !important;
|
||||||
text-shadow: none !important;
|
text-shadow: none !important;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -262,6 +270,50 @@ html, body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
background-color: #8c4a32 !important;
|
||||||
|
font-family: 'Libre Baskerville', serif !important;
|
||||||
|
color: #f4e4bc !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
letter-spacing: 0;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn:hover {
|
||||||
|
background-color: #3b4e1e !important;
|
||||||
|
color: #f4e4bc !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-success-btn {
|
||||||
|
opacity: 1 !important;
|
||||||
|
color: #f4e4bc !important;
|
||||||
|
font-family: 'Libre Baskerville', serif !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
letter-spacing: 0;
|
||||||
|
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 {
|
@media print {
|
||||||
@page {
|
@page {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
@@ -283,7 +335,7 @@ html, body {
|
|||||||
.chat-container, .v-app-bar, .no-print, .separator, .v-divider,
|
.chat-container, .v-app-bar, .no-print, .separator, .v-divider,
|
||||||
.recipe-description, button, .v-btn, .drop-zone, .v-card-actions,
|
.recipe-description, button, .v-btn, .drop-zone, .v-card-actions,
|
||||||
.v-btn--variant-text, .v-btn--variant-elevated,
|
.v-btn--variant-text, .v-btn--variant-elevated,
|
||||||
footer, .v-footer, .recipe-actions-row {
|
footer, .v-footer, .recipe-actions-row, .share-btn, .public-cta-container {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
@@ -621,6 +673,30 @@ html, body {
|
|||||||
border: 1px solid #e8dec5 !important;
|
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 */
|
/* Mobile Experience: Full-screen Parchment */
|
||||||
@media (max-width: 959px) {
|
@media (max-width: 959px) {
|
||||||
.landing-wrapper {
|
.landing-wrapper {
|
||||||
@@ -634,14 +710,12 @@ html, body {
|
|||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. Kill the Wood Background and apply Parchment to the whole page */
|
|
||||||
.recipe-bg, .landing-page, .v-application {
|
.recipe-bg, .landing-page, .v-application {
|
||||||
background-color: #f4e4bc !important;
|
background-color: #f4e4bc !important;
|
||||||
background-image: none !important;
|
background-image: none !important;
|
||||||
min-height: 100vh !important;
|
min-height: 100vh !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. Make the Card fill the width and remove the 'floating' shadow */
|
|
||||||
.v-container {
|
.v-container {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
@@ -657,11 +731,9 @@ html, body {
|
|||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
min-height: 100vh;
|
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;
|
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 {
|
.recipe-content .v-row {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
@@ -684,21 +756,18 @@ html, body {
|
|||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. Adjust the hover state for mobile (touch) */
|
|
||||||
.nav-auth-btn:hover,
|
.nav-auth-btn:hover,
|
||||||
.nav-home-btn:hover,
|
.nav-home-btn:hover,
|
||||||
.nav-btn:hover {
|
.nav-btn:hover {
|
||||||
background-color: rgba(46, 30, 10, 0.1) !important; /* Subtle dark tint */
|
background-color: rgba(46, 30, 10, 0.1) !important;
|
||||||
color: #8c4a32 !important; /* Use your rust-orange accent on hover */
|
color: #8c4a32 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. If you have an App Bar, ensure it doesn't stay transparent with white text */
|
|
||||||
.v-app-bar {
|
.v-app-bar {
|
||||||
background-color: #f4e4bc !important;
|
background-color: #f4e4bc !important;
|
||||||
border-bottom: 1px solid #dccca7 !important;
|
border-bottom: 1px solid #dccca7 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4. Fix the icon colors in the nav if they are white */
|
|
||||||
.v-app-bar .v-icon {
|
.v-app-bar .v-icon {
|
||||||
color: #2e1e0a !important;
|
color: #2e1e0a !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<transition name="fade">
|
<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>
|
<v-divider class="mb-10 separator"></v-divider>
|
||||||
|
|
||||||
<h2 class="recipe-title text-center mb-4">{{ recipe.title }}</h2>
|
<h2 class="recipe-title text-center mb-4">{{ recipe.title }}</h2>
|
||||||
@@ -39,9 +39,39 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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-row justify="center" class="mt-12 pb-10">
|
||||||
<v-btn
|
<v-btn
|
||||||
class="print-btn px-12"
|
class="px-8 print-btn"
|
||||||
size="large"
|
size="large"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
@click="printRecipe"
|
@click="printRecipe"
|
||||||
@@ -51,7 +81,8 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
class="px-12 transition-swing"
|
v-if="!isPublicView"
|
||||||
|
class="px-8 transition-swing"
|
||||||
size="large"
|
size="large"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
:loading="isSaving"
|
:loading="isSaving"
|
||||||
@@ -70,7 +101,44 @@
|
|||||||
Saved in Archives
|
Saved in Archives
|
||||||
</template>
|
</template>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
class="px-8 transition-swing"
|
||||||
|
size="large"
|
||||||
|
elevation="0"
|
||||||
|
:color="hasShared ? '#556b2f' : '#5d4a36'"
|
||||||
|
:class="hasShared ? 'share-success-btn' : 'share-btn'"
|
||||||
|
@click="shareRecipe"
|
||||||
|
>
|
||||||
|
<template v-if="!hasShared">
|
||||||
|
<v-icon icon="mdi-share-variant-outline" class="mr-2"></v-icon>
|
||||||
|
Share Recipe
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-icon icon="mdi-content-save-check-outline" class="mr-2"></v-icon>
|
||||||
|
Link Copied!
|
||||||
|
</template>
|
||||||
|
</v-btn>
|
||||||
</v-row>
|
</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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
@@ -78,11 +146,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import '@/assets/css/app-theme.css'
|
import '@/assets/css/app-theme.css'
|
||||||
|
const hasShared =ref(false)
|
||||||
|
const showSavePrompt = ref(false)
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
recipe: { type: Object, default: null },
|
recipe: { type: Object, default: null },
|
||||||
isSaving: { type: Boolean, default: false },
|
isSaving: { type: Boolean, default: false },
|
||||||
hasSaved: { type: Boolean, default: false }
|
hasSaved: { type: Boolean, default: false },
|
||||||
|
isPublicView: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['save'])
|
defineEmits(['save'])
|
||||||
@@ -91,5 +162,40 @@ const printRecipe = () => {
|
|||||||
window.print()
|
window.print()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 on Seasoned!`,
|
||||||
|
url: shareUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
}
|
||||||
|
triggerShareSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') console.error('Error sharing:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerShareSuccess = () => {
|
||||||
|
hasShared.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
hasShared.value = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -102,12 +102,16 @@ const saveToCollection = async () => {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`${config.public.apiBase}api/recipe/save`, {
|
const response = await $fetch(`${config.public.apiBase}api/recipe/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: recipe.value
|
body: recipe.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (response && response.id) {
|
||||||
|
recipe.value.id = response.id
|
||||||
|
}
|
||||||
|
|
||||||
hasSaved.value = true
|
hasSaved.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Save failed:", error)
|
console.error("Save failed:", error)
|
||||||
|
|||||||
@@ -69,7 +69,6 @@
|
|||||||
Added {{ new Date(recipe.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) }}
|
Added {{ new Date(recipe.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<v-card-actions class="justify-center">
|
<v-card-actions class="justify-center">
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -87,7 +86,12 @@
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</v-btn>
|
</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-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@@ -250,6 +254,46 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</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>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -269,6 +313,9 @@ const config = useRuntimeConfig()
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const isSearching = ref(false)
|
const isSearching = ref(false)
|
||||||
let debounceTimeout = null
|
let debounceTimeout = null
|
||||||
|
const deleteConfirmVisible = ref(false)
|
||||||
|
const recipeToDelete = ref(null)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchRecipes()
|
await fetchRecipes()
|
||||||
@@ -281,9 +328,15 @@ const fetchRecipes = async () => {
|
|||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
recipes.value = data
|
recipes.value = data
|
||||||
|
const isLoggedIn = useState('isLoggedIn')
|
||||||
|
isLoggedIn.value = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load collection:", 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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -359,7 +412,8 @@ const saveChanges = async () => {
|
|||||||
|
|
||||||
const index = recipes.value.findIndex(r => r.id === selectedRecipe.value.id);
|
const index = recipes.value.findIndex(r => r.id === selectedRecipe.value.id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
recipes.value[index] = { ...recipes.value[index], ...payload };
|
const updatedRecipe = { ...recipes.value[index], ...payload };
|
||||||
|
recipes.value.splice(index, 1, updatedRecipe);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeDetails();
|
closeDetails();
|
||||||
@@ -404,5 +458,31 @@ watch(searchQuery, (newVal) => {
|
|||||||
}, 600)
|
}, 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>
|
</script>
|
||||||
@@ -143,25 +143,29 @@ const handleAuth = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authLoading.value = true
|
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
|
const url = `${config.public.apiBase}${endpoint}`
|
||||||
? `${config.public.apiBase}${endpoint}?useCookies=true&useSessionCookies=false`
|
|
||||||
: `${config.public.apiBase}${endpoint}`
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await $fetch(url, {
|
const response = await $fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
email: email.value,
|
email: email.value,
|
||||||
password: password.value
|
password: password.value,
|
||||||
},
|
useCookies: false,
|
||||||
credentials: 'include'
|
useSessionCookies: false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLogin.value) {
|
if (isLogin.value) {
|
||||||
|
if (response.accessToken) {
|
||||||
|
localStorage.setItem('auth_token', response.accessToken)
|
||||||
isLoggedIn.value = true
|
isLoggedIn.value = true
|
||||||
navigateTo('/')
|
navigateTo('/')
|
||||||
|
} else {
|
||||||
|
throw new Error('Token not received')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
isLogin.value = true
|
isLogin.value = true
|
||||||
authLoading.value = false
|
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) {
|
if (savedRecipe) {
|
||||||
recipe.value = JSON.parse(savedRecipe)
|
recipe.value = JSON.parse(savedRecipe)
|
||||||
localStorage.removeItem('pending_recipe')
|
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) {
|
if (!isAuth) {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
localStorage.setItem('pending_recipe', JSON.stringify(recipe.value))
|
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')
|
router.push('/login')
|
||||||
}, 2000)
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`${config.public.apiBase}api/recipe/save`, {
|
const response = await $fetch(`${config.public.apiBase}api/recipe/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: recipe.value
|
body: recipe.value
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response && response.id) {
|
||||||
|
recipe.value.id = response.id
|
||||||
|
}
|
||||||
|
|
||||||
hasSaved.value = true;
|
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) {
|
} catch (error) {
|
||||||
console.error("Save failed:", 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 {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const snackbar = ref({
|
|
||||||
show: false,
|
|
||||||
message: '',
|
|
||||||
color: '#f4ede1',
|
|
||||||
icon: 'mdi-check-decagram',
|
|
||||||
iconColor: '#556b2f',
|
|
||||||
textColor: '#5d4037'
|
|
||||||
})
|
|
||||||
|
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
files.value = null
|
files.value = null
|
||||||
recipe.value = null
|
recipe.value = null
|
||||||
|
|||||||
@@ -3,18 +3,33 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
const isLoggedIn = useState('isLoggedIn');
|
const isLoggedIn = useState('isLoggedIn');
|
||||||
|
|
||||||
nuxtApp.hook('app:created', () => {
|
nuxtApp.hook('app:created', () => {
|
||||||
|
|
||||||
const originalFetch = globalThis.$fetch;
|
const originalFetch = globalThis.$fetch;
|
||||||
|
|
||||||
globalThis.$fetch = originalFetch.create({
|
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) {
|
if (response.status === 401) {
|
||||||
console.warn("Session Interceptor: Caught 401 Unauthorized.");
|
console.warn("Session Interceptor: Caught 401 Unauthorized.");
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
if (route.path !== '/login') {
|
if (route.path !== '/login') {
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
showTimeout.value = true;
|
showTimeout.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export default defineNuxtConfig({
|
|||||||
buildAssetsDir: '_nuxt',
|
buildAssetsDir: '_nuxt',
|
||||||
head: {
|
head: {
|
||||||
title: 'Seasoned',
|
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: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||||
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
|
{ 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 { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
import { createVuetify } from 'vuetify'
|
import { createVuetify } from 'vuetify'
|
||||||
import { flushPromises } from '@vue/test-utils'
|
import { flushPromises } from '@vue/test-utils'
|
||||||
import * as components from 'vuetify/components'
|
import * as components from 'vuetify/components'
|
||||||
@@ -36,6 +37,11 @@ vi.stubGlobal('$fetch', mockFetch)
|
|||||||
const mockNavigate = vi.fn()
|
const mockNavigate = vi.fn()
|
||||||
vi.stubGlobal('navigateTo', mockNavigate)
|
vi.stubGlobal('navigateTo', mockNavigate)
|
||||||
|
|
||||||
|
vi.stubGlobal('useState', (key, init) => {
|
||||||
|
const state = ref(init ? init() : null)
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
|
||||||
describe('GalleryPage.vue', () => {
|
describe('GalleryPage.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ vi.stubGlobal('$fetch', mockFetch)
|
|||||||
|
|
||||||
const mockNavigate = vi.fn()
|
const mockNavigate = vi.fn()
|
||||||
vi.stubGlobal('navigateTo', mockNavigate)
|
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 }))
|
vi.stubGlobal('useState', () => ({ value: false }))
|
||||||
|
|
||||||
describe('LoginPage.vue', () => {
|
describe('LoginPage.vue', () => {
|
||||||
@@ -37,15 +37,12 @@ describe('LoginPage.vue', () => {
|
|||||||
it('switches between Login and Register modes', async () => {
|
it('switches between Login and Register modes', async () => {
|
||||||
const wrapper = mount(LoginPage, mountOptions)
|
const wrapper = mount(LoginPage, mountOptions)
|
||||||
|
|
||||||
// Default is Login
|
|
||||||
expect(wrapper.find('h1').text()).toBe('Sign In')
|
expect(wrapper.find('h1').text()).toBe('Sign In')
|
||||||
expect(wrapper.find('input[label="Confirm Password"]').exists()).toBe(false)
|
expect(wrapper.find('input[label="Confirm Password"]').exists()).toBe(false)
|
||||||
|
|
||||||
// Click toggle
|
|
||||||
await wrapper.find('.auth-toggle-btn').trigger('click')
|
await wrapper.find('.auth-toggle-btn').trigger('click')
|
||||||
|
|
||||||
expect(wrapper.find('h1').text()).toBe('Join Us')
|
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)
|
expect(wrapper.vm.isLogin).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,7 +62,7 @@ describe('LoginPage.vue', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('calls login API and redirects on success', async () => {
|
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 wrapper = mount(LoginPage, mountOptions)
|
||||||
const vm = wrapper.vm as any
|
const vm = wrapper.vm as any
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: seasoned-main
|
container_name: seasoned-main
|
||||||
restart: always
|
restart: always
|
||||||
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE}
|
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE}
|
||||||
|
- Jwt__Key=${JWT_KEY}
|
||||||
|
- Jwt__Issuer=${JWT_ISSUER}
|
||||||
|
- Jwt__Audience=${JWT_AUDIENCE}
|
||||||
- ConnectionStrings__DefaultConnection=${ConnectionStrings__DefaultConnection}
|
- ConnectionStrings__DefaultConnection=${ConnectionStrings__DefaultConnection}
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
- ASPNETCORE_HTTP_PORTS=8080
|
- ASPNETCORE_HTTP_PORTS=8080
|
||||||
|
|||||||
Reference in New Issue
Block a user