lens @ c51fb8cc8b850b4915e083d0dd2c30d79f8b632e

feat: Add (yet again) crude album implementation

This is a initial UI album implementation. This should cover the most
basic album navigation.

This is still plenty to do :)
diff --git a/Makefile b/Makefile
index 743c0fd2e391830b12b52616a9acd68b37ef9aa3..76c271a2565bfb16b476e67c6268b9c395441f5b 100644
--- a/Makefile
+++ b/Makefile
@@ -30,7 +30,7 @@
 compress_into_oblivion: build
 	upx --best --ultra-brute $(OUT)
 
-run: sass
+run: sass tmpl
 	$(GO_RUN) $(SERVER) \
 		--db-type=$(DB_TYPE) \
 		--db-con="$(DB_CON)" \
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 035d00aba84f2c6f54ce952f28c9b28b6b719e9e..daf5356263701e47f844e450c6a9d5d491bc7808 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -109,6 +109,7 @@ 		view.NewAuthView(userController),
 		view.NewFileSystemView(*fileSystemController, settingsRepository),
 		view.NewSettingsView(settingsRepository, userController),
 		view.NewMediaView(mediaRepository, userRepository, settingsRepository),
+		view.NewAlbumView(mediaRepository, userRepository, settingsRepository),
 	} {
 		v.SetMyselfIn(extRouter)
 	}
diff --git a/pkg/database/repository/media.go b/pkg/database/repository/media.go
index d6addbf7d2189814875083776bb38b422ed5aee3..9915c90c7f8a3ba35b115113c08ca8cdde69f29d 100644
--- a/pkg/database/repository/media.go
+++ b/pkg/database/repository/media.go
@@ -35,7 +35,9 @@ 		GPSLongitude    *float64
 	}
 
 	Album struct {
-		ID uint
+		ID   uint
+		Name string
+		Path string
 	}
 
 	MediaThumbnail struct {
@@ -43,9 +45,10 @@ 		Path string
 	}
 
 	Pagination struct {
-		Page int
-		Size int
-		Path string
+		Page    int
+		Size    int
+		AlbumID *uint
+		Path    string
 	}
 
 	CreateMedia struct {
@@ -83,8 +86,10 @@ 		GetThumbnail(context.Context, uint) (*MediaThumbnail, error)
 		CreateThumbnail(context.Context, uint, *MediaThumbnail) error
 
 		ListEmptyAlbums(context.Context, *Pagination) ([]*Media, error)
+		ListAlbums(context.Context, uint) ([]*Album, error)
 		ExistsAlbumByAbsolutePath(context.Context, string) (bool, error)
 		GetAlbumByAbsolutePath(context.Context, string) (*Album, error)
+		GetAlbum(context.Context, uint) (*Album, error)
 		CreateAlbum(context.Context, *CreateAlbum) (*Album, error)
 		CreateAlbumFile(context.Context, *CreateAlbumFile) error
 	}
diff --git a/pkg/database/sql/media.go b/pkg/database/sql/media.go
index 59e39eebc37dd9a80b8e8f5e9a2452d827e88740..4b48608a19f623d1b6021171ce0942fd88d10fbb 100644
--- a/pkg/database/sql/media.go
+++ b/pkg/database/sql/media.go
@@ -23,7 +23,7 @@ 	MediaEXIF struct {
 		gorm.Model
 		Width           *float64
 		Height          *float64
-		MediaID         uint
+		MediaID         uint `gorm:"not null"`
 		Media           Media
 		Description     *string
 		Camera          *string
@@ -43,8 +43,8 @@ 	}
 
 	MediaThumbnail struct {
 		gorm.Model
-		Path    string
-		MediaID uint
+		Path    string `gorm:"not null;unique"`
+		MediaID uint   `gorm:"not null"`
 		Media   Media
 	}
 
@@ -53,7 +53,7 @@ 		gorm.Model
 		ParentID *uint
 		Parent   *MediaAlbum
 		Name     string
-		Path     string
+		Path     string `gorm:"not null; unique"`
 	}
 
 	MediaAlbumFile struct {
@@ -104,7 +104,9 @@ }
 
 func (a *MediaAlbum) ToModel() *repository.Album {
 	return &repository.Album{
-		ID: a.ID,
+		ID:   a.ID,
+		Name: a.Name,
+		Path: a.Path,
 	}
 }
 
@@ -407,6 +409,22 @@
 	return m.ToModel(), nil
 }
 
