Compare commits

..

43 Commits

Author SHA1 Message Date
elias
fb24ffbf03 Merge branch 'master' of http://192.168.1.9:3000/Tai/Aberwyn
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-03 21:52:25 +02:00
elias
274f98baa4 Test 2025-08-03 21:47:02 +02:00
elias
80ffad6d86 Torrent 2025-08-03 21:46:06 +02:00
Elias Jansson
5b0a8386ad Fixed leftover
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-24 11:18:51 +02:00
Elias Jansson
4b8c54d38d Category fix and movie 0.1
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-24 10:59:33 +02:00
Elias Jansson
cc96802637 Budget tests
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-21 15:16:31 +02:00
Elias Jansson
95811ce3f8 More css and button fixes
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-07 15:47:26 +02:00
Elias Jansson
4a7a2c30c9 Meal CSS fix
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-07 15:33:49 +02:00
Elias Jansson
8ebbb803e8 Meal rating and some fixes to meals
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-07 15:22:12 +02:00
elias
380978959b Lab changes
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-07 09:37:56 +02:00
Elias Jansson
072369fa17 Meal lab v0.1
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-03 22:00:56 +02:00
Elias Jansson
45994a9439 Today menu api
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-25 09:19:16 +02:00
Elias Jansson
3ef872ac8c Recipe publishing
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-24 17:20:08 +02:00
Elias Jansson
051ef625ba Changes Kök to Mat
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 12:46:42 +02:00
Elias Jansson
07f6451c5a Drone session persistance fixes
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 12:43:53 +02:00
Elias Jansson
979b05f2ca Test 6
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 12:32:24 +02:00
Elias Jansson
aefc653e22 Test 5
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 12:28:01 +02:00
Elias Jansson
4f14918b02 Test 4
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 12:26:31 +02:00
Elias Jansson
f8a33123d0 Test 3
Some checks failed
continuous-integration/drone/push Build encountered an error
2025-06-19 12:25:12 +02:00
Elias Jansson
95bb989e07 Test 2
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 12:23:45 +02:00
Elias Jansson
004ac0c696 Test
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 12:21:42 +02:00
Elias Jansson
f0642e4587 More optimization
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 12:20:51 +02:00
Elias Jansson
2504fab3e7 Drone optimization
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 12:18:56 +02:00
Elias Jansson
a4229594bf Another drone optimization
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 12:16:58 +02:00
Elias Jansson
55a254457b drone multiline removal
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 11:10:58 +02:00
Elias Jansson
67670cc380 Drone
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 11:09:27 +02:00
Elias Jansson
e26a809122 docker login 192.168.1.9:3000 -u ${GITEA_USERNAME} -p ${GITEA_TOKEN}
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 11:07:46 +02:00
Elias Jansson
b1f20de393 Drone test
Some checks failed
continuous-integration/drone/push Build is failing
2025-06-19 11:01:45 +02:00
Elias Jansson
c84699fd16 Drone test
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 10:55:11 +02:00
Elias Jansson
46299cb7f2 Drone fix
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-19 09:33:55 +02:00
Elias Jansson
9f40b3f8a0 Optimizing drone compiling
Some checks failed
continuous-integration/drone/push Build encountered an error
2025-06-19 09:32:38 +02:00
Elias Jansson
6cb2ebf3c8 Mobile fix for paymentstatus
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-18 23:35:22 +02:00
Elias Jansson
871fe3a070 Budget paymentdue
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-18 23:26:41 +02:00
Elias Jansson
22ae26d488 Notifications
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 22:32:13 +02:00
Elias Jansson
b785051a89 Notifications
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 22:15:31 +02:00
elias
0e58ffb735 Pizza fixes and removed school menu since summer
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 20:48:05 +02:00
elias
76656bb6a8 Lots of fixes
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-15 10:49:04 +02:00
Elias Jansson
113cce73ad New manifest with correct color
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-13 15:50:50 +02:00
Elias Jansson
fb62f076a0 Mostly css changes and welcome page thumbnails
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-13 15:46:57 +02:00
Elias Jansson
6a43435950 Layout fixad
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-13 10:41:00 +02:00
Elias Jansson
f71be26ae4 Layout fix
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-12 13:23:58 +02:00
Elias Jansson
d1e4901eee Layout ändringar och meal fixar
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-12 13:00:52 +02:00
Elias Jansson
fc78ec0813 Meal fix
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-11 13:51:45 +02:00
81 changed files with 10891 additions and 1005 deletions

View File

@@ -2,26 +2,27 @@ kind: pipeline
type: docker
name: default
steps:
- name: build-dotnet
image: alpine
commands:
- echo "Docker build will handle dotnet publish"
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
- name: build-docker
image: plugins/docker
settings:
registry: 192.168.1.9:3000
repo: 192.168.1.9:3000/tai/aberwyn/aberwyn
username:
steps:
- name: docker-build
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
GITEA_USERNAME:
from_secret: gitea_username
password:
GITEA_TOKEN:
from_secret: gitea_token
dockerfile: Aberwyn/Dockerfile
context: .
tags:
- latest
insecure: true
commands:
- export DOCKER_BUILDKIT=1
- docker buildx create --use --driver docker-container || true
- echo "$GITEA_TOKEN" | docker login 192.168.1.9:3000 -u "$GITEA_USERNAME" --password-stdin
- docker buildx build --builder default --tag 192.168.1.9:3000/tai/aberwyn/aberwyn:latest --tag 192.168.1.9:3000/tai/aberwyn/aberwyn:${DRONE_COMMIT_SHA:0:7} --cache-from=type=registry,ref=192.168.1.9:3000/tai/aberwyn/aberwyn:buildcache --push -f Aberwyn/Dockerfile .
- name: restart-unraid-container
image: appleboy/drone-ssh
@@ -37,7 +38,8 @@ steps:
- docker stop aberwyn || true
- docker rm aberwyn || true
- docker volume create aberwyn_config || true
- docker run -d --name=aberwyn --net=br0 -e TZ=Europe/Berlin -p 80:80 -v aberwyn_config:/app/infrastructure 192.168.1.9:3000/tai/aberwyn/aberwyn:latest
- docker volume create aberwyn_keys || true
- docker run -d --name=aberwyn --net=br0 -e TZ=Europe/Berlin -p 80:80 -v aberwyn_config:/app/infrastructure -v aberwyn_keys:/root/.aspnet/DataProtection-Keys 192.168.1.9:3000/tai/aberwyn/aberwyn:latest
- name: notify-result
image: alpine

View File

@@ -52,3 +52,7 @@ steps:
else
curl -X POST http://192.168.1.196:8123/api/webhook/aberwyn_update_failed
fi
trigger:
branch:
- master

View File

@@ -9,15 +9,9 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Views\NewFolder\**" />
<Content Remove="Views\NewFolder\**" />
<EmbeddedResource Remove="Views\NewFolder\**" />
<None Remove="Views\NewFolder\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngularJS.Core" Version="1.8.2" />
<PackageReference Include="BencodeNET" Version="5.0.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.67" />
<PackageReference Include="Lib.Net.Http.WebPush" Version="3.3.1" />

View File

@@ -19,32 +19,13 @@
<span asp-validation-for="Input.UserName" class="text-danger"></span>
</div>
<div class="form-floating">
<input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" />
<label asp-for="Input.Password" class="form-label"></label>
<input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" />
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div>
<div class="checkbox">
<label asp-for="Input.RememberMe" class="form-label">
<input class="form-check-input" asp-for="Input.RememberMe" />
@Html.DisplayNameFor(m => m.Input.RememberMe)
</label>
</div>
</div>
<div>
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
<div>
<p>
<a id="forgot-password" asp-page="./ForgotPassword">Forgot your password?</a>
</p>
<p>
<a asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">Register as a new user</a>
</p>
<p>
<a id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend email confirmation</a>
</p>
</div>
</form>
</section>
</div>

View File

@@ -62,7 +62,7 @@ namespace Aberwyn.Areas.Identity.Pages.Account
public class InputModel
{
[Required]
[Display(Name = "Användarnamn")]
[Display(Name = "Username")]
public string UserName { get; set; }
[Required]

View File

@@ -43,8 +43,8 @@ namespace Aberwyn.Controllers
var dto = new BudgetDto
{
Id = period.Id,
Year = period.Year,
Month = period.Month,
Year = period.Year ?? 0,
Month = period.Month ?? 0,
Categories = period.Categories
.OrderBy(cat => cat.Order)
.Select(cat => new BudgetCategoryDto
@@ -61,7 +61,8 @@ namespace Aberwyn.Controllers
Amount = i.Amount,
IsExpense = i.IsExpense,
IncludeInSummary = i.IncludeInSummary,
BudgetItemDefinitionId = i.BudgetItemDefinitionId
BudgetItemDefinitionId = i.BudgetItemDefinitionId,
PaymentStatus = i.PaymentStatus
}).ToList()
}).ToList()
};
@@ -74,6 +75,72 @@ namespace Aberwyn.Controllers
}
}
[HttpGet("byname/{name}")]
public async Task<IActionResult> GetBudgetByName(string name)
{
var period = await _context.BudgetPeriods
.Include(p => p.Categories)
.ThenInclude(c => c.Items)
.FirstOrDefaultAsync(p => p.Name != null && p.Name.ToLower() == name.ToLower());
if (period == null)
{
return Ok(new BudgetDto
{
Name = name,
Categories = new List<BudgetCategoryDto>()
});
}
var dto = new BudgetDto
{
Id = period.Id,
Name = period.Name,
Year = period.Year ?? 0,
Month = period.Month ?? 0,
Categories = period.Categories
.OrderBy(cat => cat.Order)
.Select(cat => new BudgetCategoryDto
{
Id = cat.Id,
Name = cat.Name,
Color = cat.Color,
Items = cat.Items
.OrderBy(i => i.Order)
.Select(i => new BudgetItemDto
{
Id = i.Id,
Name = i.Name,
Amount = i.Amount,
IsExpense = i.IsExpense,
IncludeInSummary = i.IncludeInSummary,
BudgetItemDefinitionId = i.BudgetItemDefinitionId,
PaymentStatus = i.PaymentStatus
}).ToList()
}).ToList()
};
return Ok(dto);
}
[HttpPut("updatePaymentStatus")]
public IActionResult UpdatePaymentStatus([FromBody] PaymentStatusUpdateDto dto)
{
if (dto == null)
return BadRequest("dto is null");
var item = _context.BudgetItems.Find(dto.ItemId);
if (item == null) return NotFound();
item.PaymentStatus = (PaymentStatus)dto.Status;
_context.SaveChanges();
return Ok();
}
[HttpPut("category/{id}")]
public async Task<IActionResult> UpdateCategory(int id, [FromBody] BudgetCategoryDto updatedCategory)
{
@@ -183,11 +250,37 @@ namespace Aberwyn.Controllers
[HttpPost]
public async Task<IActionResult> CreatePeriod([FromBody] BudgetPeriod newPeriod)
{
if (!string.IsNullOrWhiteSpace(newPeriod.Name))
{
var existingNamed = await _context.BudgetPeriods
.FirstOrDefaultAsync(p => p.Name != null && p.Name.ToLower() == newPeriod.Name.ToLower());
if (existingNamed != null)
return Conflict("En budget med detta namn finns redan.");
_context.BudgetPeriods.Add(newPeriod);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetBudget), new { year = newPeriod.Year, month = newPeriod.Month }, newPeriod);
return Ok(new { id = newPeriod.Id });
}
if (newPeriod.Year.HasValue && newPeriod.Month.HasValue)
{
var existing = await _context.BudgetPeriods
.FirstOrDefaultAsync(p => p.Year == newPeriod.Year && p.Month == newPeriod.Month);
if (existing != null)
return Conflict("En budget för denna månad finns redan.");
_context.BudgetPeriods.Add(newPeriod);
await _context.SaveChangesAsync();
return Ok(new { id = newPeriod.Id });
}
return BadRequest("Varken namn eller år/månad angivet.");
}
[HttpPut("item/{id}")]
public async Task<IActionResult> UpdateItem(int id, [FromBody] BudgetItem updatedItem)
{
@@ -314,19 +407,12 @@ namespace Aberwyn.Controllers
return BadRequest("Ogiltig data.");
var period = await _context.BudgetPeriods
.FirstOrDefaultAsync(p => p.Year == newCategoryDto.Year && p.Month == newCategoryDto.Month);
.FirstOrDefaultAsync(p => p.Id == newCategoryDto.BudgetPeriodId);
if (period == null)
{
period = new BudgetPeriod
{
Year = newCategoryDto.Year,
Month = newCategoryDto.Month
};
_context.BudgetPeriods.Add(period);
await _context.SaveChangesAsync();
}
return NotFound("Kunde inte hitta angiven budgetperiod.");
// 🔁 fortsätt som tidigare…
var definition = await _context.BudgetCategoryDefinitions
.FirstOrDefaultAsync(d => d.Name.ToLower() == newCategoryDto.Name.ToLower());
@@ -356,6 +442,8 @@ namespace Aberwyn.Controllers
return Ok(new { id = category.Id });
}
[HttpDelete("category/{id}")]
public async Task<IActionResult> DeleteCategory(int id)
{

View File

@@ -3,13 +3,31 @@ using Microsoft.AspNetCore.Mvc;
namespace Aberwyn.Controllers
{
[Authorize(Roles = "Budget")]
public class BudgetController : Controller
{
[Authorize(Roles = "Budget")]
[Route("budget/{year:int}/{month:int}")]
public IActionResult Index(int year, int month)
{
ViewBag.Year = year;
ViewBag.Month = month;
return View();
}
[Route("budget/{name}")]
public IActionResult Index(string name)
{
ViewBag.BudgetName = name;
return View();
}
// För fallback när ingen månad/år anges
[Route("budget")]
public IActionResult Index()
{
ViewData["HideSidebar"] = true;
return View();
var now = DateTime.Now;
return RedirectToAction("Index", new { year = now.Year, month = now.Month });
}
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace Aberwyn.Controllers
{
public class ErrorController : Controller
{
[Route("Error/{statusCode}")]
public IActionResult HttpStatusCodeHandler(int statusCode)
{
ViewData["ErrorCode"] = statusCode;
return View("Error");
}
[Route("Error")]
public IActionResult Error()
{
var exceptionFeature = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
ViewData["Exception"] = exceptionFeature?.Error;
return View("Error");
}
}
}

View File

@@ -10,6 +10,8 @@ using System.Linq;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Aberwyn.Services;
namespace Aberwyn.Controllers
{
@@ -19,24 +21,28 @@ namespace Aberwyn.Controllers
private readonly IHostEnvironment _env;
private readonly MenuService _menuService;
private readonly ApplicationDbContext _context;
private readonly PushNotificationService _notificationService;
private readonly PizzaNotificationService _pizzaNotifier;
public FoodMenuController(MenuService menuService, IConfiguration configuration, IHostEnvironment env, ApplicationDbContext context)
public FoodMenuController(MenuService menuService, IConfiguration configuration, IHostEnvironment env, ApplicationDbContext context, PushNotificationService notificationService, PizzaNotificationService pizzaNotificationService)
{
_menuService = menuService;
_configuration = configuration;
_env = env;
_context = context;
_notificationService = notificationService;
_pizzaNotifier = pizzaNotificationService;
}
[HttpGet]
public IActionResult PizzaOrder()
{
var pizzas = _menuService.GetMealsByCategory("Pizza")
.Where(p => p.IsAvailable)
.ToList();
var meals = _menuService.GetMealsByCategoryName("Pizza", onlyAvailable: true);
Console.WriteLine("Pizzas: ", meals);
var dtoList = meals.Select(m => MealListDto.FromMeal(m)).ToList();
ViewBag.Pizzas = dtoList;
ViewBag.Pizzas = pizzas;
ViewBag.RestaurantIsOpen = GetRestaurantStatus();
int? lastId = HttpContext.Session.GetInt32("LastPizzaOrderId");
@@ -53,6 +59,7 @@ namespace Aberwyn.Controllers
}
[HttpGet]
public IActionResult EditPizzaOrder(int id)
{
@@ -82,11 +89,11 @@ namespace Aberwyn.Controllers
[HttpPost]
public IActionResult PizzaOrder(string customerName, string pizzaName, string ingredients, int? orderId)
public async Task<IActionResult> PizzaOrder(string customerName, string pizzaName, string ingredients, int? orderId)
{
if (string.IsNullOrWhiteSpace(customerName) || string.IsNullOrWhiteSpace(pizzaName))
{
TempData["Error"] = "Fyll i b<EFBFBD>de namn och pizza!";
TempData["Error"] = "Fyll i både namn och pizza!";
return RedirectToAction("PizzaOrder");
}
@@ -102,10 +109,10 @@ namespace Aberwyn.Controllers
order.CustomerName = customerName.Trim();
order.PizzaName = pizzaName.Trim();
order.IngredientsJson = ingredients;
order.Status = "Unconfirmed";// <20>terst<73>ll status om du vill
order.Status = "Unconfirmed";
_context.SaveChanges();
TempData["Success"] = $"Din best<EFBFBD>llning har uppdaterats!";
TempData["Success"] = $"Din beställning har uppdaterats!";
return RedirectToAction("PizzaOrder");
}
}
@@ -123,11 +130,11 @@ namespace Aberwyn.Controllers
_context.PizzaOrders.Add(order);
_context.SaveChanges();
TempData["ForceShowForm"] = "true";
await _pizzaNotifier.NotifyPizzaSubscribersAsync(order.PizzaName, order.CustomerName);
HttpContext.Session.SetInt32("LastPizzaOrderId", order.Id);
TempData["Success"] = $"Tack {order.CustomerName}! Din pizza <EFBFBD>r best<EFBFBD>lld!";
TempData["Success"] = $"Tack {order.CustomerName}! Din pizza är beställd!";
return RedirectToAction("PizzaOrder");
}
@@ -152,11 +159,15 @@ namespace Aberwyn.Controllers
var allMeals = _menuService.GetMeals();
var categories = _menuService.GetMealCategories();
var pizzaCategory = categories.FirstOrDefault(c => c.Name.Equals("Pizza", StringComparison.OrdinalIgnoreCase));
viewModel.AvailablePizzas = allMeals
.Where(m => m.Category == "Pizza")
.Where(m => m.MealCategoryId == pizzaCategory?.Id)
.OrderBy(m => m.Name)
.ToList();
ViewBag.RestaurantIsOpen = GetRestaurantStatus();
return View(viewModel);
@@ -167,19 +178,42 @@ namespace Aberwyn.Controllers
[HttpPost]
[Authorize(Roles = "Chef")]
public IActionResult UpdatePizzaOrder(int id, string status, string ingredientsJson)
{
var order = _context.PizzaOrders.FirstOrDefault(p => p.Id == id);
if (order != null)
public async Task<IActionResult> UpdatePizzaOrder(int id, string status, string ingredientsJson)
{
var order = await _context.PizzaOrders.FirstOrDefaultAsync(p => p.Id == id);
if (order == null)
return RedirectToAction("PizzaAdmin");
order.Status = status;
order.IngredientsJson = ingredientsJson;
_context.SaveChanges();
await _context.SaveChangesAsync();
// Skicka pushnotiser till kopplade prenumeranter
var subscribers = await _context.PushSubscribers
.Where(s => s.PizzaOrderId == id)
.ToListAsync();
var payload = $@"{{
""title"": ""Din pizza! 🍕"",
""body"": ""Statusuppdatering: {status}""
}}";
foreach (var sub in subscribers)
{
try
{
_notificationService.SendNotification(sub.Endpoint, sub.P256DH, sub.Auth, payload);
}
catch (Exception ex)
{
Console.WriteLine($"❌ Kunde inte skicka notis till {sub.Endpoint}: {ex.Message}");
}
}
return RedirectToAction("PizzaAdmin");
}
[Authorize(Roles = "Chef")]
public IActionResult Veckomeny(int? week, int? year)
{

View File

@@ -27,7 +27,14 @@ namespace Aberwyn.Controllers
{
var isOpen = _context.AppSettings.FirstOrDefault(x => x.Key == "RestaurantIsOpen")?.Value == "True";
ViewBag.RestaurantIsOpen = isOpen;
return View();
var now = DateTime.Now;
var showDate = now.Hour >= 20 ? now.Date.AddDays(1) : now.Date;
var todaysMenu = _menuService.GetMenuForDate(showDate);
ViewBag.ShowDate = showDate;
return View(todaysMenu);
}
public IActionResult Privacy()

View File

@@ -45,7 +45,10 @@ namespace Aberwyn.Controllers
CreatedAt = DateTime.Now
};
}
ViewBag.Categories = _menuService.GetMealCategories()
.Where(c => c.IsActive)
.OrderBy(c => c.Name)
.ToList();
ViewData["IsEditing"] = edit;
return View("View", meal);
}
@@ -164,5 +167,123 @@ namespace Aberwyn.Controllers
//service.DeleteMeal(id);
return RedirectToAction("Edit"); // eller tillbaka till lista
}
[Authorize(Roles = "Admin,Chef")]
[HttpGet("/meal/categories")]
public IActionResult Categories()
{
var categories = _menuService.GetMealCategories()
.Select(cat => {
cat.MealCount = _menuService.GetMealCountForCategory(cat.Id);
return cat;
}).ToList();
return View("MealCategories", categories);
}
[Authorize(Roles = "Admin,Chef")]
[HttpPost("/meal/categories/save")]
public IActionResult SaveCategory(MealCategory category)
{
_menuService.SaveOrUpdateCategory(category);
return RedirectToAction("Categories");
}
[Authorize(Roles = "Admin,Chef")]
[HttpPost("/meal/categories/delete")]
public IActionResult DeleteCategory(int id)
{
_menuService.DeleteCategory(id);
return RedirectToAction("Categories");
}
[HttpGet("/meal/lab")]
public IActionResult Lab(int? id)
{
if (id.HasValue)
{
var entry = _menuService.GetRecipeLabEntryById(id.Value);
if (entry == null) return NotFound();
// Hämta versioner först när vi vet att entry finns
entry.Versions = _menuService.GetLabVersionsForEntry(id.Value);
return View("Lab", entry);
}
// Skapa ett tomt labb-entry för formulär
var newEntry = new RecipeLabEntry
{
Title = "",
Inspiration = "",
Notes = "",
Tags = ""
};
return View("Lab", newEntry);
}
// Lägg till dessa actions i din MealController
[HttpPost("/meal/lab/save")]
public IActionResult SaveLabEntry(RecipeLabEntry entry)
{
if (entry.Id == 0)
{
// Ny entry
entry.CreatedAt = DateTime.Now;
_menuService.AddLabEntry(entry);
}
else
{
// Uppdatera befintlig
_menuService.UpdateLabEntry(entry);
}
return RedirectToAction("Lab", new { id = entry.Id });
}
[HttpPost("/meal/lab/create")]
public IActionResult CreateLabEntry(RecipeLabEntry entry)
{
entry.CreatedAt = DateTime.Now;
_menuService.AddLabEntry(entry);
return RedirectToAction("Lab", new { id = entry.Id });
}
[HttpPost("/meal/lab/save-ingredients")]
public IActionResult SaveLabIngredients(int RecipeLabEntryId, List<LabIngredient> Ingredients)
{
_menuService.SaveIngredientsForLabEntry(RecipeLabEntryId, Ingredients);
return RedirectToAction("Lab", new { id = RecipeLabEntryId });
}
[HttpPost("/meal/lab/addversion")]
public IActionResult AddLabVersion(RecipeLabVersion version)
{
if (!ModelState.IsValid)
return RedirectToAction("Lab", new { id = version.RecipeLabEntryId });
var entry = _menuService.GetRecipeLabEntryById(version.RecipeLabEntryId);
if (entry == null) return NotFound();
version.CreatedAt = DateTime.Now;
// Kopiera nuvarande ingredienser
var copiedIngredients = entry.Ingredients
.Select(i => new LabVersionIngredient
{
Quantity = i.Quantity,
Item = i.Item
})
.ToList();
_menuService.SaveLabVersionWithIngredients(version, copiedIngredients);
return RedirectToAction("Lab", new { id = version.RecipeLabEntryId });
}
}
}

