Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Add source_file_key and thumbnail_key to user_recipes

Revision ID: a1b2c3d4e5f6
Revises: ede6c69f37ad
Create Date: 2026-04-11 00:00:00.000000

"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, Sequence[str], None] = "ede6c69f37ad"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Add file storage key columns to user_recipes."""
op.add_column("user_recipes", sa.Column("source_file_key", sa.Text(), nullable=True))
op.add_column("user_recipes", sa.Column("thumbnail_key", sa.Text(), nullable=True))


def downgrade() -> None:
"""Remove file storage key columns from user_recipes."""
op.drop_column("user_recipes", "thumbnail_key")
op.drop_column("user_recipes", "source_file_key")
78 changes: 78 additions & 0 deletions apps/kitchen_mate/frontend/src/api/recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Recipe,
SaveRecipeRequest,
SaveRecipeResponse,
ThumbnailUploadResponse,
UpdateUserRecipeRequest,
UserRecipe,
getErrorMessage,
Expand Down Expand Up @@ -178,3 +179,80 @@ export async function deleteUserRecipe(recipeId: string): Promise<void> {
throw new RecipeError(message, response.status);
}
}

/**
* Save a recipe extracted from an uploaded file (atomic file + recipe save).
*/
export async function saveRecipeFromUpload(
file: File,
recipe: Recipe,
parsingMethod: string,
tags?: string[],
notes?: string
): Promise<SaveRecipeResponse> {
const formData = new FormData();
formData.append("file", file);
formData.append("recipe_json", JSON.stringify(recipe));
formData.append("parsing_method", parsingMethod);
if (tags && tags.length > 0) {
formData.append("tags_json", JSON.stringify(tags));
}
if (notes) {
formData.append("notes", notes);
}

const response = await fetch(`${API_BASE}/me/recipes/from-upload`, {
method: "POST",
body: formData,
credentials: "include",
});

if (!response.ok) {
const error: ApiError = await response.json();
const message = getErrorMessage(error.detail, "Failed to save recipe");
throw new RecipeError(message, response.status);
}

return response.json();
}

/**
* Upload or replace the thumbnail for a saved recipe.
*/
export async function uploadRecipeThumbnail(
recipeId: string,
file: File
): Promise<ThumbnailUploadResponse> {
const formData = new FormData();
formData.append("file", file);

const response = await fetch(`${API_BASE}/me/recipes/${recipeId}/thumbnail`, {
method: "POST",
body: formData,
credentials: "include",
});

if (!response.ok) {
const error: ApiError = await response.json();
const message = getErrorMessage(error.detail, "Failed to upload thumbnail");
throw new RecipeError(message, response.status);
}

return response.json();
}

/**
* Delete the thumbnail for a saved recipe.
*/
export async function deleteRecipeThumbnail(recipeId: string): Promise<void> {
const response = await fetch(`${API_BASE}/me/recipes/${recipeId}/thumbnail`, {
method: "DELETE",
credentials: "include",
});

if (!response.ok) {
const error: ApiError = await response.json();
const message = getErrorMessage(error.detail, "Failed to delete thumbnail");
throw new RecipeError(message, response.status);
}
}
18 changes: 10 additions & 8 deletions apps/kitchen_mate/frontend/src/components/AddFromUploadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { Recipe } from "../types/recipe";
import { uploadRecipe, ClipError } from "../api/clip";
import { saveRecipe, RecipeError } from "../api/recipes";
import { saveRecipeFromUpload, RecipeError } from "../api/recipes";
import { FileDropZone } from "./FileDropZone";
import { RecipeCard } from "./RecipeCard";
import { LoadingSpinner } from "./LoadingSpinner";
Expand All @@ -13,8 +13,8 @@ import { useIsPro } from "../hooks/usePermission";
type PageState =
| { status: "idle" }
| { status: "extracting"; filename: string }
| { status: "extracted"; filename: string; recipe: Recipe; parsingMethod: string }
| { status: "saving"; filename: string; recipe: Recipe; parsingMethod: string }
| { status: "extracted"; file: File; filename: string; recipe: Recipe; parsingMethod: string }
| { status: "saving"; file: File; filename: string; recipe: Recipe; parsingMethod: string }
| { status: "saved"; filename: string; recipe: Recipe; recipeId: string }
| { status: "error"; filename: string; message: string; errorType: ErrorType };