+func (r *MediaRepository) GetAlbum(ctx context.Context, albumID uint) (*repository.Album, error) {
+	m := &MediaAlbum{}
+	result := r.db.
+		WithContext(ctx).
+		Model(&MediaAlbum{}).
+		Where("id = ?", albumID).
+		Limit(1).
+		Take(m)
+
+	if result.Error != nil {
+		return nil, result.Error
+	}
+
+	return m.ToModel(), nil
+}
+
 func (m *MediaRepository) CreateAlbum(ctx context.Context, createAlbum *repository.CreateAlbum) (*repository.Album, error) {
 	album := &MediaAlbum{
 		ParentID: createAlbum.ParentID,
@@ -439,3 +457,21 @@ 	}
 
 	return nil
 }
+
+func (m *MediaRepository) ListAlbums(ctx context.Context, albumID uint) ([]*repository.Album, error) {
+	albums := make([]*MediaAlbum, 0)
+
+	result := m.db.
+		WithContext(ctx).
+		Model(&MediaAlbum{}).
+		Where("parent_id = ?", albumID).
+		Find(&albums)
+
+	if result.Error != nil {
+		return nil, result.Error
+	}
+
+	return list.Map(albums, func(a *MediaAlbum) *repository.Album {
+		return a.ToModel()
+	}), nil
+}
diff --git a/pkg/view/album.go b/pkg/view/album.go
new file mode 100644
index 0000000000000000000000000000000000000000..a96b9bd89530fbb8e557a68e2c4fb2f299784748
--- /dev/null
+++ b/pkg/view/album.go
@@ -0,0 +1,102 @@
+package view
+
+import (
+	"net/http"
+
+	"git.sr.ht/~gabrielgio/img/pkg/database/repository"
+	"git.sr.ht/~gabrielgio/img/pkg/ext"
+	"git.sr.ht/~gabrielgio/img/templates"
+)
+
+type (
+	AlbumView struct {
+		mediaRepository    repository.MediaRepository
+		userRepository     repository.UserRepository
+		settingsRepository repository.SettingsRepository
+	}
+)
+
+func NewAlbumView(
+	mediaRepository repository.MediaRepository,
+	userRepository repository.UserRepository,
+	settingsRepository repository.SettingsRepository,
+) *AlbumView {
+	return &AlbumView{
+		mediaRepository:    mediaRepository,
+		userRepository:     userRepository,
+		settingsRepository: settingsRepository,
+	}
+}
+
+func (self *AlbumView) Index(w http.ResponseWriter, r *http.Request) error {
+	p := getPagination(r)
+	token := ext.GetTokenFromCtx(w, r)
+
+	// TODO: optmize call, GetPathFromUserID may no be necessary
+	userPath, err := self.userRepository.GetPathFromUserID(r.Context(), token.UserID)
+	if err != nil {
+		return err
+	}
+
+	var albums []*repository.Album
+	var album *repository.Album
+
+	if p.AlbumID == nil {
+		// use user path as default value
+		p.Path = userPath
+
+		album, err = self.mediaRepository.GetAlbumByAbsolutePath(r.Context(), p.Path)
+		if err != nil {
+			return err
+		}
+
+		albums, err = self.mediaRepository.ListAlbums(r.Context(), album.ID)
+		if err != nil {
+			return err
+		}
+	} else {
+		album, err = self.mediaRepository.GetAlbum(r.Context(), *p.AlbumID)
+		if err != nil {
+			return err
+		}
+
+		// TODO: User can enter a album out of its bounderies
+		p.Path = album.Path
+
+		albums, err = self.mediaRepository.ListAlbums(r.Context(), *p.AlbumID)
+		if err != nil {
+			return err
+		}
+
+	}
+
+	medias, err := self.mediaRepository.List(r.Context(), p)
+	if err != nil {
+		return err
+	}
+
+	settings, err := self.settingsRepository.Load(r.Context())
+	if err != nil {
+		return err
+	}
+
+	page := &templates.AlbumPage{
+		Medias: medias,
+		Albums: albums,
+		Name:   album.Name,
+		Next: &repository.Pagination{
+			Size: p.Size,
+			Page: p.Page + 1,
+		},
+		Settings: settings,
+	}
+
+	templates.WritePageTemplate(w, page)
+
+	return nil
+}
+
+func (self *AlbumView) SetMyselfIn(r *ext.Router) {
+	r.GET("/album/", self.Index)
+	r.POST("/album/", self.Index)
+}
diff --git a/pkg/view/media.go b/pkg/view/media.go
index c7d84ec9e8346627ef2b13fab1bc7e0ec949a2d5..3124119f901e683cddd810c4694584937b5c859f 100644
--- a/pkg/view/media.go
+++ b/pkg/view/media.go
@@ -17,12 +17,14 @@ 		settingsRepository repository.SettingsRepository
 	}
 )
 