View File

@@ -27,6 +27,24 @@ namespace Aberwyn.Controllers
return Ok(menu ?? new List<WeeklyMenu>());
}
[HttpGet("getPublishedMeals")]
public IActionResult GetPublishedMeals([FromQuery] bool includeUnpublished = false)
{
var meals = _menuService.GetMeals()
.Where(m => includeUnpublished || m.IsPublished)
.Select(m => new {
m.Id,
m.Name,
m.Description,
ThumbnailData = m.ThumbnailData != null ? Convert.ToBase64String(m.ThumbnailData) : null
})
.ToList();
return Ok(meals);
}
[HttpGet("getMeals")]
public IActionResult GetMeals()
{
@@ -45,6 +63,23 @@ namespace Aberwyn.Controllers
}
[HttpGet("today")]
public IActionResult GetTodayMenu()
{
var today = DateTime.Today;
var menu = _menuService.GetMenuForDate(today);
if (menu == null)
return NotFound(new { message = "Ingen meny hittades för idag." });
return Ok(new
{
date = today.ToString("yyyy-MM-dd"),
lunch = menu.LunchMealName ?? "",
dinner = menu.DinnerMealName ?? "",
breakfast = menu.BreakfastMealName ?? ""
});
}
[HttpPut("menu")]

View File

@@ -0,0 +1,79 @@
using Aberwyn.Data;
using Aberwyn.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Aberwyn.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class MealRatingApiController : ControllerBase
{
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
public MealRatingApiController(ApplicationDbContext context, UserManager<ApplicationUser> userManager)
{
_context = context;
_userManager = userManager;
}
[HttpGet("{mealId}")]
public async Task<IActionResult> GetRating(int mealId)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var rating = await _context.MealRatings
.FirstOrDefaultAsync(r => r.MealId == mealId && r.UserId == user.Id);
return Ok(rating?.Rating ?? 0);
}
[HttpGet("average/{mealId}")]
public async Task<IActionResult> GetAverageRating(int mealId)
{
var ratings = await _context.MealRatings
.Where(r => r.MealId == mealId)
.ToListAsync();
if (ratings.Count == 0)
return Ok(0);
var avg = ratings.Average(r => r.Rating);
return Ok(avg);
}
[HttpPost]
public async Task<IActionResult> SetRating([FromBody] MealRatingDto model)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var existing = await _context.MealRatings
.FirstOrDefaultAsync(r => r.MealId == model.MealId && r.UserId == user.Id);
if (existing != null)
{
existing.Rating = model.Rating;
existing.CreatedAt = DateTime.UtcNow;
}
else
{
_context.MealRatings.Add(new MealRating
{
MealId = model.MealId,
UserId = user.Id,
Rating = model.Rating,
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
return Ok();
}
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
namespace Aberwyn.Controllers
{
public class MovieController : Controller
{
[HttpGet("/movie/search")]
public IActionResult Search()
{
return View();
}
}
}

View File

@@ -38,37 +38,72 @@ namespace Aberwyn.Controllers
return Ok(new { message = $"Skickade pizzanotiser till {count} användare." });
}
[HttpPost("subscribe")]
public async Task<IActionResult> Subscribe([FromBody] PushSubscription subscription)
[HttpPost("subscribe-user")]
public async Task<IActionResult> SubscribeUser([FromBody] PushSubscription subscription)
{
var existing = await _context.PushSubscribers
.FirstOrDefaultAsync(s => s.Endpoint == subscription.Endpoint);
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var existing = await _context.PushSubscribers
.FirstOrDefaultAsync(s => s.Endpoint == subscription.Endpoint);
if (existing == null)
{
var newSubscriber = new PushSubscriber
var newSub = new PushSubscriber
{
Endpoint = subscription.Endpoint,
P256DH = subscription.Keys["p256dh"],
Auth = subscription.Keys["auth"],
UserId = user.Id
};
_context.PushSubscribers.Add(newSubscriber);
_context.PushSubscribers.Add(newSub);
}
else
{
existing.P256DH = subscription.Keys["p256dh"];
existing.Auth = subscription.Keys["auth"];
existing.UserId = user.Id;
}
await _context.SaveChangesAsync();
await _context.SaveChangesAsync();
return Ok();
}
[HttpPost("subscribe")]
public async Task<IActionResult> Subscribe([FromBody] PushSubscriptionWithOrder dto)
{
var existing = await _context.PushSubscribers
.FirstOrDefaultAsync(s => s.Endpoint == dto.Subscription.Endpoint);
if (existing == null)
{
var newSub = new PushSubscriber
{
Endpoint = dto.Subscription.Endpoint,
P256DH = dto.Subscription.Keys["p256dh"],
Auth = dto.Subscription.Keys["auth"],
PizzaOrderId = dto.PizzaOrderId
};
_context.PushSubscribers.Add(newSub);
}
else
{
existing.P256DH = dto.Subscription.Keys["p256dh"];
existing.Auth = dto.Subscription.Keys["auth"];
existing.PizzaOrderId = dto.PizzaOrderId; // uppdatera kopplingen
}
await _context.SaveChangesAsync();
return Ok();
}
public class PushSubscriptionWithOrder
{
public PushSubscription Subscription { get; set; }
public int PizzaOrderId { get; set; }
}
[HttpGet("vapid-public-key")]
public IActionResult GetVapidKey([FromServices] IConfiguration config)
{
@@ -109,6 +144,24 @@ namespace Aberwyn.Controllers
return Ok($"Skickade notiser till {successCount} användare.");
}
[HttpPost("unsubscribe")]
public async Task<IActionResult> Unsubscribe([FromBody] PushUnsubscribeDto dto)
{
var sub = await _context.PushSubscribers.FirstOrDefaultAsync(s => s.Endpoint == dto.Endpoint);
if (sub != null)
{
_context.PushSubscribers.Remove(sub);
await _context.SaveChangesAsync();
}
return Ok();
}
public class PushUnsubscribeDto
{
public string Endpoint { get; set; }
}
}
}

View File

@@ -22,43 +22,5 @@ namespace Aberwyn.Controllers
_context = context;
}
[HttpPost]
public async Task<IActionResult> GetReport([FromBody] BudgetReportRequestDto request)
{
var start = new DateTime(request.StartYear, request.StartMonth, 1);
var end = new DateTime(request.EndYear, request.EndMonth, 1);
var items = await _context.BudgetItems
.Include(i => i.BudgetItemDefinition)
.Include(i => i.BudgetCategory)
.ThenInclude(c => c.BudgetPeriod)
.Where(i =>
i.BudgetCategory.BudgetPeriod.Year * 12 + i.BudgetCategory.BudgetPeriod.Month >= start.Year * 12 + start.Month &&
i.BudgetCategory.BudgetPeriod.Year * 12 + i.BudgetCategory.BudgetPeriod.Month <= end.Year * 12 + end.Month &&
request.DefinitionIds.Contains(i.BudgetItemDefinitionId ?? -1))
.ToListAsync();
var grouped = items
.GroupBy(i => new { i.BudgetCategory.BudgetPeriod.Year, i.BudgetCategory.BudgetPeriod.Month })
.Select(g => new BudgetReportResultDto
{
Year = g.Key.Year,
Month = g.Key.Month,
Definitions = g
.GroupBy(i => new { i.BudgetItemDefinitionId, i.BudgetItemDefinition.Name })
.Select(dg => new DefinitionSumDto
{
DefinitionId = dg.Key.BudgetItemDefinitionId ?? 0,
DefinitionName = dg.Key.Name,
TotalAmount = dg.Sum(x => x.Amount)
}).ToList()
})
.OrderBy(r => r.Year).ThenBy(r => r.Month)
.ToList();
return Ok(grouped);
}
}
}

View File

@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Mvc;
public class TorrentController : Controller
{
private readonly ITorrentService _torrentService;
private readonly ILogger<TorrentController> _logger;
public TorrentController(ITorrentService torrentService, ILogger<TorrentController> logger)
{
_torrentService = torrentService;
_logger = logger;
}
[HttpGet]
public IActionResult Index()
{
return View(new TorrentUploadViewModel());
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Upload(TorrentUploadViewModel model)
{
if (model.TorrentFile == null || model.TorrentFile.Length == 0)
{
ModelState.AddModelError("TorrentFile", "Vänligen välj en torrent-fil");
return View("Index", model);
}
if (!model.TorrentFile.FileName.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError("TorrentFile", "Endast .torrent filer är tillåtna");
return View("Index", model);
}
if (model.TorrentFile.Length > 10 * 1024 * 1024) // 10MB limit
{
ModelState.AddModelError("TorrentFile", "Filen är för stor (max 10MB)");
return View("Index", model);
}
try
{
// Parsa torrent-filen
var torrentInfo = await _torrentService.ParseTorrentAsync(model.TorrentFile);
if (!string.IsNullOrEmpty(torrentInfo.ErrorMessage))
{
ModelState.AddModelError("", torrentInfo.ErrorMessage);
return View("Index", model);
}
// Försök hämta tracker-statistik
torrentInfo = await _torrentService.FetchTrackerStatsAsync(torrentInfo);
model.TorrentInfo = torrentInfo;
model.ShowResults = true;
return View("Index", model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Fel vid uppladdning av torrent");
ModelState.AddModelError("", "Ett oväntat fel inträffade");
return View("Index", model);
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RefreshStats(string infoHash, string scrapeUrl)
{
try
{
var torrentInfo = new TorrentInfo
{
InfoHash = infoHash,
ScrapeUrl = scrapeUrl,
InfoHashBytes = Convert.FromHexString(infoHash.Replace("%", ""))
};
var updatedInfo = await _torrentService.FetchTrackerStatsAsync(torrentInfo);
return Json(new
{
success = updatedInfo.HasTrackerData,
seeders = updatedInfo.Seeders,
leechers = updatedInfo.Leechers,
completed = updatedInfo.Completed,
error = updatedInfo.ErrorMessage
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Fel vid uppdatering av tracker-stats");
return Json(new { success = false, error = "Fel vid uppdatering" });
}
}
}

View File

@@ -16,7 +16,20 @@ namespace Aberwyn.Data
base.OnModelCreating(builder);
builder.Entity<WeeklyMenu>().ToTable("WeeklyMenu");
builder.Entity<MealCategory>().HasData(
new MealCategory
{
Id = 1,
Name = "Pizza",
Slug = "pizza",
Icon = "🍕",
Color = "#f97316",
IsActive = true,
DisplayOrder = 1
}
);
}
public DbSet<BudgetPeriod> BudgetPeriods { get; set; }
@@ -33,6 +46,13 @@ namespace Aberwyn.Data
public DbSet<Ingredient> Ingredients { get; set; }
public DbSet<UserPreferences> UserPreferences { get; set; }
public DbSet<StoredPushSubscription> PushSubscriptions { get; set; }
public DbSet<MealCategory> MealCategories { get; set; }
public DbSet<RecipeLabEntry> RecipeLabEntries { get; set; }
public DbSet<RecipeLabVersion> RecipeLabVersions { get; set; }
public DbSet<LabIngredient> LabIngredients { get; set; }
public DbSet<LabVersionIngredient> LabVersionIngredients { get; set; }
public DbSet<MealRating> MealRatings { get; set; }
}
}

View File

@@ -292,7 +292,7 @@ public List<Meal> GetMealsSlim(bool includeThumbnail = false)
public List<Meal> GetMealsByCategory(string category)
{
return _context.Meals
.Where(m => m.Category == category)
//.Where(m => m.Category == category)
.Include(m => m.Ingredients)
.OrderBy(m => m.Name)
.ToList();
@@ -339,6 +339,71 @@ public List<WeeklyMenu> GetWeeklyMenu(int weekNumber, int year)
return menus;
}
public WeeklyMenu? GetMenuForDate(DateTime date)
{
int week = ISOWeek.GetWeekOfYear(date);
int year = date.Year;
int dayOfWeek = (int)date.DayOfWeek;
if (dayOfWeek == 0) dayOfWeek = 7;
var menu = _context.WeeklyMenus
.FirstOrDefault(w => w.WeekNumber == week && w.Year == year && w.DayOfWeek == dayOfWeek);
if (menu != null)
{
var mealIds = new[] { menu.BreakfastMealId, menu.LunchMealId, menu.DinnerMealId }
.Where(id => id.HasValue)
.Select(id => id.Value)
.Distinct()
.ToList();
var allMeals = _context.Meals
.Where(m => mealIds.Contains(m.Id))
.ToDictionary(m => m.Id);
if (menu.BreakfastMealId is int bId && allMeals.TryGetValue(bId, out var breakfast))
{
menu.BreakfastMealName = breakfast.Name;
menu.BreakfastThumbnail = breakfast.ThumbnailData;
}
if (menu.LunchMealId is int lId && allMeals.TryGetValue(lId, out var lunch))
{
menu.LunchMealName = lunch.Name;
menu.LunchThumbnail = lunch.ThumbnailData;
}
if (menu.DinnerMealId is int dId && allMeals.TryGetValue(dId, out var dinner))
{
menu.DinnerMealName = dinner.Name;
menu.DinnerThumbnail = dinner.ThumbnailData;
}
}
return menu;
}
public List<Meal> GetMealsByCategoryName(string categoryName, string? searchTerm = null, bool onlyAvailable = false)
{
var query = _context.Meals
.Include(m => m.Category)
.Include(m => m.Ingredients)
.Where(m => m.Category != null && m.Category.Name == categoryName);
if (onlyAvailable)
query = query.Where(m => m.IsAvailable);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
string lowered = searchTerm.Trim().ToLower();
query = query.Where(m => m.Name.ToLower().Contains(lowered));
}
return query
.OrderBy(m => m.Name)
.ToList();
}
public List<WeeklyMenu> GetMenuEntriesByDateRange(DateTime startDate, DateTime endDate)
{
@@ -370,5 +435,126 @@ public List<WeeklyMenu> GetWeeklyMenu(int weekNumber, int year)
}
return results;
}
public List<MealCategory> GetMealCategories()
{
return _context.MealCategories.OrderBy(c => c.DisplayOrder).ToList();
}
public int GetMealCountForCategory(int categoryId)
{
return _context.Meals.Count(m => m.MealCategoryId == categoryId);
}
public void SaveOrUpdateCategory(MealCategory cat)
{
if (cat.Id == 0)
{
_context.MealCategories.Add(cat);
}
else
{
var existing = _context.MealCategories.Find(cat.Id);
if (existing != null)
{
existing.Name = cat.Name;
existing.Slug = cat.Slug;
existing.Description = cat.Description;
existing.Icon = cat.Icon;
existing.Color = cat.Color;
existing.IsActive = cat.IsActive;
existing.DisplayOrder = cat.DisplayOrder;
}
}
_context.SaveChanges();
}
#region Lab
public void DeleteCategory(int id)
{
var cat = _context.MealCategories.Find(id);
if (cat != null)
{
_context.MealCategories.Remove(cat);
_context.SaveChanges();
}
}
public RecipeLabEntry? GetRecipeLabEntryById(int id)
{
return _context.RecipeLabEntries
.Include(e => e.Versions)
.FirstOrDefault(e => e.Id == id);
}
public void AddVersionToLabEntry(RecipeLabVersion version)
{
_context.RecipeLabVersions.Add(version);
_context.SaveChanges();
}
public void AddLabEntry(RecipeLabEntry entry)
{
_context.RecipeLabEntries.Add(entry);
_context.SaveChanges();
}
public void SaveLabVersionWithIngredients(RecipeLabVersion version, List<LabVersionIngredient> ingredients)
{
_context.RecipeLabVersions.Add(version);
_context.SaveChanges(); // så vi får ett ID
foreach (var ing in ingredients)
{
ing.RecipeLabVersionId = version.Id;
}
_context.LabVersionIngredients.AddRange(ingredients);
_context.SaveChanges();
}
// Lägg till dessa metoder i din MenuService klass
public void UpdateLabEntry(RecipeLabEntry entry)
{
var existing = _context.RecipeLabEntries.Find(entry.Id);
if (existing != null)
{
existing.Title = entry.Title;
existing.Inspiration = entry.Inspiration;
existing.Notes = entry.Notes;
existing.Tags = entry.Tags;
_context.SaveChanges();
}
}
public RecipeLabEntry GetRecipeLabEntryWithIngredients(int id)
{
return _context.RecipeLabEntries
.Include(e => e.Versions)
.Include(e => e.Ingredients)
.FirstOrDefault(e => e.Id == id);
}
public void SaveIngredientsForLabEntry(int labEntryId, List<LabIngredient> ingredients)
{
var existing = _context.LabIngredients
.Where(i => i.RecipeLabEntryId == labEntryId)
.ToList();
_context.LabIngredients.RemoveRange(existing);
foreach (var ing in ingredients)
ing.RecipeLabEntryId = labEntryId;
_context.LabIngredients.AddRange(ingredients);
_context.SaveChanges();
}
public List<RecipeLabVersion> GetLabVersionsForEntry(int entryId)
{
return _context.RecipeLabVersions
.Where(v => v.RecipeLabEntryId == entryId)
.Include(v => v.Ingredients)
.OrderByDescending(v => v.CreatedAt)
.ToList();
}
#endregion
}
}

View File

