Add buttons
This commit is contained in:
parent
be720a8e2a
commit
f18cee0f86
@ -1 +1,6 @@
|
|||||||
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db
|
DATABASE_URL=postgresql://recipes_user:Aa123456@localhost:5432/recipes_db
|
||||||
|
DB_PASSWORD=Aa123456
|
||||||
|
DB_USER=recipes_user
|
||||||
|
DB_NAME=recipes_db
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -1,37 +1,45 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# load .env for local/dev; in K8s you'll use environment variables
|
# Load .env from the backend folder explicitly
|
||||||
load_dotenv()
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
load_dotenv(BASE_DIR / ".env")
|
||||||
|
|
||||||
# Prefer explicit envs (K8s), fallback to DATABASE_URL (local dev)
|
# Local/dev: DATABASE_URL
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
# K8s: explicit env vars from Secret
|
||||||
DB_HOST = os.getenv("DB_HOST")
|
DB_HOST = os.getenv("DB_HOST")
|
||||||
DB_PORT = os.getenv("DB_PORT", "5432")
|
DB_PORT = os.getenv("DB_PORT", "5432")
|
||||||
DB_NAME = os.getenv("DB_NAME")
|
DB_NAME = os.getenv("DB_NAME")
|
||||||
DB_USER = os.getenv("DB_USER")
|
DB_USER = os.getenv("DB_USER")
|
||||||
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||||
|
|
||||||
DATABASE_URL = os.getenv(
|
|
||||||
"DATABASE_URL",
|
|
||||||
"postgresql://user:password@localhost:5432/recipes_db",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_dsn() -> str:
|
def _build_dsn() -> str:
|
||||||
|
# 1. אם יש DATABASE_URL – משתמשים בזה (לוקאל)
|
||||||
|
if DATABASE_URL:
|
||||||
|
print("[DB] Using DATABASE_URL:", DATABASE_URL.replace(DB_PASSWORD or "", "***") if DB_PASSWORD else DATABASE_URL)
|
||||||
|
return DATABASE_URL
|
||||||
|
|
||||||
|
# 2. אחרת – עובדים עם DB_HOST/DB_* (בקוברנטיס)
|
||||||
if DB_HOST and DB_NAME and DB_USER and DB_PASSWORD:
|
if DB_HOST and DB_NAME and DB_USER and DB_PASSWORD:
|
||||||
return (
|
dsn = (
|
||||||
f"dbname={DB_NAME} user={DB_USER} password={DB_PASSWORD} "
|
f"dbname={DB_NAME} user={DB_USER} password={DB_PASSWORD} "
|
||||||
f"host={DB_HOST} port={DB_PORT}"
|
f"host={DB_HOST} port={DB_PORT}"
|
||||||
)
|
)
|
||||||
if DATABASE_URL:
|
print("[DB] Using explicit env vars DSN (masked):",
|
||||||
return DATABASE_URL
|
f"dbname={DB_NAME} user={DB_USER} password=*** host={DB_HOST} port={DB_PORT}")
|
||||||
|
return dsn
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"No DB configuration found. Set DB_HOST/DB_NAME/DB_USER/DB_PASSWORD "
|
"No DB configuration found. Set DATABASE_URL or DB_HOST/DB_NAME/DB_USER/DB_PASSWORD."
|
||||||
"or DATABASE_URL."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -57,11 +65,65 @@ def list_recipes_db() -> List[Dict[str, Any]]:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def update_recipe_db(recipe_id: int, recipe_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
עדכון מתכון קיים לפי id.
|
||||||
|
recipe_data: name, meal_type, time_minutes, tags, ingredients, steps
|
||||||
|
"""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recipes
|
||||||
|
SET name = %s,
|
||||||
|
meal_type = %s,
|
||||||
|
time_minutes = %s,
|
||||||
|
tags = %s,
|
||||||
|
ingredients = %s,
|
||||||
|
steps = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING id, name, meal_type, time_minutes, tags, ingredients, steps
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
recipe_data["name"],
|
||||||
|
recipe_data["meal_type"],
|
||||||
|
recipe_data["time_minutes"],
|
||||||
|
json.dumps(recipe_data.get("tags", [])),
|
||||||
|
json.dumps(recipe_data.get("ingredients", [])),
|
||||||
|
json.dumps(recipe_data.get("steps", [])),
|
||||||
|
recipe_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return row
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_recipe_db(recipe_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
מחיקת מתכון לפי id. מחזיר True אם נמחק, False אם לא נמצא.
|
||||||
|
"""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM recipes
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(recipe_id,),
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
return deleted
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
def create_recipe_db(recipe_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
|
||||||
recipe_data keys: name, meal_type, time_minutes, tags, ingredients, steps
|
|
||||||
"""
|
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from typing import List, Optional
|
|||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@ -11,6 +12,8 @@ from db_utils import (
|
|||||||
list_recipes_db,
|
list_recipes_db,
|
||||||
create_recipe_db,
|
create_recipe_db,
|
||||||
get_recipes_by_filters_db,
|
get_recipes_by_filters_db,
|
||||||
|
update_recipe_db,
|
||||||
|
delete_recipe_db,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -30,6 +33,11 @@ class RecipeCreate(RecipeBase):
|
|||||||
class Recipe(RecipeBase):
|
class Recipe(RecipeBase):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeUpdate(RecipeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Random Recipes API",
|
title="Random Recipes API",
|
||||||
@ -80,6 +88,33 @@ def create_recipe(recipe_in: RecipeCreate):
|
|||||||
steps=row["steps"] or [],
|
steps=row["steps"] or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.put("/recipes/{recipe_id}", response_model=Recipe)
|
||||||
|
def update_recipe(recipe_id: int, recipe_in: RecipeUpdate):
|
||||||
|
data = recipe_in.dict()
|
||||||
|
data["meal_type"] = data["meal_type"].lower()
|
||||||
|
|
||||||
|
row = update_recipe_db(recipe_id, data)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
||||||
|
|
||||||
|
return Recipe(
|
||||||
|
id=row["id"],
|
||||||
|
name=row["name"],
|
||||||
|
meal_type=row["meal_type"],
|
||||||
|
time_minutes=row["time_minutes"],
|
||||||
|
tags=row["tags"] or [],
|
||||||
|
ingredients=row["ingredients"] or [],
|
||||||
|
steps=row["steps"] or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/recipes/{recipe_id}", status_code=204)
|
||||||
|
def delete_recipe(recipe_id: int):
|
||||||
|
deleted = delete_recipe_db(recipe_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="המתכון לא נמצא")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
@app.get("/recipes/random", response_model=Recipe)
|
@app.get("/recipes/random", response_model=Recipe)
|
||||||
def random_recipe(
|
def random_recipe(
|
||||||
|
|||||||
@ -261,6 +261,9 @@ select {
|
|||||||
|
|
||||||
.recipe-card {
|
.recipe-card {
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-header {
|
.recipe-header {
|
||||||
@ -299,6 +302,7 @@ select {
|
|||||||
.recipe-body {
|
.recipe-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
@media (min-width: 720px) {
|
||||||
@ -319,6 +323,13 @@ select {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.tags {
|
.tags {
|
||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import TopBar from "./components/TopBar";
|
|||||||
import RecipeList from "./components/RecipeList";
|
import RecipeList from "./components/RecipeList";
|
||||||
import RecipeDetails from "./components/RecipeDetails";
|
import RecipeDetails from "./components/RecipeDetails";
|
||||||
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
import RecipeFormDrawer from "./components/RecipeFormDrawer";
|
||||||
import { getRecipes, getRandomRecipe, createRecipe } from "./api";
|
import { getRecipes, getRandomRecipe, createRecipe, updateRecipe, deleteRecipe } from "./api";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [recipes, setRecipes] = useState([]);
|
const [recipes, setRecipes] = useState([]);
|
||||||
@ -19,6 +19,7 @@ function App() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [editingRecipe, setEditingRecipe] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
@ -67,6 +68,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const created = await createRecipe(payload);
|
const created = await createRecipe(payload);
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
|
setEditingRecipe(null);
|
||||||
await loadRecipes();
|
await loadRecipes();
|
||||||
setSelectedRecipe(created);
|
setSelectedRecipe(created);
|
||||||
} catch {
|
} catch {
|
||||||
@ -74,6 +76,44 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditRecipe = (recipe) => {
|
||||||
|
setEditingRecipe(recipe);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateRecipe = async (payload) => {
|
||||||
|
try {
|
||||||
|
await updateRecipe(editingRecipe.id, payload);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setEditingRecipe(null);
|
||||||
|
await loadRecipes();
|
||||||
|
const updated = (await getRecipes()).find((r) => r.id === editingRecipe.id);
|
||||||
|
if (updated) {
|
||||||
|
setSelectedRecipe(updated);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("שגיאה בעדכון המתכון.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRecipe = async (recipeId) => {
|
||||||
|
try {
|
||||||
|
await deleteRecipe(recipeId);
|
||||||
|
await loadRecipes();
|
||||||
|
setSelectedRecipe(null);
|
||||||
|
} catch {
|
||||||
|
setError("שגיאה במחיקת המתכון.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = (payload) => {
|
||||||
|
if (editingRecipe) {
|
||||||
|
handleUpdateRecipe(payload);
|
||||||
|
} else {
|
||||||
|
handleCreateRecipe(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
<TopBar onAddClick={() => setDrawerOpen(true)} />
|
||||||
@ -136,14 +176,22 @@ function App() {
|
|||||||
|
|
||||||
<section className="content">
|
<section className="content">
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
<RecipeDetails recipe={selectedRecipe} />
|
<RecipeDetails
|
||||||
|
recipe={selectedRecipe}
|
||||||
|
onEditClick={handleEditRecipe}
|
||||||
|
onDeleteClick={handleDeleteRecipe}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<RecipeFormDrawer
|
<RecipeFormDrawer
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => {
|
||||||
onSubmit={handleCreateRecipe}
|
setDrawerOpen(false);
|
||||||
|
setEditingRecipe(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
editingRecipe={editingRecipe}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,3 +27,24 @@ export async function createRecipe(recipe) {
|
|||||||
const res = await axios.post(`${API_BASE}/recipes`, recipe);
|
const res = await axios.post(`${API_BASE}/recipes`, recipe);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateRecipe(id, payload) {
|
||||||
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to update recipe");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecipe(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/recipes/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
throw new Error("Failed to delete recipe");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
function RecipeDetails({ recipe }) {
|
function RecipeDetails({ recipe, onEditClick, onDeleteClick }) {
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return (
|
return (
|
||||||
<section className="panel placeholder">
|
<section className="panel placeholder">
|
||||||
@ -7,12 +7,18 @@ function RecipeDetails({ recipe }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm("בטוח שאתה רוצה למחוק את המתכון הזה?")) {
|
||||||
|
onDeleteClick(recipe.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel recipe-card">
|
<section className="panel recipe-card">
|
||||||
<header className="recipe-header">
|
<header className="recipe-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{recipe.name}</h2>
|
<h2>{recipe.name}</h2>
|
||||||
<p className="recipe-subtitle">
|
<p className="recipe-subtitle">qwwa
|
||||||
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
{translateMealType(recipe.meal_type)} · {recipe.time_minutes} דקות הכנה
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -51,6 +57,15 @@ function RecipeDetails({ recipe }) {
|
|||||||
))}
|
))}
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="recipe-actions">
|
||||||
|
<button className="btn ghost small" onClick={() => onEditClick(recipe)}>
|
||||||
|
✏️ ערוך
|
||||||
|
</button>
|
||||||
|
<button className="btn ghost small" onClick={handleDelete}>
|
||||||
|
🗑 מחק
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
function RecipeFormDrawer({ open, onClose, onSubmit }) {
|
function RecipeFormDrawer({ open, onClose, onSubmit, editingRecipe = null }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [mealType, setMealType] = useState("lunch");
|
const [mealType, setMealType] = useState("lunch");
|
||||||
const [timeMinutes, setTimeMinutes] = useState(15);
|
const [timeMinutes, setTimeMinutes] = useState(15);
|
||||||
@ -9,16 +9,27 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
|
|||||||
const [ingredients, setIngredients] = useState([""]);
|
const [ingredients, setIngredients] = useState([""]);
|
||||||
const [steps, setSteps] = useState([""]);
|
const [steps, setSteps] = useState([""]);
|
||||||
|
|
||||||
|
const isEditMode = !!editingRecipe;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setName("");
|
if (isEditMode) {
|
||||||
setMealType("lunch");
|
setName(editingRecipe.name || "");
|
||||||
setTimeMinutes(15);
|
setMealType(editingRecipe.meal_type || "lunch");
|
||||||
setTags("");
|
setTimeMinutes(editingRecipe.time_minutes || 15);
|
||||||
setIngredients([""]);
|
setTags((editingRecipe.tags || []).join(", "));
|
||||||
setSteps([""]);
|
setIngredients(editingRecipe.ingredients || [""]);
|
||||||
|
setSteps(editingRecipe.steps || [""]);
|
||||||
|
} else {
|
||||||
|
setName("");
|
||||||
|
setMealType("lunch");
|
||||||
|
setTimeMinutes(15);
|
||||||
|
setTags("");
|
||||||
|
setIngredients([""]);
|
||||||
|
setSteps([""]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, editingRecipe, isEditMode]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@ -70,7 +81,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
|
|||||||
<div className="drawer-backdrop" onClick={onClose}>
|
<div className="drawer-backdrop" onClick={onClose}>
|
||||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
||||||
<header className="drawer-header">
|
<header className="drawer-header">
|
||||||
<h2>מתכון חדש</h2>
|
<h2>{isEditMode ? "ערוך מתכון" : "מתכון חדש"}</h2>
|
||||||
<button className="icon-btn" onClick={onClose}>
|
<button className="icon-btn" onClick={onClose}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -182,7 +193,7 @@ function RecipeFormDrawer({ open, onClose, onSubmit }) {
|
|||||||
ביטול
|
ביטול
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn primary">
|
<button type="submit" className="btn primary">
|
||||||
שמירת מתכון
|
{isEditMode ? "עדכן מתכון" : "שמירת מתכון"}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user