-func getPagination(w http.ResponseWriter, r *http.Request) *repository.Pagination {
+func getPagination(r *http.Request) *repository.Pagination {
 	var (
-		size    int
-		page    int
-		sizeStr = r.FormValue("size")
-		pageStr = r.FormValue("page")
+		size       int
+		page       int
+		albumID    *uint
+		sizeStr    = r.FormValue("size")
+		pageStr    = r.FormValue("page")
+		albumIDStr = r.FormValue("albumId")
 	)
 
 	if sizeStr == "" {
@@ -41,9 +43,17 @@ 	} else {
 		page = p
 	}
 
+	if albumIDStr == "" {
+		page = 0
+	} else if p, err := strconv.Atoi(albumIDStr); err == nil {
+		id := uint(p)
+		albumID = &id
+	}
+
 	return &repository.Pagination{
-		Page: page,
-		Size: size,
+		Page:    page,
+		Size:    size,
+		AlbumID: albumID,
 	}
 }
 
@@ -60,7 +70,7 @@ 	}
 }
 
 func (self *MediaView) Index(w http.ResponseWriter, r *http.Request) error {
-	p := getPagination(w, r)
+	p := getPagination(r)
 	token := ext.GetTokenFromCtx(w, r)
 
 	userPath, err := self.userRepository.GetPathFromUserID(r.Context(), token.UserID)
diff --git a/pkg/worker/scanner/album_scanner.go b/pkg/worker/scanner/album_scanner.go
index 618a184d157d6fba7abfcf3e64823107ddba0583..04af9bceac2ed534d76640f3682c558ade815986 100644
--- a/pkg/worker/scanner/album_scanner.go
+++ b/pkg/worker/scanner/album_scanner.go
@@ -92,6 +92,7 @@ func FanInwards(paths []string) []string {
 	result := make([]string, 0, len(paths))
 	for i := (len(paths) - 1); i >= 0; i-- {
 		subPaths := paths[0:i]
+		subPaths = append([]string{"/"}, subPaths...)
 		result = append(result, path.Join(subPaths...))
 	}
 	return result
diff --git a/templates/album.qtpl b/templates/album.qtpl
new file mode 100644
index 0000000000000000000000000000000000000000..ce8111e926fdfff1f11d2947d5822bb7b1ef09c2
--- /dev/null
+++ b/templates/album.qtpl
@@ -0,0 +1,50 @@
+{% import "git.sr.ht/~gabrielgio/img/pkg/database/repository" %}
+
+{% code
+type AlbumPage struct {
+	Medias   []*repository.Media
+	Next     *repository.Pagination
+	Settings *repository.Settings
+	Albums   []*repository.Album
+    Name     string
+}
+
+func (m *AlbumPage) PreloadAttr() string {
+    if m.Settings.PreloadVideoMetadata {
+        return "metadata"
+    }
+    return "none"
+}
+%}
+
+{% func (p *AlbumPage) Title() %}Media{% endfunc %}
+
+{% func (p *AlbumPage) Content() %}
+<h1 class="title">{%s p.Name %}</h1>
+<div class="tags are-large">
+{% for _, a := range p.Albums %}
+  <a href="/album/?albumId={%s FromUInttoString(&a.ID) %}" class="tag">{%s a.Name %}</a>
+{% endfor %}
+</div>
+<div class="columns is-multiline">
+{% for _, media := range p.Medias %}
+    <div class="card-image">
+       {% if media.IsVideo() %}
+       <video class="image is-fit" controls muted="true" poster="/media/thumbnail/?path_hash={%s media.PathHash %}" preload="{%s p.PreloadAttr() %}">
+           <source src="/media/image/?path_hash={%s media.PathHash %}" type="{%s media.MIMEType %}">
+       </video>
+       {% else %}
+        <figure class="image is-fit">
+            <img src="/media/thumbnail/?path_hash={%s media.PathHash %}">
+        </figure>
+        {% endif %}
+    </div>
+{% endfor %}
+</div>
+<div class="row">
+    <a href="/media/?page={%d p.Next.Page %}" class="button is-pulled-right">next</a>
+</div>
+{% endfunc %}
+
+{% func (p *AlbumPage) Script() %}
+{% endfunc %}
diff --git a/templates/base.qtpl b/templates/base.qtpl
index 5a7c3b769e7767d77cb6fe18985a98d41d5e39f2..772167d70fbbcb8c1374c2f10243122c33085a25 100644
--- a/templates/base.qtpl
+++ b/templates/base.qtpl
@@ -11,8 +11,7 @@ }
 
 %}
 
-{% code 
-    func FromUInttoString(u *uint) string {
+{% code func FromUInttoString(u *uint) string {
         if u != nil {
             return strconv.FormatUint(uint64(*u), 10)
         }
@@ -39,6 +38,9 @@                     files
                 </a>
                 <a href="/media/" class="navbar-item">
                     media
+                </a>
+                <a href="/album/" class="navbar-item">
+                    album
                 </a>
                 <a href="/settings/" class="navbar-item">
                     settings