@@ -40,7 +40,14 @@ namespace Aberwyn.Services
s.Endpoint.StartsWith("https://"))
.ToListAsync();
var allSubscribers = await _context.PushSubscribers
.Include(s => s.User)
.ThenInclude(u => u.Preferences)
.ToListAsync();
foreach (var s in allSubscribers)
{
Console.WriteLine($"🔍 Sub: {s.Endpoint}, User: {s.User?.UserName}, NotifyPizza: {s.User?.Preferences?.NotifyPizza}");
}
int successCount = 0;
foreach (var sub in subscribers)
{

View File

@@ -0,0 +1,159 @@
using BencodeNET.Parsing;
using BencodeNET.Torrents;
using BencodeNET.Objects;
using System.Text;
public interface ITorrentService
{
Task<TorrentInfo> ParseTorrentAsync(IFormFile file);
Task<TorrentInfo> FetchTrackerStatsAsync(TorrentInfo info);
}
public class TorrentService : ITorrentService
{
private readonly HttpClient _httpClient;
private readonly ILogger<TorrentService> _logger;
// Kända trackers och deras egenskaper
private readonly Dictionary<string, TrackerInfo> _knownTrackers = new()
{
["hdts-announce.ru"] = new TrackerInfo
{
Name = "HD-Torrents",
SupportsScraping = true, // Ändrat till true
RequiresAuth = false, // Kan fungera utan auth för scraping
IsPrivate = true,
Notes = "Privat tracker, scraping kan fungera utan inloggning"
}
};
public TorrentService(HttpClient httpClient, ILogger<TorrentService> logger)
{
_httpClient = httpClient;
_logger = logger;
_httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public async Task<TorrentInfo> ParseTorrentAsync(IFormFile file)
{
try
{
using var stream = new MemoryStream();
await file.CopyToAsync(stream);
stream.Position = 0;
var parser = new TorrentParser();
var torrent = parser.Parse(stream);
var infoHash = torrent.GetInfoHashBytes();
var announceUrl = torrent.Trackers?.FirstOrDefault()?.FirstOrDefault()?.ToString();
return new TorrentInfo
{
FileName = torrent.DisplayName ?? file.FileName,
AnnounceUrl = announceUrl,
ScrapeUrl = ConvertAnnounceToScrape(announceUrl),
InfoHash = UrlEncodeInfoHash(infoHash),
InfoHashBytes = infoHash,
Size = torrent.TotalSize
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Fel vid parsing av torrent-fil");
return new TorrentInfo
{
FileName = file.FileName,
ErrorMessage = $"Kunde inte parsa torrent-filen: {ex.Message}"
};
}
}
public async Task<TorrentInfo> FetchTrackerStatsAsync(TorrentInfo info)
{
if (string.IsNullOrWhiteSpace(info.ScrapeUrl))
{
info.ErrorMessage = "Ingen scrape URL tillgänglig";
return info;
}
var url = $"{info.ScrapeUrl}?info_hash={info.InfoHash}";
_logger.LogInformation("Scraping tracker: {Url}", url);
try
{
var data = await _httpClient.GetByteArrayAsync(url);
var parser = new BencodeParser();
var bdict = parser.Parse<BDictionary>(data);
if (bdict.TryGetValue("files", out var filesValue) && filesValue is BDictionary files)
{
if (TryGetStatsFromFiles(files, info.InfoHash, info) ||
TryGetStatsFromFiles(files, Encoding.UTF8.GetString(info.InfoHashBytes), info))
{
info.HasTrackerData = true;
return info;
}
info.ErrorMessage = "Info hash hittades inte i tracker-svaret";
}
else
{
info.ErrorMessage = "Inget 'files' objekt i tracker-svaret";
}
}
catch (HttpRequestException ex)
{
info.ErrorMessage = $"HTTP fel: {ex.Message}";
_logger.LogWarning(ex, "HTTP fel vid tracker scraping");
}
catch (TaskCanceledException)
{
info.ErrorMessage = "Timeout vid anslutning till tracker";
_logger.LogWarning("Timeout vid tracker scraping");
}
catch (Exception ex)
{
info.ErrorMessage = $"Fel vid parsing: {ex.Message}";
_logger.LogError(ex, "Fel vid tracker scraping");
}
return info;
}
private bool TryGetStatsFromFiles(BDictionary files, string hashKey, TorrentInfo info)
{
if (files.TryGetValue(hashKey, out var hashEntry) && hashEntry is BDictionary stats)
{
info.Seeders = stats.TryGetInt("complete") ?? 0;
info.Leechers = stats.TryGetInt("incomplete") ?? 0;
info.Completed = stats.TryGetInt("downloaded") ?? 0;
return true;
}
return false;
}
private string ConvertAnnounceToScrape(string announceUrl)
{
if (string.IsNullOrEmpty(announceUrl))
return null;
return announceUrl.Replace("/announce", "/scrape");
}
private string UrlEncodeInfoHash(byte[] infoHash)
{
var sb = new StringBuilder();
foreach (byte b in infoHash)
{
sb.AppendFormat("%{0:x2}", b);
}
return sb.ToString();
}
}
public static class BDictionaryExtensions
{
public static int? TryGetInt(this BDictionary dict, string key)
{
return dict.TryGetValue(key, out var value) && value is BNumber num ? (int?)num.Value : null;
}
}

View File

@@ -1,9 +1,12 @@
# Basimage för runtime
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
# Installera svenska språkinställningar
RUN apt-get update && \
apt-get install -y locales && \
locale-gen sv_SE.UTF-8
# Ställ in svenska som standard
ENV LANG=sv_SE.UTF-8
ENV LANGUAGE=sv_SE:sv
ENV LC_ALL=sv_SE.UTF-8
@@ -13,18 +16,30 @@ WORKDIR /app
EXPOSE 80
EXPOSE 443
# Byggimage med SDK
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
# Kopiera endast .csproj först för att kunna cacha restore
COPY ["Aberwyn/Aberwyn.csproj", "Aberwyn/"]
WORKDIR /src/Aberwyn
# Restore beroenden
RUN dotnet restore "Aberwyn.csproj"
# Kopiera övrig kod
COPY Aberwyn/. .
RUN dotnet build "Aberwyn.csproj" -c Release -o /app/build
# Bygg utan att köra restore igen
RUN dotnet build "Aberwyn.csproj" -c Release -o /app/build --no-restore
# Publicera utan att köra restore eller build igen
FROM build AS publish
RUN dotnet publish "Aberwyn.csproj" -c Release -o /app/publish
RUN dotnet publish "Aberwyn.csproj" -c Release -o /app/publish --no-restore
# Slutgiltig image baserad på runtime
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Aberwyn.dll"]

View File

@@ -1,43 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AssignedToNullable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "AssignedTo",
table: "TodoTasks",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "TodoTasks",
keyColumn: "AssignedTo",
keyValue: null,
column: "AssignedTo",
value: "");
migrationBuilder.AlterColumn<string>(
name: "AssignedTo",
table: "TodoTasks",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}

View File

@@ -1,76 +0,0 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddUserPreferencesAndPushSubscriptions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PushSubscriptions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Endpoint = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
P256DH = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Auth = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_PushSubscriptions", x => x.Id);
table.ForeignKey(
name: "FK_PushSubscriptions_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "UserPreferences",
columns: table => new
{
UserId = table.Column<string>(type: "varchar(255)", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
NotifyPizza = table.Column<bool>(type: "tinyint(1)", nullable: false),
NotifyMenu = table.Column<bool>(type: "tinyint(1)", nullable: false),
NotifyBudget = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPreferences", x => x.UserId);
table.ForeignKey(
name: "FK_UserPreferences_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PushSubscriptions_UserId",
table: "PushSubscriptions",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PushSubscriptions");
migrationBuilder.DropTable(
name: "UserPreferences");
}
}
}

View File

@@ -1,48 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddPushSubscriberUserLink : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "UserId",
table: "PushSubscribers",
type: "varchar(255)",
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PushSubscribers_UserId",
table: "PushSubscribers",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_PushSubscribers_AspNetUsers_UserId",
table: "PushSubscribers",
column: "UserId",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PushSubscribers_AspNetUsers_UserId",
table: "PushSubscribers");
migrationBuilder.DropIndex(
name: "IX_PushSubscribers_UserId",
table: "PushSubscribers");
migrationBuilder.DropColumn(
name: "UserId",
table: "PushSubscribers");
}
}
}

View File

@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Aberwyn.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250606100439_AddPushSubscriberUserLink")]
partial class AddPushSubscriberUserLink
[Migration("20250618203117_AddPaymentStatusToBudgetItem")]
partial class AddPaymentStatusToBudgetItem
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
@@ -183,6 +183,9 @@ namespace Aberwyn.Migrations
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("BudgetCategoryId");
@@ -201,6 +204,9 @@ namespace Aberwyn.Migrations
b.Property<string>("DefaultCategory")
.HasColumnType("longtext");
b.Property<int?>("DefaultPaymentStatus")
.HasColumnType("int");
b.Property<bool>("IncludeInSummary")
.HasColumnType("tinyint(1)");
@@ -269,9 +275,6 @@ namespace Aberwyn.Migrations
b.Property<string>("CarbType")
.HasColumnType("longtext");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -293,6 +296,9 @@ namespace Aberwyn.Migrations
b.Property<bool>("IsAvailable")
.HasColumnType("tinyint(1)");
b.Property<int?>("MealCategoryId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
@@ -308,9 +314,56 @@ namespace Aberwyn.Migrations
b.HasKey("Id");
b.HasIndex("MealCategoryId");
b.ToTable("Meals");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Color")
.HasColumnType("longtext");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("Icon")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Slug")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("MealCategories");
b.HasData(
new
{
Id = 1,
Color = "#f97316",
DisplayOrder = 1,
Icon = "🍕",
IsActive = true,
Name = "Pizza",
Slug = "pizza"
});
});
modelBuilder.Entity("Aberwyn.Models.PizzaOrder", b =>
{
b.Property<int>("Id")
@@ -358,12 +411,17 @@ namespace Aberwyn.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<int?>("PizzaOrderId")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("PizzaOrderId");
b.HasIndex("UserId");
b.ToTable("PushSubscribers");
@@ -638,7 +696,7 @@ namespace Aberwyn.Migrations
modelBuilder.Entity("Aberwyn.Models.BudgetItem", b =>
{
b.HasOne("Aberwyn.Models.BudgetCategory", "BudgetCategory")
b.HasOne("Aberwyn.Models.BudgetCategory", null)
.WithMany("Items")
.HasForeignKey("BudgetCategoryId")
.OnDelete(DeleteBehavior.Cascade)
@@ -648,8 +706,6 @@ namespace Aberwyn.Migrations
.WithMany()
.HasForeignKey("BudgetItemDefinitionId");
b.Navigation("BudgetCategory");
b.Navigation("BudgetItemDefinition");
});
@@ -662,14 +718,29 @@ namespace Aberwyn.Migrations
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.Meal", b =>
{
b.HasOne("Aberwyn.Models.MealCategory", "Category")
.WithMany("Meals")
.HasForeignKey("MealCategoryId");
b.Navigation("Category");
});
modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b =>
{
b.HasOne("Aberwyn.Models.PizzaOrder", "PizzaOrder")
.WithMany()
.HasForeignKey("PizzaOrderId");
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PizzaOrder");
b.Navigation("User");
});
@@ -766,6 +837,11 @@ namespace Aberwyn.Migrations
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Navigation("Meals");
});
#pragma warning restore 612, 618
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddPaymentStatusToBudgetItem : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PaymentStatus",
table: "BudgetItems",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "DefaultPaymentStatus",
table: "BudgetItemDefinitions",
type: "int",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PaymentStatus",
table: "BudgetItems");
migrationBuilder.DropColumn(
name: "DefaultPaymentStatus",
table: "BudgetItemDefinitions");
}
}
}

View File

@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Aberwyn.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250606061016_AddUserPreferencesAndPushSubscriptions")]
partial class AddUserPreferencesAndPushSubscriptions
[Migration("20250624150426_AddIsPublishedToMeals")]
partial class AddIsPublishedToMeals
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
@@ -183,6 +183,9 @@ namespace Aberwyn.Migrations
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("BudgetCategoryId");
@@ -201,6 +204,9 @@ namespace Aberwyn.Migrations
b.Property<string>("DefaultCategory")
.HasColumnType("longtext");
b.Property<int?>("DefaultPaymentStatus")
.HasColumnType("int");
b.Property<bool>("IncludeInSummary")
.HasColumnType("tinyint(1)");
@@ -269,9 +275,6 @@ namespace Aberwyn.Migrations
b.Property<string>("CarbType")
.HasColumnType("longtext");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -293,6 +296,12 @@ namespace Aberwyn.Migrations
b.Property<bool>("IsAvailable")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsPublished")
.HasColumnType("tinyint(1)");
b.Property<int?>("MealCategoryId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
@@ -308,9 +317,56 @@ namespace Aberwyn.Migrations
b.HasKey("Id");
b.HasIndex("MealCategoryId");
b.ToTable("Meals");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Color")
.HasColumnType("longtext");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("Icon")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Slug")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("MealCategories");
b.HasData(
new
{
Id = 1,
Color = "#f97316",
DisplayOrder = 1,
Icon = "🍕",
IsActive = true,
Name = "Pizza",
Slug = "pizza"
});
});
modelBuilder.Entity("Aberwyn.Models.PizzaOrder", b =>
{
b.Property<int>("Id")
@@ -358,8 +414,19 @@ namespace Aberwyn.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<int?>("PizzaOrderId")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("PizzaOrderId");
b.HasIndex("UserId");
b.ToTable("PushSubscribers");
});
@@ -632,7 +699,7 @@ namespace Aberwyn.Migrations
modelBuilder.Entity("Aberwyn.Models.BudgetItem", b =>
{
b.HasOne("Aberwyn.Models.BudgetCategory", "BudgetCategory")
b.HasOne("Aberwyn.Models.BudgetCategory", null)
.WithMany("Items")
.HasForeignKey("BudgetCategoryId")
.OnDelete(DeleteBehavior.Cascade)
@@ -642,8 +709,6 @@ namespace Aberwyn.Migrations
.WithMany()
.HasForeignKey("BudgetItemDefinitionId");
b.Navigation("BudgetCategory");
b.Navigation("BudgetItemDefinition");
});
@@ -656,6 +721,32 @@ namespace Aberwyn.Migrations
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.Meal", b =>
{
b.HasOne("Aberwyn.Models.MealCategory", "Category")
.WithMany("Meals")
.HasForeignKey("MealCategoryId");
b.Navigation("Category");
});
modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b =>
{
b.HasOne("Aberwyn.Models.PizzaOrder", "PizzaOrder")
.WithMany()
.HasForeignKey("PizzaOrderId");
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PizzaOrder");
b.Navigation("User");
});
modelBuilder.Entity("Aberwyn.Models.StoredPushSubscription", b =>
{
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
@@ -749,6 +840,11 @@ namespace Aberwyn.Migrations
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Navigation("Meals");
});
#pragma warning restore 612, 618
}
}

View File

@@ -1,25 +1,25 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddThumbnailToMeal : Migration
public partial class AddIsPublishedToMeals : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "ThumbnailData",
migrationBuilder.AddColumn<bool>(
name: "IsPublished",
table: "Meals",
type: "longblob",
nullable: true);
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThumbnailData",
name: "IsPublished",
table: "Meals");
}
}

View File

