Compare commits
13 Commits
64a0fdf81d
...
0549be6d42
| Author | SHA1 | Date | |
|---|---|---|---|
| 0549be6d42 | |||
| 5cd16a7416 | |||
| 5573be1502 | |||
| 99ef2db822 | |||
| 0502cbbd76 | |||
| 6d29f053f4 | |||
|
|
75c667d1c2 | ||
|
|
740941790b | ||
| 5511bc56f2 | |||
| d90a9421e9 | |||
| ba1071ba6c | |||
| 29b021bee5 | |||
| 07fcd05031 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,4 +4,4 @@ obj/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
|
||||
storageImages/
|
||||
storageImages/dotnet-tools.json
|
||||
|
||||
19
AGENTS.md
19
AGENTS.md
@@ -50,3 +50,22 @@ dotnet ef database update --project Lactose
|
||||
## Tests
|
||||
|
||||
No test project found in solution.
|
||||
|
||||
## SCSS
|
||||
|
||||
`MilkStream/Styles/MilkyShot.scss` compiles to `MilkStream/wwwroot/css/MilkyShot.css`. The `AspNetCore.SassCompiler` package has been removed (native Dart binary causes Docker crashes), so SCSS **must be compiled manually** when changed:
|
||||
|
||||
```bash
|
||||
# Install the Sass CLI once (requires npm):
|
||||
npm install -g sass
|
||||
|
||||
# Compile:
|
||||
sass MilkStream/Styles/MilkyShot.scss MilkStream/wwwroot/css/MilkyShot.css
|
||||
```
|
||||
|
||||
Or temporarily add the package, build, then remove it:
|
||||
```bash
|
||||
dotnet add MilkStream/MilkStream.csproj package AspNetCore.SassCompiler
|
||||
dotnet build MilkyShots.sln
|
||||
dotnet remove MilkStream/MilkStream.csproj package AspNetCore.SassCompiler
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -40,14 +40,6 @@ public class AssetDto : AssetPreviewDto {
|
||||
/// </summary>
|
||||
public new string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the file size of the asset in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
|
||||
@@ -12,4 +12,20 @@ public class AssetPreviewDto {
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -12,4 +14,12 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// Gets or sets the end date for filtering assets by date range.
|
||||
/// </summary>
|
||||
public string? EndDate { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the optional asset type filter.
|
||||
/// </summary>
|
||||
public EAssetType? Type { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to order results randomly (by ID) instead of by date.
|
||||
/// </summary>
|
||||
public bool Random { get; set; }
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
||||
//Asset Relationships
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
|
||||
|
||||
@@ -107,18 +107,19 @@ public class AssetController(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches assets by date range with pagination.
|
||||
/// Searches assets with optional date range, type filter, and random ordering with pagination.
|
||||
/// </summary>
|
||||
/// <param name="searchOptionsDto">Search options including date range and pagination.</param>
|
||||
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
|
||||
/// <returns>A list of asset previews.</returns>
|
||||
[HttpGet]
|
||||
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
DateTime from = DateTime.Parse(searchOptionsDto.StartDate ?? string.Empty);
|
||||
DateTime to = DateTime.Parse(searchOptionsDto.EndDate ?? string.Empty);
|
||||
var userData = authService.GetUserData(User);
|
||||
var uid = userData?.Id;
|
||||
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||
DateTime? from = string.IsNullOrEmpty(searchOptionsDto.StartDate) ? null : DateTime.Parse(searchOptionsDto.StartDate);
|
||||
DateTime? to = string.IsNullOrEmpty(searchOptionsDto.EndDate) ? null : DateTime.Parse(searchOptionsDto.EndDate);
|
||||
|
||||
if (from > to) {
|
||||
if (from.HasValue && to.HasValue && from > to) {
|
||||
logger.LogWarning("Invalid date range provided");
|
||||
return BadRequest();
|
||||
}
|
||||
@@ -128,20 +129,25 @@ public class AssetController(
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var assets = assetRepository.FindByDateRange(from, to, searchOptionsDto.Page, searchOptionsDto.PageSize);
|
||||
var assets = assetRepository.GetAssets(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
$"""
|
||||
Requested assets
|
||||
by user: {uid}
|
||||
by user: {uid}
|
||||
access level: {accessLevel}
|
||||
type: {searchOptionsDto.Type}
|
||||
random: {searchOptionsDto.Random}
|
||||
date range: {from} - {to}
|
||||
"""
|
||||
page: {searchOptionsDto.Page}
|
||||
"""
|
||||
);
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
//Only publicly shared assets and assets shared with the user that are not deleted
|
||||
assets = assets.Where(
|
||||
a => a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null && a.DeletedAt != null
|
||||
);
|
||||
@@ -150,12 +156,15 @@ public class AssetController(
|
||||
}
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin: {
|
||||
//Admins will see this regardless
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(assets.ToAssetPreviewDto());
|
||||
var dtoList = assets.ToAssetPreviewDto().ToList();
|
||||
|
||||
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(dtoList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Configuration;
|
||||
using Lactose.Repositories;
|
||||
using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Lactose.Controllers;
|
||||
|
||||
@@ -10,17 +16,18 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="mediaRepository">Media repository.</param>
|
||||
/// <param name="signKeyOptions">JWT signing key configuration.</param>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[AllowAnonymous]
|
||||
[Route("api/[controller]")]
|
||||
public class MediaController(
|
||||
//TODO: Implement logging
|
||||
//ILogger<MediaController> logger
|
||||
LactoseAuthService authService,
|
||||
IMediaRepository mediaRepository
|
||||
IMediaRepository mediaRepository,
|
||||
IAssetRepository assetRepository,
|
||||
IOptions<SignKeyConfiguration> signKeyOptions
|
||||
) : ControllerBase {
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original image file for an asset.
|
||||
/// </summary>
|
||||
@@ -29,8 +36,14 @@ public class MediaController(
|
||||
[HttpGet("{id}")]
|
||||
[HttpGet("original/{id}")]
|
||||
public ActionResult GetImage(Guid id) {
|
||||
var user = authService.GetUserData(User);
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
if (user?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator) {
|
||||
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
||||
}
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
@@ -44,8 +57,14 @@ public class MediaController(
|
||||
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("thumb/{id}")]
|
||||
public ActionResult GetThumb(Guid id) {
|
||||
var user = authService.GetUserData(User);
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
if (user?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator) {
|
||||
return PhysicalFile(asset.ThumbnailPath, asset.MimeType);
|
||||
}
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
@@ -59,11 +78,43 @@ public class MediaController(
|
||||
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
||||
[HttpGet("preview/{id}")]
|
||||
public ActionResult GetPreview(Guid id) {
|
||||
var user = authService.GetUserData(User);
|
||||
var user = GetUserFromRequest();
|
||||
var asset = assetRepository.Find(id);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
if (user?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator) {
|
||||
return PhysicalFile(asset.PreviewPath, asset.MimeType);
|
||||
}
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the authenticated user from the request, first trying the Authorization header,
|
||||
/// then falling back to the <c>token</c> query parameter for browser image requests.
|
||||
/// </summary>
|
||||
private LactoseAuthenticatedUser? GetUserFromRequest() {
|
||||
var user = authService.GetUserData(User);
|
||||
if (user != null) return user;
|
||||
|
||||
var token = Request.Query["token"].FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(token)) return null;
|
||||
|
||||
try {
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var key = signKeyOptions.Value.GetSecurityKey();
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters {
|
||||
IssuerSigningKey = key,
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true
|
||||
}, out _);
|
||||
return authService.GetUserData(principal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
EXPOSE 5162
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR "/src"
|
||||
COPY ["Lactose/Lactose.csproj", "Lactose/"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
@@ -11,10 +11,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.15" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.15" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.15">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -22,10 +22,11 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.2" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,7 +36,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\"/>
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -37,8 +37,12 @@ public static class AssetsMapper {
|
||||
/// <param name="asset">The asset to map.</param>
|
||||
/// <returns>A GetAssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
MimeType = asset.MimeType
|
||||
Id = asset.Id,
|
||||
MimeType = asset.MimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
533
Lactose/Migrations/20260707160046_FixSharedWithManyToMany.Designer.cs
generated
Normal file
533
Lactose/Migrations/20260707160046_FixSharedWithManyToMany.Designer.cs
generated
Normal file
@@ -0,0 +1,533 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260707160046_FixSharedWithManyToMany")]
|
||||
partial class FixSharedWithManyToMany
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.15")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.Property<Guid>("SharedAssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SharedWithId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||
|
||||
b.HasIndex("SharedWithId");
|
||||
|
||||
b.ToTable("AssetUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedAssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedWithId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
119
Lactose/Migrations/20260707160046_FixSharedWithManyToMany.cs
Normal file
119
Lactose/Migrations/20260707160046_FixSharedWithManyToMany.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixSharedWithManyToMany : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Users_Assets_AssetId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_AssetId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AssetId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "JobRecords",
|
||||
type: "VARCHAR(2048)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "VARCHAR(256)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "RegexPattern",
|
||||
table: "Folders",
|
||||
type: "character varying(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "VARCHAR(2048)",
|
||||
oldMaxLength: 2048,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AssetUser",
|
||||
columns: table => new
|
||||
{
|
||||
SharedAssetsId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SharedWithId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AssetUser", x => new { x.SharedAssetsId, x.SharedWithId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetUser_Assets_SharedAssetsId",
|
||||
column: x => x.SharedAssetsId,
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetUser_Users_SharedWithId",
|
||||
column: x => x.SharedWithId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetUser_SharedWithId",
|
||||
table: "AssetUser",
|
||||
column: "SharedWithId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AssetUser");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "AssetId",
|
||||
table: "Users",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "JobRecords",
|
||||
type: "VARCHAR(256)",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "VARCHAR(2048)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "RegexPattern",
|
||||
table: "Folders",
|
||||
type: "VARCHAR(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "character varying(2048)",
|
||||
oldMaxLength: 2048,
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_AssetId",
|
||||
table: "Users",
|
||||
column: "AssetId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Users_Assets_AssetId",
|
||||
table: "Users",
|
||||
column: "AssetId",
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,21 @@ namespace Lactose.Migrations
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.Property<Guid>("SharedAssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SharedWithId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||
|
||||
b.HasIndex("SharedWithId");
|
||||
|
||||
b.ToTable("AssetUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -221,7 +236,7 @@ namespace Lactose.Migrations
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -248,7 +263,7 @@ namespace Lactose.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(256)");
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
@@ -348,9 +363,6 @@ namespace Lactose.Migrations
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
@@ -387,8 +399,6 @@ namespace Lactose.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
@@ -422,6 +432,21 @@ namespace Lactose.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedAssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedWithId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
@@ -476,18 +501,9 @@ namespace Lactose.Migrations
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany("SharedWith")
|
||||
.HasForeignKey("AssetId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
|
||||
b.Navigation("SharedWith");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
|
||||
@@ -86,6 +86,11 @@ public class User {
|
||||
/// Gets or sets the list of albums owned by the user.
|
||||
/// </summary>
|
||||
public List<Album>? OwnedAlbums { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of assets shared with the user.
|
||||
/// </summary>
|
||||
public IEnumerable<Asset>? SharedAssets { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.OpenApi;
|
||||
using Npgsql;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
@@ -117,27 +117,6 @@ builder.Services.AddSwaggerGen(
|
||||
Version = "v1"
|
||||
}
|
||||
);
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Enter the Bearer Authorization string as following: `Bearer <Generated-JWT-Token>`",
|
||||
Name = "Authorization",
|
||||
BearerFormat = "JWT",
|
||||
Scheme = "bearer",
|
||||
Type = SecuritySchemeType.ApiKey
|
||||
});
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement {
|
||||
{
|
||||
new OpenApiSecurityScheme {
|
||||
Reference = new OpenApiReference {
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer",
|
||||
},
|
||||
|
||||
},
|
||||
[]
|
||||
}
|
||||
});
|
||||
|
||||
options.SupportNonNullableReferenceTypes();
|
||||
options.DescribeAllParametersInCamelCase();
|
||||
options.UseInlineDefinitionsForEnums();
|
||||
@@ -169,16 +148,16 @@ builder.Services
|
||||
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(x =>
|
||||
{
|
||||
x.RequireHttpsMetadata = false;
|
||||
x.SaveToken = true;
|
||||
x.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
IssuerSigningKey = securityKey,
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false
|
||||
};
|
||||
});
|
||||
{
|
||||
x.RequireHttpsMetadata = false;
|
||||
x.SaveToken = true;
|
||||
x.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
IssuerSigningKey = securityKey,
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false
|
||||
};
|
||||
});
|
||||
|
||||
//Add the job manager service
|
||||
builder.Services.AddHostedService<JobManager>(p => p.GetRequiredService<JobManager>());
|
||||
@@ -201,6 +180,25 @@ if (app.Environment.IsDevelopment()) {
|
||||
|
||||
app.UseCors("MilkStreamPolicy");
|
||||
app.UseAuthentication();
|
||||
|
||||
app.Use(async (HttpContext context, Func<Task> next) => {
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
|
||||
var hasAuth = !string.IsNullOrEmpty(authHeader);
|
||||
var isAuthenticated = context.User?.Identity?.IsAuthenticated ?? false;
|
||||
var authScheme = context.User?.Identity?.AuthenticationType;
|
||||
var authName = isAuthenticated ? context.User.Identity?.Name : null;
|
||||
if (hasAuth)
|
||||
logger.LogDebug("REQ {Path} | scheme={Scheme} | isAuth={IsAuth} | name={Name}",
|
||||
context.Request.Path, authScheme, isAuthenticated, authName);
|
||||
else
|
||||
logger.LogTrace("REQ {Path} | no auth header", context.Request.Path);
|
||||
if (isAuthenticated)
|
||||
logger.LogTrace("REQ {Path} claims: [{Claims}]", context.Request.Path,
|
||||
string.Join("; ", context.User.Claims.Select(c => $"{c.Type}={c.Value}")));
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAuthorization();
|
||||
//app.UseHttpsRedirection();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -121,6 +121,33 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
.Skip(offset)
|
||||
.Take(limit);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, int pageNumber, int pageSize, out int total) {
|
||||
var countQuery = context.Assets.AsQueryable();
|
||||
if (type.HasValue)
|
||||
countQuery = countQuery.Where(a => a.Type == type.Value);
|
||||
if (from.HasValue)
|
||||
countQuery = countQuery.Where(a => a.CreatedAt >= from.Value);
|
||||
if (to.HasValue)
|
||||
countQuery = countQuery.Where(a => a.UpdatedAt <= to.Value);
|
||||
total = countQuery.Count();
|
||||
|
||||
var dataQuery = context.Assets.AsQueryable();
|
||||
if (type.HasValue)
|
||||
dataQuery = dataQuery.Where(a => a.Type == type.Value);
|
||||
if (from.HasValue)
|
||||
dataQuery = dataQuery.Where(a => a.CreatedAt >= from.Value);
|
||||
if (to.HasValue)
|
||||
dataQuery = dataQuery.Where(a => a.UpdatedAt <= to.Value);
|
||||
|
||||
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
|
||||
|
||||
return dataQuery
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => context.Dispose();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using System.Collections;
|
||||
|
||||
@@ -138,4 +139,17 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="count">The number of random paths to return.</param>
|
||||
/// <returns>A list of asset original paths.</returns>
|
||||
List<string> GetRandomPathsByFolder(Guid folderId, int count);
|
||||
|
||||
/// <summary>
|
||||
/// Queries assets with optional type filter, date range, and random ordering.
|
||||
/// </summary>
|
||||
/// <param name="type">Optional asset type to filter by.</param>
|
||||
/// <param name="from">Optional start date for the date range filter.</param>
|
||||
/// <param name="to">Optional end date for the date range filter.</param>
|
||||
/// <param name="orderRandomly">If true, orders by ID for a random-looking result.</param>
|
||||
/// <param name="pageNumber">The 1-based page number.</param>
|
||||
/// <param name="pageSize">The number of items per page.</param>
|
||||
/// <param name="total">The total count of matching assets.</param>
|
||||
/// <returns>A paginated collection of assets matching the filters.</returns>
|
||||
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, int pageNumber, int pageSize, out int total);
|
||||
}
|
||||
|
||||
@@ -52,24 +52,36 @@ public class LactoseAuthService(
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the authenticated user data from the current HTTP context's claims principal.
|
||||
/// Supports both short JWT claim names and mapped .NET claim types.
|
||||
/// </summary>
|
||||
/// <param name="user">The claims principal from the HTTP context.</param>
|
||||
/// <returns>The authenticated user data, or null if the user is not authenticated.</returns>
|
||||
public LactoseAuthenticatedUser? GetUserData(ClaimsPrincipal user) {
|
||||
string? id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
// Debug: log all claims to see what's actually present
|
||||
var allClaims = string.Join(", ", user.Claims.Select(c => c.Type + "=" + c.Value));
|
||||
logger.LogTrace("GetUserData claims: [{Claims}]", allClaims);
|
||||
|
||||
string? id = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.FindFirst("nameid")?.Value;
|
||||
if (id == null) return null;
|
||||
string? name = user.FindFirst(ClaimTypes.Name)?.Value;
|
||||
if(name == null) return null;
|
||||
string? email = user.FindFirst(ClaimTypes.Email)?.Value;
|
||||
if (email==null) return null;
|
||||
string? eAccessLevel = user.FindFirst(ClaimTypes.Role)?.Value;
|
||||
if (eAccessLevel == null) return null;
|
||||
|
||||
|
||||
string? name = user.FindFirst(ClaimTypes.Name)?.Value
|
||||
?? user.FindFirst("unique_name")?.Value;
|
||||
if (name == null) return null;
|
||||
|
||||
string? email = user.FindFirst(ClaimTypes.Email)?.Value
|
||||
?? user.FindFirst("email")?.Value;
|
||||
if (email == null) return null;
|
||||
|
||||
string? role = user.FindFirst(ClaimTypes.Role)?.Value
|
||||
?? user.FindFirst("role")?.Value;
|
||||
if (role == null) return null;
|
||||
|
||||
return new LactoseAuthenticatedUser(
|
||||
Guid.Parse(id),
|
||||
user.FindFirst(ClaimTypes.Name)!.Value,
|
||||
name,
|
||||
email,
|
||||
(EAccessLevel)Enum.Parse(typeof(EAccessLevel),eAccessLevel)
|
||||
(EAccessLevel)Enum.Parse(typeof(EAccessLevel), role)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"Lactose": "Information"
|
||||
"Lactose": "Trace"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
@@ -1,45 +1,203 @@
|
||||
@page "/"
|
||||
@using Butter.Dtos
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Microsoft.JSInterop
|
||||
@using MilkStream.Client.Components.Layout
|
||||
@using MilkStream.Client.Services
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject NavigationManager navigationManager
|
||||
@inject AssetService assetService
|
||||
@inject LoginService loginService
|
||||
@inject IJSRuntime jsRuntime
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<div class="">
|
||||
@if (isLoading) {
|
||||
<LoadSpinner/>
|
||||
} else {
|
||||
@if (mediaList.Count == 0) {
|
||||
<div class="border-warning border-5 border-opacity-100 rounded-3 p-3 m-5 bg-warning-subtle text-center">
|
||||
<h2 class="text-warning">No media available</h2>
|
||||
@if (loginService.IsLoggedIn) {
|
||||
<p class="text-warning-emphasis">You are logged in, but there is no media available at the moment.</p>
|
||||
} else {
|
||||
<p class="text-warning-emphasis">Please <a href="/login" class="link-warning">log-in</a> to be able to browse any media.</p>
|
||||
}
|
||||
</div>
|
||||
@if (isLoading) {
|
||||
<LoadSpinner/>
|
||||
} else if (flatList.Count == 0) {
|
||||
<div class="border-warning border-5 border-opacity-100 rounded-3 p-3 m-5 bg-warning-subtle text-center">
|
||||
<h2 class="text-warning">No media available</h2>
|
||||
@if (loginService.IsLoggedIn) {
|
||||
<p class="text-warning-emphasis">You are logged in, but there is no media available at the moment.</p>
|
||||
} else {
|
||||
<div class="media-list">
|
||||
@foreach (var media in mediaList) {
|
||||
<div class="media-item">
|
||||
<!-- Placeholder for media item -->
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p class="text-warning-emphasis">Please <a href="/login" class="link-warning">log-in</a> to be able to browse any media.</p>
|
||||
}
|
||||
</div>
|
||||
} else {
|
||||
<div class="masonry-grid">
|
||||
@{ var idx = 0; }
|
||||
@foreach (var page in allAssetPages) {
|
||||
@foreach (var asset in page) {
|
||||
var i = idx++;
|
||||
<div class="masonry-tile" @key="asset.Id"
|
||||
style="aspect-ratio:@GetAspectRatio(asset)"
|
||||
@onclick="() => OpenPreview(asset, i)">
|
||||
@if (asset.HasThumbnail) {
|
||||
<img src="@ThumbUrl(asset.Id)"
|
||||
loading="lazy" alt=""
|
||||
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
|
||||
<div class="tile-fallback" style="display:none">
|
||||
<i class="bi bi-image"></i>
|
||||
</div>
|
||||
} else {
|
||||
<div class="tile-fallback">
|
||||
<i class="bi bi-image"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code{
|
||||
bool isLoading = true;
|
||||
List<MediaDto> mediaList = new();
|
||||
<div @ref="sentinelRef" class="masonry-sentinel"></div>
|
||||
}
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.LoggedUserChanged += (sender, dto) => StateHasChanged();
|
||||
@if (selectedAsset != null) {
|
||||
<div class="preview-overlay" @onkeydown="OnPreviewKeyDown" tabindex="0" @onclick="ClosePreview">
|
||||
<div class="preview-content" @onclick:stopPropagation="true">
|
||||
<button class="preview-close" @onclick="ClosePreview">×</button>
|
||||
|
||||
@if (selectedIndex > 0) {
|
||||
<button class="preview-nav preview-prev" @onclick="() => NavigatePreview(-1)" @onclick:stopPropagation="true">‹</button>
|
||||
}
|
||||
@if (selectedIndex < flatList.Count - 1) {
|
||||
<button class="preview-nav preview-next" @onclick="() => NavigatePreview(1)" @onclick:stopPropagation="true">›</button>
|
||||
}
|
||||
|
||||
@if (selectedAsset.HasPreview) {
|
||||
<img class="preview-img" src="@PreviewUrl(selectedAsset.Id)" alt=""
|
||||
onerror="this.onerror=null;this.src='@ThumbUrl(selectedAsset.Id)'" />
|
||||
} else {
|
||||
<img class="preview-img" src="@ThumbUrl(selectedAsset.Id)" alt="" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
List<List<AssetPreviewDto>> allAssetPages = [];
|
||||
List<AssetPreviewDto> flatList = [];
|
||||
int currentPage = 1;
|
||||
bool hasMore = true;
|
||||
bool isLoading = true;
|
||||
bool isLoadingMore;
|
||||
AssetPreviewDto? selectedAsset;
|
||||
int selectedIndex = -1;
|
||||
ElementReference sentinelRef;
|
||||
DotNetObjectReference<Home>? dotNetRef;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
loginService.AuthInfoChanged += (_, info) => {
|
||||
token = info?.Token ?? string.Empty;
|
||||
StateHasChanged();
|
||||
};
|
||||
token = loginService.AuthInfo?.Token ?? string.Empty;
|
||||
await LoadFirstPage();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (flatList.Count > 0 && hasMore && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"masonryObserver.observe", sentinelRef, dotNetRef
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedAsset != null)
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.lockBodyScroll");
|
||||
else
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
|
||||
}
|
||||
|
||||
async Task LoadFirstPage() {
|
||||
isLoading = true;
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, 1, 30);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
RebuildFlatList();
|
||||
currentPage = 2;
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task LoadNextPage() {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, currentPage, 30);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
RebuildFlatList();
|
||||
currentPage++;
|
||||
|
||||
if (allAssetPages.Count > 10)
|
||||
allAssetPages.RemoveAt(0);
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
isLoadingMore = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
void RebuildFlatList() => flatList = allAssetPages.SelectMany(p => p).ToList();
|
||||
|
||||
void OpenPreview(AssetPreviewDto asset, int index) {
|
||||
selectedAsset = asset;
|
||||
selectedIndex = index;
|
||||
}
|
||||
|
||||
void ClosePreview() {
|
||||
selectedAsset = null;
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
void NavigatePreview(int direction) {
|
||||
var newIndex = selectedIndex + direction;
|
||||
if (newIndex < 0 || newIndex >= flatList.Count) return;
|
||||
selectedAsset = flatList[newIndex];
|
||||
selectedIndex = newIndex;
|
||||
}
|
||||
|
||||
void OnPreviewKeyDown(KeyboardEventArgs e) {
|
||||
switch (e.Key) {
|
||||
case "Escape":
|
||||
ClosePreview();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
NavigatePreview(-1);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
NavigatePreview(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static string GetAspectRatio(AssetPreviewDto asset) {
|
||||
if (asset.ResolutionWidth > 0 && asset.ResolutionHeight > 0)
|
||||
return $"{asset.ResolutionWidth} / {asset.ResolutionHeight}";
|
||||
return "4 / 3";
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
string PreviewUrl(Guid id) => $"{apiBase}/api/media/preview/{id}{TokenParam}";
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (dotNetRef != null) {
|
||||
await jsRuntime.InvokeVoidAsync("masonryObserver.dispose");
|
||||
dotNetRef.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
MilkStream.Client/Components/Pages/Home.razor.css
Normal file
131
MilkStream.Client/Components/Pages/Home.razor.css
Normal file
@@ -0,0 +1,131 @@
|
||||
.masonry-grid {
|
||||
column-count: 4;
|
||||
column-gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.masonry-tile {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--bs-dark, #1a1a1a);
|
||||
transition: filter 0.2s;
|
||||
position: relative;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 200px;
|
||||
}
|
||||
|
||||
.masonry-tile:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.masonry-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tile-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bs-dark, #1a1a1a);
|
||||
color: var(--bs-secondary, #666);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.masonry-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* Preview overlay */
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-close {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.preview-close:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.preview-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 3rem;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.preview-nav:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.preview-prev {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.preview-next {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
/* Responsive columns */
|
||||
@media (max-width: 576px) {
|
||||
.masonry-grid { column-count: 2; }
|
||||
}
|
||||
@media (min-width: 577px) and (max-width: 992px) {
|
||||
.masonry-grid { column-count: 3; }
|
||||
}
|
||||
@media (min-width: 993px) and (max-width: 1400px) {
|
||||
.masonry-grid { column-count: 4; }
|
||||
}
|
||||
@media (min-width: 1401px) {
|
||||
.masonry-grid { column-count: 5; }
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.18" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.19.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -25,6 +25,7 @@ builder.Services.AddHttpClient("MilkstreamClient")
|
||||
builder.Services.AddScoped<JwtTokenRefresher>();
|
||||
builder.Services.AddScoped<LoginService>();
|
||||
builder.Services.AddScoped<MediaService>();
|
||||
builder.Services.AddScoped<AssetService>();
|
||||
builder.Services.AddScoped<UserService>();
|
||||
builder.Services.AddScoped<SettingsService>();
|
||||
builder.Services.AddScoped<FoldersService>();
|
||||
|
||||
38
MilkStream.Client/Services/AssetService.cs
Normal file
38
MilkStream.Client/Services/AssetService.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MilkStream.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to asset listing and searching via the API.
|
||||
/// </summary>
|
||||
public sealed class AssetService(
|
||||
IOptions<ServiceOptions> options,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
LoginService loginService,
|
||||
ILogger<AssetService> logger
|
||||
) : AuthServiceBase(options, httpClientFactory, loginService, logger) {
|
||||
/// <summary>
|
||||
/// Gets a page of assets with optional type filter and random ordering.
|
||||
/// </summary>
|
||||
/// <param name="type">Optional asset type filter.</param>
|
||||
/// <param name="random">Whether to order randomly by ID.</param>
|
||||
/// <param name="page">The 1-based page number.</param>
|
||||
/// <param name="pageSize">The number of items per page.</param>
|
||||
/// <returns>A list of asset previews, or null if the request failed.</returns>
|
||||
public async Task<List<AssetPreviewDto>?> GetAssetsAsync(
|
||||
EAssetType? type = null, bool random = true, int page = 1, int pageSize = 30
|
||||
) {
|
||||
var url = $"/api/asset?page={page}&pageSize={pageSize}&random={random}";
|
||||
if (type.HasValue)
|
||||
url += $"&type={(int)type.Value}";
|
||||
|
||||
var response = await SendWithRefreshAsync(() => Client.GetAsync(url));
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<List<AssetPreviewDto>>();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<script src="lib/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_framework/blazor.webassembly.js"></script>
|
||||
<script src="js/masonryObserver.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
34
MilkStream.Client/wwwroot/js/masonryObserver.js
Normal file
34
MilkStream.Client/wwwroot/js/masonryObserver.js
Normal file
@@ -0,0 +1,34 @@
|
||||
window.masonryObserver = {
|
||||
observer: null,
|
||||
dotNetRef: null,
|
||||
|
||||
observe: function (sentinelElement, dotNetRef) {
|
||||
this.dispose();
|
||||
this.dotNetRef = dotNetRef;
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
dotNetRef.invokeMethodAsync('LoadNextPage');
|
||||
}
|
||||
},
|
||||
{ rootMargin: '600px' }
|
||||
);
|
||||
this.observer.observe(sentinelElement);
|
||||
},
|
||||
|
||||
dispose: function () {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
this.dotNetRef = null;
|
||||
},
|
||||
|
||||
lockBodyScroll: function () {
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
unlockBodyScroll: function () {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libc6 \
|
||||
libstdc++6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["MilkStream/MilkStream.csproj", "MilkStream/"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
@@ -14,8 +14,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AspNetCore.SassCompiler" Version="1.89.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.18" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,10 +2,6 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
try { builder.WebHost.UseStaticWebAssets(); } catch (DirectoryNotFoundException) { }
|
||||
|
||||
#if DEBUG // Enable the Sass compiler in debug mode for hot reload support.
|
||||
builder.Services.AddSassCompiler();
|
||||
#endif
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!app.Environment.IsDevelopment()) {
|
||||
|
||||
13
dotnet-tools.json
Normal file
13
dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.9",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.0",
|
||||
"version": "10.0.0",
|
||||
"rollForward": "latestMajor",
|
||||
"allowPrerelease": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user