Expand All @@ -31,6 +31,7 @@ export function AddFromUploadPage() {
const result = await uploadRecipe(file);
setState({
status: "extracted",
file,
filename: file.name,
recipe: result.recipe,
parsingMethod: result.parsing_method,
Expand All @@ -57,17 +58,18 @@ export function AddFromUploadPage() {

setState({
status: "saving",
file: state.file,
filename: state.filename,
recipe: state.recipe,
parsingMethod: state.parsingMethod,
});

try {
const result = await saveRecipe({
sourceType: "upload",
recipe: state.recipe,
parsingMethod: state.parsingMethod,
});
const result = await saveRecipeFromUpload(
state.file,
state.recipe,
state.parsingMethod,
);
setState({
status: "saved",
filename: state.filename,
Expand Down
124 changes: 114 additions & 10 deletions apps/kitchen_mate/frontend/src/components/SavedRecipeView.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { Recipe, UserRecipe } from "../types/recipe";
import {
getUserRecipe,
updateUserRecipe,
deleteUserRecipe,
uploadRecipeThumbnail,
deleteRecipeThumbnail,
RecipeError,
} from "../api/recipes";
import { RecipeEditor } from "./RecipeEditor";
Expand All @@ -23,6 +25,11 @@ export function SavedRecipeView() {
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);

// Thumbnail state
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [isThumbnailUploading, setIsThumbnailUploading] = useState(false);
const thumbnailInputRef = useRef<HTMLInputElement>(null);

// Edit state
const [editedRecipe, setEditedRecipe] = useState<Recipe | null>(null);
const [editedNotes, setEditedNotes] = useState("");
Expand All @@ -38,6 +45,7 @@ export function SavedRecipeView() {
setEditedRecipe(data.recipe);
setEditedNotes(data.notes || "");
setEditedTags(data.tags || []);
setThumbnailUrl(data.recipe.image || null);
})
.catch((err) => {
const message =
Expand Down Expand Up @@ -95,6 +103,34 @@ export function SavedRecipeView() {
setIsEditing(false);
};

const handleThumbnailFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!id || !e.target.files || e.target.files.length === 0) return;
const file = e.target.files[0];
e.target.value = "";
setIsThumbnailUploading(true);
try {
const result = await uploadRecipeThumbnail(id, file);
setThumbnailUrl(result.image_url);
} catch (err) {
setError(err instanceof RecipeError ? err.message : "Failed to upload thumbnail");
} finally {
setIsThumbnailUploading(false);
}
};

const handleDeleteThumbnail = async () => {
if (!id) return;
setIsThumbnailUploading(true);
try {
await deleteRecipeThumbnail(id);
setThumbnailUrl(null);
} catch (err) {
setError(err instanceof RecipeError ? err.message : "Failed to delete thumbnail");
} finally {
setIsThumbnailUploading(false);
}
};

const formatTime = (minutes: number): string => {
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
Expand Down Expand Up @@ -144,9 +180,9 @@ export function SavedRecipeView() {
if (isEditing) {
return (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
{recipe.image && (
{thumbnailUrl && (
<img
src={recipe.image}
src={thumbnailUrl}
alt={recipe.title}
className="w-full h-64 object-cover"
/>
Expand All @@ -172,13 +208,64 @@ export function SavedRecipeView() {
// View mode
return (
<div className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Image */}
{recipe.image && (
<img
src={recipe.image}
alt={recipe.title}
className="w-full h-64 object-cover"
/>
{/* Hidden file input for thumbnail */}
<input
ref={thumbnailInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleThumbnailFileChange}
/>

{/* Image / Thumbnail area */}
{thumbnailUrl ? (
<div className="relative group">
<img
src={thumbnailUrl}
alt={recipe.title}
className="w-full h-64 object-cover"
/>
{!isThumbnailUploading && (
<div className="absolute top-2 right-2 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => thumbnailInputRef.current?.click()}
className="p-1.5 bg-white bg-opacity-90 rounded shadow text-brown-medium hover:text-coral"
title="Replace thumbnail"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={handleDeleteThumbnail}
className="p-1.5 bg-white bg-opacity-90 rounded shadow text-brown-medium hover:text-red-600"
title="Remove thumbnail"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)}
{isThumbnailUploading && (
<div className="absolute inset-0 bg-black bg-opacity-30 flex items-center justify-center">
<span className="text-white text-sm">Updating...</span>
</div>
)}
</div>
) : (
<div className="w-full h-32 bg-gray-100 flex items-center justify-center">
<button
onClick={() => thumbnailInputRef.current?.click()}
disabled={isThumbnailUploading}
className="flex items-center gap-2 px-3 py-2 text-sm text-brown-medium hover:text-coral border border-gray-300 rounded-lg hover:border-coral"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Thumbnail
</button>
</div>
)}

<div className="p-6">
Expand Down Expand Up @@ -263,6 +350,23 @@ export function SavedRecipeView() {
</div>
)}

{/* Source file link for uploaded recipes */}
{userRecipe.source_file_url && (
<div className="mb-4">
<a
href={userRecipe.source_file_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm text-coral hover:text-coral-dark hover:underline"
>
<svg className="mr-1 h-4 w-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
View Source File
</a>
</div>
)}

{/* Metadata */}
{recipe.metadata && (
<div className="flex flex-wrap gap-4 mb-6 text-sm text-brown-medium">
Expand Down
6 changes: 6 additions & 0 deletions apps/kitchen_mate/frontend/src/types/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export interface UserRecipeSummary {
image_url: string | null;
is_modified: boolean;
tags: string[] | null;
source_file_url?: string | null;
created_at: string;
updated_at: string;
}
Expand All @@ -154,10 +155,15 @@ export interface UserRecipe {
tags: string[] | null;
recipe: Recipe;
lineage: RecipeLineage;
source_file_url?: string | null;
created_at: string;
updated_at: string;
}

export interface ThumbnailUploadResponse {
image_url: string;
}

export interface UpdateUserRecipeRequest {
recipe?: Recipe;
notes?: string;
Expand Down
Loading
Loading