From c1adaebd5b2e6baf655354d818eaf9fc2e8e7da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 22:31:26 +0000 Subject: [PATCH] Let the gallery fill itself while people are looking at it Uploading reloaded the whole page, and other visitors only ever saw what was in the box when they opened it. The gallery now asks every ten seconds for the prints that landed since the ones it drew and slots them into place, keeping the visitor's spot in the grid. Polling rather than a socket per viewer: a box is a handful of people for an afternoon, and a backgrounded tab asks for nothing at all. The tile markup moved into a partial so the live feed and the first render draw a print the same way. The upload stamp moved to the moment the file lands, so a slow render can't leave a photo behind the cursor. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V8LQ8sgeibd9RjKZoxQ3jo --- src/Shoebox.Web/Data/AppDbContext.cs | 3 + ...818231015_MediaUploadedAtIndex.Designer.cs | 175 +++++++++++ .../20260818231015_MediaUploadedAtIndex.cs | 27 ++ .../Migrations/AppDbContextModelSnapshot.cs | 2 + src/Shoebox.Web/Pages/Pool/Gallery.cshtml | 96 ++---- src/Shoebox.Web/Pages/Pool/Gallery.cshtml.cs | 97 ++++-- src/Shoebox.Web/Pages/Pool/_Tiles.cshtml | 49 +++ src/Shoebox.Web/Services/MediaService.cs | 5 +- src/Shoebox.Web/wwwroot/css/site.css | 6 +- src/Shoebox.Web/wwwroot/js/gallery.js | 297 ++++++++++++++++-- tests/Shoebox.Tests.Web/CoreFlowTests.cs | 52 +++ 11 files changed, 682 insertions(+), 127 deletions(-) create mode 100644 src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.Designer.cs create mode 100644 src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.cs create mode 100644 src/Shoebox.Web/Pages/Pool/_Tiles.cshtml diff --git a/src/Shoebox.Web/Data/AppDbContext.cs b/src/Shoebox.Web/Data/AppDbContext.cs index f18d313..01bba49 100644 --- a/src/Shoebox.Web/Data/AppDbContext.cs +++ b/src/Shoebox.Web/Data/AppDbContext.cs @@ -25,6 +25,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(media => { media.HasIndex(m => new { m.PoolId, m.ContentHash }); + // The gallery's live feed asks each box for what landed after a moment in time, + // over and over; without this every ask reads and sorts the whole box. + media.HasIndex(m => new { m.PoolId, m.UploadedAt }); media.Property(m => m.OriginalFileName).HasMaxLength(260); media.Property(m => m.UploaderName).HasMaxLength(80); }); diff --git a/src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.Designer.cs b/src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.Designer.cs new file mode 100644 index 0000000..d683c1a --- /dev/null +++ b/src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.Designer.cs @@ -0,0 +1,175 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Shoebox.Web.Data; + +#nullable disable + +namespace Shoebox.Web.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260818231015_MediaUploadedAtIndex")] + partial class MediaUploadedAtIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Shoebox.Web.Data.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ContentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HasAnimation") + .HasColumnType("INTEGER"); + + b.Property("HasThumbnail") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("TEXT"); + + b.Property("PoolId") + .HasColumnType("TEXT"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("TakenAt") + .HasColumnType("TEXT"); + + b.Property("UploadedAt") + .HasColumnType("TEXT"); + + b.Property("UploaderName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("UploaderUid") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PoolId", "ContentHash"); + + b.HasIndex("PoolId", "UploadedAt"); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("Shoebox.Web.Data.MediaLike", b => + { + b.Property("MediaId") + .HasColumnType("TEXT"); + + b.Property("UploaderUid") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.HasKey("MediaId", "UploaderUid"); + + b.ToTable("Likes"); + }); + + modelBuilder.Entity("Shoebox.Web.Data.Pool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AdminKey") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Pools"); + }); + + modelBuilder.Entity("Shoebox.Web.Data.Media", b => + { + b.HasOne("Shoebox.Web.Data.Pool", "Pool") + .WithMany("Media") + .HasForeignKey("PoolId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Pool"); + }); + + modelBuilder.Entity("Shoebox.Web.Data.MediaLike", b => + { + b.HasOne("Shoebox.Web.Data.Media", "Media") + .WithMany() + .HasForeignKey("MediaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("Shoebox.Web.Data.Pool", b => + { + b.Navigation("Media"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.cs b/src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.cs new file mode 100644 index 0000000..4c998db --- /dev/null +++ b/src/Shoebox.Web/Migrations/20260818231015_MediaUploadedAtIndex.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Shoebox.Web.Migrations +{ + /// + public partial class MediaUploadedAtIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Media_PoolId_UploadedAt", + table: "Media", + columns: new[] { "PoolId", "UploadedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Media_PoolId_UploadedAt", + table: "Media"); + } + } +} diff --git a/src/Shoebox.Web/Migrations/AppDbContextModelSnapshot.cs b/src/Shoebox.Web/Migrations/AppDbContextModelSnapshot.cs index 6fe8a42..63ef367 100644 --- a/src/Shoebox.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Shoebox.Web/Migrations/AppDbContextModelSnapshot.cs @@ -79,6 +79,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PoolId", "ContentHash"); + b.HasIndex("PoolId", "UploadedAt"); + b.ToTable("Media"); }); diff --git a/src/Shoebox.Web/Pages/Pool/Gallery.cshtml b/src/Shoebox.Web/Pages/Pool/Gallery.cshtml index e79ccb9..e7d314d 100644 --- a/src/Shoebox.Web/Pages/Pool/Gallery.cshtml +++ b/src/Shoebox.Web/Pages/Pool/Gallery.cshtml @@ -22,11 +22,8 @@ }

Box code: @Model.Pool.Code - · @Model.Items.Count photo@(Model.Items.Count == 1 ? "" : "s") - @if (Model.Uploaders.Count > 0) - { - · from @Model.Uploaders.Count @(Model.Uploaders.Count == 1 ? "person" : "people") - } + · @Model.Items.Count photo@(Model.Items.Count == 1 ? "" : "s") +

@if (Model.Pool.ExpiresAt.HasValue) { @@ -77,83 +74,28 @@
- @if (Model.Items.Count > 0) - { - Download all photos - @* Phone-only: revealed by gallery.js on touch devices whose browser can - hand image files to the OS share sheet (mobile Safari / Chrome). *@ - - } - else - { - Download all photos - } - @if (Model.HasOthers) - { - Download everyone else's - } - else - { - Download everyone else's - } + + + @* Phone-only: revealed by gallery.js on touch devices whose browser can + hand image files to the OS share sheet (mobile Safari / Chrome). *@ + + +
-@if (Model.Items.Count == 0) -{ -
-

Nothing here yet. You were there. Prove it.

-
-} +