feat(handlers): add file icons with standard and retro variants

Introduce file icon support to the file browser. Icons are loaded on
startup and mapped based on file name and content type.

- Load file icon mappings in the App handler initialization.
- Add `HasThumbnail`, `IconURL`, and `IconRetroURL` to the file view.
- Update CSS to support displaying file icons alongside thumbnails.
- Add retro theme support to swap standard icons with pixelated retro
  variants when the retro theme is active.
This commit is contained in:
2026-06-02 13:02:51 +03:00
parent 6c87187c6d
commit 8e3f783780
33 changed files with 594 additions and 53 deletions

View File

@@ -20,9 +20,14 @@ type App struct {
banService *services.BanService
rateLimiter *rateLimiter
uploadGroups *uploadGrouper
fileIcons *fileIconSet
}
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, reactionService *services.ReactionService, banService *services.BanService) *App {
fileIcons, err := loadFileIcons(cfg.StaticDir)
if err != nil {
logger.Warn("failed to load file icon map", "source", "handlers", "severity", "warn", "error", err.Error())
}
return &App{
cfg: cfg,
logger: logger,
@@ -34,6 +39,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
banService: banService,
rateLimiter: newRateLimiter(),
uploadGroups: newUploadGrouper(),
fileIcons: fileIcons,
}
}

View File

@@ -45,6 +45,9 @@ type fileView struct {
URL string
DownloadURL string
ThumbnailURL string
HasThumbnail bool
IconURL string
IconRetroURL string
ReactURL string
Reactions []reactionView
Reacted bool
@@ -350,6 +353,7 @@ func (a *App) fileView(box services.Box, file services.File) fileView {
}
func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView {
icon := a.fileIcons.lookup(file.Name, file.ContentType)
return fileView{
ID: file.ID,
Name: file.Name,
@@ -359,6 +363,9 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "",
IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
Reactions: a.reactionViews(reactions),
Reacted: reacted,

View File

@@ -0,0 +1,152 @@
package handlers
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// fileIcon holds the two icon filenames for a file type: the standard (modern)
// icon and the retro (Win98) icon. The filenames are resolved against
// static/file-icons/standard and static/file-icons/retro respectively.
type fileIcon struct {
Standard string `json:"standard"`
Retro string `json:"retro"`
}
type iconType struct {
Mime string `json:"mime"`
Standard string `json:"standard"`
Retro string `json:"retro"`
Extensions []string `json:"extensions"`
}
type iconMapFile struct {
Default iconType `json:"default"`
Types []iconType `json:"types"`
}
type mimeRule struct {
pattern string // exact mime ("application/pdf") or major prefix ("image/")
prefix bool
icon fileIcon
}
// fileIconSet is the loaded icon map: an extension lookup plus content-type
// rules and a fallback. It is built once at startup from icon-map.json.
type fileIconSet struct {
byExt map[string]fileIcon
byMime []mimeRule
fallback fileIcon
}
// loadFileIcons reads static/file-icons/icon-map.json and indexes it by
// extension and content type so icons can be assigned at render time.
func loadFileIcons(staticDir string) (*fileIconSet, error) {
data, err := os.ReadFile(filepath.Join(staticDir, "file-icons", "icon-map.json"))
if err != nil {
return nil, err
}
var raw iconMapFile
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
set := &fileIconSet{
byExt: make(map[string]fileIcon),
fallback: fileIcon{Standard: raw.Default.Standard, Retro: raw.Default.Retro},
}
if err := validateFileIcon(staticDir, set.fallback); err != nil {
return nil, err
}
for _, t := range raw.Types {
icon := fileIcon{Standard: t.Standard, Retro: t.Retro}
if err := validateFileIcon(staticDir, icon); err != nil {
return nil, err
}
for _, ext := range t.Extensions {
set.byExt[strings.ToLower(strings.TrimPrefix(ext, "."))] = icon
}
if t.Mime == "" {
continue
}
if strings.HasSuffix(t.Mime, "/*") {
set.byMime = append(set.byMime, mimeRule{pattern: strings.TrimSuffix(t.Mime, "*"), prefix: true, icon: icon})
} else {
set.byMime = append(set.byMime, mimeRule{pattern: strings.ToLower(t.Mime), icon: icon})
}
}
return set, nil
}
func validateFileIcon(staticDir string, icon fileIcon) error {
if icon.Standard != "" {
if err := validateFileIconPath(staticDir, "standard", icon.Standard); err != nil {
return err
}
}
if icon.Retro != "" {
if err := validateFileIconPath(staticDir, "retro", icon.Retro); err != nil {
return err
}
}
return nil
}
func validateFileIconPath(staticDir, theme, name string) error {
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
return fmt.Errorf("invalid %s file icon path %q", theme, name)
}
path := filepath.Join(staticDir, "file-icons", theme, name)
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("missing %s file icon %q: %w", theme, name, err)
}
if info.IsDir() {
return fmt.Errorf("%s file icon %q is a directory", theme, name)
}
return nil
}
// lookup resolves a file's icon from its name (extension) first, falling back to
// its content type, then to the default icon. Extension wins because stored
// content types are often the generic application/octet-stream.
func (s *fileIconSet) lookup(name, contentType string) fileIcon {
if s == nil {
return fileIcon{}
}
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")); ext != "" {
if icon, ok := s.byExt[ext]; ok {
return icon
}
}
ct := strings.ToLower(strings.TrimSpace(contentType))
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
if ct != "" && ct != "application/octet-stream" {
for _, rule := range s.byMime { // exact matches first
if !rule.prefix && rule.pattern == ct {
return rule.icon
}
}
for _, rule := range s.byMime { // then major-type prefixes
if rule.prefix && strings.HasPrefix(ct, rule.pattern) {
return rule.icon
}
}
}
return s.fallback
}
// fileIconURL builds the /static URL for an icon filename in the given theme
// directory ("standard" or "retro").
func fileIconURL(theme, name string) string {
if name == "" {
return ""
}
return "/static/file-icons/" + theme + "/" + name
}

View File

@@ -0,0 +1,54 @@
package handlers
import (
"path/filepath"
"testing"
)
func TestFileIconMapLoadsAndResolvesCommonTypes(t *testing.T) {
icons, err := loadFileIcons(filepath.Join("..", "..", "static"))
if err != nil {
t.Fatalf("loadFileIcons returned error: %v", err)
}
tests := []struct {
name string
contentType string
wantStandard string
wantRetro string
}{
{
name: "photo.jpg",
contentType: "application/octet-stream",
wantStandard: "image-document-svgrepo-com.svg",
wantRetro: "shimgvw.dll_14_1-2.png",
},
{
name: "movie.mkv",
contentType: "",
wantStandard: "video-document-svgrepo-com.svg",
wantRetro: "wmploc.dll_14_504-2.png",
},
{
name: "archive.7z",
contentType: "",
wantStandard: "zip-document-svgrepo-com.svg",
wantRetro: "zipfldr.dll_14_101-2.png",
},
{
name: "unknown.bin",
contentType: "application/octet-stream",
wantStandard: "txt-document-svgrepo-com.svg",
wantRetro: "shell32.dll_14_152-2.png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := icons.lookup(tt.name, tt.contentType)
if got.Standard != tt.wantStandard || got.Retro != tt.wantRetro {
t.Fatalf("lookup returned %+v, want standard=%q retro=%q", got, tt.wantStandard, tt.wantRetro)
}
})
}
}