@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Aberwyn.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250605062804_AddThumbnailToMeal")]
partial class AddThumbnailToMeal
[Migration("20250630123620_AddRecipeLab")]
partial class AddRecipeLab
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
@@ -183,6 +183,9 @@ namespace Aberwyn.Migrations
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("BudgetCategoryId");
@@ -201,6 +204,9 @@ namespace Aberwyn.Migrations
b.Property<string>("DefaultCategory")
.HasColumnType("longtext");
b.Property<int?>("DefaultPaymentStatus")
.HasColumnType("int");
b.Property<bool>("IncludeInSummary")
.HasColumnType("tinyint(1)");
@@ -269,9 +275,6 @@ namespace Aberwyn.Migrations
b.Property<string>("CarbType")
.HasColumnType("longtext");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -293,6 +296,12 @@ namespace Aberwyn.Migrations
b.Property<bool>("IsAvailable")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsPublished")
.HasColumnType("tinyint(1)");
b.Property<int?>("MealCategoryId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
@@ -308,9 +317,56 @@ namespace Aberwyn.Migrations
b.HasKey("Id");
b.HasIndex("MealCategoryId");
b.ToTable("Meals");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Color")
.HasColumnType("longtext");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("Icon")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Slug")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("MealCategories");
b.HasData(
new
{
Id = 1,
Color = "#f97316",
DisplayOrder = 1,
Icon = "🍕",
IsActive = true,
Name = "Pizza",
Slug = "pizza"
});
});
modelBuilder.Entity("Aberwyn.Models.PizzaOrder", b =>
{
b.Property<int>("Id")
@@ -358,11 +414,124 @@ namespace Aberwyn.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<int?>("PizzaOrderId")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("PizzaOrderId");
b.HasIndex("UserId");
b.ToTable("PushSubscribers");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int?>("BaseMealId")
.HasColumnType("int");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Inspiration")
.HasColumnType("longtext");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int?>("Rating")
.HasColumnType("int");
b.Property<string>("Tags")
.HasColumnType("longtext");
b.Property<string>("TestedBy")
.HasColumnType("longtext");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("BaseMealId");
b.ToTable("RecipeLabEntries");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Ingredients")
.HasColumnType("longtext");
b.Property<string>("Instructions")
.HasColumnType("longtext");
b.Property<int>("RecipeLabEntryId")
.HasColumnType("int");
b.Property<string>("ResultNotes")
.HasColumnType("longtext");
b.Property<string>("VersionLabel")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("RecipeLabEntryId");
b.ToTable("RecipeLabVersions");
});
modelBuilder.Entity("Aberwyn.Models.StoredPushSubscription", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("P256DH")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PushSubscriptions");
});
modelBuilder.Entity("Aberwyn.Models.TodoTask", b =>
{
b.Property<int>("Id")
@@ -402,6 +571,25 @@ namespace Aberwyn.Migrations
b.ToTable("TodoTasks");
});
modelBuilder.Entity("Aberwyn.Models.UserPreferences", b =>
{
b.Property<string>("UserId")
.HasColumnType("varchar(255)");
b.Property<bool>("NotifyBudget")
.HasColumnType("tinyint(1)");
b.Property<bool>("NotifyMenu")
.HasColumnType("tinyint(1)");
b.Property<bool>("NotifyPizza")
.HasColumnType("tinyint(1)");
b.HasKey("UserId");
b.ToTable("UserPreferences");
});
modelBuilder.Entity("Aberwyn.Models.WeeklyMenu", b =>
{
b.Property<int>("Id")
@@ -584,7 +772,7 @@ namespace Aberwyn.Migrations
modelBuilder.Entity("Aberwyn.Models.BudgetItem", b =>
{
b.HasOne("Aberwyn.Models.BudgetCategory", "BudgetCategory")
b.HasOne("Aberwyn.Models.BudgetCategory", null)
.WithMany("Items")
.HasForeignKey("BudgetCategoryId")
.OnDelete(DeleteBehavior.Cascade)
@@ -594,8 +782,6 @@ namespace Aberwyn.Migrations
.WithMany()
.HasForeignKey("BudgetItemDefinitionId");
b.Navigation("BudgetCategory");
b.Navigation("BudgetItemDefinition");
});
@@ -608,6 +794,74 @@ namespace Aberwyn.Migrations
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.Meal", b =>
{
b.HasOne("Aberwyn.Models.MealCategory", "Category")
.WithMany("Meals")
.HasForeignKey("MealCategoryId");
b.Navigation("Category");
});
modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b =>
{
b.HasOne("Aberwyn.Models.PizzaOrder", "PizzaOrder")
.WithMany()
.HasForeignKey("PizzaOrderId");
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PizzaOrder");
b.Navigation("User");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.HasOne("Aberwyn.Models.Meal", "BaseMeal")
.WithMany()
.HasForeignKey("BaseMealId");
b.Navigation("BaseMeal");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.HasOne("Aberwyn.Models.RecipeLabEntry", "Entry")
.WithMany("Versions")
.HasForeignKey("RecipeLabEntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Aberwyn.Models.StoredPushSubscription", b =>
{
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Aberwyn.Models.UserPreferences", b =>
{
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithOne("Preferences")
.HasForeignKey("Aberwyn.Models.UserPreferences", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -659,6 +913,12 @@ namespace Aberwyn.Migrations
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.ApplicationUser", b =>
{
b.Navigation("Preferences")
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.BudgetCategory", b =>
{
b.Navigation("Items");
@@ -673,6 +933,16 @@ namespace Aberwyn.Migrations
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Navigation("Meals");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.Navigation("Versions");
});
#pragma warning restore 612, 618
}
}

View File

@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddRecipeLab : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RecipeLabEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Title = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Category = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Inspiration = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
BaseMealId = table.Column<int>(type: "int", nullable: true),
Notes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Rating = table.Column<int>(type: "int", nullable: true),
TestedBy = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Tags = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RecipeLabEntries", x => x.Id);
table.ForeignKey(
name: "FK_RecipeLabEntries_Meals_BaseMealId",
column: x => x.BaseMealId,
principalTable: "Meals",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RecipeLabVersions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
RecipeLabEntryId = table.Column<int>(type: "int", nullable: false),
VersionLabel = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Ingredients = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
Instructions = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
ResultNotes = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RecipeLabVersions", x => x.Id);
table.ForeignKey(
name: "FK_RecipeLabVersions_RecipeLabEntries_RecipeLabEntryId",
column: x => x.RecipeLabEntryId,
principalTable: "RecipeLabEntries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_RecipeLabEntries_BaseMealId",
table: "RecipeLabEntries",
column: "BaseMealId");
migrationBuilder.CreateIndex(
name: "IX_RecipeLabVersions_RecipeLabEntryId",
table: "RecipeLabVersions",
column: "RecipeLabEntryId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RecipeLabVersions");
migrationBuilder.DropTable(
name: "RecipeLabEntries");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddLabIngredientModel : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LabIngredients",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
RecipeLabEntryId = table.Column<int>(type: "int", nullable: false),
Quantity = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Item = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_LabIngredients", x => x.Id);
table.ForeignKey(
name: "FK_LabIngredients_RecipeLabEntries_RecipeLabEntryId",
column: x => x.RecipeLabEntryId,
principalTable: "RecipeLabEntries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "LabVersionIngredients",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
RecipeLabVersionId = table.Column<int>(type: "int", nullable: false),
Quantity = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Item = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_LabVersionIngredients", x => x.Id);
table.ForeignKey(
name: "FK_LabVersionIngredients_RecipeLabVersions_RecipeLabVersionId",
column: x => x.RecipeLabVersionId,
principalTable: "RecipeLabVersions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_LabIngredients_RecipeLabEntryId",
table: "LabIngredients",
column: "RecipeLabEntryId");
migrationBuilder.CreateIndex(
name: "IX_LabVersionIngredients_RecipeLabVersionId",
table: "LabVersionIngredients",
column: "RecipeLabVersionId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LabIngredients");
migrationBuilder.DropTable(
name: "LabVersionIngredients");
}
}
}

View File

@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Aberwyn.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250604220420_AssignedToNullable")]
partial class AssignedToNullable
[Migration("20250630141059_AddRecipeLabModels")]
partial class AddRecipeLabModels
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
@@ -183,6 +183,9 @@ namespace Aberwyn.Migrations
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("BudgetCategoryId");
@@ -201,6 +204,9 @@ namespace Aberwyn.Migrations
b.Property<string>("DefaultCategory")
.HasColumnType("longtext");
b.Property<int?>("DefaultPaymentStatus")
.HasColumnType("int");
b.Property<bool>("IncludeInSummary")
.HasColumnType("tinyint(1)");
@@ -260,6 +266,54 @@ namespace Aberwyn.Migrations
b.ToTable("Ingredients");
});
modelBuilder.Entity("Aberwyn.Models.LabIngredient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Item")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Quantity")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("RecipeLabEntryId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RecipeLabEntryId");
b.ToTable("LabIngredients");
});
modelBuilder.Entity("Aberwyn.Models.LabVersionIngredient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Item")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Quantity")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("RecipeLabVersionId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RecipeLabVersionId");
b.ToTable("LabVersionIngredients");
});
modelBuilder.Entity("Aberwyn.Models.Meal", b =>
{
b.Property<int>("Id")
@@ -269,9 +323,6 @@ namespace Aberwyn.Migrations
b.Property<string>("CarbType")
.HasColumnType("longtext");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -293,6 +344,12 @@ namespace Aberwyn.Migrations
b.Property<bool>("IsAvailable")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsPublished")
.HasColumnType("tinyint(1)");
b.Property<int?>("MealCategoryId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
@@ -303,11 +360,61 @@ namespace Aberwyn.Migrations
b.Property<string>("RecipeUrl")
.HasColumnType("longtext");
b.Property<byte[]>("ThumbnailData")
.HasColumnType("longblob");
b.HasKey("Id");
b.HasIndex("MealCategoryId");
b.ToTable("Meals");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Color")
.HasColumnType("longtext");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("Icon")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Slug")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("MealCategories");
b.HasData(
new
{
Id = 1,
Color = "#f97316",
DisplayOrder = 1,
Icon = "🍕",
IsActive = true,
Name = "Pizza",
Slug = "pizza"
});
});
modelBuilder.Entity("Aberwyn.Models.PizzaOrder", b =>
{
b.Property<int>("Id")
@@ -355,11 +462,121 @@ namespace Aberwyn.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<int?>("PizzaOrderId")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("PizzaOrderId");
b.HasIndex("UserId");
b.ToTable("PushSubscribers");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int?>("BaseMealId")
.HasColumnType("int");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Inspiration")
.HasColumnType("longtext");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int?>("Rating")
.HasColumnType("int");
b.Property<string>("Tags")
.HasColumnType("longtext");
b.Property<string>("TestedBy")
.HasColumnType("longtext");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("BaseMealId");
b.ToTable("RecipeLabEntries");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Instructions")
.HasColumnType("longtext");
b.Property<int>("RecipeLabEntryId")
.HasColumnType("int");
b.Property<string>("ResultNotes")
.HasColumnType("longtext");
b.Property<string>("VersionLabel")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("RecipeLabEntryId");
b.ToTable("RecipeLabVersions");
});
modelBuilder.Entity("Aberwyn.Models.StoredPushSubscription", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("P256DH")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PushSubscriptions");
});
modelBuilder.Entity("Aberwyn.Models.TodoTask", b =>
{
b.Property<int>("Id")
@@ -399,6 +616,25 @@ namespace Aberwyn.Migrations
b.ToTable("TodoTasks");
});
modelBuilder.Entity("Aberwyn.Models.UserPreferences", b =>
{
b.Property<string>("UserId")
.HasColumnType("varchar(255)");
b.Property<bool>("NotifyBudget")
.HasColumnType("tinyint(1)");
b.Property<bool>("NotifyMenu")
.HasColumnType("tinyint(1)");
b.Property<bool>("NotifyPizza")
.HasColumnType("tinyint(1)");
b.HasKey("UserId");
b.ToTable("UserPreferences");
});
modelBuilder.Entity("Aberwyn.Models.WeeklyMenu", b =>
{
b.Property<int>("Id")
@@ -581,7 +817,7 @@ namespace Aberwyn.Migrations
modelBuilder.Entity("Aberwyn.Models.BudgetItem", b =>
{
b.HasOne("Aberwyn.Models.BudgetCategory", "BudgetCategory")
b.HasOne("Aberwyn.Models.BudgetCategory", null)
.WithMany("Items")
.HasForeignKey("BudgetCategoryId")
.OnDelete(DeleteBehavior.Cascade)
@@ -591,8 +827,6 @@ namespace Aberwyn.Migrations
.WithMany()
.HasForeignKey("BudgetItemDefinitionId");
b.Navigation("BudgetCategory");
b.Navigation("BudgetItemDefinition");
});
@@ -605,6 +839,96 @@ namespace Aberwyn.Migrations
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.LabIngredient", b =>
{
b.HasOne("Aberwyn.Models.RecipeLabEntry", "Entry")
.WithMany("Ingredients")
.HasForeignKey("RecipeLabEntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Aberwyn.Models.LabVersionIngredient", b =>
{
b.HasOne("Aberwyn.Models.RecipeLabVersion", "Version")
.WithMany("Ingredients")
.HasForeignKey("RecipeLabVersionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Version");
});
modelBuilder.Entity("Aberwyn.Models.Meal", b =>
{
b.HasOne("Aberwyn.Models.MealCategory", "Category")
.WithMany("Meals")
.HasForeignKey("MealCategoryId");
b.Navigation("Category");
});
modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b =>
{
b.HasOne("Aberwyn.Models.PizzaOrder", "PizzaOrder")
.WithMany()
.HasForeignKey("PizzaOrderId");
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PizzaOrder");
b.Navigation("User");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.HasOne("Aberwyn.Models.Meal", "BaseMeal")
.WithMany()
.HasForeignKey("BaseMealId");
b.Navigation("BaseMeal");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.HasOne("Aberwyn.Models.RecipeLabEntry", "Entry")
.WithMany("Versions")
.HasForeignKey("RecipeLabEntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Aberwyn.Models.StoredPushSubscription", b =>
{
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Aberwyn.Models.UserPreferences", b =>
{
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithOne("Preferences")
.HasForeignKey("Aberwyn.Models.UserPreferences", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -656,6 +980,12 @@ namespace Aberwyn.Migrations
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.ApplicationUser", b =>
{
b.Navigation("Preferences")
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.BudgetCategory", b =>
{
b.Navigation("Items");
@@ -670,6 +1000,23 @@ namespace Aberwyn.Migrations
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Navigation("Meals");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.Navigation("Ingredients");
b.Navigation("Versions");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.Navigation("Ingredients");
});
#pragma warning restore 612, 618
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddRecipeLabModels : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Ingredients",
table: "RecipeLabVersions");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Ingredients",
table: "RecipeLabVersions",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddMealRating : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MealRatings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
MealId = table.Column<int>(type: "int", nullable: false),
UserId = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Rating = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MealRatings", x => x.Id);
table.ForeignKey(
name: "FK_MealRatings_Meals_MealId",
column: x => x.MealId,
principalTable: "Meals",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_MealRatings_MealId",
table: "MealRatings",
column: "MealId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MealRatings");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddNameToBudgetPeriod : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class AddNameToBudgetPeriod2 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Name",
table: "BudgetPeriods",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Name",
table: "BudgetPeriods");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Aberwyn.Migrations
{
public partial class MakeYearMonthNullable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Year",
table: "BudgetPeriods",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.AlterColumn<int>(
name: "Month",
table: "BudgetPeriods",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Year",
table: "BudgetPeriods",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "Month",
table: "BudgetPeriods",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
}
}
}

View File

@@ -181,6 +181,9 @@ namespace Aberwyn.Migrations
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("BudgetCategoryId");
@@ -199,6 +202,9 @@ namespace Aberwyn.Migrations
b.Property<string>("DefaultCategory")
.HasColumnType("longtext");
b.Property<int?>("DefaultPaymentStatus")
.HasColumnType("int");
b.Property<bool>("IncludeInSummary")
.HasColumnType("tinyint(1)");
@@ -220,13 +226,16 @@ namespace Aberwyn.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("Month")
b.Property<int?>("Month")
.HasColumnType("int");
b.Property<string>("Name")
.HasColumnType("longtext");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<int>("Year")
b.Property<int?>("Year")
.HasColumnType("int");
b.HasKey("Id");
@@ -258,6 +267,54 @@ namespace Aberwyn.Migrations
b.ToTable("Ingredients");
});
modelBuilder.Entity("Aberwyn.Models.LabIngredient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Item")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Quantity")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("RecipeLabEntryId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RecipeLabEntryId");
b.ToTable("LabIngredients");
});
modelBuilder.Entity("Aberwyn.Models.LabVersionIngredient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Item")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Quantity")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("RecipeLabVersionId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RecipeLabVersionId");
b.ToTable("LabVersionIngredients");
});
modelBuilder.Entity("Aberwyn.Models.Meal", b =>
{
b.Property<int>("Id")
@@ -267,9 +324,6 @@ namespace Aberwyn.Migrations
b.Property<string>("CarbType")
.HasColumnType("longtext");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -291,6 +345,12 @@ namespace Aberwyn.Migrations
b.Property<bool>("IsAvailable")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsPublished")
.HasColumnType("tinyint(1)");
b.Property<int?>("MealCategoryId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
@@ -306,9 +366,82 @@ namespace Aberwyn.Migrations
b.HasKey("Id");
b.HasIndex("MealCategoryId");
b.ToTable("Meals");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Color")
.HasColumnType("longtext");
b.Property<string>("Description")
.HasColumnType("longtext");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("Icon")
.HasColumnType("longtext");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Slug")
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("MealCategories");
b.HasData(
new
{
Id = 1,
Color = "#f97316",
DisplayOrder = 1,
Icon = "🍕",
IsActive = true,
Name = "Pizza",
Slug = "pizza"
});
});
modelBuilder.Entity("Aberwyn.Models.MealRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("MealId")
.HasColumnType("int");
b.Property<int>("Rating")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("MealId");
b.ToTable("MealRatings");
});
modelBuilder.Entity("Aberwyn.Models.PizzaOrder", b =>
{
b.Property<int>("Id")
@@ -356,17 +489,92 @@ namespace Aberwyn.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<int?>("PizzaOrderId")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("PizzaOrderId");
b.HasIndex("UserId");
b.ToTable("PushSubscribers");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int?>("BaseMealId")
.HasColumnType("int");
b.Property<string>("Category")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Inspiration")
.HasColumnType("longtext");
b.Property<string>("Notes")
.HasColumnType("longtext");
b.Property<int?>("Rating")
.HasColumnType("int");
b.Property<string>("Tags")
.HasColumnType("longtext");
b.Property<string>("TestedBy")
.HasColumnType("longtext");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("BaseMealId");
b.ToTable("RecipeLabEntries");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Instructions")
.HasColumnType("longtext");
b.Property<int>("RecipeLabEntryId")
.HasColumnType("int");
b.Property<string>("ResultNotes")
.HasColumnType("longtext");
b.Property<string>("VersionLabel")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("RecipeLabEntryId");
b.ToTable("RecipeLabVersions");
});
modelBuilder.Entity("Aberwyn.Models.StoredPushSubscription", b =>
{
b.Property<int>("Id")
@@ -636,7 +844,7 @@ namespace Aberwyn.Migrations
modelBuilder.Entity("Aberwyn.Models.BudgetItem", b =>
{
b.HasOne("Aberwyn.Models.BudgetCategory", "BudgetCategory")
b.HasOne("Aberwyn.Models.BudgetCategory", null)
.WithMany("Items")
.HasForeignKey("BudgetCategoryId")
.OnDelete(DeleteBehavior.Cascade)
@@ -646,8 +854,6 @@ namespace Aberwyn.Migrations
.WithMany()
.HasForeignKey("BudgetItemDefinitionId");
b.Navigation("BudgetCategory");
b.Navigation("BudgetItemDefinition");
});
@@ -660,17 +866,85 @@ namespace Aberwyn.Migrations
.IsRequired();
});
modelBuilder.Entity("Aberwyn.Models.LabIngredient", b =>
{
b.HasOne("Aberwyn.Models.RecipeLabEntry", "Entry")
.WithMany("Ingredients")
.HasForeignKey("RecipeLabEntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Aberwyn.Models.LabVersionIngredient", b =>
{
b.HasOne("Aberwyn.Models.RecipeLabVersion", "Version")
.WithMany("Ingredients")
.HasForeignKey("RecipeLabVersionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Version");
});
modelBuilder.Entity("Aberwyn.Models.Meal", b =>
{
b.HasOne("Aberwyn.Models.MealCategory", "Category")
.WithMany("Meals")
.HasForeignKey("MealCategoryId");
b.Navigation("Category");
});
modelBuilder.Entity("Aberwyn.Models.MealRating", b =>
{
b.HasOne("Aberwyn.Models.Meal", "Meal")
.WithMany()
.HasForeignKey("MealId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Meal");
});
modelBuilder.Entity("Aberwyn.Models.PushSubscriber", b =>
{
b.HasOne("Aberwyn.Models.PizzaOrder", "PizzaOrder")
.WithMany()
.HasForeignKey("PizzaOrderId");
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PizzaOrder");
b.Navigation("User");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.HasOne("Aberwyn.Models.Meal", "BaseMeal")
.WithMany()
.HasForeignKey("BaseMealId");
b.Navigation("BaseMeal");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.HasOne("Aberwyn.Models.RecipeLabEntry", "Entry")
.WithMany("Versions")
.HasForeignKey("RecipeLabEntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Aberwyn.Models.StoredPushSubscription", b =>
{
b.HasOne("Aberwyn.Models.ApplicationUser", "User")
@@ -764,6 +1038,23 @@ namespace Aberwyn.Migrations
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("Aberwyn.Models.MealCategory", b =>
{
b.Navigation("Meals");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabEntry", b =>
{
b.Navigation("Ingredients");
b.Navigation("Versions");
});
modelBuilder.Entity("Aberwyn.Models.RecipeLabVersion", b =>
{
b.Navigation("Ingredients");
});
#pragma warning restore 612, 618
}
}

View File

@@ -7,9 +7,11 @@ namespace Aberwyn.Models
public class BudgetPeriod
{
public int Id { get; set; }
public int Year { get; set; }
public int Month { get; set; }
public int? Year { get; set; }
public int? Month { get; set; }
public int Order { get; set; }
public string? Name { get; set; }
public List<BudgetCategory> Categories { get; set; } = new();
}
@@ -47,14 +49,24 @@ namespace Aberwyn.Models
[JsonIgnore]
[ValidateNever]
public BudgetCategory BudgetCategory { get; set; }
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.None;
}
public enum PaymentStatus
{
None = 0,
Due = 1,
Paid = 2
}
// DTOs/BudgetDto.cs
public class BudgetDto
{
public int Id { get; set; }
public int Year { get; set; }
public int Month { get; set; }
public string? Name { get; set; }
public int? Year { get; set; }
public int? Month { get; set; }
public int Order { get; set; }
public List<BudgetCategoryDto> Categories { get; set; } = new();
@@ -69,7 +81,7 @@ namespace Aberwyn.Models
public int Order { get; set; }
public int? BudgetCategoryDefinitionId { get; set; }
public int? BudgetPeriodId { get; set; }
public int Year { get; set; }
public int Month { get; set; }
}
@@ -84,6 +96,7 @@ namespace Aberwyn.Models
public bool IsExpense { get; set; }
public bool IncludeInSummary { get; set; }
public PaymentStatus PaymentStatus { get; set; }
}
@@ -91,6 +104,11 @@ namespace Aberwyn.Models
{
public List<BudgetItem> BudgetItems { get; set; } = new List<BudgetItem>();
}
public class PaymentStatusUpdateDto
{
public int ItemId { get; set; }
public PaymentStatus Status { get; set; }
}
public class BudgetItemDefinition
{
@@ -99,6 +117,8 @@ namespace Aberwyn.Models
public string? DefaultCategory { get; set; } // valfritt, kan föreslå "Bilar"
public bool IsExpense { get; set; }
public bool IncludeInSummary { get; set; }
public PaymentStatus? DefaultPaymentStatus { get; set; }
}
public class BudgetCategoryDefinition
{

View File

@@ -0,0 +1,33 @@
namespace Aberwyn.Models
{
public class StreamingService
{
public int Id { get; set; }
public string Name { get; set; } // T.ex. "Netflix", "Plex"
}
public class MediaItem
{
public int Id { get; set; }
public string TmdbId { get; set; } // TMDb ID
public string Title { get; set; }
public string Overview { get; set; }
public string PosterPath { get; set; }
public string MediaType { get; set; } // "movie" eller "tv"
public double Rating { get; set; }
public DateTime? ReleaseDate { get; set; }
public List<MediaItemStreamingService> MediaItemStreamingServices { get; set; } = new();
}
public class MediaItemStreamingService
{
public int MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public int StreamingServiceId { get; set; }
public StreamingService StreamingService { get; set; }
}
}

View File

@@ -14,7 +14,7 @@ namespace Aberwyn.Models
public int Year { get; set; } // Year for the menu
}
public class WeeklyMenuDto
{
{
public int Id { get; set; }
public int DayOfWeek { get; set; }
@@ -43,6 +43,9 @@ public class WeeklyMenu
public int WeekNumber { get; set; }
public int Year { get; set; }
public DateTime CreatedAt { get; set; }
[NotMapped] public byte[]? BreakfastThumbnail { get; set; }
[NotMapped] public byte[]? LunchThumbnail { get; set; }
[NotMapped] public byte[]? DinnerThumbnail { get; set; }
[NotMapped] public string? BreakfastMealName { get; set; }
[NotMapped] public string? LunchMealName { get; set; }
@@ -70,7 +73,10 @@ public class WeeklyMenu
public string? Description { get; set; }
public string? ProteinType { get; set; }
public string? Category { get; set; }
public int? MealCategoryId { get; set; }
[ForeignKey("MealCategoryId")]
public MealCategory? Category { get; set; }
public string? CarbType { get; set; }
public string? RecipeUrl { get; set; }
public string? ImageUrl { get; set; }
@@ -78,7 +84,7 @@ public class WeeklyMenu
public bool IsAvailable { get; set; }
public DateTime CreatedAt { get; set; }
public byte[]? ThumbnailData { get; set; }
public bool IsPublished { get; set; } = false;
public byte[]? ImageData { get; set; }
public string? ImageMimeType { get; set; }
public string? Instructions { get; set; }
@@ -119,12 +125,14 @@ public class WeeklyMenu
}
public class MealListDto
{
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public string? ThumbnailData { get; set; }
public List<IngredientDto> Ingredients { get; set; } = new();
public static MealListDto FromMeal(Meal meal, bool includeThumbnail = false)
{
return new MealListDto
@@ -134,9 +142,57 @@ public class WeeklyMenu
Description = meal.Description,
ThumbnailData = includeThumbnail && meal.ThumbnailData != null
? Convert.ToBase64String(meal.ThumbnailData)
: null
: null,
Ingredients = meal.Ingredients?.Select(i => new IngredientDto
{
Item = i.Item
}).ToList() ?? new List<IngredientDto>()
};
}
public class IngredientDto
{
public string Item { get; set; } = "";
}
}
public class MealCategory
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public string? Slug { get; set; }
public string? Description { get; set; }
public string? Icon { get; set; }
public string? Color { get; set; }
public bool IsActive { get; set; } = true;
public int DisplayOrder { get; set; } = 0;
[NotMapped] // krävs om du inte har kolumnen i databasen
public int MealCount { get; set; }
public List<Meal> Meals { get; set; } = new();
}
public class MealRating
{
public int Id { get; set; }
public int MealId { get; set; }
public string UserId { get; set; }
public int Rating { get; set; }
public DateTime CreatedAt { get; set; }
public Meal Meal { get; set; }
}
public class MealRatingDto
{
public int MealId { get; set; }
public int Rating { get; set; }
}
}

View File

@@ -8,6 +8,9 @@
public string Auth { get; set; }
public string UserId { get; set; }
public virtual ApplicationUser User { get; set; }
public int? PizzaOrderId { get; set; }
public virtual PizzaOrder PizzaOrder { get; set; }
}
public class PushMessageDto
{

View File

@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Aberwyn.Models
{
public class RecipeLabEntry
{
[Key]
public int Id { get; set; }
[Required]
public string Title { get; set; }
public string? Category { get; set; }
public string? Inspiration { get; set; }
public int? BaseMealId { get; set; }
[ForeignKey("BaseMealId")]
public Meal? BaseMeal { get; set; }
public string? Notes { get; set; }
public int? Rating { get; set; }
public string? TestedBy { get; set; }
public string? Tags { get; set; } // "vego,snabbt"
public DateTime CreatedAt { get; set; } = DateTime.Now;
public List<LabIngredient> Ingredients { get; set; } = new();
public List<RecipeLabVersion> Versions { get; set; } = new();
}
public class RecipeLabVersion
{
[Key]
public int Id { get; set; }
public int RecipeLabEntryId { get; set; }
[ForeignKey("RecipeLabEntryId")]
public RecipeLabEntry Entry { get; set; }
public string VersionLabel { get; set; } = "v1";
public List<LabVersionIngredient> Ingredients { get; set; } = new();
public string? Instructions { get; set; }
public string? ResultNotes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.Now;
}
public class LabIngredient
{
[Key]
public int Id { get; set; }
public int RecipeLabEntryId { get; set; }
[ForeignKey("RecipeLabEntryId")]
public RecipeLabEntry Entry { get; set; }
public string Quantity { get; set; } = "";
public string Item { get; set; } = "";
}
public class LabVersionIngredient
{
public int Id { get; set; }
public int RecipeLabVersionId { get; set; }
[ForeignKey("RecipeLabVersionId")]
public RecipeLabVersion Version { get; set; }
public string Quantity { get; set; } = "";
public string Item { get; set; } = "";
}
}

View File

@@ -0,0 +1,29 @@
public class TorrentInfo
{
public string FileName { get; set; }
public string AnnounceUrl { get; set; }
public string ScrapeUrl { get; set; }
public string InfoHash { get; set; }
public byte[] InfoHashBytes { get; set; }
public long Size { get; set; }
public int Seeders { get; set; } = 0;
public int Leechers { get; set; } = 0;
public int Completed { get; set; } = 0;
public bool HasTrackerData { get; set; } = false;
public string ErrorMessage { get; set; }
}
public class TrackerInfo
{
public string Name { get; set; }
public bool SupportsScraping { get; set; }
public bool RequiresAuth { get; set; }
public bool IsPrivate { get; set; }
public string Notes { get; set; }
}
public class TorrentUploadViewModel
{
public IFormFile TorrentFile { get; set; }
public TorrentInfo TorrentInfo { get; set; }
public bool ShowResults { get; set; } = false;
}

View File

@@ -64,6 +64,8 @@ catch (Exception ex)
}
builder.Services.AddHttpClient<ITorrentService, TorrentService>();
builder.Services.AddScoped<ITorrentService, TorrentService>();
// Add services to the container
builder.Services.AddControllersWithViews()
@@ -191,14 +193,6 @@ app.Use(async (context, next) =>
// Configure the HTTP request pipeline
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseRouting();
app.UseSession();
app.UseAuthentication();
@@ -211,6 +205,11 @@ app.MapControllerRoute(
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
app.UseExceptionHandler("/Error");
app.UseStatusCodePagesWithReExecute("/Error/{0}");
app.UseHsts();
// Init: migrera databas och skapa admin
if (setup.IsConfigured)
{

View File

@@ -35,7 +35,6 @@
}
.todo-board {
padding: 20px;
font-family: sans-serif;
}
.todo-columns {

View File

@@ -5,7 +5,7 @@
ViewData["Title"] = "Budget";
}
<div ng-app="budgetApp" ng-controller="BudgetController" class="budget-page" ng-init="loadBudget()">
<div ng-app="budgetApp" ng-controller="BudgetController">
<div class="budget-header" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px;">
<div class="month-nav-bar" style="display: flex; align-items: center; gap: 10px; position: relative;">
<button class="nav-button" ng-click="previousMonth()">←</button>
@@ -26,15 +26,15 @@
<i class="fa fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu" ng-show="menuOpen">
<button ng-click="copyPreviousMonthSafe()">Kopiera föregående månad</button>
<button ng-click="deleteMonth(); menuOpen = false;" class="danger">Ta bort hela månaden</button>
<button ng-click="copyPreviousMonthSafe()">Kopiera föregående månad</button>
<button ng-click="deleteMonth(); menuOpen = false;" class="danger">Ta bort hela månaden</button>
<button ng-click="createNewCategory(); menuOpen = false;">Lägg till ny kategori</button>
<!--<button ng-click="openImportModule(); menuOpen = false;">📥 Importera rader</button> -->
</div>
</div>
</div>
<div class="budget-overview-row" ng-if="budget && budget.categories.length > 0">
<div class="budget-overview-row" ng-if="budget && budget.categories.length > 0">
<!-- Vänster: Summering -->
<div class="budget-summary-box compact">
<h3>Sammanställning</h3>
@@ -81,7 +81,7 @@
</div>
</div>
</div>
</div>
@@ -94,7 +94,7 @@
on-category-drop="handleCategoryDrop(data, targetCategory)">
<div class="card-header" style="background-color: {{cat.color}};">
<div class="header-left" ng-if="!cat.editing">{{ cat.name }}</div>
<div class="header-left" ng-if="!cat.editing" >{{ cat.name }}</div>
<input class="header-edit" type="text" ng-model="cat.name" ng-if="cat.editing" />
<input class="color-edit" type="color" ng-model="cat.color" ng-if="cat.editing" />
<div class="header-actions">
@@ -112,6 +112,7 @@
</button>
</div>
</div>
<div class="item-list">
<div ng-repeat="item in cat.items track by item.id">
<div drop-placeholder
@@ -125,14 +126,18 @@
item="item"
category="cat"
on-item-drop="handleItemDrop(event, data, cat)"
ng-class="{ dragging: dragInProgress && draggedItemId === item.id }">
ng-class="{
dragging: dragInProgress && draggedItemId === item.id,
due: item.paymentStatus === 1,
paid: item.paymentStatus === 2
}"
ng-click="handleItemInteraction($event, item)">
<i class="fa fa-grip-lines drag-handle"
ng-show="cat.editing"
style="opacity: 0.5; padding-right: 6px; cursor: grab;"></i>
<input type="text" ng-model="item.name" ng-if="cat.editing" />
<span ng-if="!cat.editing" title="{{ item.definitionName }}">{{ item.name }}</span>
<!-- <span ng-if="!cat.editing">#{{ item.definitionName }}</span>-->
<input type="text" class="item-label" ng-model="item.name" ng-if="cat.editing" />
<span ng-if="!cat.editing" class="item-label" title="{{ item.name }}">{{ item.name }}</span>
<input type="number" ng-model="item.amount" ng-if="cat.editing" />
<span class="amount" ng-if="!cat.editing">{{ item.amount | number:0 }}</span>
@@ -185,15 +190,23 @@
<div>Summa</div>
<div class="amount">{{ getCategorySum(cat) | number:0 }}</div>
</div>
</div>
</div>
<div class="no-data" ng-if="!loading && budget && budget.categories.length === 0">
Det finns ingen budgetdata för vald månad ({{ selectedMonth }}/{{ selectedYear }}).
<button ng-click="copyPreviousMonthSafe()">[Test] Kopiera föregående</button>
</div>
</div>
</div>
<p>Det finns ingen budgetdata för
<strong>{{ budget.name || (selectedMonth + '/' + selectedYear) }}</strong>.
</p>
<div class="add-item-popup" ng-show="addPopupVisible" ng-style="addPopupStyle" ng-class="{ 'above': addPopupAbove }">
<div style="margin-top: 10px;">
<button ng-click="createEmptyBudget()" style="margin-right: 10px;">
Skapa ny budget
</button>
<button ng-click="copyPreviousMonthSafe()">
Kopiera föregående månad
</button>
</div>
</div>
<div class="add-item-popup" ng-show="addPopupVisible" ng-style="addPopupStyle" ng-class="{ 'above': addPopupAbove }">
<label>Typ:</label>
<select ng-model="addPopupData.newItemType">
<option value="expense">💸 Utgift</option>
@@ -225,12 +238,42 @@
<button ng-click="addItemFromPopup()">Lägg till</button>
<button ng-click="addPopupVisible = false">Avbryt</button>
</div>
</div>
<div class="import-module" ng-show="importing" style="position: fixed; top: 10vh; left: 50%; transform: translateX(-50%);
background: #1F2C3C; color: white; padding: 24px; border-radius: 8px; z-index: 2000; width: 90%; max-width: 600px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);">
<h3 style="margin-top: 0;">📥 Importera rader till {{ importTargetCategory.name }}</h3>
<label for="importText">Klistra in rader (en per rad, t.ex. <code>Kallhyra 11315</code>):</label>
<textarea id="importText" ng-model="importText" rows="6" style="width: 100%; margin-bottom: 10px;"></textarea>
<button ng-click="parseImportText()">Förhandsgranska</button>
<button ng-click="cancelImport()">Avbryt</button>
<div class="preview-list" ng-if="importPreview.length > 0" style="margin-top: 16px;">
<h4>Förhandsvisning:</h4>
<div class="item-row" ng-repeat="item in importPreview">
<span class="item-label">{{ item.name }}</span>
<span class="amount">{{ item.amount | number:0 }} kr</span>
</div>
<button ng-click="applyImport()">✔️ Skapa {{ importPreview.length }} rader</button>
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="~/css/budget.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<script>
window.initialYear = @(ViewBag.Year ?? "null");
window.initialMonth = @(ViewBag.Month ?? "null");
window.initialName = "@(ViewBag.BudgetName ?? "")";
</script>
<script src="~/js/budget.js"></script>
<script src="~/js/budget-dragdrop.js"></script>

View File

@@ -0,0 +1,75 @@
@{
Layout = "_Layout";
var errorCode = ViewData["ErrorCode"] as int?;
var exception = ViewData["Exception"] as Exception;
var isDebug = System.Diagnostics.Debugger.IsAttached;
}
<style>
.error-page {
max-width: 600px;
margin: 4rem auto;
padding: 2rem;
background-color: #1F2C3C;
color: white;
border-radius: 16px;
box-shadow: 0 0 10px rgba(0,0,0,0.4);
font-family: 'Segoe UI', sans-serif;
}
.error-page h1 {
font-size: 2.2rem;
margin-bottom: 1rem;
color: #f87171;
}
.error-page .code {
font-size: 1.5rem;
margin-bottom: 1rem;
color: #facc15;
}
.error-page pre {
background-color: #2E3C4F;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin-top: 1rem;
color: #fefefe;
font-size: 0.95rem;
}
.error-page details summary {
cursor: pointer;
color: #6a0dad;
margin-top: 1rem;
font-weight: bold;
}
.error-page p {
margin-top: 0.5rem;
}
</style>
<div class="error-page">
<h1>🚨 Något gick fel</h1>
<a href="/" class="btn-outline">Till startsidan</a>
@if (errorCode.HasValue)
{
<div class="code">Statuskod: <strong>@errorCode</strong></div>
}
@if (exception != null)
{
<h3>Felmeddelande:</h3>
<pre>@exception.Message</pre>
@if (isDebug)
{
<details>
<summary>Visa stacktrace</summary>
<pre>@exception.StackTrace</pre>
</details>
}
}
else
{
<p>Vi kunde tyvärr inte visa sidan. Det kan bero på ett tillfälligt fel.</p>
}
</div>

View File

@@ -3,7 +3,7 @@
@{
ViewData["Title"] = "Pizza Admin";
var availablePizzas = Model.AvailablePizzas as List<Meal> ?? new List<Meal>();
var restaurantOpen = ViewBag.RestaurantOpen as bool? ?? true;
var restaurantOpen = ViewBag.RestaurantIsOpen as bool? ?? false;
}
<link rel="stylesheet" href="~/css/pizza-admin.css" />

View File

@@ -2,7 +2,7 @@
@using Newtonsoft.Json.Serialization
@{
ViewData["Title"] = "Beställ pizza";
var pizzas = ViewBag.Pizzas as List<Meal>;
var pizzas = ViewBag.Pizzas as List<MealListDto> ?? new List<MealListDto>();
var previousOrder = ViewBag.PreviousOrder as PizzaOrder;
var lastId = Context.Session.GetInt32("LastPizzaOrderId");
var isCurrentOrder = previousOrder?.Id == lastId;

View File

@@ -145,7 +145,7 @@
<datalist id="meals-list">
@foreach (var meal in (List<Meal>)ViewBag.AvailableMeals)
{
<option value="@meal.Id">@meal.Name</option>
<option value="@meal.Name">@meal.Name</option>
}
</datalist>
</form>

View File

@@ -7,16 +7,21 @@
<!DOCTYPE html>
<html lang="en" ng-app="budgetApp">
<head>
<style>
[ng-cloak] {
display: none !important;
}
</style>
<meta charset="utf-8">
<title>Budget Overview</title>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
<link rel="stylesheet" type="text/css" href="~/css/budget.css">
</head>
<body ng-controller="BudgetController">
<body ng-controller="BudgetController" ng-cloak>
<div class="budget-page">
<h1>Budget Overview</h1>
<h1 ng-cloak>Budget Overview</h1>
<div class="date-picker" ng-click="toggleDatePicker($event)">
<button type="button" class="date-picker-toggle">
@@ -28,7 +33,11 @@
</div>
</div>
<div class="budget-container">
<div ng-if="isLoadingBudget" class="loading-indicator">
⏳ Laddar budget...
</div>
<div ng-if="!isLoadingBudget" class="budget-container">
<div ng-repeat="category in categories" class="category-block">
<div class="category-header">
{{ category }}
@@ -55,7 +64,7 @@
angular.module('budgetApp', [])
.controller('BudgetController', function ($scope, $http) {
const today = new Date();
$scope.isLoadingBudget = true;
// Initialize months and years
$scope.months = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
$scope.years = [...Array(11).keys()].map(i => today.getFullYear() - 10 + i);
@@ -72,11 +81,16 @@
// Automatically filter when month or year changes
$scope.filterBudget = function () {
$scope.isLoadingBudget = true;
$http.get('/api/budgetapi/items', {
params: { month: $scope.selectedMonth, year: $scope.selectedYear }
}).then(response => {
$scope.budgetItems = response.data;
}).catch(error => console.error("Error fetching budget items:", error));
$scope.isLoadingBudget = false;
}).catch(error => {
console.error("Error fetching budget items:", error);
$scope.isLoadingBudget = false;
});
};
$scope.getItemsByCategory = function (category) {

View File

@@ -1,18 +1,111 @@
@{
ViewData["Title"] = "Welcome to Lewel!";
@model Aberwyn.Models.WeeklyMenu
@{
var showDate = (DateTime)ViewBag.ShowDate;
var day = showDate.ToString("dddd", new System.Globalization.CultureInfo("sv-SE"));
}
<link rel="stylesheet" href="~/css/Welcome.css" />
<section class="welcome-section light-mode">
<div class="welcome-content">
<h1 class="welcome-title">Välkommen till
<span class="initial L">L</span>
<span class="initial E">E</span>
<span class="initial W">W</span>
<span class="initial E2">E</span>
<span class="initial L2">L</span></h1>
<p class="welcome-subtitle">Meny för <strong>@day</strong> är:</p>
@if (Model != null)
{
<div class="meal-lines">
@if (!string.IsNullOrWhiteSpace(Model.BreakfastMealName)) {
<div class="meal-line">
<p><strong>Frukost:</strong>
<a href="/meal/view/@Model.BreakfastMealId">@Model.BreakfastMealName</a>
</p>
@if (Model.BreakfastThumbnail != null)
{
var b64 = Convert.ToBase64String(Model.BreakfastThumbnail);
<div class="meal-large-thumb-container">
<img class="meal-large-thumb" src="data:image/jpeg;base64,@b64" alt="@Model.BreakfastMealName" />
</div>
}
</div>
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@ViewData["Title"]</title>
</head>
<body>
<div class="container">
<h1>Välkommen</h1> Kika på <a href="/Home/Menu" class="button">Menyn</a> om du vill.
@if (!string.IsNullOrWhiteSpace(Model.LunchMealName)) {
<div class="meal-line">
<p><strong>Lunch:</strong>
<a href="/meal/view/@Model.LunchMealId">@Model.LunchMealName</a>
</p>
@if (Model.LunchThumbnail != null)
{
var b64 = Convert.ToBase64String(Model.LunchThumbnail);
<div class="meal-large-thumb-container">
<img class="meal-large-thumb" src="data:image/jpeg;base64,@b64" alt="@Model.LunchMealName" />
</div>
</body>
</html>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(Model.DinnerMealName)) {
<div class="meal-line">
<p><strong>Middag:</strong>
<a href="/meal/view/@Model.DinnerMealId">@Model.DinnerMealName</a>
</p>
@if (Model.DinnerThumbnail != null)
{
var b64 = Convert.ToBase64String(Model.DinnerThumbnail);
<div class="meal-large-thumb-container">
<img class="meal-large-thumb" src="data:image/jpeg;base64,@b64" alt="@Model.DinnerMealName" />
</div>
}
</div>
}
@if (ViewBag.RestaurantIsOpen as bool? == true)
{
<p><strong>Pizzerian är öppen!</strong></p>
<a asp-controller="FoodMenu" asp-action="PizzaOrder">Klicka här för att Beställa pizza</a>
}
</div>
}
else
{
<p class="no-menu">Ingen meny är inlagd för denna dag.</p>
}
<a class="nav-button" href="/Home/Menu">Visa hela veckomenyn</a>
</div>
</section>
<div id="lightboxOverlay" class="lightbox-overlay" onclick="hideLargeImage()" style="display:none;">
<div class="lightbox-content" onclick="event.stopPropagation()">
<button class="close-lightbox" onclick="hideLargeImage()">×</button>
<img id="lightboxImage" class="lightbox-image" src="" alt="Meal image" />
<p id="lightboxCaption" class="lightbox-caption"></p>
</div>
</div>
<script>
function showLargeImage(base64Data, caption) {
const overlay = document.getElementById("lightboxOverlay");
const image = document.getElementById("lightboxImage");
const text = document.getElementById("lightboxCaption");
image.src = `data:image/jpeg;base64,${base64Data}`;
text.textContent = caption || '';
overlay.style.display = "flex";
document.body.classList.add("no-scroll");
}
function hideLargeImage() {
document.getElementById("lightboxOverlay").style.display = "none";
document.body.classList.remove("no-scroll");
}
document.addEventListener('keydown', function (e) {
if (e.key === "Escape") hideLargeImage();
});
</script>

View File

@@ -32,13 +32,13 @@
<div class="day-header">{{day}}</div>
<div class="meal-info" ng-if="menu[day]">
<div ng-if="menu[day].breakfastMealId" class="meal-selection">
<a href="/Meal/View/{{menu[day].breakfastMealId}}" target="_blank"><strong>Frukost:</strong> {{menu[day].breakfastMealName}}</a>
<a href="/Meal/View/{{menu[day].breakfastMealId}}"><strong>Frukost:</strong> {{menu[day].breakfastMealName}}</a>
</div>
<div ng-if="menu[day].lunchMealId" class="meal-selection">
<a href="/Meal/View/{{menu[day].lunchMealId}}" target="_blank"><strong>Lunch:</strong> {{menu[day].lunchMealName}}</a>
<a href="/Meal/View/{{menu[day].lunchMealId}}"><strong>Lunch:</strong> {{menu[day].lunchMealName}}</a>
</div>
<div ng-if="menu[day].dinnerMealId" class="meal-selection">
<a href="/Meal/View/{{menu[day].dinnerMealId}}" target="_blank"><strong>Middag:</strong> {{menu[day].dinnerMealName}}</a>
<a href="/Meal/View/{{menu[day].dinnerMealId}}"><strong>Middag:</strong> {{menu[day].dinnerMealName}}</a>
</div>
</div>
<div ng-if="!menu[day]">
@@ -65,6 +65,7 @@
</div>
</div>
<!--
<div class="school-meals card-view">
<h2>Skolmat</h2>
@@ -85,7 +86,7 @@
</div>
</div>
</div>
-->
</div>
</body>

View File

@@ -1,4 +1,11 @@
<!DOCTYPE html>
@using Microsoft.AspNetCore.Http
@inject IHttpContextAccessor HttpContextAccessor
@{
bool isChef = HttpContextAccessor.HttpContext.User.IsInRole("Chef");
}
<html lang="sv" ng-app="mealGalleryApp">
<head>
<meta charset="utf-8">
@@ -6,19 +13,37 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
<link rel="stylesheet" href="/css/meal-gallery.css">
<script src="https://kit.fontawesome.com/yourkit.js" crossorigin="anonymous"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { overflow-x: hidden; }
</style>
</head>
<body ng-controller="MealGalleryController">
<div class="meal-gallery-container" ng-controller="MealGalleryController">
<div class="meal-gallery-container">
<div class="meal-gallery-header">
<h1>Recept</h1>
<div class="search-container">
<input type="text" ng-model="search" placeholder="Sök recept...">
<input type="text" ng-model="search" placeholder="Sök recept..." />
<i class="fa-solid fa-magnifying-glass"></i>
@if (isChef)
{
<label class="toggle-published">
<input type="checkbox" ng-model="includeUnpublished" ng-change="reloadMeals()" />
<span>Visa alla</span>
</label>
<a href="/meal/view?edit=true" class="btn-create-meal">+ Ny rätt</a>
}
</div>
</div>
<div class="meal-gallery-grid">
<div class="meal-card" ng-repeat="meal in meals | filter:search">
<div class="meal-card"
ng-repeat="meal in (meals | filter:search) | limitTo:visibleCount track by meal.Id">
<img ng-src="{{ meal.ThumbnailData ? 'data:image/webp;base64,' + meal.ThumbnailData : '/images/fallback.jpg' }}"
alt="{{ meal.Name }}">
<div class="meal-card-content">
@@ -28,21 +53,37 @@
</div>
</div>
</div>
</div>
</body>
</div>
<script>
angular.module("mealGalleryApp", []).controller("MealGalleryController", function ($scope, $http) {
angular.module("mealGalleryApp", []).controller("MealGalleryController", function ($scope, $http) {
$scope.meals = [];
$scope.search = "";
$scope.visibleCount = 12;
$scope.includeUnpublished = false;
$http.get("/api/mealMenuApi/getMeals").then(res => {
$scope.reloadMeals = function () {
const url = `/api/mealMenuApi/getPublishedMeals?includeUnpublished=${$scope.includeUnpublished}`;
$http.get(url).then(res => {
$scope.meals = res.data;
});
};
$scope.reloadMeals();
window.addEventListener('scroll', function () {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 150) {
$scope.$applyAsync(() => {
$scope.visibleCount += 8;
});
}
});
$scope.$watch('search', function (newVal) {
$scope.visibleCount = newVal ? 9999 : 12;
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,173 @@
@model Aberwyn.Models.RecipeLabEntry
@{
ViewData["Title"] = "Matlabb";
}
<link rel="stylesheet" href="/css/lab.css" />
<div class="lab-container">
<!-- Vänster: formulär för labb-entry -->
<div class="lab-main">
<h2>Matlabb @(string.IsNullOrEmpty(Model.Title) ? "Ny" : Model.Title)</h2>
<form asp-action="SaveLabEntry" method="post">
<input type="hidden" name="Id" value="@Model.Id" />
<div class="lab-form-group">
<label>Titel</label>
<input type="text" name="Title" value="@Model.Title" class="lab-input" required />
</div>
<div class="lab-form-group">
<label>Inspiration</label>
<input type="text" name="Inspiration" value="@Model.Inspiration" class="lab-input" />
</div>
<div class="lab-form-group">
<label>Anteckningar</label>
<textarea name="Notes" class="lab-textarea">@Model.Notes</textarea>
</div>
<div class="lab-form-group">
<label>Taggar</label>
<input type="text" name="Tags" value="@Model.Tags" class="lab-input" />
</div>
<button type="submit" class="lab-save-button">Spara labb</button>
</form>
@if (Model.Id > 0)
{
<hr />
<!-- Ingredienser sektion -->
<h3>Ingredienser</h3>
<div id="ingredients-section">
<form asp-action="SaveLabIngredients" method="post">
<input type="hidden" name="RecipeLabEntryId" value="@Model.Id" />
<div id="ingredients-list">
@if (Model.Ingredients?.Any() == true)
{
@for (int i = 0; i < Model.Ingredients.Count; i++)
{
<div class="ingredient-row">
<input type="text" name="Ingredients[@i].Quantity" value="@Model.Ingredients[i].Quantity" placeholder="Mängd" class="lab-input" />
<input type="text" name="Ingredients[@i].Item" value="@Model.Ingredients[i].Item" placeholder="Ingrediens" class="lab-input" />
<button type="button" class="remove-ingredient" onclick="removeIngredient(this)">Ta bort</button>
</div>
}
}
else
{
<div class="ingredient-row">
<input type="text" name="Ingredients[0].Quantity" placeholder="Mängd" class="lab-input" />
<input type="text" name="Ingredients[0].Item" placeholder="Ingrediens" class="lab-input" />
<button type="button" class="remove-ingredient" onclick="removeIngredient(this)">Ta bort</button>
</div>
}
</div>
<button type="button" onclick="addIngredient()" class="lab-add-button">Lägg till ingrediens</button>
<button type="submit" class="lab-save-button">Spara ingredienser</button>
</form>
</div>
<hr />
<!-- Ny version sektion -->
<h3>Ny version</h3>
<form asp-action="AddLabVersion" method="post">
<input type="hidden" name="RecipeLabEntryId" value="@Model.Id" />
<div class="lab-form-group">
<label>Version-label</label>
<input type="text" name="VersionLabel" value="v@((Model.Versions?.Count + 1 ?? 1))" class="lab-input" />
</div>
<div class="lab-form-group">
<label>Instruktioner</label>
<textarea name="Instructions" class="lab-textarea"></textarea>
</div>
<div class="lab-form-group">
<label>Resultat</label>
<textarea name="ResultNotes" class="lab-textarea"></textarea>
</div>
<button type="submit" class="lab-add-button">Lägg till version</button>
</form>
}
else
{
<p><em>Spara labbet först för att kunna lägga till ingredienser och versioner.</em></p>
}
</div>
<!-- Höger: versioner -->
<div class="lab-versions">
<h3>Versioner</h3>
@if (Model.Versions?.Any() == true)
{
<div class="version-scroll">
@foreach (var version in Model.Versions.OrderByDescending(v => v.CreatedAt))
{
<div class="version-card">
<h4>@version.VersionLabel</h4>
<p><strong>Skapat:</strong> @version.CreatedAt.ToString("yyyy-MM-dd")</p>
@if (!string.IsNullOrEmpty(version.Instructions))
{
<p><strong>Instruktioner:</strong><br />@version.Instructions</p>
}
@if (!string.IsNullOrEmpty(version.ResultNotes))
{
<p><strong>Resultat:</strong><br />@version.ResultNotes</p>
}
@if (version.Ingredients?.Any() == true)
{
<p><strong>Ingredienser:</strong></p>
<ul>
@foreach (var ing in version.Ingredients)
{
<li>@ing.Quantity @ing.Item</li>
}
</ul>
}
</div>
}
</div>
}
else
{
<p>Inga tidigare versioner ännu.</p>
}
</div>
</div>
<script>
let ingredientIndex = @(Model.Ingredients?.Count ?? 1);
function addIngredient() {
const list = document.getElementById('ingredients-list');
const div = document.createElement('div');
div.className = 'ingredient-row';
div.innerHTML = `
<input type="text" name="Ingredients[${ingredientIndex}].Quantity" placeholder="Mängd" class="lab-input" />
<input type="text" name="Ingredients[${ingredientIndex}].Item" placeholder="Ingrediens" class="lab-input" />
<button type="button" class="remove-ingredient" onclick="removeIngredient(this)">Ta bort</button>
`;
list.appendChild(div);
ingredientIndex++;
}
function removeIngredient(button) {
const row = button.closest('.ingredient-row');
row.remove();
reindexIngredients();
}
function reindexIngredients() {
const rows = document.querySelectorAll('.ingredient-row');
rows.forEach((row, index) => {
const quantityInput = row.querySelector('input[name*="Quantity"]');
const itemInput = row.querySelector('input[name*="Item"]');
if (quantityInput) quantityInput.name = `Ingredients[${index}].Quantity`;
if (itemInput) itemInput.name = `Ingredients[${index}].Item`;
});
ingredientIndex = rows.length;
}
</script>

View File

@@ -0,0 +1,74 @@
@model List<Aberwyn.Models.MealCategory>
@{
ViewData["Title"] = "Måltidskategorier";
}
<h1 class="page-title">Måltidskategorier</h1>
<div class="category-list">
@foreach (var cat in Model)
{
<form method="post" action="/meal/categories/save" class="category-row">
<input type="hidden" name="Id" value="@cat.Id" />
<div class="name-with-preview">
<div class="icon-preview" style="background-color: @cat.Color">
@if (!string.IsNullOrWhiteSpace(cat.Icon))
{
<span>@cat.Icon</span>
}
else
{
<span>🍽️</span>
}
</div>
<input name="Name" value="@cat.Name" placeholder="Namn" />
</div>
<input name="Icon" value="@cat.Icon" placeholder="Ikon (emoji eller klass)" />
<input name="Slug" value="@cat.Slug" placeholder="Slug" />
<input name="Color" value="@cat.Color" type="color" title="Färg" />
<label class="checkbox-label">
<input type="checkbox" name="IsActive" @(cat.IsActive ? "checked" : "") />
Aktiv
</label>
<span class="meal-count">@cat.MealCount st</span>
<div class="row-actions">
<button type="submit" title="Spara"><i class="fas fa-save"></i></button>
<form method="post" action="/meal/categories/delete" style="display:inline;">
<input type="hidden" name="id" value="@cat.Id" />
<button type="submit" class="delete" title="Ta bort"><i class="fas fa-trash"></i></button>
</form>
</div>
</form>
}
<!-- Ny kategori -->
<form method="post" action="/meal/categories/save" class="category-row new">
<div class="name-with-preview">
<div class="icon-preview" style="background-color: #6a0dad"></div>
<input name="Name" placeholder="Ny kategori" autofocus />
</div>
<input name="Icon" placeholder="Ikon" />
<input name="Slug" placeholder="Slug" />
<input name="Color" type="color" value="#6a0dad" />
<label class="checkbox-label">
<input type="checkbox" name="IsActive" checked />
Aktiv
</label>
<span></span>
<div class="row-actions">
<button type="submit" title="Lägg till"><i class="fas fa-plus"></i></button>
</div>
</form>
</div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link rel="stylesheet" href="/css/meal-categories.css">

View File

@@ -14,12 +14,13 @@
}
else
{
imageSrc = "/images/placeholder-meal.jpg";
imageSrc = "/images/fallback.jpg";
}
bool isChef = HttpContextAccessor.HttpContext.User.IsInRole("Chef");
isEditing = isEditing && isChef;
}
<link rel="stylesheet" href="/css/meal.css">
<div class="meal-container">
<div class="meal-header">
@@ -29,7 +30,7 @@
@if (isEditing)
{
<div style="margin-top: 0.5rem;">
<button type="button" class="btn-outline" onclick="openImageModal()">Byt bild</button>
<button type="button" class="btn-lewel-outline" onclick="openImageModal()">Byt bild</button>
</div>
}
@@ -37,6 +38,7 @@
<h1 class="meal-title">@Model.Name</h1>
<p class="description">@Model.Description</p>
</div>
</div>
@if (isEditing)
@@ -64,11 +66,16 @@
<label for="CarbType">Kolhydrat</label>
<input type="text" name="CarbType" value="@Model.CarbType" class="form-control" />
</div>
<div class="form-group">
<label for="Category">Kategori</label>
<input type="text" name="Category" value="@Model.Category" class="form-control" />
<label for="MealCategoryId">Kategori</label>
<select asp-for="MealCategoryId" asp-items="@(new SelectList((List<MealCategory>)ViewBag.Categories, "Id", "Name", Model.MealCategoryId))" class="form-control">
<option value="">-- Välj kategori --</option>
</select>
</div>
<div class="form-group">
<label for="RecipeUrl">Receptlänk</label>
<input type="url" name="RecipeUrl" value="@Model.RecipeUrl" class="form-control" />
@@ -91,14 +98,26 @@
</div>
}
</div>
<button type="button" class="btn-outline" onclick="addIngredientRow()">+ Lägg till ingrediens</button>
</div>
</div>
<div style="margin-top: 0.5rem;">
<label for="bulkIngredients">Klistra in flera ingredienser</label>
<textarea id="bulkIngredients" placeholder="1 dl mjölk&#10;2 tsk socker" class="form-control" rows="3"></textarea>
<button type="button" class="btn-lewel-outline" onclick="parseBulkIngredients()">Lägg till från lista</button>
<button type="button" class="btn-lewel-outline" onclick="addIngredientRow()">+ Lägg till rad</button>
</div>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="IsPublished" value="true" @(Model.IsPublished ? "checked" : "") />
Publicera recept på receptsidan
</label>
</div>
<div class="buttons">
<button type="submit" class="btn">Spara</button>
<button type="submit" formaction="@Url.Action("DeleteMeal", new { id = Model.Id })" formmethod="post" onclick="return confirm('Vill du verkligen ta bort denna måltid?');" class="btn-outline">Ta bort</button>
<a href="@Url.Action("View", new { id = Model.Id, edit = false })" class="btn-outline">Avbryt</a>
<button type="submit" class="btn-lewel">Spara</button>
<button type="submit" formaction="@Url.Action("DeleteMeal", new { id = Model.Id })" formmethod="post" onclick="return confirm('Vill du verkligen ta bort denna måltid?');" class="btn-lewel-outline">Ta bort</button>
<a href="@Url.Action("View", new { id = Model.Id, edit = false })" class="btn-lewel-outline">Avbryt</a>
</div>
</form>
}
@@ -119,15 +138,29 @@
{
<p><a href="@Model.RecipeUrl" class="recipe-link" target="_blank">Visa Recept</a></p>
}
@if (User.Identity.IsAuthenticated)
{
<div class="rating-box" data-meal-id="@Model.Id">
<p>Ditt betyg:</p>
<div class="star-container">
@for (int i = 1; i <= 5; i++)
{
<i class="fa fa-star" data-value="@i"></i>
}
</div>
</div>
}
<div class="buttons">
<div style="display: flex; gap: 0.5rem;">
@if (isChef)
{
<a class="btn-outline" href="@Url.Action("View", new { id = Model.Id, edit = true })">Redigera</a>
<a class="btn-lewel-outline slim" href="@Url.Action("View", new { id = Model.Id, edit = true })">Redigera</a>
}
<button type="button" class="btn-outline" onclick="toggleRecipe()">Recept</button>
<button type="button" class="btn-lewel-outline slim" onclick="toggleRecipe()">Recept</button>
</div>
<div id="recipe-section" style="display:none; margin-top:1.5rem;">
<h2>Så här gör du</h2>
@if (!string.IsNullOrWhiteSpace(Model.Instructions))
@@ -169,8 +202,95 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/trumbowyg@2.25.1/dist/ui/trumbowyg.min.css">
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/trumbowyg@2.25.1/dist/trumbowyg.min.js"></script>
<script src="https://kit.fontawesome.com/yourkit.js" crossorigin="anonymous"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const ratingBox = document.querySelector(".rating-box");
if (!ratingBox) return;
const mealId = ratingBox.dataset.mealId;
const stars = ratingBox.querySelectorAll(".fa-star");
fetch(`/api/MealRatingApi/${mealId}`)
.then(res => res.json())
.then(rating => {
stars.forEach(star => {
if (parseInt(star.dataset.value) <= rating) {
star.classList.add("rated");
}
});
});
stars.forEach(star => {
star.addEventListener("click", () => {
const rating = star.dataset.value;
fetch("/api/MealRatingApi", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mealId: mealId, rating: parseInt(rating) })
}).then(() => {
stars.forEach(s => s.classList.remove("rated"));
stars.forEach(s => {
if (parseInt(s.dataset.value) <= rating) {
s.classList.add("rated");
}
});
});
});
});
});
function addIngredientRow(quantity = '', item = '') {
const list = document.getElementById('ingredients-list');
const index = list.children.length;
const div = document.createElement('div');
div.className = 'ingredient-row';
const qtyInput = document.createElement('input');
qtyInput.type = 'text';
qtyInput.name = `Ingredients[${index}].Quantity`;
qtyInput.placeholder = 'Mängd';
qtyInput.className = 'form-control ingredient-qty';
qtyInput.value = quantity;
const itemInput = document.createElement('input');
itemInput.type = 'text';
itemInput.name = `Ingredients[${index}].Item`;
itemInput.placeholder = 'Ingrediens';
itemInput.className = 'form-control ingredient-item';
itemInput.value = item;
div.appendChild(qtyInput);
div.appendChild(itemInput);
list.appendChild(div);
}
function parseBulkIngredients() {
const bulk = document.getElementById('bulkIngredients').value;
if (!bulk.trim()) return;
const lines = bulk.trim().split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Försök dela på första mellanslag → mängd + ingrediens
const parts = trimmed.split(' ');
const quantity = parts.slice(0, 2).join(' '); // typ "2 dl" eller "1 tsk"
const item = parts.slice(2).join(' '); // resten
// fallback om bara ett ord: "ägg"
const safeQuantity = item ? quantity : '';
const safeItem = item || quantity;
addIngredientRow(safeQuantity, safeItem);
}
document.getElementById('bulkIngredients').value = '';
}
function toggleRecipe() {
const section = document.getElementById('recipe-section');
if (!section) return;
@@ -231,166 +351,6 @@
</script>
<style>
.dragging {
outline: 2px dashed #6a0dad;
}
.meal-image-wrapper {
position: relative;
}
.meal-container {
max-width: 900px;
margin: 2rem auto;
background: #fff;
padding: 2rem;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
font-family: 'Segoe UI', sans-serif;
}
.meal-header {
display: flex;
flex-direction: row;
gap: 1.5rem;
align-items: center;
}
.meal-meta {
flex: 1;
}
.meal-title {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 1rem;
color: #333;
}
.meal-image {
width: 250px;
height: 250px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #ccc;
}
.description {
font-size: 1.1rem;
color: #555;
margin-top: 0.5rem;
margin-bottom: 1.5rem;
}
.meal-details p {
font-size: 1rem;
color: #333;
margin: 0.3rem 0;
}
.label {
font-weight: bold;
color: #6a0dad;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.3rem;
display: block;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 1rem;
}
.buttons {
margin-top: 1.5rem;
}
.btn,
.btn-outline {
background-color: #6a0dad;
color: white;
border: none;
padding: 0.6rem 1.4rem;
border-radius: 6px;
margin: 0.4rem;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
}
.btn-outline {
background-color: transparent;
border: 2px solid #6a0dad;
color: #6a0dad;
}
.recipe-link {
color: #6a0dad;
text-decoration: underline;
font-weight: 500;
}
.placeholder {
color: #999;
font-style: italic;
}
.ingredient-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.ingredient-qty {
flex: 1 0 30%;
}
.ingredient-item {
flex: 2 0 60%;
}
@@media (max-width: 768px) {
.meal-container {
padding: 1rem;
}
.meal-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.meal-meta {
width: 100%;
}
.meal-title {
font-size: 2rem;
}
.meal-image {
width: 100%;
max-width: 300px;
height: auto;
}
.form-control {
font-size: 1rem;
}
.ingredient-row {
flex-direction: column;
}
.ingredient-qty,
.ingredient-item {
flex: 1 0 auto;
width: 100%;
}
.btn,
.btn-outline {
width: 100%;
margin: 0.25rem 0;
}
.buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,43 @@
@{
ViewData["Title"] = "Filmsök";
}
<div class="container mt-4">
<h2>🎬 Filmsök</h2>
<input type="text" class="form-control mb-3" id="searchInput" placeholder="Sök efter en film..." oninput="searchMovie()" />
<div class="results row" id="results"></div>
</div>
@section Scripts {
<script>
const API_KEY = 'aef2f49296b77b9b9c269678d04bdbc6';
const BASE_URL = 'https://api.themoviedb.org/3';
const IMG_BASE = 'https://image.tmdb.org/t/p/w300';
async function searchMovie() {
const query = document.getElementById('searchInput').value.trim();
const resultContainer = document.getElementById('results');
resultContainer.innerHTML = '';
if (query.length < 2) return;
const res = await fetch(`${BASE_URL}/search/movie?api_key=${API_KEY}&language=sv-SE&query=${encodeURIComponent(query)}`);
const data = await res.json();
data.results.forEach(movie => {
const col = document.createElement('div');
col.className = 'col-md-3 mb-4';
col.innerHTML = `
<div class="card h-100 bg-dark text-white border-0 shadow">
<img src="${movie.poster_path ? IMG_BASE + movie.poster_path : 'https://via.placeholder.com/300x450?text=Ingen+bild'}" class="card-img-top" alt="${movie.title}" />
<div class="card-body">
<h5 class="card-title">${movie.title}</h5>
<p class="card-text"><small>${movie.release_date || 'Okänt datum'}</small></p>
</div>
</div>
`;
resultContainer.appendChild(col);
});
}
</script>
}

View File

@@ -1,9 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="manifest" href="/manifest.json">
<link rel="manifest" href="/manifest-v2.json">
<link rel="icon" type="image/png" sizes="512x512" href="/images/lewel-icon.png">
<meta name="theme-color" content="#6a0dad">
<style>
[ng-cloak] {
display: none !important;
}
</style>
<meta name="theme-color" content="#1F2C3C" />
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LEWEL - Dashboard</title>
@@ -28,49 +34,59 @@
</header>
<div class="page-content">
<aside class="sidebar">
@if (ViewBag.IsSetupMode as bool? != true)
{
<ul class="nav-list">
<li><a asp-controller="Home" asp-action="Index"><i class="fas fa-home"></i> Home</a></li>
@if (User.IsInRole("Budget"))
{
<li><a asp-controller="Budget" asp-action="Index"><i class="fas fa-wallet"></i> Budget</a></li>
}
<nav class="main-nav">
<ul class="nav-list-horizontal">
<li><a asp-controller="Home" asp-action="Index"><i class="fas fa-home"></i> Hem</a></li>
<li><a asp-controller="Home" asp-action="Menu"><i class="fas fa-utensils"></i> Veckomeny</a></li>
<li><a asp-controller="Meal" asp-action="Index">Recept</a></li>
@if (ViewBag.RestaurantIsOpen as bool? == true)
{
<li><a asp-controller="FoodMenu" asp-action="PizzaOrder"><i class="fas fa-pizza-slice"></i> Beställ pizza</a></li>
}
@if (User.IsInRole("Budget"))
{
<li><a asp-controller="Budget" asp-action="Index"> Budget</a></li>
}
@if (User.IsInRole("Chef"))
{
<li><a asp-controller="FoodMenu" asp-action="Veckomeny"><i class="fas fa-calendar-week"></i> Administrera Veckomeny</a></li>
<li><a asp-controller="FoodMenu" asp-action="MealAdmin"><i class="fas fa-list"></i> Måltider</a></li>
<li><a asp-controller="FoodMenu" asp-action="PizzaAdmin"><i class="fas fa-list"></i> Pizza Admin</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" role="button" tabindex="0">
<i class="fas fa-list"></i> Mat<i class="fas fa-caret-down"></i>
</a>
<ul class="dropdown-menu">
<li><a asp-controller="FoodMenu" asp-action="Veckomeny">Planera Veckomeny</a></li>
<li><a asp-controller="FoodMenu" asp-action="PizzaAdmin">Pizza Admin</a></li>
<li><a asp-controller="Meal" asp-action="Categories">Hantera Kategorier</a></li>
</ul>
</li>
}
@if (User.IsInRole("Admin"))
{
<li><a asp-controller="Admin" asp-action="Index"><i class="fas fa-cog"></i> Adminpanel</a></li>
<li><a asp-controller="Admin" asp-action="Todo"><i class="fas fa-cog"></i> Todo</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" role="button" tabindex="0">
<i class="fas fa-cog"></i> Admin <i class="fas fa-caret-down"></i>
</a>
<ul class="dropdown-menu">
<li><a asp-controller="Admin" asp-action="Index">Adminpanel</a></li>
<li><a asp-controller="Admin" asp-action="Todo">Todo</a></li>
</ul>
</li>
}
</ul>
</nav>
}
</ul>
} else
{
<ul class="nav-list">
<li><a asp-controller="Setup" asp-action="Index"> Setup</a></li>
</ul>
}
</aside>
<main class="main-panel">
@RenderBody()
@RenderSection("Scripts", required: false)
</main>
</div>
<div id="global-dropdown-container" style="position: relative; z-index: 10000;"></div>
<script src="~/js/site.js" asp-append-version="true"></script>
</body>

View File

@@ -0,0 +1,225 @@
@model TorrentUploadViewModel
@{
ViewData["Title"] = "Torrent Analyzer";
}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="mb-0">
<i class="fas fa-download"></i> Torrent Analyzer
</h3>
<p class="text-muted mb-0">Ladda upp en torrent-fil för att se seeders/leechers</p>
</div>
<div class="card-body">
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger">
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<div>@error.ErrorMessage</div>
}
</div>
}
<form asp-action="Upload" method="post" enctype="multipart/form-data" class="mb-4">
@Html.AntiForgeryToken()
<div class="mb-3">
<label for="torrentFile" class="form-label">Välj torrent-fil</label>
<input type="file"
class="form-control"
asp-for="TorrentFile"
accept=".torrent"
required>
<div class="form-text">Endast .torrent filer, max 10MB</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload"></i> Analysera Torrent
</button>
</form>
@if (Model.ShowResults && Model.TorrentInfo != null)
{
<hr>
<div class="torrent-results">
<h4 class="mb-3">
<i class="fas fa-info-circle"></i> Torrent Information
</h4>
<div class="row mb-3">
<div class="col-sm-3"><strong>Filnamn:</strong></div>
<div class="col-sm-9">@Model.TorrentInfo.FileName</div>
</div>
<div class="row mb-3">
<div class="col-sm-3"><strong>Storlek:</strong></div>
<div class="col-sm-9">@FormatFileSize(Model.TorrentInfo.Size)</div>
</div>
<div class="row mb-3">
<div class="col-sm-3"><strong>Announce URL:</strong></div>
<div class="col-sm-9">
<small class="text-muted font-monospace">@Model.TorrentInfo.AnnounceUrl</small>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3"><strong>Info Hash:</strong></div>
<div class="col-sm-9">
<small class="text-muted font-monospace">@Model.TorrentInfo.InfoHash</small>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.TorrentInfo.ErrorMessage))
{
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Tracker-info:</strong> @Model.TorrentInfo.ErrorMessage
</div>
}
else if (Model.TorrentInfo.HasTrackerData)
{
<div class="card bg-light">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-chart-bar"></i> Tracker Statistik
</h5>
<button type="button"
class="btn btn-sm btn-outline-primary"
onclick="refreshStats()"
id="refreshBtn">
<i class="fas fa-sync-alt"></i> Uppdatera
</button>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-4">
<div class="stat-box p-3">
<h3 class="text-success mb-1" id="seeders">@Model.TorrentInfo.Seeders</h3>
<small class="text-muted">Seeders</small>
</div>
</div>
<div class="col-md-4">
<div class="stat-box p-3">
<h3 class="text-warning mb-1" id="leechers">@Model.TorrentInfo.Leechers</h3>
<small class="text-muted">Leechers</small>
</div>
</div>
<div class="col-md-4">
<div class="stat-box p-3">
<h3 class="text-info mb-1" id="completed">@Model.TorrentInfo.Completed</h3>
<small class="text-muted">Completed</small>
</div>
</div>
</div>
</div>
</div>
}
else
{
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
Tracker-statistik kunde inte hämtas. Detta kan bero på att trackern inte stöder scraping eller kräver autentisering.
</div>
}
</div>
}
</div>
</div>
</div>
</div>
</div>
@functions {
string FormatFileSize(long bytes)
{
if (bytes == 0) return "Okänd storlek";
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
int order = 0;
double size = bytes;
while (size >= 1024 && order < sizes.Length - 1)
{
order++;
size = size / 1024;
}
return $"{size:0.##} {sizes[order]}";
}
}
<style>
.stat-box {
border: 1px solid #dee2e6;
border-radius: 8px;
background: white;
margin: 5px;
}
.torrent-results {
animation: fadeIn 0.5s ease-in;
}
@@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.font-monospace {
font-family: 'Courier New', monospace;
word-break: break-all;
}
</style>
<script>
async function refreshStats() {
const refreshBtn = document.getElementById('refreshBtn');
const seedersEl = document.getElementById('seeders');
const leechersEl = document.getElementById('leechers');
const completedEl = document.getElementById('completed');
if (!seedersEl) return; // Ingen tracker data att uppdatera
refreshBtn.disabled = true;
refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Uppdaterar...';
try {
const response = await fetch('@Url.Action("RefreshStats", "Torrent")', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value
},
body: JSON.stringify({
infoHash: '@Model.TorrentInfo?.InfoHash',
scrapeUrl: '@Model.TorrentInfo?.ScrapeUrl'
})
});
const data = await response.json();
if (data.success) {
seedersEl.textContent = data.seeders;
leechersEl.textContent = data.leechers;
completedEl.textContent = data.completed;
// Visa success animation
[seedersEl, leechersEl, completedEl].forEach(el => {
el.style.transform = 'scale(1.1)';
setTimeout(() => el.style.transform = 'scale(1)', 200);
});
} else {
alert('Fel vid uppdatering: ' + (data.error || 'Okänt fel'));
}
} catch (error) {
console.error('Fel:', error);
alert('Nätverksfel vid uppdatering');
} finally {
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i> Uppdatera';
}
}
</script>

