Compare commits
3 Commits
main
...
a24e901b7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24e901b7f | ||
|
|
dca065517e | ||
|
|
5ccc4dca8c |
@@ -1,3 +0,0 @@
|
|||||||
[user]
|
|
||||||
email = chloestanton117@gmail.com
|
|
||||||
name = chloe
|
|
||||||
38
.gitignore
vendored
@@ -1,22 +1,24 @@
|
|||||||
# IDE and System Caches
|
# Nuxt dev/build outputs
|
||||||
.npm/
|
.output
|
||||||
.cache/
|
.data
|
||||||
.nvm/
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
# .NET / Backend
|
# Node dependencies
|
||||||
bin/
|
node_modules
|
||||||
obj/
|
|
||||||
*.user
|
|
||||||
*.userprefs
|
|
||||||
.vs/
|
|
||||||
|
|
||||||
# Nuxt / Frontend
|
# Logs
|
||||||
node_modules/
|
logs
|
||||||
Seasoned.Frontend/node_modules/
|
*.log
|
||||||
.nuxt/
|
|
||||||
.output/
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Secrets
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|||||||
30
Dockerfile
@@ -1,30 +0,0 @@
|
|||||||
# Stage 1: Build the Nuxt 4 frontend (Debian Slim)
|
|
||||||
FROM node:20-bookworm-slim AS frontend-build
|
|
||||||
WORKDIR /src/frontend
|
|
||||||
COPY Seasoned.Frontend/package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY Seasoned.Frontend/ .
|
|
||||||
RUN npm run test
|
|
||||||
RUN npx nuxi generate
|
|
||||||
|
|
||||||
# Stage 2: Build the .NET 9 backend (Debian Slim)
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS backend-build
|
|
||||||
WORKDIR /src
|
|
||||||
COPY Seasoned.Backend/*.csproj ./Seasoned.Backend/
|
|
||||||
RUN dotnet restore ./Seasoned.Backend/Seasoned.Backend.csproj
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN dotnet test ./Seasoned.Tests/Seasoned.Tests.csproj --configuration Release --no-restore
|
|
||||||
# Copy Nuxt static files into .NET wwwroot
|
|
||||||
COPY --from=frontend-build /src/frontend/.output/public ./Seasoned.Backend/wwwroot
|
|
||||||
|
|
||||||
RUN dotnet publish ./Seasoned.Backend/Seasoned.Backend.csproj -c Release -o /app
|
|
||||||
|
|
||||||
# Stage 3: Final Runtime (Debian Slim)
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=backend-build /app .
|
|
||||||
|
|
||||||
# Essential for PGVector and Gemini logic to run smoothly
|
|
||||||
ENV ASPNETCORE_URLS=http://+:5000
|
|
||||||
ENTRYPOINT ["dotnet", "Seasoned.Backend.dll"]
|
|
||||||
89
Jenkinsfile
vendored
@@ -1,89 +0,0 @@
|
|||||||
pipeline {
|
|
||||||
agent any
|
|
||||||
|
|
||||||
environment {
|
|
||||||
// Configure these in Jenkins or as pipeline parameters
|
|
||||||
DOCKER_REGISTRY = 'git.wrigglyt.xyz'
|
|
||||||
DOCKER_IMAGE = 'chloe/seasoned' // e.g., username/repo for Docker Hub
|
|
||||||
DEPLOY_HOST = '10.0.11.3'
|
|
||||||
DEPLOY_USER = 'chloe'
|
|
||||||
DEPLOY_PATH = '/opt/seasoned'
|
|
||||||
GIT_REPO_URL = 'https://git.wrigglyt.xyz/chloe/Seasoned.git'
|
|
||||||
}
|
|
||||||
|
|
||||||
options {
|
|
||||||
buildDiscarder(logRotator(numToKeepStr: '10'))
|
|
||||||
timeout(time: 30, unit: 'MINUTES')
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('Build Docker Image') {
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
env.IMAGE_TAG = env.BRANCH_NAME == 'main' ? 'latest' : "${env.BUILD_NUMBER}-${env.GIT_COMMIT.take(7)}"
|
|
||||||
env.FULL_IMAGE = "${env.DOCKER_REGISTRY}/${env.DOCKER_IMAGE}:${env.IMAGE_TAG}"
|
|
||||||
}
|
|
||||||
echo "Building image: ${env.FULL_IMAGE}"
|
|
||||||
sh """
|
|
||||||
docker build -t ${env.FULL_IMAGE} .
|
|
||||||
docker tag ${env.FULL_IMAGE} ${env.DOCKER_REGISTRY}/${env.DOCKER_IMAGE}:latest
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Push to Registry') {
|
|
||||||
steps {
|
|
||||||
withCredentials([usernamePassword(
|
|
||||||
credentialsId: 'c-gitea',
|
|
||||||
usernameVariable: 'DOCKER_USER',
|
|
||||||
passwordVariable: 'DOCKER_PASS'
|
|
||||||
)]) {
|
|
||||||
sh """
|
|
||||||
echo \$DOCKER_PASS | docker login -u \$DOCKER_USER --password-stdin ${env.DOCKER_REGISTRY}
|
|
||||||
docker push ${env.FULL_IMAGE}
|
|
||||||
docker push ${env.DOCKER_REGISTRY}/${env.DOCKER_IMAGE}:latest
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Deploy via SSH') {
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
env.DEPLOY_BRANCH = env.BRANCH_NAME ?: 'main'
|
|
||||||
}
|
|
||||||
sshagent(credentials: ['mini2']) {
|
|
||||||
sh """
|
|
||||||
ssh -o StrictHostKeyChecking=no ${env.DEPLOY_USER}@${env.DEPLOY_HOST} << 'DEPLOY_EOF'
|
|
||||||
set -e
|
|
||||||
cd ${env.DEPLOY_PATH} || mkdir -p ${env.DEPLOY_PATH} && cd ${env.DEPLOY_PATH}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
export IMAGE_TAG=${env.IMAGE_TAG}
|
|
||||||
export DOCKER_REGISTRY=${env.DOCKER_REGISTRY}
|
|
||||||
export DOCKER_IMAGE=${env.DOCKER_IMAGE}
|
|
||||||
|
|
||||||
docker compose --env-file .env pull
|
|
||||||
docker compose --env-file .env up -d --force-recreate
|
|
||||||
DEPLOY_EOF
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
success {
|
|
||||||
echo "Deployment successful! Image: ${env.FULL_IMAGE}"
|
|
||||||
}
|
|
||||||
failure {
|
|
||||||
echo "Deployment failed!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
README.md
@@ -1,11 +1,8 @@
|
|||||||
# Seasoned
|
# Seasoned
|
||||||
[](https://jenkins.wrigglyt.xyz/job/Seasoned/)
|
|
||||||
|
|
||||||
In progress link: https://seasoned.ddns.net/
|
|
||||||
|
|
||||||
The Pitch:
|
The Pitch:
|
||||||
|
|
||||||
Seasoned is a high-performance, private digital cookbook that bridges the gap between web discovery and kitchen execution. By combining the multimodal power of Gemini 3.1 Flash Lite with a secure, self-hosted PostgreSQL backbone, Seasoned allows users to instantly "distill" messy recipe blogs and food photos into a standardized, searchable, and shareable library they truly own.
|
Seasoned is a high-performance, private digital cookbook that bridges the gap between web discovery and kitchen execution. By combining the multimodal power of Gemini 1.5 Flash with a secure, self-hosted PostgreSQL backbone, Seasoned allows users to instantly "distill" messy recipe blogs and food photos into a standardized, searchable, and shareable library they truly own.
|
||||||
|
|
||||||
Target Audience:
|
Target Audience:
|
||||||
|
|
||||||
@@ -22,16 +19,16 @@ The Hybrid Tech Stack:
|
|||||||
| **Hosting** | Private Server (Dockerized on home hardware) |
|
| **Hosting** | Private Server (Dockerized on home hardware) |
|
||||||
| **CI/CD** | Jenkins server |
|
| **CI/CD** | Jenkins server |
|
||||||
| **Frontend** | Nuxt 4 + Vuetify + CSS |
|
| **Frontend** | Nuxt 4 + Vuetify + CSS |
|
||||||
| **Backend** | Dotnet |
|
| **Backend** | Nuxt Nitro |
|
||||||
| **Database** | Postgres + pgvector |
|
| **Database** | Postgres + pgvector |
|
||||||
| **Intelligence** | Gemini 3.1 Flash Lite |
|
| **Intelligence** | Gemini 2.5 Flash |
|
||||||
| **Storage** | Local File System |
|
| **Storage** | Local File System |
|
||||||
|
|
||||||
Technical Requirements:
|
Technical Requirements:
|
||||||
|
|
||||||
1. AI & Multimodal Intelligence
|
1. AI & Multimodal Intelligence
|
||||||
|
|
||||||
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.
|
Multimodal Extraction: Use Gemini 1.5 Flash 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,6 +36,8 @@ 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
|
||||||
@@ -47,10 +46,14 @@ 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Seasoned.Backend.Services;
|
|
||||||
using Seasoned.Backend.DTOs;
|
|
||||||
using Seasoned.Backend.Data;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using Seasoned.Backend.Models;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Pgvector;
|
|
||||||
using Pgvector.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Controllers;
|
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/recipe")]
|
|
||||||
public class RecipeController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IRecipeService _recipeService;
|
|
||||||
private readonly ApplicationDbContext _context;
|
|
||||||
|
|
||||||
public RecipeController(IRecipeService recipeService, ApplicationDbContext context)
|
|
||||||
{
|
|
||||||
_recipeService = recipeService;
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("upload")]
|
|
||||||
public async Task<ActionResult<RecipeResponseDto>> UploadRecipe([FromForm] IFormFile image)
|
|
||||||
{
|
|
||||||
if (image == null || image.Length == 0)
|
|
||||||
{
|
|
||||||
return BadRequest("No image uploaded.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _recipeService.ParseRecipeImageAsync(image);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("save")]
|
|
||||||
public async Task<IActionResult> SaveRecipe([FromBody] RecipeResponseDto recipeDto)
|
|
||||||
{
|
|
||||||
if (recipeDto == null)
|
|
||||||
{
|
|
||||||
return BadRequest("Invalid recipe data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
|
|
||||||
var recipe = new Recipe
|
|
||||||
{
|
|
||||||
Title = recipeDto.Title,
|
|
||||||
ImageUrl = recipeDto.ImageUrl,
|
|
||||||
Ingredients = recipeDto.Ingredients,
|
|
||||||
Instructions = recipeDto.Instructions,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UserId = userId
|
|
||||||
};
|
|
||||||
|
|
||||||
var fullText = $"{recipeDto.Title} {string.Join(" ", recipeDto.Ingredients)} {string.Join(" ", recipeDto.Instructions)}";
|
|
||||||
recipe.Embedding = await _recipeService.GetEmbeddingAsync(fullText);
|
|
||||||
|
|
||||||
_context.Recipes.Add(recipe);
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
|
|
||||||
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}")]
|
|
||||||
public async Task<IActionResult> UpdateRecipe(int id, [FromBody] RecipeUpdateDto updatedRecipe)
|
|
||||||
{
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
|
|
||||||
var existingRecipe = await _context.Recipes
|
|
||||||
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId);
|
|
||||||
|
|
||||||
if (existingRecipe == null)
|
|
||||||
{
|
|
||||||
return NotFound("Recipe not found or permission denied.");
|
|
||||||
}
|
|
||||||
|
|
||||||
existingRecipe.Title = updatedRecipe.Title;
|
|
||||||
existingRecipe.Ingredients = updatedRecipe.Ingredients;
|
|
||||||
existingRecipe.Instructions = updatedRecipe.Instructions;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(updatedRecipe.ImageUrl))
|
|
||||||
{
|
|
||||||
existingRecipe.ImageUrl = updatedRecipe.ImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullText = $"{updatedRecipe.Title} {string.Join(" ", updatedRecipe.Ingredients)} {string.Join(" ", updatedRecipe.Instructions)}";
|
|
||||||
existingRecipe.Embedding = await _recipeService.GetEmbeddingAsync(fullText);
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(new { message = "Recipe updated successfully!" });
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("my-collection")]
|
|
||||||
public async Task<ActionResult<IEnumerable<Recipe>>> GetMyRecipes()
|
|
||||||
{
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
|
|
||||||
var myRecipes = await _context.Recipes
|
|
||||||
.Where(r => r.UserId == userId)
|
|
||||||
.OrderByDescending(r => r.CreatedAt)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return Ok(myRecipes);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("consult")]
|
|
||||||
public async Task<IActionResult> Consult([FromBody] ChatRequestDto request)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Prompt))
|
|
||||||
return BadRequest("The Chef needs a prompt.");
|
|
||||||
|
|
||||||
var result = await _recipeService.ConsultChefAsync(request.Prompt);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("search")]
|
|
||||||
public async Task<ActionResult<IEnumerable<Recipe>>> SearchRecipes([FromQuery] string query)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"--> Search hit! Query: {query}");
|
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
|
||||||
return await GetMyRecipes();
|
|
||||||
|
|
||||||
var queryVector = await _recipeService.GetEmbeddingAsync(query);
|
|
||||||
|
|
||||||
var results = await _context.Recipes
|
|
||||||
.Where(r => r.UserId == userId)
|
|
||||||
.OrderBy(r => r.Embedding!.CosineDistance(queryVector))
|
|
||||||
.Take(15)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
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,6 +0,0 @@
|
|||||||
namespace Seasoned.Backend.DTOs;
|
|
||||||
|
|
||||||
public class ChatRequestDto
|
|
||||||
{
|
|
||||||
public string Prompt { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Seasoned.Backend.DTOs;
|
|
||||||
|
|
||||||
public class ChefConsultResponseDto
|
|
||||||
{
|
|
||||||
public string Reply { get; set; } = string.Empty;
|
|
||||||
public RecipeResponseDto? Recipe { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Seasoned.Backend.DTOs;
|
|
||||||
|
|
||||||
public class RecipeResponseDto
|
|
||||||
{
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public string? ImageUrl { get; set; }
|
|
||||||
public List<string> Ingredients { get; set; } = new();
|
|
||||||
public List<string> Instructions { get; set; } = new();
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
public class RecipeUpdateDto
|
|
||||||
{
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public List<string> Ingredients { get; set; } = new();
|
|
||||||
public List<string> Instructions { get; set; } = new();
|
|
||||||
public string? ImageUrl { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Seasoned.Backend.Models;
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Data;
|
|
||||||
|
|
||||||
public class ApplicationDbContext : IdentityDbContext<IdentityUser>
|
|
||||||
{
|
|
||||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
|
||||||
: base(options) { }
|
|
||||||
|
|
||||||
public DbSet<Recipe> Recipes { get; set; }
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
base.OnModelCreating(modelBuilder);
|
|
||||||
|
|
||||||
if (Database.IsNpgsql())
|
|
||||||
{
|
|
||||||
modelBuilder.HasPostgresExtension("vector");
|
|
||||||
}
|
|
||||||
|
|
||||||
modelBuilder.Entity<Recipe>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasOne<IdentityUser>()
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(r => r.UserId);
|
|
||||||
|
|
||||||
if (Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
|
||||||
{
|
|
||||||
entity.Ignore(r => r.Embedding);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
entity.Property(r => r.Embedding)
|
|
||||||
.HasColumnType("vector(768)");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
using Seasoned.Backend.Models;
|
|
||||||
using Seasoned.Backend.Data;
|
|
||||||
using Pgvector;
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Data
|
|
||||||
{
|
|
||||||
public static class DbInitializer
|
|
||||||
{
|
|
||||||
public static void Initialize(ApplicationDbContext context)
|
|
||||||
{
|
|
||||||
context.Database.EnsureCreated();
|
|
||||||
|
|
||||||
if (context.Recipes.Any()) return;
|
|
||||||
|
|
||||||
string testUserId = "bfc4c4b1-d3b7-4642-a87c-f7186fcd775d";
|
|
||||||
|
|
||||||
var dummyEmbedding = new Vector(new float[768]);
|
|
||||||
|
|
||||||
var recipes = new List<Recipe>
|
|
||||||
{
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Miso-Glazed Smashed Burger",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1550547660-d9450f859349?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "1/2 lb Ground Beef", "1 tbsp White Miso", "Brioche Bun", "Pickled Ginger" },
|
|
||||||
Instructions = new List<string> { "Mix miso into beef.", "Smash thin on high heat.", "Sear until crispy." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-1),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Heirloom Tomato Galette",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1595126731003-7337def8d5ee?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Puff Pastry", "Heirloom Tomatoes", "Ricotta", "Thyme" },
|
|
||||||
Instructions = new List<string> { "Spread ricotta on pastry.", "Layer tomatoes.", "Bake at 200°C until golden." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-2),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Whipped Feta & Hot Honey Toast",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1525351484163-7529414344d8?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Sourdough", "Feta Cheese", "Greek Yogurt", "Hot Honey" },
|
|
||||||
Instructions = new List<string> { "Whip feta and yogurt.", "Toast sourdough.", "Spread and drizzle honey." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-3),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Thai Basil Pesto Pasta",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1473093226795-af9932fe5856?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Linguine", "Thai Basil", "Cashews", "Garlic", "Chili Flakes", "Olive Oil" },
|
|
||||||
Instructions = new List<string> { "Blend basil, cashews, and garlic into a paste.", "Toss with hot pasta.", "Garnish with chili flakes and fresh basil." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-4),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Smoked Gouda & Broccolini Soup",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1547592166-23ac45744acd?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Broccolini", "Smoked Gouda", "Heavy Cream", "Vegetable Broth", "Nutmeg" },
|
|
||||||
Instructions = new List<string> { "Simmer broccolini in broth until tender.", "Blend until smooth.", "Whisk in cream and grated gouda until melted." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-5),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Charred Octopus with Romesco",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1551024506-0bccd828d307?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Octopus Tentacles", "Roasted Red Peppers", "Almonds", "Sherry Vinegar", "Smoked Paprika" },
|
|
||||||
Instructions = new List<string> { "Blanch octopus then grill over high heat.", "Process peppers, nuts, and vinegar for romesco.", "Serve charred over a bed of sauce." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-6),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Truffle Honey Fried Chicken",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1562967914-608f82629710?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Chicken Thighs", "Buttermilk", "Flour", "Truffle Oil", "Wildflower Honey" },
|
|
||||||
Instructions = new List<string> { "Marinate chicken in buttermilk.", "Dredge and deep fry until 75°C internal.", "Drizzle with truffle-infused honey immediately." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-7),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Burrata with Roasted Grapes",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1604908176997-125f25cc6f3d?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Burrata Cheese", "Red Grapes", "Balsamic Glaze", "Fresh Mint", "Sourdough" },
|
|
||||||
Instructions = new List<string> { "Roast grapes at 200°C until they burst.", "Place burrata in the center of the plate.", "Top with warm grapes and balsamic drizzle." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-8),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Black Garlic Shoyu Ramen",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Ramen Noodles", "Pork Belly", "Black Garlic Oil", "Soft Boiled Egg", "Nori" },
|
|
||||||
Instructions = new List<string> { "Slow roast pork belly until tender.", "Prepare shoyu broth with black garlic oil.", "Assemble with noodles and traditional toppings." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-9),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Espresso Rubbed Skirt Steak",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1546241072-48010ad28c2c?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Skirt Steak", "Fine Coffee Grounds", "Brown Sugar", "Ancho Chili Powder", "Cumin" },
|
|
||||||
Instructions = new List<string> { "Mix coffee and spices for the rub.", "Coat steak and let sit for 30 minutes.", "Sear on a cast iron skillet for 3 minutes per side." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-10),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
},
|
|
||||||
new Recipe
|
|
||||||
{
|
|
||||||
Title = "Matcha White Chocolate Blondies",
|
|
||||||
UserId = testUserId,
|
|
||||||
ImageUrl = "https://images.unsplash.com/photo-1515037893149-de7f840978e2?q=80&w=800",
|
|
||||||
Ingredients = new List<string> { "Matcha Powder", "White Chocolate Chips", "Butter", "Flour", "Sea Salt" },
|
|
||||||
Instructions = new List<string> { "Melt butter and whisk with sugar and matcha.", "Fold in flour and chocolate chips.", "Bake at 175°C for 25 minutes until edges set." },
|
|
||||||
CreatedAt = DateTime.UtcNow.AddDays(-11),
|
|
||||||
Embedding = dummyEmbedding
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
context.Recipes.AddRange(recipes);
|
|
||||||
context.SaveChanges();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
using Pgvector;
|
|
||||||
using Seasoned.Backend.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
|
||||||
[Migration("20260318220841_FixEmbeddingDimensions")]
|
|
||||||
partial class FixEmbeddingDimensions
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.2")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("RoleNameIndex");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoleClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("AccessFailedCount")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<bool>("EmailConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("LockoutEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedEmail")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedUserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PhoneNumber")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("PhoneNumberConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("SecurityStamp")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("UserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedEmail")
|
|
||||||
.HasDatabaseName("EmailIndex");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedUserName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("UserNameIndex");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderKey")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderDisplayName")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("LoginProvider", "ProviderKey");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserLogins", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "RoleId");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "LoginProvider", "Name");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserTokens", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<Vector>("Embedding")
|
|
||||||
.HasColumnType("vector(768)");
|
|
||||||
|
|
||||||
b.Property<string>("ImageUrl")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.PrimitiveCollection<List<string>>("Ingredients")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text[]");
|
|
||||||
|
|
||||||
b.PrimitiveCollection<List<string>>("Instructions")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text[]");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Recipes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
using Pgvector;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class FixEmbeddingDimensions : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AlterDatabase()
|
|
||||||
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetRoles",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "text", nullable: false),
|
|
||||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
|
||||||
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
|
||||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUsers",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "text", nullable: false),
|
|
||||||
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
|
||||||
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
|
||||||
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
|
||||||
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
|
||||||
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
PasswordHash = table.Column<string>(type: "text", nullable: true),
|
|
||||||
SecurityStamp = table.Column<string>(type: "text", nullable: true),
|
|
||||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
|
|
||||||
PhoneNumber = table.Column<string>(type: "text", nullable: true),
|
|
||||||
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
|
||||||
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
|
||||||
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetRoleClaims",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
RoleId = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
|
||||||
ClaimValue = table.Column<string>(type: "text", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
|
||||||
column: x => x.RoleId,
|
|
||||||
principalTable: "AspNetRoles",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserClaims",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
UserId = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
|
||||||
ClaimValue = table.Column<string>(type: "text", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserLogins",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ProviderKey = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
|
|
||||||
UserId = table.Column<string>(type: "text", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserRoles",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
UserId = table.Column<string>(type: "text", nullable: false),
|
|
||||||
RoleId = table.Column<string>(type: "text", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
|
||||||
column: x => x.RoleId,
|
|
||||||
principalTable: "AspNetRoles",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserTokens",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
UserId = table.Column<string>(type: "text", nullable: false),
|
|
||||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
|
||||||
Name = table.Column<string>(type: "text", nullable: false),
|
|
||||||
Value = table.Column<string>(type: "text", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Recipes",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
Title = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ImageUrl = table.Column<string>(type: "text", nullable: true),
|
|
||||||
Ingredients = table.Column<List<string>>(type: "text[]", nullable: false),
|
|
||||||
Instructions = table.Column<List<string>>(type: "text[]", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UserId = table.Column<string>(type: "text", nullable: true),
|
|
||||||
Embedding = table.Column<Vector>(type: "vector(768)", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Recipes", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Recipes_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id");
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetRoleClaims_RoleId",
|
|
||||||
table: "AspNetRoleClaims",
|
|
||||||
column: "RoleId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "RoleNameIndex",
|
|
||||||
table: "AspNetRoles",
|
|
||||||
column: "NormalizedName",
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetUserClaims_UserId",
|
|
||||||
table: "AspNetUserClaims",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetUserLogins_UserId",
|
|
||||||
table: "AspNetUserLogins",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetUserRoles_RoleId",
|
|
||||||
table: "AspNetUserRoles",
|
|
||||||
column: "RoleId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "EmailIndex",
|
|
||||||
table: "AspNetUsers",
|
|
||||||
column: "NormalizedEmail");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "UserNameIndex",
|
|
||||||
table: "AspNetUsers",
|
|
||||||
column: "NormalizedUserName",
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Recipes_UserId",
|
|
||||||
table: "Recipes",
|
|
||||||
column: "UserId");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetRoleClaims");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserClaims");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserLogins");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserRoles");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserTokens");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Recipes");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetRoles");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUsers");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
using Pgvector;
|
|
||||||
using Seasoned.Backend.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
|
||||||
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
|
||||||
{
|
|
||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "9.0.2")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("RoleNameIndex");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetRoleClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<int>("AccessFailedCount")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("ConcurrencyStamp")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<bool>("EmailConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("LockoutEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedEmail")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("NormalizedUserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("PhoneNumber")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("PhoneNumberConfirmed")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("SecurityStamp")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<bool>("TwoFactorEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("UserName")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("character varying(256)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedEmail")
|
|
||||||
.HasDatabaseName("EmailIndex");
|
|
||||||
|
|
||||||
b.HasIndex("NormalizedUserName")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("UserNameIndex");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUsers", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ClaimType")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ClaimValue")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserClaims", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderKey")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("ProviderDisplayName")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("LoginProvider", "ProviderKey");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserLogins", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("RoleId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "RoleId");
|
|
||||||
|
|
||||||
b.HasIndex("RoleId");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserRoles", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("LoginProvider")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("UserId", "LoginProvider", "Name");
|
|
||||||
|
|
||||||
b.ToTable("AspNetUserTokens", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<Vector>("Embedding")
|
|
||||||
.HasColumnType("vector(768)");
|
|
||||||
|
|
||||||
b.Property<string>("ImageUrl")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.PrimitiveCollection<List<string>>("Ingredients")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text[]");
|
|
||||||
|
|
||||||
b.PrimitiveCollection<List<string>>("Instructions")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text[]");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.Property<string>("UserId")
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
|
||||||
|
|
||||||
b.ToTable("Recipes");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("RoleId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Seasoned.Backend.Models.Recipe", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("UserId");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using Pgvector;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Models;
|
|
||||||
|
|
||||||
public class Recipe {
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
public string? ImageUrl { get; set; }
|
|
||||||
public List<string> Ingredients { get; set; } = new();
|
|
||||||
public List<string> Instructions { get; set; } = new();
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public string? UserId { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "vector(768)")]
|
|
||||||
public Vector? Embedding { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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 => {
|
|
||||||
options.Password.RequireDigit = false;
|
|
||||||
options.Password.RequiredLength = 6;
|
|
||||||
options.Password.RequireNonAlphanumeric = false;
|
|
||||||
options.Password.RequireUppercase = false;
|
|
||||||
options.Password.RequireLowercase = false;
|
|
||||||
options.User.RequireUniqueEmail = true;
|
|
||||||
})
|
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>();
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
|
||||||
builder.Services.AddControllers()
|
|
||||||
.AddJsonOptions(options => {
|
|
||||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
|
||||||
{
|
|
||||||
options.AddPolicy("SeasonedOriginPolicy", policy =>
|
|
||||||
{
|
|
||||||
policy.WithOrigins("https://seasoned.ddns.net")
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowCredentials();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"),
|
|
||||||
o => o.UseVector()));
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
var services = scope.ServiceProvider;
|
|
||||||
var db = services.GetRequiredService<ApplicationDbContext>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (db.Database.GetPendingMigrations().Any())
|
|
||||||
{
|
|
||||||
db.Database.Migrate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Migration notice: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Console.WriteLine("--> Checking if Seeding is needed...");
|
|
||||||
DbInitializer.Initialize(db);
|
|
||||||
Console.WriteLine("--> Database Seed Completed!");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Seeding failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.MapFallbackToFile("index.html");
|
|
||||||
app.Run();
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "http://localhost:5243",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<UserSecretsId>71e44981-0c1f-4d90-8e87-317af8bd03ce</UserSecretsId>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
@Seasoned.Backend_HostAddress = http://localhost:5243
|
|
||||||
|
|
||||||
GET {{Seasoned.Backend_HostAddress}}/weatherforecast/
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Seasoned.Backend.DTOs;
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Services;
|
|
||||||
|
|
||||||
public interface IRecipeService
|
|
||||||
{
|
|
||||||
Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image);
|
|
||||||
Task<ChefConsultResponseDto> ConsultChefAsync(string userPrompt);
|
|
||||||
Task<Pgvector.Vector> GetEmbeddingAsync(string text);
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
using Seasoned.Backend.DTOs;
|
|
||||||
using Mscc.GenerativeAI;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using System.IO;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Pgvector;
|
|
||||||
|
|
||||||
namespace Seasoned.Backend.Services;
|
|
||||||
|
|
||||||
public class RecipeService : IRecipeService
|
|
||||||
{
|
|
||||||
private readonly string _apiKey;
|
|
||||||
private readonly GoogleAI _googleAI;
|
|
||||||
|
|
||||||
public RecipeService(IConfiguration config)
|
|
||||||
{
|
|
||||||
_apiKey = config["GEMINI_API_KEY"] ?? throw new ArgumentNullException("API Key missing");
|
|
||||||
_googleAI = new GoogleAI(_apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Vector> GetEmbeddingAsync(string text)
|
|
||||||
{
|
|
||||||
using var client = new HttpClient();
|
|
||||||
|
|
||||||
var url = $"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key={_apiKey}";
|
|
||||||
|
|
||||||
var requestBody = new
|
|
||||||
{
|
|
||||||
model = "models/gemini-embedding-001",
|
|
||||||
content = new { parts = new[] { new { text = text } } },
|
|
||||||
outputDimensionality = 768
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await client.PostAsJsonAsync(url, requestBody);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var error = await response.Content.ReadAsStringAsync();
|
|
||||||
throw new Exception($"Google API Error: {error}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
|
||||||
|
|
||||||
if (result.TryGetProperty("embedding", out var embedding) &&
|
|
||||||
embedding.TryGetProperty("values", out var values))
|
|
||||||
{
|
|
||||||
var floatArray = values.EnumerateArray()
|
|
||||||
.Select(v => v.GetSingle())
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
return new Vector(floatArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Exception("The Chef couldn't find the embeddings in the response.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RecipeResponseDto> ParseRecipeImageAsync(IFormFile image)
|
|
||||||
{
|
|
||||||
if (image == null || image.Length == 0)
|
|
||||||
{
|
|
||||||
return new RecipeResponseDto { Title = "Error: No image provided" };
|
|
||||||
}
|
|
||||||
|
|
||||||
var model = _googleAI.GenerativeModel("gemini-3.1-flash-lite-preview");
|
|
||||||
|
|
||||||
using var ms = new MemoryStream();
|
|
||||||
await image.CopyToAsync(ms);
|
|
||||||
var base64Image = Convert.ToBase64String(ms.ToArray());
|
|
||||||
|
|
||||||
var prompt = @"Extract the recipe details from this image.
|
|
||||||
Return ONLY a raw JSON string.
|
|
||||||
DO NOT include markdown formatting.
|
|
||||||
JSON structure:
|
|
||||||
{
|
|
||||||
""title"": ""string"",
|
|
||||||
""ingredients"": [""string"", ""string""],
|
|
||||||
""instructions"": [""string"", ""string""]
|
|
||||||
}";
|
|
||||||
|
|
||||||
var generationConfig = new GenerationConfig {
|
|
||||||
ResponseMimeType = "application/json",
|
|
||||||
Temperature = 0.1f
|
|
||||||
};
|
|
||||||
|
|
||||||
var request = new GenerateContentRequest(prompt, generationConfig);
|
|
||||||
|
|
||||||
await Task.Run(() => request.AddMedia(base64Image, image.ContentType ?? "image/png"));
|
|
||||||
|
|
||||||
var response = await model.GenerateContent(request);
|
|
||||||
string rawText = response.Text?.Trim() ?? "";
|
|
||||||
|
|
||||||
string cleanJson = CleanJsonResponse(rawText);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cleanJson))
|
|
||||||
{
|
|
||||||
return new RecipeResponseDto { Title = "Error: Invalid AI Response" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
||||||
var result = JsonSerializer.Deserialize<RecipeResponseDto>(cleanJson, options);
|
|
||||||
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
result.ImageUrl = $"data:{image.ContentType};base64,{base64Image}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return result ?? new RecipeResponseDto { Title = "Empty Response" };
|
|
||||||
}
|
|
||||||
catch (JsonException ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Chef's Error: JSON Parsing failed. Message: {ex.Message}");
|
|
||||||
Console.WriteLine($"Raw AI Output: {rawText}");
|
|
||||||
return new RecipeResponseDto { Title = "Parsing Error" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ChefConsultResponseDto> ConsultChefAsync(string userPrompt)
|
|
||||||
{
|
|
||||||
var model = _googleAI.GenerativeModel("gemini-3.1-flash-lite-preview");
|
|
||||||
|
|
||||||
var systemPrompt = @"You are the 'Seasoned' Head Chef, a master of real-world culinary arts.
|
|
||||||
You operate a professional kitchen and only provide advice that can be used in a real kitchen.
|
|
||||||
|
|
||||||
STRICT CONTENT RULES:
|
|
||||||
1. REAL FOOD ONLY: You specialize in real-world ingredients and techniques.
|
|
||||||
2. CELEBRITY CHEFS: You can provide recipes from real chefs like Gordon Ramsay or Julia Child.
|
|
||||||
3. FICTIONAL FOOD TRANSLATION: If a user asks for food from a game (like a Skyrim Sweetroll) or movie,
|
|
||||||
do NOT give game mechanics. Instead, provide a REAL-WORLD recipe that recreates that item.
|
|
||||||
In your 'reply', treat it like a fun culinary challenge.
|
|
||||||
4. REFUSAL POLICY: If the user asks about non-food topics (video game strategies, tech support, politics, or 'Minecraft crafting grids'),
|
|
||||||
politely refuse. Stay in character: 'The Chef's Grimoire is for spices, not spells' or 'I deal in pans, not pixels.'
|
|
||||||
|
|
||||||
RESPONSE FORMAT:
|
|
||||||
You MUST return ONLY a raw JSON object with these keys:
|
|
||||||
{
|
|
||||||
""reply"": ""A friendly, thematic response from the Chef."",
|
|
||||||
""recipe"": {
|
|
||||||
""title"": ""string"",
|
|
||||||
""ingredients"": [""string"", ""string""],
|
|
||||||
""instructions"": [""string"", ""string""]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Note: Set the 'recipe' object to null if you are only chatting or refusing a non-food prompt.";
|
|
||||||
|
|
||||||
var fullPrompt = $"{systemPrompt}\n\nUser Question: {userPrompt}";
|
|
||||||
|
|
||||||
var generationConfig = new GenerationConfig {
|
|
||||||
ResponseMimeType = "application/json",
|
|
||||||
Temperature = 0.7f
|
|
||||||
};
|
|
||||||
|
|
||||||
var request = new GenerateContentRequest(fullPrompt, generationConfig);
|
|
||||||
var response = await model.GenerateContent(request);
|
|
||||||
|
|
||||||
string rawText = response.Text ?? "";
|
|
||||||
string jsonToParse = CleanJsonResponse(rawText);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(jsonToParse))
|
|
||||||
return new ChefConsultResponseDto { Reply = "The Chef is at a loss for words. Try rephrasing?" };
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
||||||
var result = JsonSerializer.Deserialize<ChefConsultResponseDto>(jsonToParse, options);
|
|
||||||
|
|
||||||
return result ?? new ChefConsultResponseDto { Reply = "Chef is a bit confused!" };
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return new ChefConsultResponseDto { Reply = "The kitchen is a mess right now. Try again?" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal string CleanJsonResponse(string rawText)
|
|
||||||
{
|
|
||||||
int start = rawText.IndexOf('{');
|
|
||||||
int end = rawText.LastIndexOf('}');
|
|
||||||
if (start == -1 || end == -1) return string.Empty;
|
|
||||||
return rawText.Substring(start, (end - start) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
||||||
24
Seasoned.Frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Nuxt dev/build outputs
|
|
||||||
.output
|
|
||||||
.data
|
|
||||||
.nuxt
|
|
||||||
.nitro
|
|
||||||
.cache
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Node dependencies
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Misc
|
|
||||||
.DS_Store
|
|
||||||
.fleet
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Local env files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
telemetry.enabled=false
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"path": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../Seasoned.Backend"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../Seasoned.Tests"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"settings": {}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-app :class="['recipe-bg', { 'landing-page': $route.path === '/' }]">
|
|
||||||
|
|
||||||
<v-app-bar color="transparent" flat elevation="0" class="px-4" height="70">
|
|
||||||
<v-btn to="/" variant="text" class="nav-home-btn">Seasoned</v-btn>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
|
|
||||||
<div class="nav-links d-flex align-center">
|
|
||||||
<v-menu v-if="isLoggedIn" transition="slide-y-transition" offset="8">
|
|
||||||
<template v-slot:activator="{ props }">
|
|
||||||
<v-btn v-bind="props" variant="text" class="nav-auth-btn px-4">
|
|
||||||
<v-icon icon="mdi-pot-steam" size="small" class="mr-2"></v-icon>
|
|
||||||
<span>Menu</span>
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-list class="menu-card mt-2" min-width="220">
|
|
||||||
<v-list-item to="/uploader" prepend-icon="mdi-camera-outline">
|
|
||||||
<v-list-item-title class="menu-text">Recipe Uploader</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-list-item to="/chat" prepend-icon="mdi-chef-hat">
|
|
||||||
<v-list-item-title class="menu-text">Chef Consultation</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-list-item to="/gallery" prepend-icon="mdi-book-open-variant">
|
|
||||||
<v-list-item-title class="menu-text">My Collection</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider class="ma-0" color="#dccca7"></v-divider>
|
|
||||||
|
|
||||||
<v-list-item @click="logout" prepend-icon="mdi-logout">
|
|
||||||
<v-list-item-title class="menu-text" style="color: #8c4a32;">Sign Out</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
|
|
||||||
<v-btn v-else to="/login" variant="text" class="nav-auth-btn">Sign In</v-btn>
|
|
||||||
</div>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-main>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import '@/assets/css/app-theme.css'
|
|
||||||
import SessionTimeout from './components/SessionTimeout.vue'
|
|
||||||
const isLoggedIn = useState('isLoggedIn', () => false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (import.meta.client) {
|
|
||||||
const token = localStorage.getItem('auth_token')
|
|
||||||
if (token) {
|
|
||||||
isLoggedIn.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
isLoggedIn.value = false
|
|
||||||
if (import.meta.client) {
|
|
||||||
localStorage.removeItem('auth_token')
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
}
|
|
||||||
navigateTo('/login')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,774 +0,0 @@
|
|||||||
html, body {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-bg, .landing-page {
|
|
||||||
background-color: #5d4a36 !important;
|
|
||||||
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 {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-card {
|
|
||||||
background-color: #f4e4bc !important;
|
|
||||||
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
|
|
||||||
border: 1px solid #dccca7 !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
padding: 40px !important;
|
|
||||||
box-shadow: 0 35px 60px rgba(0, 0, 0, 0.5) !important;
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-title {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-size: 1.5rem !important;
|
|
||||||
color: #1e1408;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-app-bar {
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-title {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 2.8rem;
|
|
||||||
background: linear-gradient(to bottom, #8c4a32 20%, #4a2a14 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
line-height: 1.1 !important;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
filter: drop-shadow(1px 1px 0px rgba(255,255,255,0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-title {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
margin-top: 5px !important;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.0 !important;
|
|
||||||
background: linear-gradient(to bottom, #8c4a32 20%, #4a2a14 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
filter: drop-shadow(1px 1px 0px rgba(255,255,255,0.1));
|
|
||||||
line-height: 1.1 !important;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-subtitle {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
color: #6d5e4a;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-text {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
color: #5d4a36 !important;
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 260px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-btn-solid {
|
|
||||||
background-color: #dccca7 !important;
|
|
||||||
color: #4a3a2a !important;
|
|
||||||
min-width: 56px !important;
|
|
||||||
height: 56px !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone {
|
|
||||||
width: 100%;
|
|
||||||
background-color: rgba(62, 42, 20, 0.03) !important;
|
|
||||||
border: 2px dashed #8c857b;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 40px 20px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone:hover {
|
|
||||||
border-color: #556b2f;
|
|
||||||
background-color: rgba(85, 107, 47, 0.05) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-text {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
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 {
|
|
||||||
color: #8c4a32;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analyze-btn {
|
|
||||||
background-color: #556b2f !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
height: 56px !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-btn {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
background-color: #556b2f !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-btn:hover {
|
|
||||||
background-color: #2e1e0a !important;
|
|
||||||
text-shadow: none !important;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-auth-btn, .nav-home-btn, .nav-btn {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.5rem !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8) !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-auth-btn:hover, .nav-home-btn:hover, .nav-btn:hover {
|
|
||||||
background-color: #f4e4bc !important;
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
text-shadow: none !important;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.separator {
|
|
||||||
border-color: #dccca7 !important;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-text {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-card {
|
|
||||||
background-color: #f4e4bc !important;
|
|
||||||
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png") !important;
|
|
||||||
border: 1px solid #dccca7 !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
padding: 8px !important;
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-list.menu-card {
|
|
||||||
background: #f4e4bc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-card .v-list-item {
|
|
||||||
border-radius: 6px !important;
|
|
||||||
margin-bottom: 2px !important;
|
|
||||||
transition: background-color 0.2s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-card .v-list-item:last-child {
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-card .v-list-item:hover {
|
|
||||||
background-color: rgba(85, 107, 47, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-list-item__prepend .v-icon {
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-list-item:hover {
|
|
||||||
background-color: rgba(85, 107, 47, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-list-item[color="error"] .menu-text {
|
|
||||||
color: #8c4a32 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-recipe-btn {
|
|
||||||
background-color: #2e1e0a !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
letter-spacing: 0;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-recipe-btn:hover {
|
|
||||||
background-color: #8c4a32 !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
text-shadow: none !important;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transition-swing {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-btn {
|
|
||||||
background-color: #3b4e1e !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
letter-spacing: 0;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-btn:hover {
|
|
||||||
background-color: #2e1e0a !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
text-shadow: none !important;
|
|
||||||
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 {
|
|
||||||
@page {
|
|
||||||
margin: 0 !important;
|
|
||||||
size: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: white !important;
|
|
||||||
padding: 0.75in !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
min-height: 0 !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
position: relative !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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, .public-cta-container {
|
|
||||||
display: none !important;
|
|
||||||
visibility: hidden !important;
|
|
||||||
opacity: 0 !important;
|
|
||||||
height: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
position: absolute !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-application, .v-application__wrap, main.v-main, .v-container, .recipe-card {
|
|
||||||
height: auto !important;
|
|
||||||
min-height: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
position: static !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: auto !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-application {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer, .brand-subtitle {
|
|
||||||
page-break-after: avoid !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-card,
|
|
||||||
.recipe-card,
|
|
||||||
.v-theme--light,
|
|
||||||
.v-card--variant-elevated,
|
|
||||||
.v-card__underlay,
|
|
||||||
[class*="elevation-"],
|
|
||||||
[class*="v-card--variant"] {
|
|
||||||
box-shadow: none !important;
|
|
||||||
-webkit-box-shadow: none !important;
|
|
||||||
filter: none !important;
|
|
||||||
--v-shadow-key-umbra: none !important;
|
|
||||||
--v-shadow-key-penumbra: none !important;
|
|
||||||
--v-shadow-key-ambient: none !important;
|
|
||||||
border: 0px solid transparent !important;
|
|
||||||
outline: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
background-image: none !important;
|
|
||||||
display: contents !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.text-center {
|
|
||||||
margin-bottom: 0px !important;
|
|
||||||
padding-bottom: 0px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.text-center img, .v-img, [class*="v-img"] {
|
|
||||||
max-height: 65px !important;
|
|
||||||
margin-bottom: 2px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-title {
|
|
||||||
margin-top: -20px !important;
|
|
||||||
margin-bottom: 20px !important;
|
|
||||||
font-size: 1.6rem !important;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold !important;
|
|
||||||
line-height: 1.2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content .v-row {
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: row !important;
|
|
||||||
flex-wrap: nowrap !important;
|
|
||||||
width: 100% !important;
|
|
||||||
gap: 0.5in !important;
|
|
||||||
align-items: flex-start !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content .v-row > div:first-child {
|
|
||||||
flex: 0 0 33% !important;
|
|
||||||
max-width: 33% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content .v-row > div:last-child {
|
|
||||||
flex: 0 0 62% !important;
|
|
||||||
max-width: 62% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
border: none !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
margin-bottom: 15px !important;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredient-item, .step-text {
|
|
||||||
font-size: 0.95rem !important;
|
|
||||||
line-height: 1.4 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instruction-step {
|
|
||||||
margin-bottom: 10px !important;
|
|
||||||
display: flex !important;
|
|
||||||
gap: 10px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content, .v-row, .v-col, * {
|
|
||||||
overflow: visible !important;
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-number, .ingredient-item::before {
|
|
||||||
color: #3b4e1e !important;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
border-bottom: 2px solid #dccca7;
|
|
||||||
color: #4a3a2a;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredients-list {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredient-item {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 45px;
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
line-height: 1.4 !important;
|
|
||||||
color: #2c2925 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredients-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
padding-left:20px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ingredient-item::before {
|
|
||||||
content: "❦";
|
|
||||||
position: absolute;
|
|
||||||
padding-left: 10px;
|
|
||||||
left: 0;
|
|
||||||
color: #3b4e1e;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instruction-step{
|
|
||||||
display: flex !important;
|
|
||||||
align-items: flex-start !important;
|
|
||||||
text-align: left;
|
|
||||||
gap: 12px !important;
|
|
||||||
margin-bottom: 24px !important;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-number {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
color: #3b4e1e !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
min-width: 30px !important;
|
|
||||||
text-align: center !important;
|
|
||||||
line-height: 1.4 !important;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-text {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
line-height: 1.6 !important;
|
|
||||||
color: #2c2925 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-container {
|
|
||||||
width: 100%;
|
|
||||||
background-color: rgba(62, 42, 20, 0.03) !important;
|
|
||||||
border: 2px solid #544521;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-display {
|
|
||||||
flex-grow: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-display::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-display::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-display::-webkit-scrollbar-thumb {
|
|
||||||
background: #dccca7;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-display::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #8c7e6a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input .v-field__input {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
min-height: 56px !important;
|
|
||||||
padding-top: 15px !important;
|
|
||||||
color: #2c2925 !important;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-container:focus-within {
|
|
||||||
background-color: rgba(85, 107, 47, 0.05) !important;
|
|
||||||
border-color: #556b2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-placeholder {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #8c7e6a;
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn {
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
height: 36px !important;
|
|
||||||
width: 36px !important;
|
|
||||||
min-width: 36px !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn:hover {
|
|
||||||
background-color: #556b2f !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-bubble {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 16px !important;
|
|
||||||
width: fit-content;
|
|
||||||
background-color: #fcf8ef !important;
|
|
||||||
border: 1px solid #e8dec5 !important;
|
|
||||||
border-radius: 15px 15px 15px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing .dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background-color: #e8dec5;
|
|
||||||
border-radius: 50%;
|
|
||||||
opacity: 0.4;
|
|
||||||
animation: typing-bounce 1.4s infinite ease-in-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typing .dot:nth-child(1) { animation-delay: 0s; }
|
|
||||||
.typing .dot:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.typing .dot:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
@keyframes typing-bounce {
|
|
||||||
0%, 80%, 100% { transform: scale(0); }
|
|
||||||
40% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
max-width: 85%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.user {
|
|
||||||
background-color: #8c4a32 !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
align-self: flex-end;
|
|
||||||
border-radius: 12px 12px 2px 12px;
|
|
||||||
border: 1px solid #7a3e29
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-display .message.assistant {
|
|
||||||
background-color: #5d4a36 !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
align-self: flex-start;
|
|
||||||
border-radius: 12px 12px 12px 2px;
|
|
||||||
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 {
|
|
||||||
height: auto !important;
|
|
||||||
min-height: 100vh !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
height: auto !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-bg, .landing-page, .v-application {
|
|
||||||
background-color: #f4e4bc !important;
|
|
||||||
background-image: none !important;
|
|
||||||
min-height: 100vh !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-container {
|
|
||||||
padding: 0 !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-card {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 25px 15px !important;
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
min-height: 100vh;
|
|
||||||
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png") !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content .v-row {
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-content .v-col {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
padding-bottom: 20px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-auth-btn,
|
|
||||||
.nav-home-btn,
|
|
||||||
.nav-btn,
|
|
||||||
.v-btn.v-btn--variant-text {
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-shadow: none !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-auth-btn:hover,
|
|
||||||
.nav-home-btn:hover,
|
|
||||||
.nav-btn:hover {
|
|
||||||
background-color: rgba(46, 30, 10, 0.1) !important;
|
|
||||||
color: #8c4a32 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-app-bar {
|
|
||||||
background-color: #f4e4bc !important;
|
|
||||||
border-bottom: 1px solid #dccca7 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-app-bar .v-icon {
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
.collection-title {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
margin-top: -5px !important;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.0 !important;
|
|
||||||
background: linear-gradient(to bottom, #8c4a32 20%, #4a2a14 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
filter: drop-shadow(1px 1px 0px rgba(255,255,255,0.1));
|
|
||||||
line-height: 1.1 !important;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-item-card {
|
|
||||||
background-color: #FBF6E9 !important;
|
|
||||||
border: 1px solid #e2d7ba !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-item-card:hover {
|
|
||||||
transform: translateY(-5px) rotate(1deg);
|
|
||||||
box-shadow: 0 10px 20px rgba(0,0,0,0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-thumbnail {
|
|
||||||
filter: sepia(0.15) contrast(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-item-title {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: #2e1e0a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-item-date {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: #8c857b;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-card {
|
|
||||||
background-color: #f4e4bc !important;
|
|
||||||
border: 1px solid #e2d7ba !important;
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1) !important;
|
|
||||||
overflow-y: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-card::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-drop-zone {
|
|
||||||
background-color: transparent !important;
|
|
||||||
border: 2px dashed #2e1e0a !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-drop-zone .v-card__underlay {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-card .v-card.rounded-lg:not(.image-drop-zone) {
|
|
||||||
border: none !important;
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-title-edit .v-field__input {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
color: #1e1408 !important;
|
|
||||||
font-size: 2.4rem !important;
|
|
||||||
text-align: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-title-edit input::placeholder,
|
|
||||||
.recipe-title-edit .v-field-label {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-style: italic !important;
|
|
||||||
color: #8c857b !important;
|
|
||||||
opacity: 0.6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-title {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #1e1408 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-text {
|
|
||||||
font-family: 'Inter', sans-serif !important;
|
|
||||||
font-size: 0.75rem !important;
|
|
||||||
letter-spacing: 1.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #6d5e4a !important;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-title-edit ::placeholder {
|
|
||||||
color: #8c857b !important;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-textarea .v-field {
|
|
||||||
background-color: transparent !important;
|
|
||||||
border-radius: 4px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-textarea textarea {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
line-height: 1.6 !important;
|
|
||||||
color: #2c2925 !important;
|
|
||||||
min-height: 280px !important;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #d1c7b7 transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field-label {
|
|
||||||
color: #5d4037 !important;
|
|
||||||
opacity: 0.6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field__outline {
|
|
||||||
--v-field-border-opacity: 0.2 !important;
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-field--focused .v-field__outline {
|
|
||||||
color: #556b2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-textarea .v-field__input,
|
|
||||||
.v-textarea textarea {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
line-height: 1.6 !important;
|
|
||||||
color: #2c2925 !important;
|
|
||||||
min-height: 280px !important;
|
|
||||||
overflow-y: auto !important;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #d1c7b7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-textarea textarea::-webkit-scrollbar {
|
|
||||||
width: 8px !important;
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-textarea textarea::-webkit-scrollbar-track {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-textarea textarea::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #d1c7b7 !important;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 2px solid #f4e4bc;
|
|
||||||
background-clip: padding-box
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipe-textarea textarea::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: #556b2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn, .cancel-btn {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
letter-spacing: 0px !important;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
color: #556b2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn:hover {
|
|
||||||
background-color: rgba(85, 107, 47, 0.08) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn {
|
|
||||||
color: #8c4a32 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn:hover {
|
|
||||||
background-color: rgba(140, 74, 50, 0.05) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar .v-field {
|
|
||||||
background-color: #FBF6E9 !important;
|
|
||||||
border: 1px solid #e2d7ba !important;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar input::placeholder {
|
|
||||||
color: #86571D !important;
|
|
||||||
opacity: 0.7 !important;
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-style: italic;
|
|
||||||
-webkit-text-fill-color: #86571D !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar .v-field__input,
|
|
||||||
.search-bar input {
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.0rem !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
-webkit-text-fill-color: #2e1e0a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar .v-field--focused {
|
|
||||||
border-color: #556b2f !important;
|
|
||||||
background-color: #556b2f0d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mdi-auto-fix {
|
|
||||||
animation: pulse-green 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-green {
|
|
||||||
0% { opacity: 1; transform: scale(1); }
|
|
||||||
50% { opacity: 0.5; transform: scale(1.1); }
|
|
||||||
100% { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar .v-field__clearable {
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar .v-field__clearable .v-icon {
|
|
||||||
color: #8c4a32 !important;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 1.2rem !important;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar .v-field__clearable .v-icon:hover {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
.auth-card {
|
|
||||||
max-width: 450px;
|
|
||||||
margin-top: 5vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-title {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
margin-top: -5px !important;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
line-height: 1.0 !important;
|
|
||||||
background: linear-gradient(to bottom, #8c4a32 20%, #4a2a14 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
filter: drop-shadow(1px 1px 0px rgba(255,255,255,0.1));
|
|
||||||
line-height: 1.1 !important;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-btn {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
font-size: 1.1rem !important;
|
|
||||||
background-color: #556b2f !important;
|
|
||||||
color: #f4e4bc !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
text-transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-btn:hover {
|
|
||||||
background-color: #2e1e0a !important;
|
|
||||||
text-shadow: none !important;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-input .v-field__prepend-inner {
|
|
||||||
display: flex !important;
|
|
||||||
padding-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-input .v-icon {
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-toggle-btn {
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-size: 1.0rem !important;
|
|
||||||
color: #6d5e4a !important;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-toggle-btn:hover {
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-switch-enter-active, .auth-switch-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-switch-enter-from, .auth-switch-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-input :deep(.v-field) {
|
|
||||||
--v-theme-on-surface: 46, 30, 10 !important;
|
|
||||||
--v-field-label-color: 46, 30, 10 !important;
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-input :deep(input),
|
|
||||||
.auth-input :deep(.v-field__input) {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
-webkit-text-fill-color: #2e1e0a !important;
|
|
||||||
caret-color: #2e1e0a !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-input :deep(.v-label) {
|
|
||||||
font-family: 'Libre Baskerville', serif !important;
|
|
||||||
color: #2e1e0a !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-success {
|
|
||||||
background-color: rgba(85, 107, 47, 0.1) !important;
|
|
||||||
border-color: #556b2f !important;
|
|
||||||
color: #556b2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-success :deep(.v-icon) {
|
|
||||||
color: #556b2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-message {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
font-family: 'Libre Baskerville', serif;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-error {
|
|
||||||
background-color: #EDC9C9 !important;
|
|
||||||
color: #8c2f2f !important;
|
|
||||||
border-color: #CB6262 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-error .v-icon {
|
|
||||||
color: #8c2f2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-success {
|
|
||||||
background-color: rgba(85, 107, 47, 0.1) !important;
|
|
||||||
color: #556b2f !important;
|
|
||||||
border-color: #556b2f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-success .v-icon {
|
|
||||||
color: #556b2f !important;
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<template>
|
|
||||||
<transition name="fade">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<v-img
|
|
||||||
v-if="recipe.imageUrl"
|
|
||||||
:src="recipe.imageUrl"
|
|
||||||
class="recipe-image rounded-lg mb-8 mx-auto"
|
|
||||||
elevation="2"
|
|
||||||
max-height="400"
|
|
||||||
cover
|
|
||||||
></v-img>
|
|
||||||
|
|
||||||
<v-row class="mt-10" density="compact">
|
|
||||||
<v-col cols="12" md="5" class="pe-md-10">
|
|
||||||
<div class="section-header justify-center mb-6">
|
|
||||||
<v-icon icon="mdi-spoon-sugar" class="mr-2" size="small"></v-icon>
|
|
||||||
<span>Ingredients</span>
|
|
||||||
</div>
|
|
||||||
<div class="ingredients-container">
|
|
||||||
<div v-for="(item, i) in recipe.ingredients" :key="i" class="ingredient-item">
|
|
||||||
{{ item }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" md="7" class="ps-md-10">
|
|
||||||
<div class="section-header justify-center mb-6">
|
|
||||||
<v-icon icon="mdi-pot-steam-outline" class="mr-2" size="small"></v-icon>
|
|
||||||
<span>Instructions</span>
|
|
||||||
</div>
|
|
||||||
<div v-for="(step, i) in recipe.instructions" :key="i" class="instruction-step mb-8">
|
|
||||||
<span class="step-number">{{ i + 1 }}.</span>
|
|
||||||
<p class="step-text">{{ step }}</p>
|
|
||||||
</div>
|
|
||||||
</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="px-8 print-btn"
|
|
||||||
size="large"
|
|
||||||
elevation="0"
|
|
||||||
@click="printRecipe"
|
|
||||||
>
|
|
||||||
<v-icon icon="mdi-printer-outline" class="mr-2"></v-icon>
|
|
||||||
Print Recipe
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
v-if="!isPublicView"
|
|
||||||
class="px-8 transition-swing"
|
|
||||||
size="large"
|
|
||||||
elevation="0"
|
|
||||||
:loading="isSaving"
|
|
||||||
:disabled="hasSaved"
|
|
||||||
:color="hasSaved ? '#556b2f' : '#5d4a36'"
|
|
||||||
:class="hasSaved ? 'save-success-btn' : 'save-recipe-btn'"
|
|
||||||
@click="$emit('save')"
|
|
||||||
>
|
|
||||||
<template v-if="!hasSaved">
|
|
||||||
<v-icon icon="mdi-content-save-check-outline" class="mr-2"></v-icon>
|
|
||||||
Save to Collection
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<v-icon icon="mdi-check-all" class="mr-2"></v-icon>
|
|
||||||
Saved in Archives
|
|
||||||
</template>
|
|
||||||
</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-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>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
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 },
|
|
||||||
isPublicView: { type: Boolean, default: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['save'])
|
|
||||||
|
|
||||||
const printRecipe = () => {
|
|
||||||
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>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-dialog v-model="showTimeout" persistent max-width="450">
|
|
||||||
<v-card class="recipe-card pa-6 text-center" theme="light">
|
|
||||||
<div class="mb-4">
|
|
||||||
<v-icon color="#8c4a32" size="64">mdi-clock-outline</v-icon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="auth-title mb-2">Session Expired</h2>
|
|
||||||
|
|
||||||
<v-card-text class="text-body-1" style="font-family: 'Libre Baskerville', serif;">
|
|
||||||
Your kitchen session has timed out after 30 minutes of inactivity.
|
|
||||||
Please sign back in to continue managing your recipes.
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-divider class="my-4 separator"></v-divider>
|
|
||||||
|
|
||||||
<v-card-actions>
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="auth-btn"
|
|
||||||
size="large"
|
|
||||||
@click="redirectToLogin"
|
|
||||||
>
|
|
||||||
Return to Sign In
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import '@/assets/css/app-theme.css'
|
|
||||||
const showTimeout = useState('showSessionTimeout')
|
|
||||||
|
|
||||||
const redirectToLogin = () => {
|
|
||||||
showTimeout.value = false
|
|
||||||
navigateTo('/login')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container>
|
|
||||||
<v-card class="recipe-card pa-6 mx-auto mt-4" max-width="950" elevation="1">
|
|
||||||
<header class="text-center mb-4">
|
|
||||||
<v-img
|
|
||||||
src="/images/seasoned-logo.png"
|
|
||||||
width="180"
|
|
||||||
class="mx-auto"
|
|
||||||
contain
|
|
||||||
>
|
|
||||||
</v-img>
|
|
||||||
<p class="chat-title">Kitchen Consultation</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<v-divider class="mb-6 separator"></v-divider>
|
|
||||||
|
|
||||||
<v-row justify="center" class="mb-6">
|
|
||||||
<v-col cols="12" md="11">
|
|
||||||
<div class="chat-container">
|
|
||||||
<div class="section-header mb-4 d-flex align-center">
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
|
|
||||||
<span>Ask the Chef</span>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-btn v-if="chatMessages.length > 0" icon="mdi-delete-sweep-outline" variant="text" color="#8c7e6a" @click="chatMessages = []"></v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-display" ref="chatDisplay">
|
|
||||||
<div v-if="chatMessages.length === 0" class="chat-placeholder">"What shall we create today?"</div>
|
|
||||||
<div v-for="(msg, i) in chatMessages" :key="i" :class="['message', msg.role]">
|
|
||||||
<span class="message-text">{{ msg.text }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="chatLoading" class="message assistant thinking-bubble">
|
|
||||||
<div class="typing">
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="dot"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-textarea
|
|
||||||
v-model="userQuery"
|
|
||||||
variant="outlined"
|
|
||||||
auto-grow
|
|
||||||
rows="1"
|
|
||||||
max-rows="6"
|
|
||||||
hide-details
|
|
||||||
class="chat-input"
|
|
||||||
@keydown.enter.exact.prevent="askChef"
|
|
||||||
:loading="chatLoading"
|
|
||||||
>
|
|
||||||
<template v-slot:append-inner>
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-send-variant"
|
|
||||||
variant="text"
|
|
||||||
class="mt-1 send-btn"
|
|
||||||
@click="askChef"
|
|
||||||
></v-btn>
|
|
||||||
</template>
|
|
||||||
</v-textarea>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<RecipeDisplay
|
|
||||||
:recipe="recipe"
|
|
||||||
:is-saving="saving"
|
|
||||||
:has-saved="hasSaved"
|
|
||||||
@save="saveToCollection"
|
|
||||||
/>
|
|
||||||
</v-card>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, nextTick } from 'vue'
|
|
||||||
import '@/assets/css/app-theme.css'
|
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const recipe = ref(null)
|
|
||||||
const userQuery = ref('')
|
|
||||||
const chatLoading = ref(false)
|
|
||||||
const chatMessages = ref([])
|
|
||||||
const chatDisplay = ref(null)
|
|
||||||
const router = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const hasSaved = ref(false)
|
|
||||||
|
|
||||||
const isAuthenticated = async () => {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/auth/manage/info', { credentials: 'include' })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveToCollection = async () => {
|
|
||||||
if (!recipe.value || hasSaved.value) return
|
|
||||||
|
|
||||||
saving.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
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)
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const askChef = async () => {
|
|
||||||
if (!userQuery.value.trim()) return
|
|
||||||
|
|
||||||
const query = userQuery.value
|
|
||||||
chatMessages.value.push({ role: 'user', text: userQuery.value })
|
|
||||||
userQuery.value = ''
|
|
||||||
chatLoading.value = true
|
|
||||||
|
|
||||||
await scrollToBottom()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await $fetch(`${config.public.apiBase}api/recipe/consult`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { prompt: query }
|
|
||||||
})
|
|
||||||
|
|
||||||
chatMessages.value.push({ role: 'assistant', text: data.reply })
|
|
||||||
|
|
||||||
if (data.recipe && data.recipe.title) {
|
|
||||||
recipe.value = data.recipe
|
|
||||||
hasSaved.value = false
|
|
||||||
localStorage.removeItem('pending_recipe')
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
chatMessages.value.push({
|
|
||||||
role: 'assistant',
|
|
||||||
text: "The kitchen is currently closed for repairs. Try again in a moment?"
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
chatLoading.value = false
|
|
||||||
await scrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
|
||||||
await nextTick()
|
|
||||||
if (chatDisplay.value) {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = chatDisplay.value
|
|
||||||
const isAtBottom = scrollHeight - scrollTop <= clientHeight + 100
|
|
||||||
|
|
||||||
if (isAtBottom) {
|
|
||||||
chatDisplay.value.scrollTo({
|
|
||||||
top: scrollHeight,
|
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container>
|
|
||||||
<v-card class="recipe-card pa-10 mx-auto mt-10" max-width="1200" elevation="1">
|
|
||||||
|
|
||||||
<header class="text-center mb-10">
|
|
||||||
<v-img
|
|
||||||
src="/images/seasoned-logo.png"
|
|
||||||
width="180"
|
|
||||||
class="mx-auto"
|
|
||||||
contain
|
|
||||||
></v-img>
|
|
||||||
<p class="collection-title">Your Recipe Collection</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<v-row justify="center" class="mb-6">
|
|
||||||
<v-col cols="12" md="8" lg="6">
|
|
||||||
<v-text-field
|
|
||||||
v-model="searchQuery"
|
|
||||||
placeholder="Search for 'comfort food'"
|
|
||||||
variant="outlined"
|
|
||||||
class="search-bar"
|
|
||||||
hide-details
|
|
||||||
clearable
|
|
||||||
@click:clear="fetchRecipes"
|
|
||||||
:loading="isSearching"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend-inner>
|
|
||||||
<v-icon :color="isSearching ? '#556b2f' : '#2e1e0a'">
|
|
||||||
{{ isSearching ? 'mdi-auto-fix' : 'mdi-magnify' }}
|
|
||||||
</v-icon>
|
|
||||||
</template>
|
|
||||||
</v-text-field>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-divider class="mb-10 separator"></v-divider>
|
|
||||||
|
|
||||||
<v-row v-if="loading" justify="center" class="py-16">
|
|
||||||
<v-col cols="12" class="d-flex flex-column align-center">
|
|
||||||
<v-progress-circular indeterminate color="#556b2f" size="64" width="3"></v-progress-circular>
|
|
||||||
<p class="brand-subtitle mt-4">Opening Collection...</p>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-row v-else-if="recipes?.length">
|
|
||||||
<v-col v-for="recipe in (searchQuery?.trim() ? recipes : sortedRecipes)" :key="recipe.id" cols="12" sm="6" md="4">
|
|
||||||
<v-card class="gallery-item-card pa-4">
|
|
||||||
<v-sheet
|
|
||||||
height="200"
|
|
||||||
color="#f8f5f0"
|
|
||||||
class="rounded-sm mb-4 d-flex align-center justify-center"
|
|
||||||
style="border: 1px solid #e8e2d6;"
|
|
||||||
>
|
|
||||||
<v-img
|
|
||||||
v-if="recipe.imageUrl"
|
|
||||||
:src="recipe.imageUrl.startsWith('http') ? recipe.imageUrl : `${config.public.apiBase}${recipe.imageUrl}`"
|
|
||||||
cover
|
|
||||||
class="recipe-thumbnail"
|
|
||||||
></v-img>
|
|
||||||
<v-icon
|
|
||||||
v-else
|
|
||||||
icon="mdi-camera-outline"
|
|
||||||
size="80"
|
|
||||||
color="#d1c7b7"
|
|
||||||
></v-icon>
|
|
||||||
</v-sheet>
|
|
||||||
<h3 class="gallery-item-title text-center">{{ recipe.title }}</h3>
|
|
||||||
<p class="gallery-item-date text-center">
|
|
||||||
Added {{ new Date(recipe.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<v-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>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-row v-else justify="center" class=" text-center">
|
|
||||||
<v-col cols="12">
|
|
||||||
<p class="brand-subtitle mb-10">Your collection is empty.</p>
|
|
||||||
<v-btn to="/" variant="text" class="column-btn">Return Home</v-btn>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<v-dialog v-model="showDetails" max-width="950" persistent>
|
|
||||||
<v-card v-if="selectedRecipe" class="recipe-card pa-8">
|
|
||||||
<v-btn
|
|
||||||
v-if="!isEditing"
|
|
||||||
icon="mdi-close"
|
|
||||||
variant="text"
|
|
||||||
position="absolute"
|
|
||||||
style="top: 10px; right: 10px; z-index: 10;"
|
|
||||||
color="#5d4037"
|
|
||||||
@click="closeDetails"
|
|
||||||
></v-btn>
|
|
||||||
|
|
||||||
<v-row
|
|
||||||
align="center"
|
|
||||||
class="mb-9 px-md-5"
|
|
||||||
:justify="!(selectedRecipe.imageUrl || isEditing) ? 'center' : 'start'"
|
|
||||||
>
|
|
||||||
<v-col
|
|
||||||
v-if="selectedRecipe.imageUrl || isEditing"
|
|
||||||
cols="12"
|
|
||||||
md="3"
|
|
||||||
class="d-flex justify-end pe-6"
|
|
||||||
style="flex: 0 0 auto;"
|
|
||||||
>
|
|
||||||
<v-hover v-slot="{ isHovering, props }">
|
|
||||||
<v-card
|
|
||||||
v-bind="props"
|
|
||||||
width="150"
|
|
||||||
height="150"
|
|
||||||
:class="[
|
|
||||||
'rounded-lg d-flex align-center justify-center position-relative overflow-hidden elevation-1',
|
|
||||||
{ 'image-drop-zone': isEditing, 'cursor-pointer': isEditing }
|
|
||||||
]"
|
|
||||||
:style="{
|
|
||||||
pointerEvents: isEditing ? 'auto' : 'none',
|
|
||||||
border: !selectedRecipe.imageUrl && isEditing ? '2px dashed #d1c7b7' : 'none'
|
|
||||||
}"
|
|
||||||
@click="isEditing ? $refs.fileInput.click() : null"
|
|
||||||
>
|
|
||||||
<v-img
|
|
||||||
v-if="selectedRecipe.imageUrl"
|
|
||||||
:src="selectedRecipe.imageUrl.startsWith('http') ? selectedRecipe.imageUrl : `${config.public.apiBase}${selectedRecipe.imageUrl}`"
|
|
||||||
cover
|
|
||||||
class="rounded-lg fill-height"
|
|
||||||
></v-img>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isEditing && (isHovering || !selectedRecipe.imageUrl)"
|
|
||||||
class="d-flex flex-column align-center justify-center position-absolute"
|
|
||||||
style="background: rgba(226,215,186,0.4); inset: 0;"
|
|
||||||
>
|
|
||||||
<v-icon icon="mdi-camera-plus" color="#556b2f" size="large"></v-icon>
|
|
||||||
<span class="brand-subtitle" style="font-size: 0.7rem; color: #556b2f;">Update Photo</span>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
|
||||||
</v-hover>
|
|
||||||
<input type="file" ref="fileInput" accept="image/*" style="display: none" @change="handleImageUpload" />
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col
|
|
||||||
cols="12"
|
|
||||||
:md="selectedRecipe.imageUrl || isEditing ? 8 : 12"
|
|
||||||
:class="!(selectedRecipe.imageUrl || isEditing) ? 'text-center' : 'ps-0'"
|
|
||||||
>
|
|
||||||
<v-text-field
|
|
||||||
v-if="isEditing"
|
|
||||||
v-model="selectedRecipe.title"
|
|
||||||
label="Recipe Title"
|
|
||||||
variant="underlined"
|
|
||||||
class="recipe-title-edit"
|
|
||||||
hide-details
|
|
||||||
></v-text-field>
|
|
||||||
<h2
|
|
||||||
v-else
|
|
||||||
class="recipe-title"
|
|
||||||
style="font-size: 2.2rem; line-height: 1.2; margin-bottom: 0;"
|
|
||||||
>
|
|
||||||
{{ selectedRecipe.title }}
|
|
||||||
</h2>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-divider class="mb-0 separator"></v-divider>
|
|
||||||
|
|
||||||
<v-row class="mt-10" density="compact">
|
|
||||||
<v-col cols="12" md="5" class="pe-md-10">
|
|
||||||
<h3 class="section-header justify-center mb-6">
|
|
||||||
<v-icon icon="mdi-spoon-sugar" class="mr-2"></v-icon>
|
|
||||||
Ingredients
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<v-textarea
|
|
||||||
v-if="isEditing"
|
|
||||||
v-model="selectedRecipe.ingredients"
|
|
||||||
variant="outlined"
|
|
||||||
auto-grow
|
|
||||||
density="comfortable"
|
|
||||||
class="auth-input recipe-textarea"
|
|
||||||
bg-color="transparent"
|
|
||||||
:persistent-placeholder="true"
|
|
||||||
></v-textarea>
|
|
||||||
|
|
||||||
<div v-else class="ingredients-container">
|
|
||||||
<div
|
|
||||||
v-for="(ing, index) in (Array.isArray(selectedRecipe.ingredients) ? selectedRecipe.ingredients : selectedRecipe.ingredients?.split('\n') || [])"
|
|
||||||
:key="index"
|
|
||||||
class="ingredient-item d-flex align-start mb-4"
|
|
||||||
>
|
|
||||||
{{ ing }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
|
|
||||||
<v-col cols="12" md="7" class="ps-md-1">
|
|
||||||
<h3 class="section-header justify-center mb-6">
|
|
||||||
<v-icon icon="mdi-pot-steam-outline" class="mr-2" size="small"></v-icon>
|
|
||||||
Instructions
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<v-textarea
|
|
||||||
v-if="isEditing"
|
|
||||||
v-model="selectedRecipe.instructions"
|
|
||||||
variant="outlined"
|
|
||||||
auto-grow
|
|
||||||
rows="10"
|
|
||||||
density="comfortable"
|
|
||||||
class="auth-input recipe-textarea"
|
|
||||||
bg-color="transparent"
|
|
||||||
:persistent-placeholder="true"
|
|
||||||
></v-textarea>
|
|
||||||
|
|
||||||
<div v-else
|
|
||||||
v-for="(step, index) in (Array.isArray(selectedRecipe.instructions) ? selectedRecipe.instructions : selectedRecipe.instructions?.split('\n') || [])"
|
|
||||||
:key="index"
|
|
||||||
class="instruction-step mb-8 d-flex align-start"
|
|
||||||
>
|
|
||||||
<span class="step-number mr-4">{{ index + 1 }}.</span>
|
|
||||||
<p class="step-text flex-grow-1 mb-0">{{ step }}</p>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-card-actions v-if="isEditing" class="justify-center mt-8">
|
|
||||||
<v-btn variant="text" @click="saveChanges" class="save-btn px-8">Save Changes</v-btn>
|
|
||||||
<v-btn variant="text" @click="isEditing = false" class="cancel-btn px-8">Cancel</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<v-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>
|
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
|
||||||
import '@/assets/css/gallery.css'
|
|
||||||
import '@/assets/css/app-theme.css'
|
|
||||||
|
|
||||||
const recipes = ref([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const showDetails = ref(false)
|
|
||||||
const selectedRecipe = ref(null)
|
|
||||||
const isEditing = ref(false)
|
|
||||||
const originalRecipe = ref(null)
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const isSearching = ref(false)
|
|
||||||
let debounceTimeout = null
|
|
||||||
const deleteConfirmVisible = ref(false)
|
|
||||||
const recipeToDelete = ref(null)
|
|
||||||
const isDeleting = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchRecipes()
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchRecipes = async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const data = await $fetch(`${config.public.apiBase}api/recipe/my-collection`, {
|
|
||||||
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) {
|
|
||||||
const isLoggedIn = useState('isLoggedIn')
|
|
||||||
isLoggedIn.value = false
|
|
||||||
navigateTo('/login')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImageUpload = (event) => {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
selectedRecipe.value.imageUrl = e.target.result;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRecipe = (recipe) => {
|
|
||||||
selectedRecipe.value = { ...recipe }
|
|
||||||
isEditing.value = false
|
|
||||||
showDetails.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const editRecipe = (recipe) => {
|
|
||||||
const editableRecipe = JSON.parse(JSON.stringify(recipe));
|
|
||||||
|
|
||||||
if (Array.isArray(editableRecipe.ingredients)) {
|
|
||||||
editableRecipe.ingredients = editableRecipe.ingredients.join('\n');
|
|
||||||
}
|
|
||||||
if (Array.isArray(editableRecipe.instructions)) {
|
|
||||||
editableRecipe.instructions = editableRecipe.instructions.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
originalRecipe.value = JSON.parse(JSON.stringify(recipe));
|
|
||||||
selectedRecipe.value = editableRecipe;
|
|
||||||
isEditing.value = true;
|
|
||||||
showDetails.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDetails = () => {
|
|
||||||
if (isEditing.value && originalRecipe.value) {
|
|
||||||
const index = recipes.value.findIndex(r => r.id === originalRecipe.value.id)
|
|
||||||
if (index !== -1) {
|
|
||||||
recipes.value[index] = originalRecipe.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showDetails.value = false
|
|
||||||
isEditing.value = false
|
|
||||||
originalRecipe.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveChanges = async () => {
|
|
||||||
try {
|
|
||||||
const { embedding, user, ...recipeData } = selectedRecipe.value;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
title: recipeData.title,
|
|
||||||
imageUrl: recipeData.imageUrl,
|
|
||||||
ingredients: typeof recipeData.ingredients === 'string'
|
|
||||||
? recipeData.ingredients.split('\n').filter(i => i.trim())
|
|
||||||
: recipeData.ingredients,
|
|
||||||
instructions: typeof recipeData.instructions === 'string'
|
|
||||||
? recipeData.instructions.split('\n').filter(i => i.trim())
|
|
||||||
: recipeData.instructions
|
|
||||||
};
|
|
||||||
|
|
||||||
await $fetch(`${config.public.apiBase}api/recipe/update/${selectedRecipe.value.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
const index = recipes.value.findIndex(r => r.id === selectedRecipe.value.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
const updatedRecipe = { ...recipes.value[index], ...payload };
|
|
||||||
recipes.value.splice(index, 1, updatedRecipe);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDetails();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("The kitchen ledger could not be updated:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedRecipes = computed(() => {
|
|
||||||
return [...recipes.value].sort((a, b) => {
|
|
||||||
return new Date(b.createdAt) - new Date(a.createdAt)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const performSearch = async () => {
|
|
||||||
const currentQuery = searchQuery.value
|
|
||||||
if (!searchQuery.value) {
|
|
||||||
await fetchRecipes()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isSearching.value = true
|
|
||||||
const data = await $fetch(`${config.public.apiBase}api/recipe/search`, {
|
|
||||||
query: { query: currentQuery },
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
if (searchQuery.value === currentQuery) {
|
|
||||||
recipes.value = data
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Search failed:", err)
|
|
||||||
} finally {
|
|
||||||
isSearching.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(searchQuery, (newVal) => {
|
|
||||||
clearTimeout(debounceTimeout)
|
|
||||||
debounceTimeout = setTimeout(() => {
|
|
||||||
performSearch()
|
|
||||||
}, 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>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container fluid class="pa-0 landing-wrapper">
|
|
||||||
<v-row density="compact" justify="center" align="start" class="pt-6">
|
|
||||||
<v-col cols="12" class="text-center px-4">
|
|
||||||
|
|
||||||
<v-card class="recipe-card pa-8 mx-auto mt-2" max-width="900">
|
|
||||||
|
|
||||||
<header class="mb-10">
|
|
||||||
<div class="brand-icon-container mb-2">
|
|
||||||
<v-img
|
|
||||||
src="/images/seasoned-logo.png"
|
|
||||||
width="180"
|
|
||||||
class="mx-auto"
|
|
||||||
contain
|
|
||||||
></v-img>
|
|
||||||
</div>
|
|
||||||
<h1 class="brand-title mt-0 mb-1">Seasoned</h1>
|
|
||||||
<p class="brand-subtitle mb-8">A Recipe Generator and Collection Tool</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<v-divider class="mb-10 separator"></v-divider>
|
|
||||||
|
|
||||||
<v-row class="mb-12 px-6" justify="center">
|
|
||||||
<v-col cols="12" md="4" class="text-center d-flex flex-column align-center">
|
|
||||||
<v-icon icon="mdi-folder-text" size="large" style="color: #2e1e0a" class="mb-3"></v-icon>
|
|
||||||
<h3 class="section-header justify-center mb-3">Scan Archives</h3>
|
|
||||||
<p class="feature-text text-body-2">
|
|
||||||
Turn handwritten cards into searchable digital text instantly.
|
|
||||||
</p>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-btn v-if="isLoggedIn" to="/uploader" class="mt-12 column-btn">
|
|
||||||
Go to Uploader
|
|
||||||
</v-btn>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="4" class="text-center d-flex flex-column align-center">
|
|
||||||
<v-icon icon="mdi-chef-hat" size="large" style="color: #2e1e0a" class="mb-3"></v-icon>
|
|
||||||
<h3 class="section-header justify-center mb-3">Consult the Chef</h3>
|
|
||||||
<p class="feature-text text-body-2">
|
|
||||||
Chat with an AI chef to scale ingredients, find substitutes, or get inspiration.
|
|
||||||
</p>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-btn v-if="isLoggedIn" to="/chat" class="mt-12 column-btn">
|
|
||||||
Talk to Chef
|
|
||||||
</v-btn>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="4" class="text-center d-flex flex-column align-center">
|
|
||||||
<v-icon icon="mdi-book-open-variant" size="large" style="color: #2e1e0a" class="mb-3"></v-icon>
|
|
||||||
<h3 class="section-header justify-center mb-3">Preserve History</h3>
|
|
||||||
<p class="feature-text text-body-2">
|
|
||||||
Build a private collection that keeps your family traditions alive and organized.
|
|
||||||
</p>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
<v-btn v-if="isLoggedIn" to="/gallery" class="mt-12 column-btn">
|
|
||||||
View Collection
|
|
||||||
</v-btn>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
<div v-if="!isLoggedIn" class="d-flex flex-column align-center">
|
|
||||||
<v-btn to="/login" class="column-btn" size="x-large">
|
|
||||||
Get Started
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import '@/assets/css/app-theme.css'
|
|
||||||
|
|
||||||
const isLoggedIn = useState('isLoggedIn', () => false)
|
|
||||||
|
|
||||||
</script>
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container class="fill-height">
|
|
||||||
<v-card theme="light" class="recipe-card auth-card pa-10 mx-auto" elevation="10" max-width="500">
|
|
||||||
|
|
||||||
<v-fade-transition mode="out-in">
|
|
||||||
<div :key="isLogin">
|
|
||||||
<header class="text-center mb-10">
|
|
||||||
<div class="brand-icon-container mb-1">
|
|
||||||
<v-img
|
|
||||||
src="/images/seasoned-logo.png"
|
|
||||||
width="180"
|
|
||||||
class="mx-auto"
|
|
||||||
contain
|
|
||||||
></v-img>
|
|
||||||
</div>
|
|
||||||
<h1 class="auth-title">{{ isLogin ? 'Sign In' : 'Join Us' }}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<v-form @submit.prevent="handleAuth">
|
|
||||||
<v-expand-transition>
|
|
||||||
<div v-if="errorMessage" class="mb-6"> <div
|
|
||||||
:class="[
|
|
||||||
'auth-message',
|
|
||||||
errorMessage.includes('created') ? 'auth-success' : 'auth-error'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<v-icon
|
|
||||||
:icon="errorMessage.includes('created') ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'"
|
|
||||||
size="small"
|
|
||||||
class="mr-2"
|
|
||||||
></v-icon>
|
|
||||||
<span>{{ errorMessage }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-expand-transition>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
autofocus
|
|
||||||
v-model="email"
|
|
||||||
label="Email Address"
|
|
||||||
class="mb-0 auth-input"
|
|
||||||
color="#8c4a32"
|
|
||||||
variant="outlined"
|
|
||||||
prepend-inner-icon="mdi-email-outline"
|
|
||||||
@input="errorMessage = ''"
|
|
||||||
:style="{
|
|
||||||
caretColor: '#2e1e0a !important',
|
|
||||||
fontFamily: 'Libre Baskerville, serif !important'
|
|
||||||
}"
|
|
||||||
></v-text-field>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="password"
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
class="mb-6 auth-input"
|
|
||||||
variant="outlined"
|
|
||||||
color="#8c4a32"
|
|
||||||
hide-details
|
|
||||||
prepend-inner-icon="mdi-lock-outline"
|
|
||||||
@input="errorMessage = ''"
|
|
||||||
:style="{
|
|
||||||
caretColor: '#2e1e0a !important',
|
|
||||||
fontFamily: 'Libre Baskerville, serif !important'
|
|
||||||
}"
|
|
||||||
></v-text-field>
|
|
||||||
|
|
||||||
<v-expand-transition>
|
|
||||||
<v-text-field
|
|
||||||
v-if="!isLogin"
|
|
||||||
v-model="confirmPassword"
|
|
||||||
label="Confirm Password"
|
|
||||||
type="password"
|
|
||||||
class="mb-6 auth-input"
|
|
||||||
variant="outlined"
|
|
||||||
color="#8c4a32"
|
|
||||||
hide-details
|
|
||||||
prepend-inner-icon="mdi-lock-check-outline"
|
|
||||||
@input="errorMessage = ''"
|
|
||||||
:style="{
|
|
||||||
caretColor: '#2e1e0a !important',
|
|
||||||
fontFamily: 'Libre Baskerville, serif !important'
|
|
||||||
}"
|
|
||||||
></v-text-field>
|
|
||||||
</v-expand-transition>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="auth-btn mb-4"
|
|
||||||
size="large"
|
|
||||||
type="submit"
|
|
||||||
:loading="authLoading"
|
|
||||||
:disabled="authLoading"
|
|
||||||
>
|
|
||||||
{{ isLogin ? 'Login' : 'Create Account' }}
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<span class="auth-toggle-btn" @click="isLogin = !isLogin" style="cursor: pointer;">
|
|
||||||
{{ isLogin ? "New here? Register an account" : "Already a member? Sign in" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</v-form>
|
|
||||||
</div>
|
|
||||||
</v-fade-transition>
|
|
||||||
|
|
||||||
<v-divider class="my-6 separator"></v-divider>
|
|
||||||
|
|
||||||
<v-btn to="/" variant="text" color="#6d5e4a" block class="view-recipe-btn">
|
|
||||||
<v-icon icon="mdi-chevron-left" class="mr-1"></v-icon>
|
|
||||||
Return to Home
|
|
||||||
</v-btn>
|
|
||||||
</v-card>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import '@/assets/css/login.css'
|
|
||||||
import '@/assets/css/app-theme.css'
|
|
||||||
|
|
||||||
const isLogin = ref(true)
|
|
||||||
const email = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const authLoading = ref(false)
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
const isLoggedIn = useState('isLoggedIn', () => false)
|
|
||||||
|
|
||||||
const toggleMode = () => {
|
|
||||||
isLogin.value = !isLogin.value
|
|
||||||
errorMessage.value = ''
|
|
||||||
confirmPassword.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAuth = async () => {
|
|
||||||
errorMessage.value = ''
|
|
||||||
if (!isLogin.value && password.value !== confirmPassword.value) {
|
|
||||||
errorMessage.value = "Passwords do not match."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
authLoading.value = true
|
|
||||||
const endpoint = isLogin.value ? '/api/auth/login' : '/api/auth/register'
|
|
||||||
|
|
||||||
const url = `${config.public.apiBase}${endpoint}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await $fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
email: email.value,
|
|
||||||
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
|
|
||||||
errorMessage.value = "Account created! Please sign in."
|
|
||||||
password.value = ''
|
|
||||||
confirmPassword.value = ''
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
authLoading.value = false
|
|
||||||
if (err.status === 401) {
|
|
||||||
errorMessage.value = "Invalid email or password. Please try again."
|
|
||||||
} else if (err.status === 400) {
|
|
||||||
errorMessage.value = "Registration failed. Check your password length."
|
|
||||||
} else if (err.status === 404) {
|
|
||||||
errorMessage.value = "Account not found. Would you like to register?"
|
|
||||||
} else {
|
|
||||||
errorMessage.value = "Something went wrong."
|
|
||||||
}
|
|
||||||
console.error('Auth error:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container>
|
|
||||||
<v-card class="recipe-card pa-10 mx-auto mt-10" max-width="950" elevation="1">
|
|
||||||
<header class="text-center mb-10">
|
|
||||||
<v-img
|
|
||||||
src="/images/seasoned-logo.png"
|
|
||||||
width="180"
|
|
||||||
class="mx-auto"
|
|
||||||
contain
|
|
||||||
>
|
|
||||||
</v-img>
|
|
||||||
<p class="chat-title">Recipe Uploader</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<v-divider class="mb-10 separator"></v-divider>
|
|
||||||
|
|
||||||
<v-row justify="center" class="mb-12">
|
|
||||||
<v-col cols="12" md="8" class="d-flex flex-column align-center">
|
|
||||||
<div
|
|
||||||
class="drop-zone mb-4"
|
|
||||||
:class="{ 'drop-zone--active': isDragging }"
|
|
||||||
@dragover.prevent="isDragging = true"
|
|
||||||
@dragleave.prevent="isDragging = false"
|
|
||||||
@drop.prevent="handleDrop"
|
|
||||||
@click="$refs.fileInput.click()"
|
|
||||||
>
|
|
||||||
<v-icon icon="mdi-cloud-upload-outline" size="large" class="mb-2" color="#5d4a36"></v-icon>
|
|
||||||
<p v-if="!files || files.length === 0" class="drop-text">
|
|
||||||
Drag your recipe photo here or <strong>click to browse</strong>
|
|
||||||
</p>
|
|
||||||
<p v-else class="drop-text selected-text">
|
|
||||||
{{ Array.isArray(files) ? files[0]?.name : files?.name }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<v-file-input
|
|
||||||
ref="fileInput"
|
|
||||||
v-model="files"
|
|
||||||
accept="image/*"
|
|
||||||
class="d-none"
|
|
||||||
hide-details
|
|
||||||
></v-file-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="d-flex w-100 mt-4">
|
|
||||||
<v-btn class="analyze-btn flex-grow-1 mr-2" size="large" :loading="loading" :disabled="!files" @click="uploadImage">
|
|
||||||
<v-icon icon="mdi-pot-steam" class="mr-2"></v-icon>Analyze Recipe
|
|
||||||
</v-btn>
|
|
||||||
<v-btn class="clear-btn-solid" size="large" @click="clearAll"><v-icon icon="mdi-refresh"></v-icon></v-btn>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<RecipeDisplay
|
|
||||||
:recipe="recipe"
|
|
||||||
:is-saving="saving"
|
|
||||||
:has-saved="hasSaved"
|
|
||||||
@save="saveToCollection"
|
|
||||||
/>
|
|
||||||
</v-card>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, nextTick } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import '@/assets/css/app-theme.css'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const files = ref(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const recipe = ref(null)
|
|
||||||
const isDragging = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const hasSaved = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const savedRecipe = localStorage.getItem('pending_recipe')
|
|
||||||
if (savedRecipe) {
|
|
||||||
recipe.value = JSON.parse(savedRecipe)
|
|
||||||
localStorage.removeItem('pending_recipe')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isAuthenticated = async () => {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/auth/manage/info', { credentials: 'include' })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
|
||||||
isDragging.value = false
|
|
||||||
const droppedFiles = e.dataTransfer.files
|
|
||||||
if (droppedFiles.length > 0) {
|
|
||||||
files.value = [droppedFiles[0]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadImage = async () => {
|
|
||||||
const fileToUpload = Array.isArray(files.value) ? files.value[0] : files.value;
|
|
||||||
if (!fileToUpload) return;
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
recipe.value = null;
|
|
||||||
hasSaved.value = false;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', fileToUpload);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await $fetch(`${config.public.apiBase}api/recipe/upload`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response) {
|
|
||||||
response.imageUrl = null;
|
|
||||||
}
|
|
||||||
recipe.value = response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error:", error);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveToCollection = async () => {
|
|
||||||
if (!recipe.value || hasSaved.value) return;
|
|
||||||
|
|
||||||
saving.value = true;
|
|
||||||
|
|
||||||
const isAuth = await isAuthenticated();
|
|
||||||
|
|
||||||
if (!isAuth) {
|
|
||||||
saving.value = false;
|
|
||||||
localStorage.setItem('pending_recipe', JSON.stringify(recipe.value))
|
|
||||||
router.push('/login')
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
files.value = null
|
|
||||||
recipe.value = null
|
|
||||||
hasSaved.value = false
|
|
||||||
loading.value = false
|
|
||||||
saving.value = false
|
|
||||||
localStorage.removeItem('pending_recipe')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export default defineNuxtPlugin((nuxtApp) => {
|
|
||||||
const showTimeout = useState('showSessionTimeout', () => false);
|
|
||||||
const isLoggedIn = useState('isLoggedIn');
|
|
||||||
|
|
||||||
nuxtApp.hook('app:created', () => {
|
|
||||||
const originalFetch = globalThis.$fetch;
|
|
||||||
|
|
||||||
globalThis.$fetch = originalFetch.create({
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
export default defineNuxtConfig({
|
|
||||||
future: {
|
|
||||||
compatibilityVersion: 4,
|
|
||||||
},
|
|
||||||
ssr: false,
|
|
||||||
srcDir: 'app/',
|
|
||||||
dir: {
|
|
||||||
public: 'public/'
|
|
||||||
},
|
|
||||||
modules: [
|
|
||||||
'vuetify-nuxt-module'
|
|
||||||
],
|
|
||||||
compatibilityDate: '2025-07-15',
|
|
||||||
devtools: { enabled: true },
|
|
||||||
build: {
|
|
||||||
transpile: ['vuetify'],
|
|
||||||
},
|
|
||||||
app: {
|
|
||||||
baseURL: '/',
|
|
||||||
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' },
|
|
||||||
{ rel: 'icon', type: 'image/png', sizes: '96x96', href: '/favicon-96x96.png' },
|
|
||||||
{ rel: 'icon', type: 'image/png', sizes: '192x192', href: '/web-app-manifest-192x192.png' },
|
|
||||||
{ rel : 'icon', type: 'image/png', sizes: '512x512', href: '/web-app-manifest-512x512.png' },
|
|
||||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
|
||||||
{ rel: 'manifest', href: '/site.webmanifest' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
css: [
|
|
||||||
'vuetify/lib/styles/main.sass',
|
|
||||||
'@mdi/font/css/materialdesignicons.min.css',
|
|
||||||
'@/assets/css/app-theme.css',
|
|
||||||
'@/assets/css/gallery.css',
|
|
||||||
'@/assets/css/login.css',
|
|
||||||
],
|
|
||||||
runtimeConfig: {
|
|
||||||
public: {
|
|
||||||
apiBase: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Seasoned",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"build": "nuxi build",
|
|
||||||
"dev": "nuxi dev",
|
|
||||||
"generate": "nuxi generate",
|
|
||||||
"preview": "nuxi preview",
|
|
||||||
"postinstall": "nuxi prepare",
|
|
||||||
"test": "vitest"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@google/generative-ai": "^0.24.1",
|
|
||||||
"@mdi/font": "^7.4.47",
|
|
||||||
"@prisma/client": "^7.4.2",
|
|
||||||
"axios": "^1.13.6",
|
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"mdi": "^2.2.43",
|
|
||||||
"nuxt": "^4.1.3",
|
|
||||||
"prisma": "^6.19.2",
|
|
||||||
"sass": "^1.97.3",
|
|
||||||
"vite-plugin-vuetify": "^2.1.3",
|
|
||||||
"vue": "^3.5.29",
|
|
||||||
"vue-router": "^4.6.4",
|
|
||||||
"vuetify": "^4.0.1",
|
|
||||||
"vuetify-nuxt-module": "^0.19.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@nuxt/test-utils": "^4.0.0",
|
|
||||||
"@types/node": "^25.3.3",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
|
||||||
"@vue/test-utils": "^2.4.6",
|
|
||||||
"happy-dom": "^20.8.4",
|
|
||||||
"jsdom": "^28.1.0",
|
|
||||||
"vitest": "^4.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 934 KiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "MyWebSite",
|
|
||||||
"short_name": "MySite",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/web-app-manifest-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/web-app-manifest-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 502 KiB |
@@ -1,77 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createVuetify } from 'vuetify'
|
|
||||||
import * as components from 'vuetify/components'
|
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import ChatPage from "@/pages/chat.vue"
|
|
||||||
|
|
||||||
const vuetify = createVuetify({ components, directives })
|
|
||||||
|
|
||||||
global.ResizeObserver = class ResizeObserver {
|
|
||||||
observe() {} unobserve() {} disconnect() {}
|
|
||||||
};
|
|
||||||
|
|
||||||
global.Element.prototype.scrollTo = vi.fn();
|
|
||||||
|
|
||||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
|
||||||
public: { apiBase: 'http://localhost:5000/' }
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockRouter = { push: vi.fn(), resolve: vi.fn(() => ({ href: '' })) }
|
|
||||||
vi.stubGlobal('useRouter', () => mockRouter)
|
|
||||||
|
|
||||||
const mockFetch = vi.fn()
|
|
||||||
vi.stubGlobal('$fetch', mockFetch)
|
|
||||||
|
|
||||||
describe('ChatPage.vue', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
global.Element.prototype.scrollTo = vi.fn()
|
|
||||||
})
|
|
||||||
|
|
||||||
const mountOptions = {
|
|
||||||
global: {
|
|
||||||
plugins: [vuetify],
|
|
||||||
stubs: { RecipeDisplay: true },
|
|
||||||
provide: { 'router': mockRouter }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('shows the placeholder when chat is empty', () => {
|
|
||||||
const wrapper = mount(ChatPage, mountOptions)
|
|
||||||
expect(wrapper.text()).toContain('"What shall we create today?"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('adds a user message and clears input on send', async () => {
|
|
||||||
const wrapper = mount(ChatPage, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
vm.userQuery = 'How do I make a roux?'
|
|
||||||
mockFetch.mockResolvedValueOnce({ reply: 'Test', recipe: null })
|
|
||||||
|
|
||||||
await vm.askChef()
|
|
||||||
|
|
||||||
expect(vm.chatMessages[0].text).toBe('How do I make a roux?')
|
|
||||||
expect(vm.userQuery).toBe('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays the assistant reply from the .NET API', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
reply: 'A roux is equal parts flour and fat.',
|
|
||||||
recipe: null
|
|
||||||
})
|
|
||||||
const wrapper = mount(ChatPage, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
vm.userQuery = 'Tell me about roux'
|
|
||||||
await vm.askChef()
|
|
||||||
expect(vm.chatMessages[1].text).toContain('equal parts flour')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error message if the API fails', async () => {
|
|
||||||
mockFetch.mockRejectedValueOnce(new Error('Backend Down'))
|
|
||||||
const wrapper = mount(ChatPage, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
vm.userQuery = 'Help!'
|
|
||||||
await vm.askChef()
|
|
||||||
expect(wrapper.text()).toContain('The kitchen is currently closed')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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'
|
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import GalleryPage from "@/pages/gallery.vue"
|
|
||||||
|
|
||||||
const vuetify = createVuetify({ components, directives })
|
|
||||||
|
|
||||||
global.ResizeObserver = class ResizeObserver {
|
|
||||||
observe() {} unobserve() {} disconnect() {}
|
|
||||||
};
|
|
||||||
|
|
||||||
global.visualViewport = {
|
|
||||||
width: 1024,
|
|
||||||
height: 768,
|
|
||||||
offsetLeft: 0,
|
|
||||||
offsetTop: 0,
|
|
||||||
pageLeft: 0,
|
|
||||||
pageTop: 0,
|
|
||||||
scale: 1,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
} as unknown as VisualViewport;
|
|
||||||
|
|
||||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
|
||||||
public: { apiBase: 'http://localhost:5000/' }
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockFetch = vi.fn()
|
|
||||||
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()
|
|
||||||
mockFetch.mockResolvedValue([])
|
|
||||||
})
|
|
||||||
|
|
||||||
const mountOptions = {
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('shows loading state initially and then renders recipes', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce([
|
|
||||||
{ id: 1, title: 'Bolognese', createdAt: new Date().toISOString(), ingredients: [], instructions: [] }
|
|
||||||
])
|
|
||||||
|
|
||||||
const wrapper = mount(GalleryPage, mountOptions)
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Opening Collection...')
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(wrapper.text()).toContain('Bolognese')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('triggers semantic search when searchQuery changes', async () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce([])
|
|
||||||
|
|
||||||
const wrapper = mount(GalleryPage, mountOptions)
|
|
||||||
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
vm.searchQuery = 'spicy pasta'
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(600)
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce([{ id: 2, title: 'Spicy Pasta' }])
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('api/recipe/search'),
|
|
||||||
expect.objectContaining({
|
|
||||||
query: { query: 'spicy pasta' },
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('redirects to login if API returns 401', async () => {
|
|
||||||
mockFetch.mockReset()
|
|
||||||
mockFetch.mockRejectedValue({ status: 401 })
|
|
||||||
|
|
||||||
mount(GalleryPage, mountOptions)
|
|
||||||
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/login')
|
|
||||||
}, { timeout: 1000 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('enters editing mode and formats arrays into strings', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: 'Muffins',
|
|
||||||
ingredients: ['Flour', 'Sugar'],
|
|
||||||
instructions: ['Mix', 'Bake'],
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const wrapper = mount(GalleryPage, mountOptions)
|
|
||||||
await vi.waitFor(() => expect(wrapper.vm.recipes.length).toBe(1))
|
|
||||||
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
vm.editRecipe(vm.recipes[0])
|
|
||||||
|
|
||||||
expect(vm.isEditing).toBe(true)
|
|
||||||
expect(vm.selectedRecipe.ingredients).toBe('Flour\nSugar')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shoves updated recipe back to .NET API on saveChanges', async () => {
|
|
||||||
const mockRecipe = { id: 1, title: 'Old Title', ingredients: 'Water', instructions: 'Boil', createdAt: new Date().toISOString() }
|
|
||||||
mockFetch.mockResolvedValueOnce([mockRecipe])
|
|
||||||
|
|
||||||
const wrapper = mount(GalleryPage, mountOptions)
|
|
||||||
await vi.waitFor(() => expect(wrapper.vm.recipes.length).toBe(1))
|
|
||||||
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
vm.selectedRecipe = { ...mockRecipe, title: 'New Title' }
|
|
||||||
vm.isEditing = true
|
|
||||||
|
|
||||||
mockFetch.mockResolvedValueOnce({ success: true })
|
|
||||||
|
|
||||||
await vm.saveChanges()
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('api/recipe/update/1'),
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'PUT',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
expect(vm.recipes[0].title).toBe('New Title')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { createVuetify } from 'vuetify'
|
|
||||||
import * as components from 'vuetify/components'
|
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import IndexPage from '@/pages/index.vue'
|
|
||||||
|
|
||||||
const vuetify = createVuetify({ components, directives })
|
|
||||||
|
|
||||||
const mockIsLoggedIn = ref(false)
|
|
||||||
vi.stubGlobal('useState', vi.fn((key, init) => {
|
|
||||||
if (key === 'isLoggedIn') return mockIsLoggedIn
|
|
||||||
return ref(init ? init() : null)
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('IndexPage.vue', () => {
|
|
||||||
it('renders the brand title and subtitle', () => {
|
|
||||||
const wrapper = mount(IndexPage, {
|
|
||||||
global: {
|
|
||||||
plugins: [vuetify],
|
|
||||||
stubs: { 'nuxt-link': true }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Seasoned')
|
|
||||||
expect(wrapper.text()).toContain('A Recipe Generator')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows "Get Started" button when NOT logged in', () => {
|
|
||||||
const wrapper = mount(IndexPage, {
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Get Started')
|
|
||||||
expect(wrapper.text()).not.toContain('Talk to Chef')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides "Get Started" and shows action buttons when logged in', async () => {
|
|
||||||
|
|
||||||
const wrapper = mount(IndexPage, {
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
})
|
|
||||||
|
|
||||||
const isLoggedIn = useState('isLoggedIn')
|
|
||||||
isLoggedIn.value = true
|
|
||||||
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Talk to Chef')
|
|
||||||
expect(wrapper.text()).toContain('Go to Uploader')
|
|
||||||
expect(wrapper.text()).not.toContain('Get Started')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createVuetify } from 'vuetify'
|
|
||||||
import * as components from 'vuetify/components'
|
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import LoginPage from "@/pages/login.vue"
|
|
||||||
|
|
||||||
const vuetify = createVuetify({ components, directives })
|
|
||||||
|
|
||||||
// Standard Mocks
|
|
||||||
global.ResizeObserver = class ResizeObserver {
|
|
||||||
observe() {} unobserve() {} disconnect() {}
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
|
||||||
public: { apiBase: 'http://localhost:5000/' }
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockFetch = vi.fn()
|
|
||||||
vi.stubGlobal('$fetch', mockFetch)
|
|
||||||
|
|
||||||
const mockNavigate = vi.fn()
|
|
||||||
vi.stubGlobal('navigateTo', mockNavigate)
|
|
||||||
vi.stubGlobal('localStorage', { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn() })
|
|
||||||
|
|
||||||
vi.stubGlobal('useState', () => ({ value: false }))
|
|
||||||
|
|
||||||
describe('LoginPage.vue', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
const mountOptions = {
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('switches between Login and Register modes', async () => {
|
|
||||||
const wrapper = mount(LoginPage, mountOptions)
|
|
||||||
|
|
||||||
expect(wrapper.find('h1').text()).toBe('Sign In')
|
|
||||||
expect(wrapper.find('input[label="Confirm Password"]').exists()).toBe(false)
|
|
||||||
|
|
||||||
await wrapper.find('.auth-toggle-btn').trigger('click')
|
|
||||||
|
|
||||||
expect(wrapper.find('h1').text()).toBe('Join Us')
|
|
||||||
expect(wrapper.vm.isLogin).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error if passwords do not match in registration mode', async () => {
|
|
||||||
const wrapper = mount(LoginPage, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
|
|
||||||
vm.isLogin = false
|
|
||||||
vm.email = 'test@test.com'
|
|
||||||
vm.password = 'password123'
|
|
||||||
vm.confirmPassword = 'differentPassword'
|
|
||||||
|
|
||||||
await vm.handleAuth()
|
|
||||||
|
|
||||||
expect(vm.errorMessage).toBe('Passwords do not match.')
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls login API and redirects on success', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({ accessToken: 'fake-token' })
|
|
||||||
const wrapper = mount(LoginPage, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
|
|
||||||
vm.email = 'chef@seasoned.com'
|
|
||||||
vm.password = 'secret'
|
|
||||||
|
|
||||||
await vm.handleAuth()
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('api/auth/login'),
|
|
||||||
expect.any(Object)
|
|
||||||
)
|
|
||||||
expect(mockNavigate).toHaveBeenCalledWith('/')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays specific error for 401 Unauthorized', async () => {
|
|
||||||
mockFetch.mockRejectedValueOnce({ status: 401 })
|
|
||||||
const wrapper = mount(LoginPage, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
|
|
||||||
await vm.handleAuth()
|
|
||||||
|
|
||||||
expect(vm.errorMessage).toContain('Invalid email or password')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createVuetify } from 'vuetify'
|
|
||||||
import * as components from 'vuetify/components'
|
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import RecipeDisplay from '../app/components/RecipeDisplay.vue'
|
|
||||||
|
|
||||||
const vuetify = createVuetify({
|
|
||||||
components,
|
|
||||||
directives,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('RecipeDisplay.vue', () => {
|
|
||||||
const mockRecipe = {
|
|
||||||
title: 'Bakery-Style Muffins',
|
|
||||||
description: 'Fresh from the oven.',
|
|
||||||
ingredients: ['2 cups flour', '1 cup sugar'],
|
|
||||||
instructions: ['Preheat oven', 'Bake muffins'],
|
|
||||||
imageUrl: 'data:image/png;base64,header_captured_image'
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the title and all ingredients correctly', () => {
|
|
||||||
const wrapper = mount(RecipeDisplay, {
|
|
||||||
props: { recipe: mockRecipe },
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.find('.recipe-title').text()).toBe('Bakery-Style Muffins')
|
|
||||||
|
|
||||||
const ingredientItems = wrapper.findAll('.ingredient-item')
|
|
||||||
expect(ingredientItems).toHaveLength(2)
|
|
||||||
expect(ingredientItems[0].text()).toContain('2 cups flour')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays the recipe image when imageUrl is provided', () => {
|
|
||||||
const wrapper = mount(RecipeDisplay, {
|
|
||||||
props: { recipe: mockRecipe },
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
})
|
|
||||||
|
|
||||||
const img = wrapper.findComponent({ name: 'VImg' })
|
|
||||||
expect(img.exists()).toBe(true)
|
|
||||||
expect(img.props('src')).toBe(mockRecipe.imageUrl)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits "save" when the save button is clicked', async () => {
|
|
||||||
const wrapper = mount(RecipeDisplay, {
|
|
||||||
props: {
|
|
||||||
recipe: mockRecipe,
|
|
||||||
isSaving: false,
|
|
||||||
hasSaved: false
|
|
||||||
},
|
|
||||||
global: {
|
|
||||||
plugins: [vuetify]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveBtn = wrapper.find('.save-recipe-btn')
|
|
||||||
await saveBtn.trigger('click')
|
|
||||||
|
|
||||||
expect(wrapper.emitted()).toHaveProperty('save')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the "Saved in Archives" state when hasSaved is true', async () => {
|
|
||||||
const recipe = { title: 'Bakery-Style Muffins', ingredients: [], instructions: [] }
|
|
||||||
const wrapper = mount(RecipeDisplay, {
|
|
||||||
props: {
|
|
||||||
recipe,
|
|
||||||
hasSaved: true
|
|
||||||
},
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Saved in Archives')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('triggers the browser print dialog when the print button is clicked', async () => {
|
|
||||||
const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {})
|
|
||||||
|
|
||||||
const wrapper = mount(RecipeDisplay, {
|
|
||||||
props: { recipe: mockRecipe },
|
|
||||||
global: { plugins: [vuetify] }
|
|
||||||
})
|
|
||||||
|
|
||||||
const printBtn = wrapper.find('.print-btn')
|
|
||||||
await printBtn.trigger('click')
|
|
||||||
|
|
||||||
expect(printSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { createVuetify } from 'vuetify'
|
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import * as components from 'vuetify/components'
|
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import Uploader from "@/pages/uploader.vue"
|
|
||||||
|
|
||||||
const vuetify = createVuetify({ components, directives })
|
|
||||||
|
|
||||||
global.ResizeObserver = class ResizeObserver {
|
|
||||||
observe() {} unobserve() {} disconnect() {}
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
|
||||||
public: { apiBase: 'http://localhost:5000/' }
|
|
||||||
}))
|
|
||||||
|
|
||||||
const mockRouter = { push: vi.fn() }
|
|
||||||
vi.stubGlobal('useRouter', () => mockRouter)
|
|
||||||
|
|
||||||
const mockFetch = vi.fn()
|
|
||||||
vi.stubGlobal('$fetch', mockFetch)
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Uploader.vue', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
const mountOptions = {
|
|
||||||
global: {
|
|
||||||
plugins: [vuetify, router],
|
|
||||||
stubs: { RecipeDisplay: true },
|
|
||||||
provide: { 'router': mockRouter }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the drop zone and upload button', () => {
|
|
||||||
const wrapper = mount(Uploader, mountOptions)
|
|
||||||
expect(wrapper.text()).toContain('Analyze Recipe')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the filename when a file is selected', async () => {
|
|
||||||
const wrapper = mount(Uploader, mountOptions)
|
|
||||||
const file = new File(['(data)'], 'grandmas-cookies.png', { type: 'image/png' })
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
|
|
||||||
vm.files = [file]
|
|
||||||
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
expect(wrapper.text()).toContain('grandmas-cookies.png')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows loading state on the button when analyzing', async () => {
|
|
||||||
const wrapper = mount(Uploader, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
|
|
||||||
vm.loading = true
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
const btn = wrapper.find('.analyze-btn')
|
|
||||||
expect(btn.attributes('class')).toContain('v-btn--loading')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restores a recipe from localStorage on mount', async () => {
|
|
||||||
const savedRecipe = { title: 'Restored Cake', ingredients: [], instructions: [] }
|
|
||||||
localStorage.setItem('pending_recipe', JSON.stringify(savedRecipe))
|
|
||||||
|
|
||||||
const wrapper = mount(Uploader, mountOptions)
|
|
||||||
const vm = wrapper.vm as any
|
|
||||||
|
|
||||||
expect(vm.recipe.title).toBe('Restored Cake')
|
|
||||||
expect(localStorage.getItem('pending_recipe')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// vitest.config.ts
|
|
||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
css: false,
|
|
||||||
server: {
|
|
||||||
deps: {
|
|
||||||
inline: [
|
|
||||||
/@exodus\/bytes/,
|
|
||||||
/html-encoding-sniffer/,
|
|
||||||
/vuetify/
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './app'),
|
|
||||||
'~': path.resolve(__dirname, './app')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
using Moq;
|
|
||||||
using Xunit;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Seasoned.Backend.Controllers;
|
|
||||||
using Seasoned.Backend.Services;
|
|
||||||
using Seasoned.Backend.DTOs;
|
|
||||||
using Seasoned.Backend.Data;
|
|
||||||
using Seasoned.Backend.Models;
|
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace Seasoned.Tests;
|
|
||||||
|
|
||||||
public class RecipeControllerTests
|
|
||||||
{
|
|
||||||
private readonly Mock<IRecipeService> _mockService;
|
|
||||||
private readonly ApplicationDbContext _context;
|
|
||||||
private readonly RecipeController _controller;
|
|
||||||
private readonly string _testUserId = "chef-123";
|
|
||||||
|
|
||||||
public RecipeControllerTests()
|
|
||||||
{
|
|
||||||
_mockService = new Mock<IRecipeService>();
|
|
||||||
|
|
||||||
// Setup InMemory Postgres replacement
|
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
_context = new ApplicationDbContext(options);
|
|
||||||
|
|
||||||
_controller = new RecipeController(_mockService.Object, _context);
|
|
||||||
|
|
||||||
// Mock the User Identity (User.FindFirstValue(ClaimTypes.NameIdentifier))
|
|
||||||
var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, _testUserId),
|
|
||||||
}, "mock"));
|
|
||||||
|
|
||||||
_controller.ControllerContext = new ControllerContext()
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext() { User = user }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task UploadRecipe_ReturnsOk_WithParsedDataAndImage()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var fakeRecipe = new RecipeResponseDto
|
|
||||||
{
|
|
||||||
Title = "Roasted Garlic Pasta",
|
|
||||||
ImageUrl = "data:image/png;base64,header_captured_image"
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockService.Setup(s => s.ParseRecipeImageAsync(It.IsAny<IFormFile>()))
|
|
||||||
.ReturnsAsync(fakeRecipe);
|
|
||||||
|
|
||||||
var file = CreateMockFile("test-recipe.jpg");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.UploadRecipe(file);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var okResult = result.Result as OkObjectResult;
|
|
||||||
okResult.Should().NotBeNull();
|
|
||||||
var returned = okResult!.Value as RecipeResponseDto;
|
|
||||||
returned!.Title.Should().Be("Roasted Garlic Pasta");
|
|
||||||
returned.ImageUrl.Should().Contain("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SaveRecipe_PersistsToPostgres_ForCurrentUser()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var dto = new RecipeResponseDto
|
|
||||||
{
|
|
||||||
Title = "Jenkins Special Brew",
|
|
||||||
Ingredients = new List<string> { "Coffee", "Code" },
|
|
||||||
ImageUrl = "base64-string"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.SaveRecipe(dto);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var okResult = result as OkResult; // Or OkObjectResult based on your Controller return
|
|
||||||
var savedRecipe = await _context.Recipes.FirstOrDefaultAsync(r => r.Title == "Jenkins Special Brew");
|
|
||||||
|
|
||||||
savedRecipe.Should().NotBeNull();
|
|
||||||
savedRecipe!.UserId.Should().Be(_testUserId);
|
|
||||||
savedRecipe.ImageUrl.Should().Be("base64-string");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ConsultChef_ReturnsChefResponse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var request = new ChatRequestDto { Prompt = "How do I sear a steak?" };
|
|
||||||
var expectedResponse = new ChefConsultResponseDto { Reply = "High heat, my friend!" };
|
|
||||||
|
|
||||||
_mockService.Setup(s => s.ConsultChefAsync(It.IsAny<string>()))
|
|
||||||
.ReturnsAsync(expectedResponse);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _controller.Consult(request);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
var okResult = result as OkObjectResult;
|
|
||||||
var response = okResult!.Value as ChefConsultResponseDto;
|
|
||||||
response!.Reply.Should().Be("High heat, my friend!");
|
|
||||||
}
|
|
||||||
|
|
||||||
private IFormFile CreateMockFile(string fileName)
|
|
||||||
{
|
|
||||||
var ms = new MemoryStream();
|
|
||||||
var writer = new StreamWriter(ms);
|
|
||||||
writer.Write("fake image binary data");
|
|
||||||
writer.Flush();
|
|
||||||
ms.Position = 0;
|
|
||||||
return new FormFile(ms, 0, ms.Length, "image", fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using Moq;
|
|
||||||
using Xunit;
|
|
||||||
using FluentAssertions;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Seasoned.Backend.Services;
|
|
||||||
using Seasoned.Backend.DTOs;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Seasoned.Tests;
|
|
||||||
|
|
||||||
public class RecipeServiceTests
|
|
||||||
{
|
|
||||||
private readonly Mock<IConfiguration> _mockConfig;
|
|
||||||
private readonly RecipeService _service;
|
|
||||||
|
|
||||||
public RecipeServiceTests()
|
|
||||||
{
|
|
||||||
_mockConfig = new Mock<IConfiguration>();
|
|
||||||
// Mock the API Key retrieval
|
|
||||||
_mockConfig.Setup(c => c["GEMINI_API_KEY"]).Returns("fake-api-key-123");
|
|
||||||
|
|
||||||
_service = new RecipeService(_mockConfig.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_ThrowsException_WhenApiKeyIsMissing()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var emptyConfig = new Mock<IConfiguration>();
|
|
||||||
emptyConfig.Setup(c => c["GEMINI_API_KEY"]).Returns((string?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
Action act = () => new RecipeService(emptyConfig.Object);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
act.Should().Throw<ArgumentNullException>().WithMessage("*API Key missing*");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ParseRecipeImageAsync_ReturnsError_WhenImageIsEmpty()
|
|
||||||
{
|
|
||||||
// Arrange: Create an empty file
|
|
||||||
var fileMock = new Mock<IFormFile>();
|
|
||||||
fileMock.Setup(f => f.Length).Returns(0);
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
var result = await _service.ParseRecipeImageAsync(fileMock.Object);
|
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ParseRecipeImageAsync_AttachesCorrectBase64Header()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var content = "fake-image-data";
|
|
||||||
var fileName = "test.png";
|
|
||||||
var ms = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
||||||
var mockFile = new FormFile(ms, 0, ms.Length, "image", fileName)
|
|
||||||
{
|
|
||||||
Headers = new HeaderDictionary(),
|
|
||||||
ContentType = "image/png"
|
|
||||||
};
|
|
||||||
|
|
||||||
var resultImageUrl = $"data:{mockFile.ContentType};base64,{Convert.ToBase64String(ms.ToArray())}";
|
|
||||||
|
|
||||||
resultImageUrl.Should().StartWith("data:image/png;base64,");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
|
||||||
<_Parameter1>Seasoned.Tests</_Parameter1>
|
|
||||||
</AssemblyAttribute>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
|
||||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
|
||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Seasoned.Backend\Seasoned.Backend.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"path": ".."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
30
Seasoned.sln
@@ -1,30 +0,0 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.5.2.0
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seasoned.Tests", "Seasoned.Tests\Seasoned.Tests.csproj", "{FEF19240-CC2D-9BD4-7AC8-277372525020}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seasoned.Backend", "Seasoned.Backend\Seasoned.Backend.csproj", "{874CAB7B-A43B-C80D-494C-C243C69AEE7A}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{FEF19240-CC2D-9BD4-7AC8-277372525020}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{874CAB7B-A43B-C80D-494C-C243C69AEE7A}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
|
||||||
SolutionGuid = {ED9A436F-5793-4685-B738-3AA5653D4AF1}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
6
app/app.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<NuxtWelcome />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
39
compose.yaml
@@ -1,39 +0,0 @@
|
|||||||
services:
|
|
||||||
# Database: Postgres with pgvector
|
|
||||||
db:
|
|
||||||
image: pgvector/pgvector:pg16
|
|
||||||
container_name: seasoned-db
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=${DB_USER}
|
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
||||||
- POSTGRES_DB=${DB_NAME}
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
- ./init-db:/docker-entrypoint-initdb.d
|
|
||||||
|
|
||||||
# Seasoned App: C# .NET 9 + Nuxt 4
|
|
||||||
app:
|
|
||||||
image: git.wrigglyt.xyz/chloe/seasoned:latest
|
|
||||||
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
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgdata:
|
|
||||||
21
nuxt.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
future: {
|
||||||
|
compatibilityVersion: 4,
|
||||||
|
},
|
||||||
|
srcDir: 'app/',
|
||||||
|
css: [
|
||||||
|
'vuetify/lib/styles/main.sass',
|
||||||
|
'@mdi/font/css/materialdesignicons.min.css',
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
transpile: ['vuetify'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Environment Variables for Gemini
|
||||||
|
runtimeConfig: {
|
||||||
|
geminiApiKey: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
4681
Seasoned.Frontend/package-lock.json → package-lock.json
generated
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "Seasoned",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"@prisma/client": "^7.4.2",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"nuxt": "^4.1.3",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
|
"sass": "^1.97.3",
|
||||||
|
"vue": "^3.5.29",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
|
"vuetify": "^4.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |