13 Commits

Author SHA1 Message Date
0549be6d42 Merge branch 'feature/masonryLayoutInHome' into develop 2026-07-07 18:37:45 +02:00
5cd16a7416 build: upgrade to .NET 10 — packages, Docker images, and Swagger fix
- Bump all NuGet packages to 10.0.x (ASP.NET, EF Core, Npgsql, Swashbuckle)
- Bump Pgvector.EntityFrameworkCore to 0.3.0, Microsoft.IdentityModel.JsonWebTokens to 8.19.1
- Pin Microsoft.OpenApi to 2.7.5, strip security scheme from SwaggerGen (API removed in v2)
- Docker images already at 10.0
2026-07-07 18:25:18 +02:00
5573be1502 Merge remote-tracking branch 'origin/feature/masonryLayoutInHome' into feature/masonryLayoutInHome 2026-07-07 18:09:04 +02:00
99ef2db822 chore: add dotnet-tools.json for ef tool 2026-07-07 18:08:08 +02:00
0502cbbd76 chore: upgrade target framework to net10.0
Update all projects from net8.0 to net10.0 and global.json SDK to 10.0.0.
Gitignore dotnet-tools.json.
2026-07-07 18:03:42 +02:00
6d29f053f4 fix: proper bidirectional SharedWith many-to-many migration
Replace broken one-to-many User.AssetId shadow FK with proper
AssetUser join table using bidirectional navigation:
  HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets)

Join table columns: SharedAssetsId (FK to Assets) + SharedWithId (FK to Users).
Adds SharedAssets navigation to User model.

Generated via dotnet ef migrations add.
2026-07-07 18:03:22 +02:00
MrFastwind
75c667d1c2 Merge remote-tracking branch 'origin/feature/masonryLayoutInHome' into feature/masonryLayoutInHome 2026-07-07 18:02:51 +02:00
MrFastwind
740941790b fix(Lactose): Admin and Curators media visibility 2026-07-07 18:02:29 +02:00
5511bc56f2 fix: remove AspNetCore.SassCompiler to fix Docker startup crash
The native Dart binary at runtimes/linux-x64/src/dart is not
published into the runtime container, causing a crash when the
SassCompilerHostedService tries to start at runtime.

SCSS compilation now must be done manually with the  CLI
or by temporarily re-adding the package. Instructions added
to AGENTS.md.
2026-07-07 17:25:13 +02:00
d90a9421e9 dotnet tools 2026-07-07 17:16:04 +02:00
ba1071ba6c fix: add AssetUser many-to-many join table for SharedWith
Replace broken one-to-many User.AssetId shadow FK with proper
AssetUser join table (AssetsId + SharedWithId composite PK).
Configure explicit UsingEntity in OnModelCreating to match
database column names and prevent convention mismatches.

Migration: 20260707160000_FixSharedWithManyToMany
2026-07-07 17:13:50 +02:00
29b021bee5 feat: pass JWT token in media URLs for authenticated thumbnail/preview requests
- MediaController: accept ?token=<jwt> query param as alt auth for browser img requests,
  validating against signing key via JwtSecurityTokenHandler
- Home.razor: append current JWT token to all thumbnail/preview URLs,
  update on AuthInfoChanged, empty when not logged in
2026-07-07 15:58:56 +02:00
07fcd05031 feat: masonry gallery on homepage with infinite scroll
- Enrich AssetPreviewDto with ResolutionWidth/Height, HasThumbnail, HasPreview
- Extend GET /api/asset with ?type=&random=true for gallery queries
- Add general GetAssets repository method (type, dates, random, pagination)
- Fix LactoseAuthService.GetUserData to handle unmapped JWT claim names
- Add request-level auth debug logging middleware
- New AssetService (MilkStream.Client) for gallery API calls
- Rewrite Home.razor: CSS column-count masonry, infinite scroll via
  IntersectionObserver JS interop, inline preview overlay with
  progressive loading (preview -> thumbnail fallback), arrow/Esc nav
- Home.razor.css: responsive 2-5 column masonry, overlay styles,
  content-visibility:auto for memory optimization
- Sliding window of 10 pages (300 tiles) with loading=lazy images
- Broken thumbnail fallback to Bootstrap bi-image icon
2026-07-07 15:44:03 +02:00
33 changed files with 1347 additions and 146 deletions

2
.gitignore vendored
View File

@@ -4,4 +4,4 @@ obj/
riderModule.iml
/_ReSharper.Caches/
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
storageImages/
storageImages/dotnet-tools.json

View File

@@ -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
```

View File

@@ -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>

View File

@@ -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; }

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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/"]

View File

@@ -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>

View File

@@ -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>

View 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
}
}
}

View 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");
}
}
}

View File

@@ -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 =>

View File

@@ -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
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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)
);
}

View File

@@ -4,7 +4,7 @@
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Lactose": "Information"
"Lactose": "Trace"
}
},
"AllowedHosts": "*",

View File

@@ -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">&times;</button>
@if (selectedIndex > 0) {
<button class="preview-nav preview-prev" @onclick="() => NavigatePreview(-1)" @onclick:stopPropagation="true">&#8249;</button>
}
@if (selectedIndex < flatList.Count - 1) {
<button class="preview-nav preview-next" @onclick="() => NavigatePreview(1)" @onclick:stopPropagation="true">&#8250;</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();
}
}
}

View 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; }
}

View File

@@ -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>

View File

@@ -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>();

View 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;
}
}

View File

@@ -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>

View 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 = '';
}
};

View File

@@ -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/"]

View File

@@ -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>

View File

@@ -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
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -1,6 +1,6 @@
{
"sdk": {
"version": "8.0.0",
"version": "10.0.0",
"rollForward": "latestMajor",
"allowPrerelease": true
}