View File

@@ -1,9 +1,10 @@
@model Aberwyn.Models.UserProfileViewModel
@{
ViewData["Title"] = "Userprofile";
ViewData["Title"] = "Min profil";
}
<h2>Min profil</h2>
<form method="post" asp-action="SaveProfile">
<div>
<label asp-for="Name"></label>
@@ -29,4 +30,108 @@
</div>
<button type="submit">Spara</button>
<div style="margin-top: 1em;">
<div id="notificationStatus" style="font-weight: bold;"></div>
<button type="button" id="enableNotificationsBtn">Aktivera notiser</button>
<button type="button" id="disableNotificationsBtn">Stäng av notiser</button>
</div>
</form>
@section Scripts {
<script>
async function updateNotificationStatus() {
const el = document.getElementById("notificationStatus");
if (!("Notification" in window)) {
el.textContent = "⛔️ Pushnotiser stöds inte i denna webbläsare.";
el.style.color = "gray";
return;
}
const status = Notification.permission;
const reg = await navigator.serviceWorker.ready;
const existing = await reg.pushManager.getSubscription();
switch (status) {
case "granted":
el.textContent = existing ? "✅ Pushnotiser är aktiverade." : "🟡 Tillåtet men ingen prenumeration aktiv.";
el.style.color = existing ? "green" : "orange";
break;
case "denied":
el.textContent = "❌ Pushnotiser är blockerade.";
el.style.color = "red";
break;
case "default":
el.textContent = "❔ Pushnotiser har inte begärts ännu.";
el.style.color = "orange";
break;
}
}
document.addEventListener("DOMContentLoaded", updateNotificationStatus);
document.getElementById("enableNotificationsBtn").addEventListener("click", async () => {
if (!("Notification" in window)) {
alert("Notiser stöds inte i denna webbläsare.");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
alert("Du måste tillåta notiser.");
updateNotificationStatus();
return;
}
const reg = await navigator.serviceWorker.ready;
const existing = await reg.pushManager.getSubscription();
if (existing) {
alert("🔔 Du är redan prenumererad på notiser.");
updateNotificationStatus();
return;
}
const vapidPublicKey = await fetch('/api/push/vapid-public-key').then(r => r.text());
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey.trim())
});
await fetch("/api/push/subscribe-user", {
method: "POST",
body: JSON.stringify(sub),
headers: { "Content-Type": "application/json" }
});
alert("✅ Du är nu prenumererad!");
updateNotificationStatus();
});
document.getElementById("disableNotificationsBtn").addEventListener("click", async () => {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (!sub) {
alert(" Ingen prenumeration att ta bort.");
return;
}
await sub.unsubscribe();
await fetch("/api/push/unsubscribe", {
method: "POST",
body: JSON.stringify({ endpoint: sub.endpoint }),
headers: { "Content-Type": "application/json" }
});
alert("🚫 Prenumeration avslutad.");
updateNotificationStatus();
});
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return new Uint8Array([...raw].map(char => char.charCodeAt(0)));
}
</script>
}

