feat(photos): add square medium variant for sharp grid thumbnails
This commit is contained in:
@@ -61,13 +61,27 @@ func (s *Service) List(ctx context.Context, userID string) ([]models.Album, erro
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachCover fills CoverThumbURL from the signed thumbnail URL, when present.
|
||||
// attachCover fills CoverThumbURL and CoverMediumURL from signed URLs. The
|
||||
// medium URL falls back to the thumbnail while the medium variant is not ready,
|
||||
// so a cover URL suitable for large display is always present when a cover is
|
||||
// set.
|
||||
func (s *Service) attachCover(ctx context.Context, a *models.Album) {
|
||||
if s.storage == nil || a == nil || a.CoverThumbKey == "" {
|
||||
if s.storage == nil || a == nil {
|
||||
return
|
||||
}
|
||||
if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil {
|
||||
a.CoverThumbURL = url
|
||||
if a.CoverThumbKey != "" {
|
||||
if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil {
|
||||
a.CoverThumbURL = url
|
||||
}
|
||||
}
|
||||
key := a.CoverMediumKey
|
||||
if key == "" {
|
||||
key = a.CoverThumbKey
|
||||
}
|
||||
if key != "" {
|
||||
if url, err := s.storage.URL(ctx, key); err == nil {
|
||||
a.CoverMediumURL = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,20 +46,22 @@ func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) {
|
||||
}
|
||||
|
||||
const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
|
||||
a.created_at, a.updated_at, COALESCE(co.storage_key, ''),
|
||||
a.created_at, a.updated_at, COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
|
||||
|
||||
const sqliteAlbumJoins = `
|
||||
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
|
||||
LEFT JOIN objects co ON co.id = cpo.object_id`
|
||||
LEFT JOIN objects co ON co.id = cpo.object_id
|
||||
LEFT JOIN photo_objects cpm ON cpm.photo_id = a.cover_photo_id AND cpm.role = 'medium'
|
||||
LEFT JOIN objects com ON com.id = cpm.object_id`
|
||||
|
||||
func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) {
|
||||
var a models.Album
|
||||
var cover sql.NullString
|
||||
var createdAt, updatedAt string
|
||||
if err := scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &cover, &createdAt, &updatedAt,
|
||||
&a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
|
||||
&a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cover.Valid {
|
||||
|
||||
@@ -44,14 +44,16 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
var a models.Album
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
|
||||
COALESCE(co.storage_key, ''),
|
||||
COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||
FROM albums a
|
||||
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
|
||||
LEFT JOIN objects co ON co.id = cpo.object_id
|
||||
LEFT JOIN photo_objects cpm ON cpm.photo_id = a.cover_photo_id AND cpm.role = 'medium'
|
||||
LEFT JOIN objects com ON com.id = cpm.object_id
|
||||
WHERE a.id = $1`, id).
|
||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount)
|
||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -64,13 +66,15 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
|
||||
COALESCE(co.storage_key, ''),
|
||||
COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
|
||||
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||
FROM albums a
|
||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
||||
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
|
||||
LEFT JOIN objects co ON co.id = cpo.object_id
|
||||
LEFT JOIN photo_objects cpm ON cpm.photo_id = a.cover_photo_id AND cpm.role = 'medium'
|
||||
LEFT JOIN objects com ON com.id = cpm.object_id
|
||||
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
||||
ORDER BY a.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
@@ -81,7 +85,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
|
||||
out := []models.Album{}
|
||||
for rows.Next() {
|
||||
var a models.Album
|
||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.CoverMediumKey, &a.PhotosCount, &a.MembersCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
|
||||
@@ -32,8 +32,10 @@ type Album struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||
CoverThumbKey string `json:"-"`
|
||||
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
|
||||
CoverThumbKey string `json:"-"`
|
||||
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
|
||||
CoverMediumKey string `json:"-"`
|
||||
CoverMediumURL string `json:"cover_medium_url,omitempty"`
|
||||
PhotosCount int `json:"photos_count"`
|
||||
MembersCount int `json:"members_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -67,6 +69,7 @@ type Photo struct {
|
||||
UploaderID string `json:"uploader_id"`
|
||||
StorageKey string `json:"-"`
|
||||
ThumbKey string `json:"-"`
|
||||
MediumKey string `json:"-"`
|
||||
PreviewKey string `json:"-"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
@@ -87,6 +90,7 @@ type Photo struct {
|
||||
CommentsCount int `json:"comments_count"`
|
||||
LikedByMe bool `json:"liked_by_me"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
MediumURL string `json:"medium_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
OriginalURL string `json:"original_url,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
const (
|
||||
RoleOriginal = "original"
|
||||
RoleThumb = "thumb"
|
||||
RoleMedium = "medium"
|
||||
RolePreview = "preview"
|
||||
)
|
||||
|
||||
|
||||
@@ -121,24 +121,31 @@ func ExtractEXIFAttributes(data []byte) map[string]any {
|
||||
return extractEXIF(data).Attributes
|
||||
}
|
||||
|
||||
func buildVariants(data []byte) (thumb, preview []byte, err error) {
|
||||
func buildVariants(data []byte) (thumb, medium, preview []byte, err error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
|
||||
// Grid variants are square crops so a square tile is never upscaled; the
|
||||
// viewer preview keeps the aspect ratio.
|
||||
thumbImg := imaging.Fill(img, 200, 200, imaging.Center, imaging.Lanczos)
|
||||
mediumImg := imaging.Fill(img, 800, 800, imaging.Center, imaging.Lanczos)
|
||||
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
|
||||
|
||||
opts := webp.Options{Quality: 82}
|
||||
var thumbBuf bytes.Buffer
|
||||
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var mediumBuf bytes.Buffer
|
||||
if err := webp.Encode(&mediumBuf, mediumImg, opts); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var previewBuf bytes.Buffer
|
||||
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
|
||||
return thumbBuf.Bytes(), mediumBuf.Bytes(), previewBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
// checksum returns the hex-encoded SHA-256 of the given bytes.
|
||||
|
||||
@@ -133,6 +133,11 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
||||
p.ThumbURL = url
|
||||
}
|
||||
}
|
||||
if p.MediumKey != "" {
|
||||
if url, err := s.storage.URL(ctx, p.MediumKey); err == nil {
|
||||
p.MediumURL = url
|
||||
}
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil {
|
||||
p.PreviewURL = url
|
||||
|
||||
@@ -22,6 +22,7 @@ func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
|
||||
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE(o_o.storage_key, ''),
|
||||
COALESCE(o_t.storage_key, ''),
|
||||
COALESCE(o_m.storage_key, ''),
|
||||
COALESCE(o_p.storage_key, ''),
|
||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
@@ -40,6 +41,8 @@ const sqlitePhotoJoins = `
|
||||
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
|
||||
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
|
||||
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
|
||||
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
||||
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
||||
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
||||
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
|
||||
|
||||
@@ -50,7 +53,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
||||
var lat, long sql.NullFloat64
|
||||
var liked int
|
||||
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
@@ -271,7 +274,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
|
||||
var lat, long sql.NullFloat64
|
||||
var liked int
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
|
||||
@@ -29,6 +29,7 @@ func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE(o_o.storage_key, ''),
|
||||
COALESCE(o_t.storage_key, ''),
|
||||
COALESCE(o_m.storage_key, ''),
|
||||
COALESCE(o_p.storage_key, ''),
|
||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
@@ -46,6 +47,8 @@ const photoJoins = `
|
||||
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
|
||||
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
|
||||
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
|
||||
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
||||
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
||||
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
||||
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
|
||||
|
||||
@@ -53,7 +56,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
var exifText, tagsText string
|
||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
@@ -186,7 +189,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
var p models.Photo
|
||||
var exifText, tagsText string
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
@@ -299,7 +302,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
||||
var p models.Photo
|
||||
var exifText, tagsText string
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
|
||||
@@ -125,7 +125,7 @@ func (w *Worker) generateVariants(ctx context.Context, photoID string) {
|
||||
w.fail(ctx, photoID, "fetch original: "+err.Error())
|
||||
return
|
||||
}
|
||||
thumb, preview, err := buildVariants(data)
|
||||
thumb, medium, preview, err := buildVariants(data)
|
||||
if err != nil {
|
||||
w.fail(ctx, photoID, "build variants: "+err.Error())
|
||||
return
|
||||
@@ -138,6 +138,10 @@ func (w *Worker) generateVariants(ctx context.Context, photoID string) {
|
||||
w.fail(ctx, photoID, "store thumb: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := w.createVariant(ctx, siblings, RoleMedium, medium); err != nil {
|
||||
w.fail(ctx, photoID, "store medium: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := w.createVariant(ctx, siblings, RolePreview, preview); err != nil {
|
||||
w.fail(ctx, photoID, "store preview: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -59,13 +59,13 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
|
||||
if err != nil {
|
||||
t.Fatalf("get refreshed: %v", err)
|
||||
}
|
||||
if refreshed.ThumbKey == "" || refreshed.PreviewKey == "" {
|
||||
t.Fatalf("variants not bound: thumb=%q preview=%q", refreshed.ThumbKey, refreshed.PreviewKey)
|
||||
if refreshed.ThumbKey == "" || refreshed.MediumKey == "" || refreshed.PreviewKey == "" {
|
||||
t.Fatalf("variants not bound: thumb=%q medium=%q preview=%q", refreshed.ThumbKey, refreshed.MediumKey, refreshed.PreviewKey)
|
||||
}
|
||||
if err := svc.attachURLs(ctx, refreshed); err != nil {
|
||||
t.Fatalf("attach urls: %v", err)
|
||||
}
|
||||
if refreshed.ThumbURL == "" || refreshed.PreviewURL == "" {
|
||||
if refreshed.ThumbURL == "" || refreshed.MediumURL == "" || refreshed.PreviewURL == "" {
|
||||
t.Fatalf("variant urls not set")
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
|
||||
}
|
||||
|
||||
objectIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
|
||||
if err != nil || len(objectIDs) != 3 {
|
||||
t.Fatalf("expected 3 bound objects, got %v err=%v", objectIDs, err)
|
||||
if err != nil || len(objectIDs) != 4 {
|
||||
t.Fatalf("expected 4 bound objects, got %v err=%v", objectIDs, err)
|
||||
}
|
||||
|
||||
if err := svc.PermanentDelete(ctx, p.ID, user); err != nil {
|
||||
|
||||
@@ -382,6 +382,23 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
t.Fatalf("cover not set: %+v", al.CoverPhotoID)
|
||||
}
|
||||
|
||||
// The album exposes the cover's medium variant so the overview can render
|
||||
// it sharply.
|
||||
coverMedium := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectReady}
|
||||
if err := r.Photos.CreateObject(ctx, coverMedium); err != nil {
|
||||
t.Fatalf("create cover medium: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, coverMedium.ID); err != nil {
|
||||
t.Fatalf("activate cover medium: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindObject(ctx, p.ID, coverMedium.ID, photos.RoleMedium); err != nil {
|
||||
t.Fatalf("bind cover medium: %v", err)
|
||||
}
|
||||
al, _ = r.Albums.GetByID(ctx, a.ID)
|
||||
if al.CoverMediumKey != coverMedium.StorageKey {
|
||||
t.Fatalf("cover medium key = %q want %q", al.CoverMediumKey, coverMedium.StorageKey)
|
||||
}
|
||||
|
||||
// Clearing the cover only affects a matching photo id.
|
||||
r.Photos.ClearCoverIfMatches(ctx, a.ID, newID())
|
||||
al, _ = r.Albums.GetByID(ctx, a.ID)
|
||||
@@ -468,6 +485,19 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
t.Fatalf("thumb refs = %d, want 1", refs)
|
||||
}
|
||||
|
||||
// The medium role is a first-class binding, so it must be accepted by the
|
||||
// role constraint on every backend.
|
||||
medium := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||
if err := r.Photos.CreateObject(ctx, medium); err != nil {
|
||||
t.Fatalf("create medium object: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, medium.ID); err != nil {
|
||||
t.Fatalf("activate medium: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindObject(ctx, p.ID, medium.ID, photos.RoleMedium); err != nil {
|
||||
t.Fatalf("bind medium: %v", err)
|
||||
}
|
||||
|
||||
// After the photo is gone, its objects have no bindings and are collectible.
|
||||
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
|
||||
t.Fatalf("hard delete: %v", err)
|
||||
@@ -480,8 +510,8 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
for _, o := range claimed {
|
||||
got[o.ID] = true
|
||||
}
|
||||
if !got[orig.ID] || !got[thumb.ID] {
|
||||
t.Fatalf("claimed = %+v, want original and thumb", got)
|
||||
if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] {
|
||||
t.Fatalf("claimed = %+v, want original, thumb and medium", got)
|
||||
}
|
||||
for _, o := range claimed {
|
||||
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Drop medium bindings before narrowing the role constraint again.
|
||||
DELETE FROM photo_objects WHERE role = 'medium';
|
||||
ALTER TABLE photo_objects DROP CONSTRAINT IF EXISTS photo_objects_role_check;
|
||||
ALTER TABLE photo_objects ADD CONSTRAINT photo_objects_role_check
|
||||
CHECK (role IN ('original', 'thumb', 'preview'));
|
||||
@@ -0,0 +1,11 @@
|
||||
-- The grid serves a square medium variant between thumb and preview.
|
||||
ALTER TABLE photo_objects DROP CONSTRAINT IF EXISTS photo_objects_role_check;
|
||||
ALTER TABLE photo_objects ADD CONSTRAINT photo_objects_role_check
|
||||
CHECK (role IN ('original', 'thumb', 'medium', 'preview'));
|
||||
|
||||
-- Regenerate variants for existing photos so they gain the medium variant and a
|
||||
-- square thumb. The worker rebinds roles; superseded variants are reclaimed by
|
||||
-- the reference-counting collector.
|
||||
INSERT INTO variant_jobs (photo_id)
|
||||
SELECT id FROM photos
|
||||
ON CONFLICT (photo_id) DO NOTHING;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Drop medium bindings and restore the original three-role constraint.
|
||||
DELETE FROM photo_objects WHERE role = 'medium';
|
||||
|
||||
CREATE TABLE photo_objects_old (
|
||||
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
|
||||
role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'preview')),
|
||||
PRIMARY KEY (photo_id, role)
|
||||
);
|
||||
|
||||
INSERT INTO photo_objects_old (photo_id, object_id, role)
|
||||
SELECT photo_id, object_id, role FROM photo_objects;
|
||||
|
||||
DROP TABLE photo_objects;
|
||||
|
||||
ALTER TABLE photo_objects_old RENAME TO photo_objects;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- SQLite cannot alter a CHECK constraint, so recreate photo_objects with the
|
||||
-- extended role set (medium added) and copy the existing bindings across.
|
||||
CREATE TABLE photo_objects_new (
|
||||
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
|
||||
role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'medium', 'preview')),
|
||||
PRIMARY KEY (photo_id, role)
|
||||
);
|
||||
|
||||
INSERT INTO photo_objects_new (photo_id, object_id, role)
|
||||
SELECT photo_id, object_id, role FROM photo_objects;
|
||||
|
||||
DROP TABLE photo_objects;
|
||||
|
||||
ALTER TABLE photo_objects_new RENAME TO photo_objects;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id);
|
||||
|
||||
-- Regenerate variants for existing photos so they gain the medium variant and a
|
||||
-- square thumb.
|
||||
INSERT INTO variant_jobs (photo_id)
|
||||
SELECT id FROM photos
|
||||
WHERE 1
|
||||
ON CONFLICT (photo_id) DO NOTHING;
|
||||
@@ -28,6 +28,26 @@
|
||||
onCursorFocus
|
||||
}: Props = $props();
|
||||
|
||||
// gap-2 in the grid template is 0.5rem.
|
||||
const GAP_PX = 8;
|
||||
let gridWidth = $state(0);
|
||||
|
||||
// Offer every variant the photo actually has; the backend generates the
|
||||
// medium variant asynchronously, so an incomplete set must still render.
|
||||
function srcsetFor(photo: Photo): string {
|
||||
const parts: string[] = [];
|
||||
if (photo.thumb_url) parts.push(`${photo.thumb_url} 200w`);
|
||||
if (photo.medium_url) parts.push(`${photo.medium_url} 800w`);
|
||||
if (photo.preview_url) parts.push(`${photo.preview_url} 1200w`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
const sizes = $derived(
|
||||
gridWidth > 0
|
||||
? `${Math.max(1, Math.round((gridWidth - (density.cols - 1) * GAP_PX) / density.cols))}px`
|
||||
: undefined
|
||||
);
|
||||
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
const isCursor = (i: number) => cursorIndex === i;
|
||||
const tabIndexFor = (i: number) =>
|
||||
@@ -47,6 +67,7 @@
|
||||
{:else}
|
||||
<ul
|
||||
class="grid gap-2"
|
||||
bind:clientWidth={gridWidth}
|
||||
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||
>
|
||||
{#each photos as photo, i (photo.id)}
|
||||
@@ -76,6 +97,8 @@
|
||||
{#if photo.thumb_url}
|
||||
<img
|
||||
src={photo.thumb_url}
|
||||
srcset={srcsetFor(photo)}
|
||||
{sizes}
|
||||
alt={photo.original_filename}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Album {
|
||||
description: string;
|
||||
cover_photo_id?: string | null;
|
||||
cover_thumb_url?: string;
|
||||
cover_medium_url?: string;
|
||||
photos_count?: number;
|
||||
members_count?: number;
|
||||
created_at: string;
|
||||
@@ -64,6 +65,7 @@ export interface Photo {
|
||||
comments_count: number;
|
||||
liked_by_me: boolean;
|
||||
thumb_url?: string;
|
||||
medium_url?: string;
|
||||
preview_url?: string;
|
||||
original_url?: string;
|
||||
download_url?: string;
|
||||
|
||||
@@ -12,6 +12,26 @@
|
||||
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||
});
|
||||
|
||||
// gap-3 in the grid template is 0.75rem.
|
||||
const GAP_PX = 12;
|
||||
let gridWidth = $state(0);
|
||||
|
||||
// The medium cover URL falls back to the thumbnail until the medium variant
|
||||
// is generated, so dedupe identical sources.
|
||||
function coverSrcset(album: Album): string {
|
||||
const parts: string[] = [];
|
||||
if (album.cover_thumb_url) parts.push(`${album.cover_thumb_url} 200w`);
|
||||
if (album.cover_medium_url && album.cover_medium_url !== album.cover_thumb_url)
|
||||
parts.push(`${album.cover_medium_url} 800w`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
const coverSizes = $derived(
|
||||
gridWidth > 0
|
||||
? `${Math.max(1, Math.round((gridWidth - (density.cols - 1) * GAP_PX) / density.cols))}px`
|
||||
: undefined
|
||||
);
|
||||
|
||||
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -53,6 +73,7 @@
|
||||
{/if}
|
||||
<ul
|
||||
class="grid gap-3"
|
||||
bind:clientWidth={gridWidth}
|
||||
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||
>
|
||||
{#each albums.list as album (album.id)}
|
||||
@@ -68,6 +89,8 @@
|
||||
{#if album.cover_thumb_url}
|
||||
<img
|
||||
src={album.cover_thumb_url}
|
||||
srcset={coverSrcset(album)}
|
||||
sizes={coverSizes}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,73 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` для мотивации. Технические факты, формирующие подход:
|
||||
|
||||
- Генерация вариантов — `photos/pipeline.go:buildVariants`: `thumb = Fit(200,200)`,
|
||||
`preview = Fit(1200,1200)`, WebP q82. Оба кропа вписывают по длинной стороне.
|
||||
- Метаданные имеют две реализации (Postgres и SQLite) с общей контрактной проверкой
|
||||
`internal/repo/contract`; SQL дублируется (`photos/store.go`, `photos/sqlite_store.go`).
|
||||
- Роли заданы жёстко: `photo_objects.role CHECK (role IN ('original','thumb','preview'))`
|
||||
(`migrations/000008`, `migrations/sqlite/000001`).
|
||||
- Варианты генерирует фоновый воркер из очереди `variant_jobs`; лишние объекты удаляются
|
||||
сборщиком мусора по отсутствию ссылок (`photos/worker.go`).
|
||||
- Фронтенд-сетка берёт только `thumb_url` (`PhotoGrid.svelte:78`); обложки — только
|
||||
`cover_thumb_url` (`albums/store.go` джойнит `role='thumb'`).
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Сетка фото и обзор альбомов показывают изображение без апскейла при любом выбранном размере.
|
||||
- Единое поведение на Postgres и SQLite; контрактные тесты покрывают новую роль.
|
||||
- Бэкфилл существующих фото без ручных операций.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Динамоческое ресайзирование на лету / image-proxy.
|
||||
- Иные форматы (AVIF) или согласование форматов.
|
||||
- Изменение `preview` (просмотрщик) и оригинала.
|
||||
- Замер точной плотности DPR-пикселей и «идеальный» выбор варианта — допускается запас.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Набор вариантов.** `thumb = Fill(200,200)` (квадрат), `medium = Fill(800,800)` (квадрат),
|
||||
`preview = Fit(1200,1200)`. Причина: плитка сетки квадратная (`aspect-square object-cover`),
|
||||
квадратный кроп исключает лишний апскейл короткой стороны; просмотрщик не квадратный, поэтому
|
||||
`preview` остаётся вписанным. Альтернатива — оставить `thumb` вписанным: отклонена, короткая
|
||||
сторона выходит < 200 и мылит уже мелкие плитки.
|
||||
- **Роль `medium` в `photo_objects`.** Роли маппятся 1:1 на поля API и на привязки; расширяем
|
||||
ограничение значением `medium`. Альтернатива — отдельная таблица вариантов: избыточна для
|
||||
одного размера.
|
||||
- **`srcset` + `sizes`.** Ширины `200w/800w/1200w`; `sizes` берётся из фактической ширины сетки
|
||||
(`bind:clientWidth` на `<ul>`, делённой на число колонок). Альтернатива — оценка по `100vw`:
|
||||
неточна из-за рейла и правой панели, ведёт к перекачке 1200.
|
||||
- **Бэкфилл через очередь.** Миграция делает `INSERT ... SELECT id FROM photos` в
|
||||
`variant_jobs`; воркер пересоздаёт роли и перепривязывает, старые объекты уходят в GC по
|
||||
refcount. Альтернатива — воркер, пропускающий уже готовые роли: требует версионирования
|
||||
вариантов; альтернатива — отдельный `cmd/backfill`: лишний код. Выбран самый простой путь.
|
||||
- **Обложка.** Джойн medium-варианта обложки с откатом на `thumb`, если medium ещё не готов;
|
||||
отдельного поля «preview обложки» не вводим.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Перегенерация `preview` впустую при бэкфилле] → воркер работает батчами и параллельно
|
||||
(настраивается); лишние объекты удаляются сборщиком; разовая стоимость.
|
||||
- [Рост хранилища на один объект 800px на фото] → WebP q82 скромного размера; приемлемо.
|
||||
- [Неточный `sizes`] → запас в пользу резкости; при отсутствии `medium_url` вариант просто не
|
||||
попадает в `srcset`, сетка падает на доступные (thumb/preview).
|
||||
- [SQLite не умеет `ALTER CHECK`] → миграция пересоздаёт `photo_objects`; на больших таблицах
|
||||
копирование строк. Приемлемо для текущего масштаба, выполняется в окне обслуживания.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Применить миграцию: расширить `CHECK` роли, налить `variant_jobs` для существующих фото.
|
||||
2. Выкатить код: воркер генерирует `medium` и квадратный `thumb`; API отдаёт новые поля.
|
||||
3. Воркер разбирает очередь; сетка показывает `srcset` с откатом на доступные варианты.
|
||||
4. Откат: сначала код (API перестаёт отдавать medium, воркер не генерирует); затем down-миграция
|
||||
восстанавливает `CHECK`, предварительно удалив привязки `medium`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Сетка миниатюр часто отображает изображение крупнее, чем единственный вариант `thumb` (200px),
|
||||
потому что между миниатюрой 200px и превью 1200px нет промежуточного размера. При увеличении
|
||||
плиток (малое число колонок) и на экранах с высокой плотностью пикселей фото расплывается;
|
||||
обложки альбомов в обзоре и вовсе отдают только 200px.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Добавить четвёртый вариант `medium` — квадратный кроп 800×800, WebP — и роль `medium` в
|
||||
журнале объектов. `thumb` перевести на квадратный кроп 200×200; `preview` остаётся вписанным
|
||||
1200×1200 для просмотрщика.
|
||||
- API фото SHALL отдавать `medium_url` рядом с `thumb_url`/`preview_url`; данные обложки
|
||||
альбома SHALL отдавать URL более крупного варианта обложки.
|
||||
- Сетка фото и обзор альбомов SHALL выбирать вариант под фактический размер плитки через
|
||||
`srcset` (200/800/1200) и `sizes`, а не всегда тянуть 200px.
|
||||
- Миграция расширяет ограничение роли `photo_objects` значением `medium` (Postgres и SQLite) и
|
||||
ставит в очередь перегенерацию вариантов для уже загруженных фото.
|
||||
- Из-за смены кропа у `thumb` существующие миниатюры перегенерируются (квадратными) вместе с
|
||||
добавлением `medium`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `photos`: требование «Варианты изображений» — четыре варианта, квадратный кроп grid-вариантов.
|
||||
- `media-objects`: требование «Журнал объектов» — роль `medium` в наборе ролей.
|
||||
- `albums`: требование «Отображение обложки альбома» — URL обложки крупнее миниатюры.
|
||||
- `ui-workspace`: новое требование — сетка и обзор альбомов подбирают вариант изображения под
|
||||
размер плитки.
|
||||
|
||||
## Impact
|
||||
|
||||
- Backend: `photos/{pipeline,worker,objects,models,service,store,sqlite_store}.go`,
|
||||
`albums/{models,store,sqlite_store,service}.go`, миграции Postgres и SQLite, бэкфилл
|
||||
`variant_jobs`, контрактные тесты `repo/contract`.
|
||||
- Frontend: `PhotoGrid.svelte` и обзор альбомов (`(app)/+page.svelte`).
|
||||
- API: новые поля `medium_url` (фото) и URL medium-обложки (альбом); существующие клиенты
|
||||
продолжают работать (аддитивно).
|
||||
- Хранилище: +1 производный объект (800px WebP) на фото; preview у существующих фото
|
||||
перегенерируется впустую и удаляется сборщиком мусора по ссылкам.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Отображение обложки альбома
|
||||
|
||||
Данные альбома SHALL включать URL обложки, когда обложка задана, чтобы клиенты могли
|
||||
показать её в списке, рейле и карточке; URL SHALL указывать на вариант, достаточный для
|
||||
крупного отображения, с откатом на миниатюру, пока крупный вариант не готов.
|
||||
|
||||
#### Scenario: Обложка задана
|
||||
|
||||
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
|
||||
- **THEN** ответ содержит URL обложки
|
||||
|
||||
#### Scenario: Обложка отсутствует
|
||||
|
||||
- **WHEN** у альбома нет обложки или фото обложки удалено
|
||||
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
|
||||
|
||||
#### Scenario: Крупный вариант ещё не готов
|
||||
|
||||
- **WHEN** у обложки готов только вариант-миниатюра
|
||||
- **THEN** ответ содержит URL миниатюры обложки
|
||||
@@ -0,0 +1,19 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Журнал объектов
|
||||
|
||||
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
|
||||
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
|
||||
ролям original, thumb, medium и preview.
|
||||
|
||||
#### Scenario: Привязка ролей
|
||||
|
||||
- **WHEN** фото загружено и обработано
|
||||
- **THEN** фото ссылается на объект-оригинал и, когда они готовы, на объекты thumb, medium и preview
|
||||
|
||||
#### Scenario: Оригинал без вариантов
|
||||
|
||||
- **WHEN** варианты ещё не сгенерированы
|
||||
- **THEN** фото доступно с оригиналом, а thumb, medium и preview отсутствуют
|
||||
@@ -0,0 +1,19 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Варианты изображений
|
||||
|
||||
Система SHALL генерировать четыре варианта: thumb (200×200, квадратный кроп), medium
|
||||
(800×800, квадратный кроп), preview (1200×1200, вписанный) и оригинал, в WebP, и извлекать
|
||||
EXIF (дата, координаты, размеры).
|
||||
|
||||
#### Scenario: Генерация вариантов
|
||||
|
||||
- **WHEN** фото загружено
|
||||
- **THEN** система асинхронно генерирует thumb, medium и preview и заполняет EXIF-поля
|
||||
|
||||
#### Scenario: Форма вариантов сетки
|
||||
|
||||
- **WHEN** генерируются thumb и medium
|
||||
- **THEN** они обрезаны до квадрата, а preview вписан с сохранением пропорций
|
||||
@@ -0,0 +1,23 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Резкость миниатюр
|
||||
|
||||
Сетка фото и обзор альбомов SHALL подбирать вариант изображения под фактический размер
|
||||
плитки, чтобы миниатюры не отображались крупнее доступного разрешения.
|
||||
|
||||
#### Scenario: Крупные плитки
|
||||
|
||||
- **WHEN** пользователь увеличивает миниатюры до крупных плиток
|
||||
- **THEN** показывается вариант изображения, достаточный для их размера, без расплывания
|
||||
|
||||
#### Scenario: Мелкие плитки
|
||||
|
||||
- **WHEN** миниатюры показаны мелкими плитками
|
||||
- **THEN** показывается компактный вариант, чтобы не загружать крупное изображение
|
||||
|
||||
#### Scenario: Вариант ещё не готов
|
||||
|
||||
- **WHEN** для фото доступен только оригинал или только миниатюра
|
||||
- **THEN** показывается доступный вариант, и сетка отображается корректно
|
||||
@@ -0,0 +1,37 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Варианты и роли
|
||||
|
||||
- [x] 1.1 `backend/internal/photos/pipeline.go`: `thumb` → `imaging.Fill(200,200)`, добавить `medium = imaging.Fill(800,800)`; `buildVariants` возвращает thumb, medium, preview — проверка: обновлённый тест генерации подтверждает квадратную форму thumb/medium и наличие preview.
|
||||
- [x] 1.2 `backend/internal/photos/objects.go`: `RoleMedium = "medium"`; `backend/internal/models/models.go`: `MediumKey`, `MediumURL` — проверка: `cd backend && go build ./...`.
|
||||
|
||||
## 2. Миграции и бэкфилл
|
||||
|
||||
- [x] 2.1 Postgres `backend/migrations/000011_*`: расширить `CHECK` роли `photo_objects` значением `medium`; налить `variant_jobs` для всех фото — проверка: `make migrate` применяется без ошибок на dev-БД.
|
||||
- [x] 2.2 SQLite `backend/migrations/sqlite/000003_*`: пересоздать `photo_objects` с новым `CHECK` (перенос строк) + бэкфилл `variant_jobs` — проверка: `cd backend && go test ./internal/db/...`.
|
||||
|
||||
## 3. Метаданные (Postgres и SQLite)
|
||||
|
||||
- [x] 3.1 Postgres `photos/store.go` + `photos/service.go`: JOIN/scan роли `medium`, `attachURLs` заполняет `MediumURL` — проверка: `make test` (с `TEST_DATABASE_URL`).
|
||||
- [x] 3.2 SQLite `photos/sqlite_store.go`: JOIN/scan роли `medium` — проверка: `make test`.
|
||||
- [x] 3.3 `albums` (models, `store.go`, `sqlite_store.go`, `service.go`): URL medium-обложки с откатом на `thumb` — проверка: `make test`.
|
||||
- [x] 3.4 `backend/internal/repo/contract`: тесты привязки роли `medium` и URL обложки для обоих бэкендов — проверка: `make test` (и с `TEST_DATABASE_URL`).
|
||||
|
||||
## 4. Генерация вариантов
|
||||
|
||||
- [x] 4.1 `backend/internal/photos/worker.go`: создавать и привязывать `medium` (порядок thumb → medium → preview); обновить `worker_test.go` (4 привязанных объекта, `MediumURL`) — проверка: `make test`.
|
||||
|
||||
## 5. Фронтенд
|
||||
|
||||
- [x] 5.1 `frontend/src/lib/types.ts`: добавить `medium_url`, `cover_medium_url` — проверка: `cd frontend && npm run check`.
|
||||
- [x] 5.2 `frontend/src/lib/components/PhotoGrid.svelte`: `srcset`/`sizes` по фактической ширине сетки (`bind:clientWidth`), откат на доступные варианты — проверка: `npm run dev` — крупные плитки резкие, мелкие тянут компактный вариант.
|
||||
- [x] 5.3 Обзор альбомов `frontend/src/routes/(app)/+page.svelte`: `srcset` обложки с откатом на миниатюру — проверка: `npm run dev` — обложки не мылят при крупном размере.
|
||||
|
||||
## 6. Интеграционная проверка
|
||||
|
||||
- [x] 6.1 `make test` и `cd frontend && npm run check && npm run build` проходят; вручную: резкость на всех размерах, обложки в обзоре, бэкфилл существующих фото не ломает сетку.
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Заархивировать change после выполнения требований ревью проекта (`/opsx-archive`).
|
||||
- Проверить результат после архивации.
|
||||
Reference in New Issue
Block a user