View File

@@ -0,0 +1,194 @@
.welcome-section.light-mode {
display: flex;
justify-content: center;
align-items: center;
min-height: 90vh;
text-align: center;
padding: 0 20px;
background: linear-gradient(to bottom right, #f9fafb, #e2e8f0);
animation: fadeIn 0.6s ease-in;
}
*, *::before, *::after {
box-sizing: border-box;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.welcome-content {
max-width: 700px;
color: #1e293b;
font-family: 'Segoe UI', sans-serif;
}
.welcome-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 10px;
color: #1e293b;
}
.highlight {
color: #eab308;
}
.welcome-subtitle {
font-size: 1.35rem;
color: #334155;
margin-bottom: 28px;
}
.meal-line {
margin-bottom: 0rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.meal-line p {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.meal-line a {
color: #007d36;
text-decoration: none;
font-weight: 500;
}
.meal-line a:hover {
text-decoration: underline;
}
.meal-large-thumb-container {
margin-top: 0rem;
width: 100%;
max-width: 300px;
height: 100px;
overflow: hidden;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.meal-large-thumb {
width: 100%;
max-width: 300px;
height: 160px;
object-fit: cover;
object-position: center%;
display: block;
border-radius: 8px;
margin-top: 0rem;
}
.no-menu {
font-size: 1.1rem;
color: #64748b;
margin-bottom: 24px;
}
.nav-button {
background: #3b82f6;
color: white;
text-decoration: none;
padding: 12px 26px;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
display: inline-block;
margin-top: 24px;
transition: background 0.2s ease, transform 0.2s ease;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
.nav-button:hover {
background: #2563eb;
transform: translateY(-2px);
}
.thumb-button {
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.meal-line {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.meal-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.lightbox-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
}
.lightbox-content {
position: relative;
max-width: 90%;
max-height: 90%;
display: flex;
flex-direction: column;
align-items: center;
}
.lightbox-image {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.lightbox-caption {
color: white;
margin-top: 10px;
font-size: 16px;
text-align: center;
}
.close-lightbox {
position: absolute;
top: -10px;
right: -10px;
background: #fff;
color: #333;
border: none;
border-radius: 50%;
font-size: 20px;
width: 32px;
height: 32px;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
.no-scroll {
overflow: hidden;
}

View File

@@ -1,18 +1,23 @@
:root {
--text-main: #1E293B;
--text-sub: #64748B;
--bg-main: #f9fafb;
--bg-card: #f1f5f9;
--border-color: #e5e7eb;
--card-income: #f97316;
--text-main: #1F2937; /* Mörkblågrå tydlig men mjuk */
--text-sub: #64748B; /* Sekundär gråblå */
--bg-main: #1F2C3C; /* SID-bakgrund */
--bg-card: #dbe3ec;
--bg-card-summary: #d6dde6;
--border-color: #cbd5e1; /* Ljus kant men inte skarp */
--item-divider: rgba(0, 0, 0, 0.05); /* ljus standard */
--card-income: #fb923c;
--card-expense: #ef4444;
--card-savings: #facc15;
--card-leftover: #86efac;
--card-leftover: #4ade80;
--btn-edit: #3b82f6;
--btn-check: #10b981;
--btn-delete: #ef4444;
}
body {
background-color: var(--bg-main);
color: var(--text-main);
@@ -105,6 +110,7 @@ body {
height: 350px;
border: 1px solid var(--border-color);
border-radius: 12px;
color: var(--text-main);
background-color: var(--bg-card);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
overflow: hidden;
@@ -134,6 +140,7 @@ body {
border-top: 1px solid var(--border-color);
border-radius: 0 0 12px 12px;
flex-shrink: 0;
background: var(--bg-card-summary);
}
.card-header {
@@ -235,7 +242,35 @@ body {
text-align: right;
font-weight: 600;
}
@keyframes fadeRed {
0% {
color: rgb(200, 50, 50);
}
100% {
color: rgb(160, 0, 0);
}
}
@keyframes fadeGreen {
0% {
color: rgb(50, 200, 50);
}
100% {
color: rgb(0, 160, 0);
}
}
.item-row.due {
animation: fadeRed 0.2s ease-in-out;
animation-fill-mode: forwards;
}
.item-row.paid {
animation: fadeGreen 0.2s ease-in-out;
animation-fill-mode: forwards;
}
.icon-button {
@@ -261,11 +296,17 @@ color: var(--btn-edit);
.icon-button.confirm {
color: var(--btn-check);
}
.menu-container {
position: relative;
display: inline-block;
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
left: auto;
right: 0;
transform: translateX(-100%) translateX(100%); /* Effektivt: ingen förskjutning */
background-color: #ffffff;
border: 1px solid var(--border-color);
border-radius: 6px;
@@ -273,27 +314,29 @@ color: var(--btn-check);
z-index: 1000;
padding: 6px;
display: none;
gap: 4px;
flex-wrap: wrap;
min-width: 200px;
flex-direction: column;
min-width: 160px;
max-width: 240px;
box-sizing: border-box;
}
.menu-container.open .dropdown-menu {
display: flex;
}
.dropdown-menu button {
padding: 5px 8px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #ddd;
border: none;
background: #f3f4f6;
cursor: pointer;
font-size: 12px;
flex: 1;
font-weight: 500;
font-size: 13px;
text-align: left;
width: 100%;
}
.dropdown-menu button:hover {
.dropdown-menu button:hover {
background-color: #e5e7eb;
}
@@ -521,7 +564,10 @@ color: var(--btn-check);
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
min-width: 220px;
background-color: var(--bg-card);
color: var(--text-main);
}
.budget-summary-box h3,
@@ -601,7 +647,18 @@ canvas {
height: auto;
}
body.dark-mode {
--item-divider: rgba(255, 255, 255, 0.05); /* för mörk bakgrund */
}
.item-row:not(.total-row) {
border-bottom: 1px solid var(--item-divider);
padding-bottom: 4px;
margin-bottom: 4px;
}
@media (min-width: 769px) {
.budget-overview-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -634,10 +691,51 @@ canvas {
max-width: 100%;
height: auto;
}
.item-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color);
}
.item-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
display: inline-block;
}
.amount {
flex-shrink: 0;
white-space: nowrap;
font-weight: bold;
}
}
@media (max-width: 768px) {
.item-label {
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
flex: 1 1 auto;
max-width: 100%;
}
.amount {
white-space: nowrap;
text-align: right;
flex-shrink: 0;
}
.budget-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.budget-overview-row {
display: flex;
flex-direction: column;
@@ -662,6 +760,8 @@ canvas {
width: 100%;
text-align: center;
padding-bottom: 10px;
color: #1E293B; /* djupare textfärg */
font-weight: 600;
}
.chart-area canvas {
@@ -669,3 +769,13 @@ canvas {
height: auto;
}
}
.loading-indicator {
text-align: center;
margin-top: 40px;
font-size: 18px;
color: #64748B;
font-style: italic;
}
[ng-cloak] {
display: none !important;
}

116
Aberwyn/wwwroot/css/lab.css Normal file
View File

@@ -0,0 +1,116 @@
.lab-flex-layout {
display: flex;
gap: 24px;
padding: 24px;
box-sizing: border-box;
}
.lab-section {
background: #fff;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
flex-grow: 1;
min-width: 0;
color: #1F2C3C;
}
.lab-section.info {
flex: 1;
}
.lab-section.ingredients {
flex: 2;
}
.lab-section.versions {
flex: 1;
max-width: 300px;
overflow-y: auto;
position: sticky;
top: 70px;
}
.lab-section h2 {
margin-top: 0;
font-size: 1.2rem;
}
.lab-field {
margin-bottom: 12px;
display: flex;
flex-direction: column;
}
.lab-field input,
.lab-field textarea {
padding: 6px;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 14px;
}
.ingredient-row {
display: flex;
gap: 10px;
margin-bottom: 8px;
}
.ingredient-row input {
flex: 1;
}
.add-version-form {
margin-top: 24px;
}
.lab-version {
border-top: 1px solid #ddd;
padding-top: 12px;
margin-top: 12px;
}
.version-notes {
font-style: italic;
margin-bottom: 4px;
}
.version-result {
margin-bottom: 6px;
}
input[type="text"],
input[type="number"],
textarea {
width: 100%;
padding: 10px;
font-size: 14px;
border-radius: 6px;
border: 1px solid #ccc;
box-sizing: border-box;
margin-bottom: 12px;
background: #f9f9f9;
}
input:focus,
textarea:focus {
outline: none;
border-color: #6a0dad;
background-color: #fff;
}
button {
background-color: #6a0dad;
color: #fff;
padding: 10px 16px;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 14px;
cursor: pointer;
margin-top: 12px;
transition: background 0.2s ease;
}
button:hover {
background-color: #5a0ca0;
}

View File

@@ -0,0 +1,110 @@
.page-title {
font-size: 2rem;
color: white;
margin-bottom: 1rem;
text-align: center;
}
.category-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 1200px;
margin: auto;
}
.category-row {
display: grid;
grid-template-columns: 2.5fr 1.2fr 1.2fr 1fr auto auto auto;
gap: 0.5rem;
background-color: #1F2C3C;
padding: 0.75rem 1rem;
border-radius: 8px;
align-items: center;
color: white;
}
.category-row.new {
background-color: #2E3C4F;
border: 2px dashed #6a0dad;
}
.category-row input[type="text"],
.category-row input[type="color"],
.category-row input:not([type="checkbox"]) {
padding: 0.4rem;
border: none;
border-radius: 6px;
background-color: #334155;
color: #fefefe;
width: 100%;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
color: #ddd;
justify-content: center;
}
.row-actions {
display: flex;
gap: 0.5rem;
}
.row-actions button {
background-color: #6a0dad;
border: none;
color: white;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
}
.row-actions button.delete {
background-color: #ef4444;
}
.row-actions i {
pointer-events: none;
}
.name-with-preview {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-preview {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #6a0dad;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
color: white;
flex-shrink: 0;
border: 2px solid #ffffff22;
}
.meal-count {
font-size: 0.85rem;
color: #94a3b8;
text-align: right;
white-space: nowrap;
}
@media (max-width: 768px) {
.category-row {
grid-template-columns: 1fr;
}
.meal-count {
text-align: left;
margin-top: 0.25rem;
}
}

View File

@@ -3,84 +3,114 @@ body {
font-family: 'Segoe UI', sans-serif;
color: #222;
margin: 0;
}
}
.meal-gallery-container {
max-width: 1200px;
.meal-gallery-container {
margin: 0 auto;
padding: 2rem 1rem;
}
width: 100%;
max-width: 1200px;
}
.meal-gallery-header {
/* === Header och sökfält === */
.meal-gallery-header {
text-align: center;
margin-bottom: 2rem;
}
margin-bottom: 1.5rem;
}
.meal-gallery-header h1 {
font-size: 2.4rem;
font-size: 2.2rem;
font-weight: 600;
margin: 0;
}
.search-container {
/* Sökfält + checkbox */
.search-container {
margin-top: 1rem;
position: relative;
max-width: 400px;
margin-inline: auto;
}
.search-container input {
width: 100%;
padding: 10px 38px 10px 12px;
border-radius: 25px;
max-width: 600px;
margin-inline: auto;
display: flex;
align-items: center;
gap: 1rem;
background: #e9eef3;
padding: 0.5rem 1rem;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.search-container input[type="text"] {
flex-grow: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #ccc;
font-size: 1rem;
box-sizing: border-box;
}
.search-container i {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #888;
font-size: 1rem;
}
.meal-gallery-grid {
/* Checkboxdel */
.toggle-published {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
color: #222;
user-select: none;
white-space: nowrap;
}
.toggle-published input[type="checkbox"] {
accent-color: #3399ff;
transform: scale(1.1);
cursor: pointer;
}
/* === Grid layout === */
.meal-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 24px;
}
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
padding-top: 16px;
}
.meal-card {
/* === Kortstruktur === */
.meal-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
}
}
.meal-card:hover {
transform: scale(1.015);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.meal-card img {
width: 100%;
height: 200px;
height: 180px;
object-fit: cover;
}
.meal-card-content {
padding: 16px;
.meal-card-content {
padding: 14px;
display: flex;
flex-direction: column;
flex-grow: 1;
}
}
.meal-card-content h3 {
margin: 0 0 8px;
font-size: 1.2rem;
margin: 0 0 6px;
font-size: 1.15rem;
color: #111;
}
@@ -88,20 +118,96 @@ body {
flex-grow: 1;
font-size: 0.95rem;
color: #555;
margin: 0;
}
.btn-readmore {
/* === Läs mer-knapp === */
.btn-readmore {
align-self: flex-start;
background: #007d36;
color: white;
padding: 8px 12px;
padding: 7px 12px;
border-radius: 6px;
text-decoration: none;
font-size: 0.95rem;
margin-top: 12px;
}
font-size: 0.9rem;
margin-top: auto;
transition: background-color 0.2s ease;
}
.btn-readmore:hover {
background: #005c27;
}
/* === Mobilanpassning === */
@media (max-width: 600px) {
.meal-gallery-grid {
grid-template-columns: repeat(2, 1fr);
}
.meal-gallery-header h1 {
font-size: 1.6rem;
}
.search-container {
flex-direction: column;
align-items: stretch;
padding: 1rem;
gap: 0.75rem;
max-width: 90%;
}
.search-container input[type="text"] {
font-size: 0.95rem;
}
.toggle-published {
justify-content: center;
}
.meal-card img {
height: 130px;
}
.meal-card-content {
padding: 10px;
}
.meal-card-content h3 {
font-size: 1rem;
}
.meal-card-content p {
font-size: 0.85rem;
}
.btn-readmore {
padding: 6px 10px;
font-size: 0.85rem;
}
}
.btn-create-meal {
background-color: #3399ff;
color: white;
padding: 8px 14px;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
text-decoration: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.btn-create-meal:hover {
background-color: #2389e0;
}
@media (max-width: 600px) {
.btn-create-meal {
width: 100%;
text-align: center;
}
}

View File

@@ -0,0 +1,204 @@
.admin-actions a.btn, .admin-actions a.btn-outline {
font-size: 1rem;
padding: 0.5rem 1.2rem;
}
.dragging {
outline: 2px dashed #6a0dad;
}
.meal-image-wrapper {
position: relative;
}
.meal-container {
max-width: 900px;
margin: 2rem auto;
background: #fff;
padding: 2rem;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
font-family: 'Segoe UI', sans-serif;
}
.meal-header {
display: flex;
flex-direction: row;
gap: 1.5rem;
align-items: center;
}
.meal-meta {
flex: 1;
}
.meal-title {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 1rem;
color: #333;
}
.meal-image {
width: 250px;
height: 250px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #ccc;
}
.description {
font-size: 1.1rem;
color: #555;
margin-top: 0.5rem;
margin-bottom: 1.5rem;
}
.meal-details p {
font-size: 1rem;
color: #333;
margin: 0.3rem 0;
}
.label {
font-weight: bold;
color: #6a0dad;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
font-weight: 600;
margin-bottom: 0.3rem;
display: block;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 1rem;
}
.buttons {
margin-top: 1.5rem;
}
.btn,
.btn-outline {
background-color: #6a0dad;
color: white;
border: none;
padding: 0.6rem 1.4rem;
border-radius: 6px;
margin: 0.4rem;
cursor: pointer;
font-size: 1rem;
text-decoration: none;
}
.btn-outline {
background-color: transparent;
border: 2px solid #6a0dad;
color: #6a0dad;
}
.recipe-link {
color: #6a0dad;
text-decoration: underline;
font-weight: 500;
}
.placeholder {
color: #999;
font-style: italic;
}
.ingredient-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.ingredient-qty {
flex: 1 0 30%;
}
.ingredient-item {
flex: 2 0 60%;
}
@media (max-width: 768px) {
.meal-container {
padding: 1rem;
}
.meal-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.meal-meta {
width: 100%;
}
.meal-title {
font-size: 2rem;
}
.meal-image {
width: 100%;
max-width: 300px;
height: auto;
}
.form-control {
font-size: 1rem;
}
.ingredient-row {
flex-direction: column;
}
.ingredient-qty,
.ingredient-item {
flex: 1 0 auto;
width: 100%;
}
.btn,
.btn-outline {
width: 100%;
margin: 0.25rem 0;
}
.buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.star-container {
display: inline-flex;
gap: 5px;
cursor: pointer;
}
.fa-star {
font-size: 1.5rem;
color: #ccc;
transition: color 0.2s ease;
}
.fa-star.rated {
color: #ffcc00;
}
.slim {
padding: 0.3rem 0.6rem;
font-size: 0.85rem;
}

View File

@@ -1,5 +1,4 @@
:root {
--bg: #F9FAFB;
--bg-mid: #EDF2F7;
--text: #2D3748;
--accent: #3182CE;
@@ -34,6 +33,10 @@ body {
background-color: var(--bg);
color: var(--text);
}
.fade-out {
opacity: 0;
transition: opacity 0.1s ease-out;
}
.meal-menu-page {
position: relative;

View File

@@ -1,5 +1,5 @@
/* ==========================================================================
HEADER <20> LEWEL DESIGN (utan meny)
HEADER LEWEL DESIGN (utan meny)
========================================================================== */
.top-bar {
display: flex;
@@ -147,25 +147,22 @@ body {
.page-content {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 16px;
gap: 16px;
background-color: #223344;
}
.sidebar {
width: 155px;
background-color: #f9f9f9;
border-radius: 8px;
padding: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
width: 100%; /* force full width */
max-width: unset;
position: relative;
z-index: 0;
overflow: visible !important;
}
.main-panel {
flex: 1;
background-color: #ffffff;
border-radius: 10px;
padding: 20px;
background: linear-gradient(to bottom right, #f9fafb, #e2e8f0);
border-radius: 0 0 10px 10px;
overflow: visible;
padding: 0px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
@@ -173,11 +170,22 @@ body {
color: #2C3E50;
font-size: 16px;
line-height: 1.7;
margin-top: 0;
position: static !important;
z-index: auto !important;
}
/* ==========================================================================
NAVIGATIONSLISTA <20> KOMPAKT STIL
========================================================================== */
/*.sidebar {
width: 155px;
background-color: #f9f9f9;
border-radius: 8px;
padding: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.nav-list {
list-style: none;
padding: 0;
@@ -210,16 +218,250 @@ body {
width: 14px;
text-align: center;
}
*/
.main-nav {
position: sticky;
top: 54px;
left: 0;
right: 0;
z-index: 1000;
background-color: #f3f4f6;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
padding: 5px 8px;
font-size: 13px;
overflow-x: auto;
overflow-y: visible;
overflow: visible !important;
-webkit-overflow-scrolling: touch;
}
@media (min-width: 769px) {
.nav-list-horizontal {
overflow: visible;
}
}
.nav-list-horizontal {
display: flex;
flex-wrap: nowrap;
gap: 5px;
margin: 5px 0 0 0;
padding: 0;
list-style: none;
white-space: nowrap;
position: relative;
z-index: 1;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
}
.nav-list-horizontal > li {
position: relative;
}
.nav-list-horizontal li a {
text-decoration: none;
color: #1F2C3C;
padding: 0px 5px;
border-radius: 4px;
font-weight: 500;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
transition: background-color 0.2s;
}
.nav-list-horizontal li a:hover {
background-color: #e5e7eb;
}
/* ==========================================================================
RESPONSIVT
========================================================================== */
@media (max-width: 768px) {
.page-content {
flex-direction: column;
/* ==========================================================================
Dropdown
========================================================================== */
.dropdown {
position: relative;
}
.dropdown-toggle {
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: #1F2C3C;
padding: 0px 5px;
border: none;
background: none;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
height: 100%; /* viktigt för vertikal alignment */
user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.dropdown-toggle:hover {
background-color: #e5e7eb;
}
.sidebar {
width: 100%;
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
transform: translateY(4px);
background-color: #ffffff;
border: 1px solid #ccc;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
border-radius: 8px;
display: none;
flex-direction: column;
min-width: 180px;
z-index: 1001;
padding: 4px 0;
font-size: 14px;
max-width: 90vw;
}
.dropdown.open .dropdown-menu {
display: flex;
}
.dropdown-menu li {
list-style: none;
}
.dropdown-menu li a {
padding: 8px 12px;
font-size: 13px;
color: #1F2C3C;
text-decoration: none;
display: block;
}
.dropdown-menu li a:hover {
background-color: #f0f0f0;
}
@media (max-width: 768px) {
html, body {
overflow-x: hidden;
overscroll-behavior-x: contain;
}
.main-panel {
position: relative !important;
z-index: 1 !important;
}
.main-nav {
position: sticky;
top: 0;
z-index: 1000;
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
background-color: #f3f4f6;
padding: 5px 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.nav-list-horizontal {
display: flex;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
padding: 0;
margin: 0;
gap: 5px;
list-style: none;
}
.dropdown-menu {
position: fixed !important;
right: 12px;
background-color: #ffffff;
border: 1px solid #ccc;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
border-radius: 8px;
display: none;
flex-direction: column;
padding: 4px 0;
font-size: 14px;
z-index: 99999;
width: max-content;
max-width: 90vw;
min-width: 160px; /* valfritt, för att undvika att det blir för smalt */
}
.dropdown.open .dropdown-menu {
display: flex !important;
}
.dropdown,
.auth-menu,
.auth-links {
position: relative;
z-index: 99998;
}
.dropdown-menu li {
list-style: none;
}
.dropdown-menu li a {
padding: 8px 12px;
color: #1F2C3C;
text-decoration: none;
font-size: 13px;
}
.dropdown-menu li a:hover {
background-color: #f0f0f0;
}
}
.btn-lewel {
background-color: #3399ff; /* blå accent */
color: white;
border: none;
padding: 0.5rem 1.2rem;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
display: inline-block;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-lewel:hover {
background-color: #2389e0;
}
.btn-lewel-outline {
background-color: transparent;
color: #3399ff;
border: 2px solid #3399ff;
padding: 0.5rem 1.2rem;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
display: inline-block;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.btn-lewel-outline:hover {
background-color: #e0f0ff;
color: #1F2C3C;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -2,18 +2,24 @@
console.log("budget.js loaded");
app.controller('BudgetController', function ($scope, $http) {
$scope.budget = null;
$scope.loading = false;
$scope.loading = true;
$scope.error = null;
$scope.menuOpen = false;
$scope.chartMode = "pie";
const today = new Date();
$scope.selectedYear = today.getFullYear();
$scope.selectedMonth = today.getMonth() + 1;
$scope.tempMonth = $scope.monthNames?.[today.getMonth()] || "";
$scope.tempYear = $scope.selectedYear;
const initialName = window.initialName;
$scope.monthNames = ["Januari", "Februari", "Mars", "April", "Maj", "Juni", "Juli", "Augusti", "September", "Oktober", "November", "December"];
$scope.getMonthName = function (month) {
return $scope.monthNames[month - 1] || "";
};
$scope.selectedYear = window.initialYear || new Date().getFullYear();
$scope.selectedMonth = window.initialMonth || new Date().getMonth() + 1;
$scope.tempMonth = $scope.monthNames[$scope.selectedMonth - 1];
$scope.tempYear = $scope.selectedYear;
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.getMonthName = function (month) {
return $scope.monthNames[month - 1] || "";
@@ -42,6 +48,17 @@ app.controller('BudgetController', function ($scope, $http) {
};
$scope.menuVisible = true;
};
$scope.updateMonthAndUrl = function () {
const year = $scope.selectedYear;
const month = $scope.selectedMonth.toString();
const newUrl = `/budget/${year}/${month}`;
window.history.replaceState(null, '', newUrl); // Uppdaterar URL utan reload
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.tempMonth = $scope.selectedMonthName;
$scope.tempYear = $scope.selectedYear;
$scope.loadBudget();
};
$scope.setItemType = function (item, type) {
if (type === 'expense') {
@@ -75,6 +92,39 @@ app.controller('BudgetController', function ($scope, $http) {
$scope.menuOpen = false;
});
});
let lastTapTime = 0;
$scope.handleItemInteraction = function (event, item) {
const now = new Date().getTime();
// Ctrl-klick på desktop
if (event.ctrlKey) {
togglePaymentStatus(item);
return;
}
// Dubbeltap på mobil (inom 400ms)
if (now - lastTapTime < 400) {
togglePaymentStatus(item);
event.preventDefault();
}
lastTapTime = now;
};
function togglePaymentStatus(item) {
item.paymentStatus = (item.paymentStatus + 1) % 3;
$http.put("/api/budget/updatePaymentStatus", {
itemId: item.id,
status: item.paymentStatus
}).then(() => {
$scope.showToast("Betalstatus uppdaterad");
});
}
$scope.showToast = function (message, isError = false) {
const toast = document.createElement("div");
@@ -92,10 +142,21 @@ app.controller('BudgetController', function ($scope, $http) {
$scope.loading = true;
$scope.error = null;
$scope.budget = null;
$scope.budgetNotFound = false;
$http.get(`/api/budget/${$scope.selectedYear}/${$scope.selectedMonth}`)
const useName = typeof initialName === 'string' && initialName !== "null" && initialName !== "";
let url = "";
if (useName) {
url = `/api/budget/byname/${initialName}`;
} else {
url = `/api/budget/${$scope.selectedYear}/${$scope.selectedMonth}`;
}
$http.get(url)
.then(function (response) {
const raw = response.data;
if (raw && raw.Categories) {
const categories = raw.Categories.map(cat => ({
id: cat.Id,
@@ -103,6 +164,7 @@ app.controller('BudgetController', function ($scope, $http) {
color: cat.Color,
editing: false,
allowDrag: false,
order: cat.Order ?? 0,
items: (cat.Items || []).map((item, index) => {
const definition = $scope.itemDefinitions.find(d => d.id === item.BudgetItemDefinitionId || d.Id === item.BudgetItemDefinitionId);
return {
@@ -113,26 +175,39 @@ app.controller('BudgetController', function ($scope, $http) {
includeInSummary: item.IncludeInSummary === true,
order: item.Order ?? index,
budgetItemDefinitionId: item.BudgetItemDefinitionId,
definitionName: definition?.Name || null
definitionName: definition?.Name || null,
paymentStatus: item.PaymentStatus
};
}).sort((a, b) => a.order - b.order)
}));
$scope.budget = {
id: raw.Id,
name: raw.Name || null,
year: raw.Year,
month: raw.Month,
categories: categories.sort((a, b) => a.order - b.order)
};
$scope.budgetNotFound = false;
if (!useName) {
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
}
} else {
$scope.budget = { categories: [] };
$scope.budgetNotFound = true;
}
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
})
.catch(function (error) {
if (error.status === 404) {
$scope.budget = { categories: [] };
$scope.budget = {
name: useName ? initialName : null,
year: useName ? null : $scope.selectedYear,
month: useName ? null : $scope.selectedMonth,
categories: []
};
$scope.budgetNotFound = true;
} else {
$scope.error = "Kunde inte ladda budgetdata.";
$scope.showToast("Fel vid laddning av budgetdata", true);
@@ -145,6 +220,7 @@ app.controller('BudgetController', function ($scope, $http) {
});
};
$scope.saveCategory = function (category) {
if (category.newItemName && category.newItemAmount) {
const newItem = {
@@ -286,7 +362,7 @@ app.controller('BudgetController', function ($scope, $http) {
};
$scope.getLeftover = function () {
return $scope.getTotalIncome() - $scope.getTotalExpense();
return $scope.getTotalIncome() - $scope.getTotalExpense() - $scope.getTotalSaving();
};
function positionAddItemPopup(popup, triggerButton) {
@@ -307,12 +383,12 @@ app.controller('BudgetController', function ($scope, $http) {
if (monthIndex >= 0 && $scope.tempYear) {
$scope.selectedMonth = monthIndex + 1;
$scope.selectedYear = parseInt($scope.tempYear);
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.updateMonthAndUrl();
$scope.showMonthPicker = false;
$scope.loadBudget();
}
};
$scope.previousMonth = function () {
if ($scope.selectedMonth === 1) {
$scope.selectedMonth = 12;
@@ -320,12 +396,10 @@ app.controller('BudgetController', function ($scope, $http) {
} else {
$scope.selectedMonth--;
}
$scope.tempMonth = $scope.getMonthName($scope.selectedMonth);
$scope.tempYear = $scope.selectedYear;
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.loadBudget();
$scope.updateMonthAndUrl();
};
$scope.nextMonth = function () {
if ($scope.selectedMonth === 12) {
$scope.selectedMonth = 1;
@@ -333,12 +407,10 @@ app.controller('BudgetController', function ($scope, $http) {
} else {
$scope.selectedMonth++;
}
$scope.tempMonth = $scope.getMonthName($scope.selectedMonth);
$scope.tempYear = $scope.selectedYear;
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.loadBudget();
$scope.updateMonthAndUrl();
};
$scope.handleCategoryDrop = function (data, targetCategory) {
if (data.type !== 'category') return; // ⛔ stoppa om det är ett item-drag
@@ -485,14 +557,13 @@ app.controller('BudgetController', function ($scope, $http) {
};
$scope.createNewCategory = function () {
const defaultName = "Ny kategori";
const newOrder = $scope.budget.categories.length; // sist i listan
const newOrder = $scope.budget.categories.length;
const newCategory = {
name: defaultName,
color: "#666666",
year: $scope.selectedYear,
month: $scope.selectedMonth,
order: newOrder
order: newOrder,
budgetPeriodId: $scope.budget.id // <- 💡 den viktiga raden
};
$http.post("/api/budget/category", newCategory)
@@ -517,6 +588,8 @@ app.controller('BudgetController', function ($scope, $http) {
$scope.showToast("Fel vid skapande av kategori", true);
});
};
$scope.addItem = function (category) {
if (!category.newItemName || !category.newItemAmount) return;
@@ -766,6 +839,28 @@ $scope.addItemFromDefinition = function (cat) {
return cat.items.some(i => i.isExpense);
};
$scope.createEmptyBudget = function () {
if (!$scope.budget || !$scope.budget.name) {
$scope.showToast("Ogiltigt budgetnamn.");
return;
}
const dto = {
name: $scope.budget.name
};
$http.post('/api/budget', dto)
.then(() => {
$scope.showToast("Ny budget skapad.");
$scope.loadBudget(); // ladda om efter skapandet
})
.catch(error => {
console.error("Fel vid skapande:", error);
$scope.showToast("Kunde inte skapa budget.");
});
};
$scope.drawCategoryChart = function () {
const ctx = document.getElementById("expenseChart");
if (!ctx || !$scope.budget?.categories) return;
@@ -824,6 +919,63 @@ $scope.addItemFromDefinition = function (cat) {
};
// Import
$scope.importing = false;
$scope.importText = '';
$scope.importPreview = [];
$scope.importTargetCategory = null;
$scope.openImportModule = function () {
// Välj första redigerbara kategori som standard (eller be om val senare)
const editable = $scope.budget.categories.find(c => c.editing);
if (!editable) {
alert("Redigera en kategori först!");
return;
}
$scope.importTargetCategory = editable;
$scope.importText = '';
$scope.importPreview = [];
$scope.importing = true;
};
$scope.cancelImport = function () {
$scope.importing = false;
$scope.importText = '';
$scope.importPreview = [];
};
$scope.parseImportText = function () {
const lines = $scope.importText.trim().split('\n');
$scope.importPreview = lines.map(l => {
const parts = l.trim().match(/^(.+?)\s+(-?\d+(?:[,.]\d+)?)/);
return {
name: parts?.[1] || '',
amount: parseFloat((parts?.[2] || '0').replace(',', '.'))
};
}).filter(item => item.name && !isNaN(item.amount));
};
$scope.applyImport = function () {
const cat = $scope.importTargetCategory;
$scope.importPreview.forEach(p => {
cat.items.push({
id: -1 * Math.floor(Math.random() * 1000000), // temporärt ID
name: p.name,
amount: p.amount,
isExpense: true,
includeInSummary: true,
paymentStatus: 0
});
});
$scope.importing = false;
$scope.importText = '';
$scope.importPreview = [];
};
$scope.loading = true;
$scope.loadItemDefinitions().then(() => {
$scope.loadBudget();
});

View File

@@ -44,7 +44,7 @@ angular.module('mealMenuApp', ['ngSanitize'])
});
}).catch(err => console.error("Fel vid hämtning av meny:", err));
$scope.loadSchoolMeals(); // Lägg till här
//$scope.loadSchoolMeals(); // Lägg till här
};
$scope.schoolMeals = [];
@@ -94,8 +94,13 @@ angular.module('mealMenuApp', ['ngSanitize'])
$scope.openMeal = function (mealId) {
if (!mealId) return;
window.open('/Meal/View/' + mealId, '_blank');
if (mealId) {
const page = document.querySelector('.meal-menu-page');
page.classList.add('fade-out');
setTimeout(() => {
window.location.href = "/Meal/View/" + mealId;
}, 400); // matchar CSS-transition
}
};
$scope.getDayImage = function (day) {

View File

@@ -6,13 +6,102 @@ if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(function (registration) {
//console.log('✅ Service Worker registrerad med scope:', registration.scope);
subscribeToPush().catch(console.error);
//subscribeToPush().catch(console.error);
})
.catch(function (error) {
console.log('❌ Service Worker-registrering misslyckades:', error);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
const dropdowns = document.querySelectorAll(".dropdown");
const globalContainer = document.getElementById("global-dropdown-container");
dropdowns.forEach((dropdown, index) => {
const toggle = dropdown.querySelector(".dropdown-toggle");
const menu = dropdown.querySelector(".dropdown-menu");
if (!toggle || !menu) return;
let originalParent = dropdown;
toggle.addEventListener("click", e => {
e.preventDefault();
e.stopPropagation();
const isOpen = dropdown.classList.contains("open");
// Stäng andra
document.querySelectorAll(".dropdown.open").forEach(d => {
d.classList.remove("open");
const m = d.querySelector(".dropdown-menu");
if (m && d !== dropdown) {
d.appendChild(m);
resetStyles(m);
}
});
dropdown.classList.toggle("open", !isOpen);
if (!isOpen && window.innerWidth <= 768) {
const rect = toggle.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
let top = rect.bottom + scrollTop - 20;
let left = rect.left + scrollLeft;
const vw = window.innerWidth;
const mw = menu.offsetWidth;
if (left + mw > vw) {
left = vw - mw - 10;
}
if (left < 10) left = 10;
// Flytta till global container
globalContainer.appendChild(menu);
Object.assign(menu.style, {
position: "fixed",
top: `${top}px`,
left: `${left}px`,
zIndex: "99999",
display: "flex"
});
} else {
// Återställ till original
originalParent.appendChild(menu);
resetStyles(menu);
}
});
});
document.addEventListener("click", () => {
document.querySelectorAll(".dropdown.open").forEach(d => {
d.classList.remove("open");
const m = d.querySelector(".dropdown-menu");
if (m) {
d.appendChild(m);
resetStyles(m);
}
});
// Töm extra container
if (globalContainer) globalContainer.innerHTML = '';
});
function resetStyles(menu) {
Object.assign(menu.style, {
position: "",
top: "",
left: "",
zIndex: "",
display: ""
});
}
});
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
@@ -38,6 +127,11 @@ async function subscribeToPush() {
async function enablePush() {
const permission = await Notification.requestPermission();
const existingSub = await registration.pushManager.getSubscription();
if (existingSub) {
alert("🔔 Du är redan prenumererad på notiser.");
return;
}
if (permission !== "granted") {
alert("Du måste tillåta notiser för att få push.");