feat: Lightroom-style workspace UI #2
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"shared-album/internal/config"
|
||||
"shared-album/internal/db"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
loadDotEnv(".env")
|
||||
loadDotEnv("../.env")
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
slog.Error("config error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
|
||||
if err != nil {
|
||||
slog.Error("database connect failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
store, err := storage.New(ctx, cfg.S3)
|
||||
if err != nil {
|
||||
slog.Error("storage init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
photoStore := photos.NewStore(pool)
|
||||
updated, err := photoStore.BackfillEXIF(ctx, store.Get)
|
||||
if err != nil {
|
||||
slog.Error("backfill failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("exif backfill complete", "updated", updated)
|
||||
}
|
||||
|
||||
func loadDotEnv(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -9,13 +9,13 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
|
||||
github.com/aws/smithy-go v1.28.5
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/gen2brain/webp v0.6.4
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.20.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||
golang.org/x/crypto v0.58.0
|
||||
)
|
||||
|
||||
@@ -37,14 +37,18 @@ require (
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-errors/errors v1.4.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -68,4 +72,5 @@ require (
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
+55
-2
@@ -68,6 +68,23 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc=
|
||||
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
|
||||
github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
|
||||
github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
@@ -80,6 +97,11 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -100,6 +122,10 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
|
||||
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -115,10 +141,16 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
@@ -155,8 +187,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -189,23 +221,44 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -76,6 +76,7 @@ type Photo struct {
|
||||
Latitude *float64 `json:"latitude,omitempty"`
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
Caption string `json:"caption"`
|
||||
EXIF map[string]any `json:"exif,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Uploader PublicUser `json:"uploader"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
|
||||
@@ -2,12 +2,16 @@ package photos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
exif "github.com/dsoprea/go-exif/v3"
|
||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gen2brain/webp"
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
)
|
||||
|
||||
type ImageMeta struct {
|
||||
@@ -29,23 +33,90 @@ func inspect(data []byte) (width, height int, ok bool) {
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
|
||||
x, err := exif.Decode(bytes.NewReader(data))
|
||||
type exifInfo struct {
|
||||
TakenAt *time.Time
|
||||
Latitude *float64
|
||||
Longitude *float64
|
||||
Attributes map[string]any
|
||||
}
|
||||
|
||||
func collectTags(ifd *exif.Ifd, dst map[string]any) {
|
||||
if ifd == nil {
|
||||
return
|
||||
}
|
||||
for _, ite := range ifd.Entries() {
|
||||
name := ite.TagName()
|
||||
if name == "" || strings.HasPrefix(name, "UnknownTag") {
|
||||
continue
|
||||
}
|
||||
if _, exists := dst[name]; exists {
|
||||
continue
|
||||
}
|
||||
s, err := ite.FormatFirst()
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
continue
|
||||
}
|
||||
if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
|
||||
if s, err := dt.StringVal(); err == nil {
|
||||
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
|
||||
s = strings.TrimSpace(strings.TrimRight(s, "\x00"))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
dst[name] = normalizeExifValue(s)
|
||||
}
|
||||
for _, child := range ifd.Children() {
|
||||
collectTags(child, dst)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExifValue(s string) any {
|
||||
base := strings.TrimSuffix(s, "...")
|
||||
if i := strings.IndexByte(base, '/'); i > 0 {
|
||||
num, err1 := strconv.ParseInt(strings.TrimSpace(base[:i]), 10, 64)
|
||||
den, err2 := strconv.ParseInt(strings.TrimSpace(base[i+1:]), 10, 64)
|
||||
if err1 == nil && err2 == nil && den != 0 {
|
||||
return float64(num) / float64(den)
|
||||
}
|
||||
}
|
||||
if n, err := strconv.ParseInt(base, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
if f, err := strconv.ParseFloat(base, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func extractEXIF(data []byte) exifInfo {
|
||||
out := exifInfo{Attributes: map[string]any{}}
|
||||
raw, err := exif.SearchAndExtractExif(data)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
im, err := exifcommon.NewIfdMappingWithStandard()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
ti := exif.NewTagIndex()
|
||||
_, index, err := exif.Collect(im, ti, raw)
|
||||
if err != nil || index.RootIfd == nil {
|
||||
return out
|
||||
}
|
||||
collectTags(index.RootIfd, out.Attributes)
|
||||
if v, ok := out.Attributes["DateTimeOriginal"]; ok {
|
||||
if t, err := time.Parse("2006:01:02 15:04:05", fmt.Sprintf("%v", v)); err == nil {
|
||||
utc := t.UTC()
|
||||
taken = &utc
|
||||
out.TakenAt = &utc
|
||||
}
|
||||
}
|
||||
if gi, err := index.RootIfd.GpsInfo(); err == nil {
|
||||
lat := gi.Latitude.Decimal()
|
||||
lon := gi.Longitude.Decimal()
|
||||
out.Latitude, out.Longitude = &lat, &lon
|
||||
}
|
||||
if la, lo, err := x.LatLong(); err == nil {
|
||||
lat, lon = &la, &lo
|
||||
return out
|
||||
}
|
||||
return taken, lat, lon
|
||||
|
||||
func ExtractEXIFAttributes(data []byte) map[string]any {
|
||||
return extractEXIF(data).Attributes
|
||||
}
|
||||
|
||||
func buildVariants(data []byte) (thumb, preview []byte, err error) {
|
||||
|
||||
@@ -62,7 +62,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
||||
if !ok {
|
||||
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
||||
}
|
||||
taken, lat, lon := extractEXIF(in.Data)
|
||||
meta := extractEXIF(in.Data)
|
||||
|
||||
id := auth.NewID()
|
||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
||||
@@ -85,9 +85,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
||||
Width: width,
|
||||
Height: height,
|
||||
MimeType: mime,
|
||||
TakenAt: taken,
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
TakenAt: meta.TakenAt,
|
||||
Latitude: meta.Latitude,
|
||||
Longitude: meta.Longitude,
|
||||
EXIF: meta.Attributes,
|
||||
}
|
||||
if err := s.store.Create(ctx, p); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2,6 +2,7 @@ package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
@@ -26,33 +27,50 @@ const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
||||
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2)`
|
||||
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
|
||||
COALESCE(p.exif, '{}'::jsonb)::text`
|
||||
|
||||
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
var exifText string
|
||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &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,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe)
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func setExif(p *models.Photo, raw string) {
|
||||
if raw == "" || raw == "null" || raw == "{}" {
|
||||
return
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 {
|
||||
p.EXIF = m
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
|
||||
exifJSON, err := json.Marshal(p.EXIF)
|
||||
if err != nil || len(exifJSON) == 0 {
|
||||
exifJSON = []byte("{}")
|
||||
}
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
|
||||
size, width, height, mime_type, taken_at, latitude, longitude, caption)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
|
||||
RETURNING created_at`,
|
||||
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
|
||||
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption,
|
||||
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
|
||||
).Scan(&p.CreatedAt)
|
||||
}
|
||||
|
||||
@@ -98,14 +116,16 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
var p models.Photo
|
||||
var exifText string
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &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,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -138,3 +158,59 @@ func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type backfillRow struct {
|
||||
ID string
|
||||
StorageKey string
|
||||
}
|
||||
|
||||
func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, storage_key FROM photos
|
||||
WHERE exif IS NULL OR exif = '{}'::jsonb
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var pending []backfillRow
|
||||
for rows.Next() {
|
||||
var r backfillRow
|
||||
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
pending = append(pending, r)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, r := range pending {
|
||||
data, err := fetch(ctx, r.StorageKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
meta := extractEXIF(data)
|
||||
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
|
||||
continue
|
||||
}
|
||||
attrs, err := json.Marshal(meta.Attributes)
|
||||
if err != nil || len(attrs) == 0 {
|
||||
attrs = []byte("{}")
|
||||
}
|
||||
_, err = s.pool.Exec(ctx,
|
||||
`UPDATE photos SET exif = $2::jsonb,
|
||||
taken_at = COALESCE(taken_at, $3),
|
||||
latitude = COALESCE(latitude, $4),
|
||||
longitude = COALESCE(longitude, $5)
|
||||
WHERE id = $1`,
|
||||
r.ID, string(attrs), meta.TakenAt, meta.Latitude, meta.Longitude)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -122,8 +122,23 @@ func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
return c.Delete(ctx, keys...)
|
||||
}
|
||||
|
||||
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) {
|
||||
out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get object %s: %w", key, err)
|
||||
}
|
||||
defer out.Body.Close()
|
||||
data, err := io.ReadAll(out.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read object %s: %w", key, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(c.presignTTL))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE photos DROP COLUMN IF EXISTS exif;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -26,6 +26,206 @@
|
||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||
const canRename = $derived(!!photo && (canEdit || photo.uploader_id === auth.user?.id));
|
||||
|
||||
const exif = $derived((photo?.exif ?? {}) as Record<string, unknown>);
|
||||
|
||||
const EXIF_LABELS: Record<string, string> = {
|
||||
Make: 'Производитель',
|
||||
Model: 'Модель',
|
||||
LensMake: 'Производитель объектива',
|
||||
LensModel: 'Объектив',
|
||||
ExposureTime: 'Выдержка',
|
||||
ShutterSpeedValue: 'Выдержка (APEX)',
|
||||
FNumber: 'Диафрагма',
|
||||
ApertureValue: 'Диафрагма (APEX)',
|
||||
MaxApertureValue: 'Макс. диафрагма',
|
||||
FocalLength: 'Фокусное расстояние',
|
||||
FocalLengthIn35mmFilm: 'Фокусное (35 мм)',
|
||||
ISOSpeedRatings: 'ISO',
|
||||
PhotographicSensitivity: 'ISO',
|
||||
ExposureProgram: 'Программа съёмки',
|
||||
ExposureMode: 'Режим экспозиции',
|
||||
ExposureBiasValue: 'Экспокоррекция',
|
||||
BrightnessValue: 'Яркость (APEX)',
|
||||
MeteringMode: 'Режим замера',
|
||||
LightSource: 'Источник света',
|
||||
Flash: 'Вспышка',
|
||||
WhiteBalance: 'Баланс белого',
|
||||
Orientation: 'Ориентация',
|
||||
Software: 'ПО',
|
||||
ColorSpace: 'Цветовое пространство',
|
||||
SceneCaptureType: 'Тип сцены',
|
||||
Contrast: 'Контраст',
|
||||
Saturation: 'Насыщенность',
|
||||
Sharpness: 'Резкость',
|
||||
CustomRendered: 'Обработка',
|
||||
GainControl: 'Усиление',
|
||||
SensingMethod: 'Метод съёмки',
|
||||
SubjectDistanceRange: 'Дистанция',
|
||||
DigitalZoomRatio: 'Цифровой зум',
|
||||
CompressedBitsPerPixel: 'Сжатие (бит/пиксель)',
|
||||
ImageDescription: 'Описание'
|
||||
};
|
||||
const EXIF_SKIP = new Set([
|
||||
'GPSLatitude',
|
||||
'GPSLongitude',
|
||||
'GPSLatitudeRef',
|
||||
'GPSLongitudeRef',
|
||||
'GPSAltitude',
|
||||
'GPSAltitudeRef',
|
||||
'GPSVersionID',
|
||||
'GPSDateStamp',
|
||||
'GPSTimeStamp',
|
||||
'GPSProcessingMethod',
|
||||
'GPSAreaInformation',
|
||||
'GPSImgDirection',
|
||||
'GPSImgDirectionRef',
|
||||
'GPSDestBearing',
|
||||
'GPSDestBearingRef',
|
||||
'GPSSpeed',
|
||||
'GPSSpeedRef',
|
||||
'GPSTrack',
|
||||
'GPSTrackRef',
|
||||
'GPSMapDatum',
|
||||
'GPSDifferential',
|
||||
'DateTime',
|
||||
'DateTimeDigitized',
|
||||
'DateTimeOriginal',
|
||||
'SubSecTime',
|
||||
'SubSecTimeDigitized',
|
||||
'SubSecTimeOriginal',
|
||||
'ExifVersion',
|
||||
'FlashpixVersion',
|
||||
'InteroperabilityVersion',
|
||||
'InteroperabilityIndex',
|
||||
'ExifTag',
|
||||
'GPSTag',
|
||||
'InteroperabilityTag',
|
||||
'ExifIFDPointer',
|
||||
'GPSInfoIFDPointer',
|
||||
'ComponentsConfiguration',
|
||||
'MakerNote',
|
||||
'UserComment',
|
||||
'FileSource',
|
||||
'SceneType',
|
||||
'PhotometricInterpretation',
|
||||
'YCbCrPositioning',
|
||||
'ResolutionUnit',
|
||||
'BitsPerSample',
|
||||
'XResolution',
|
||||
'YResolution',
|
||||
'ImageWidth',
|
||||
'ImageLength',
|
||||
'PixelXDimension',
|
||||
'PixelYDimension'
|
||||
]);
|
||||
|
||||
const EXIF_ENUMS: Record<string, Record<number, string>> = {
|
||||
Orientation: {
|
||||
1: 'Горизонтальная',
|
||||
2: 'Зеркальная (гориз.)',
|
||||
3: 'Повёрнута на 180°',
|
||||
4: 'Зеркальная (верт.)',
|
||||
5: 'Зеркальная + 90° CW',
|
||||
6: 'Повёрнута на 90° CW',
|
||||
7: 'Зеркальная + 90° CCW',
|
||||
8: 'Повёрнута на 90° CCW'
|
||||
},
|
||||
ExposureProgram: {
|
||||
0: 'Не задано',
|
||||
1: 'Ручная',
|
||||
2: 'Программная',
|
||||
3: 'Приоритет диафрагмы',
|
||||
4: 'Приоритет выдержки',
|
||||
5: 'Творческая',
|
||||
6: 'Спортивная',
|
||||
7: 'Портрет',
|
||||
8: 'Пейзаж'
|
||||
},
|
||||
ExposureMode: { 0: 'Авто', 1: 'Ручной', 2: 'Авто-брекетинг' },
|
||||
MeteringMode: {
|
||||
0: 'Неизвестно',
|
||||
1: 'Средневзвешенный',
|
||||
2: 'Центрально-взвешенный',
|
||||
3: 'Точечный',
|
||||
4: 'Многозонный',
|
||||
5: 'Матричный',
|
||||
6: 'Частичный',
|
||||
255: 'Другое'
|
||||
},
|
||||
LightSource: {
|
||||
0: 'Неизвестно',
|
||||
1: 'Дневной свет',
|
||||
2: 'Люминесцентный',
|
||||
3: 'Лампа накаливания',
|
||||
4: 'Вспышка',
|
||||
9: 'Хорошая погода',
|
||||
10: 'Облачно',
|
||||
11: 'Тень'
|
||||
},
|
||||
Flash: {
|
||||
0: 'Не сработала',
|
||||
1: 'Сработала',
|
||||
9: 'Сработала (принуд.)',
|
||||
16: 'Отключена',
|
||||
24: 'Не сработала (авто)',
|
||||
25: 'Сработала (авто)'
|
||||
},
|
||||
WhiteBalance: { 0: 'Авто', 1: 'Ручной' },
|
||||
SceneCaptureType: { 0: 'Стандартный', 1: 'Пейзаж', 2: 'Портрет', 3: 'Ночной' },
|
||||
Contrast: { 0: 'Обычный', 1: 'Мягкий', 2: 'Жёсткий' },
|
||||
Saturation: { 0: 'Обычная', 1: 'Низкая', 2: 'Высокая' },
|
||||
Sharpness: { 0: 'Обычная', 1: 'Мягкая', 2: 'Жёсткая' },
|
||||
CustomRendered: { 0: 'Обычная', 1: 'Особая' },
|
||||
GainControl: {
|
||||
0: 'Нет',
|
||||
1: 'Малое усиление',
|
||||
2: 'Большое усиление',
|
||||
3: 'Низкое усиление',
|
||||
4: 'Высокое усиление'
|
||||
},
|
||||
SensingMethod: {
|
||||
1: 'Не задано',
|
||||
2: 'Однокристальный',
|
||||
3: 'Двухчиповый',
|
||||
4: 'Трёхчиповый',
|
||||
5: 'Цветной последовательный',
|
||||
7: 'Трёхлинейный',
|
||||
8: 'Цветной последовательный'
|
||||
},
|
||||
SubjectDistanceRange: { 0: 'Неизвестно', 1: 'Макро', 2: 'Близко', 3: 'Далеко' },
|
||||
ColorSpace: { 1: 'sRGB', 65535: 'Некалиброванное' }
|
||||
};
|
||||
|
||||
function fmtExif(key: string, value: unknown): string {
|
||||
if (value == null) return '';
|
||||
const n = Number(value);
|
||||
if (key === 'ExposureTime' && !Number.isNaN(n) && n > 0) {
|
||||
return n >= 1 ? `${n} с` : `1/${Math.round(1 / n)}`;
|
||||
}
|
||||
if (
|
||||
(key === 'FNumber' || key === 'ApertureValue' || key === 'MaxApertureValue') &&
|
||||
!Number.isNaN(n)
|
||||
) {
|
||||
return `f/${n}`;
|
||||
}
|
||||
if ((key === 'FocalLength' || key === 'FocalLengthIn35mmFilm') && !Number.isNaN(n)) {
|
||||
return `${n} мм`;
|
||||
}
|
||||
if (key === 'ExposureBiasValue' && !Number.isNaN(n)) {
|
||||
return `${n > 0 ? '+' : ''}${n} EV`;
|
||||
}
|
||||
const enums = EXIF_ENUMS[key];
|
||||
if (enums && !Number.isNaN(n) && enums[n] != null) return enums[n];
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const exifRows = $derived(
|
||||
Object.entries(exif)
|
||||
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
|
||||
.map(([k, v]) => ({ key: k, label: EXIF_LABELS[k] ?? k, value: fmtExif(k, v) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const id = albumId;
|
||||
error = '';
|
||||
@@ -170,6 +370,12 @@
|
||||
<span>Разрешение</span>
|
||||
<span class="text-text">{photo.width}×{photo.height}</span>
|
||||
</div>
|
||||
{#each exifRows as row (row.key)}
|
||||
<div class={metaRow}>
|
||||
<span>{row.label}</span>
|
||||
<span class="text-text">{row.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if photo.taken_at}
|
||||
<div class={metaRow}>
|
||||
<span>Снято</span>
|
||||
|
||||
@@ -7,24 +7,13 @@
|
||||
interface Props {
|
||||
photos: Photo[];
|
||||
index: number;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
onClose: () => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onMakeCover?: (photo: Photo) => void;
|
||||
onDelete?: (photo: Photo) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
photos,
|
||||
index,
|
||||
canEdit = false,
|
||||
canDelete = false,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
onMakeCover,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
let { photos, index, canDelete = false, onClose, onIndexChange, onDelete }: Props = $props();
|
||||
|
||||
let fresh = $state<Photo | null>(null);
|
||||
let touchX = 0;
|
||||
@@ -398,21 +387,16 @@
|
||||
>
|
||||
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} />
|
||||
</button>
|
||||
{#if canEdit}
|
||||
<button
|
||||
type="button"
|
||||
class={btn}
|
||||
title="Сделать обложкой"
|
||||
onclick={() => current && onMakeCover?.(current)}
|
||||
>
|
||||
<Icon name="wallpaper" class="size-4" />
|
||||
<span class="hidden sm:inline">Обложка</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if fresh?.download_url}
|
||||
<a href={fresh.download_url} class={btn} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href={fresh.download_url}
|
||||
class={btn}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Скачать"
|
||||
title="Скачать"
|
||||
>
|
||||
<Icon name="download" class="size-4" />
|
||||
<span class="hidden sm:inline">Скачать</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Photo {
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
caption: string;
|
||||
exif?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
uploader: User;
|
||||
likes_count: number;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { api } from '$lib/api';
|
||||
import Viewer from '$lib/components/Viewer.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
@@ -15,7 +14,6 @@
|
||||
let role = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let saved = $state(false);
|
||||
let pendingDelete = $state<Photo | null>(null);
|
||||
let deleting = $state(false);
|
||||
|
||||
@@ -51,17 +49,6 @@
|
||||
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
|
||||
}
|
||||
|
||||
async function makeCover(photo: Photo) {
|
||||
if (!canEdit) return;
|
||||
try {
|
||||
await albums.setCover(albumId, photo.id);
|
||||
saved = true;
|
||||
setTimeout(() => (saved = false), 2000);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
async function removePhoto(photo: Photo) {
|
||||
pendingDelete = photo;
|
||||
}
|
||||
@@ -104,18 +91,13 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full flex-col">
|
||||
{#if saved}
|
||||
<p class="bg-success/10 text-success px-3 py-1 text-center text-xs">Обложка обновлена</p>
|
||||
{/if}
|
||||
<div class="min-h-0 flex-1">
|
||||
<Viewer
|
||||
{photos}
|
||||
{index}
|
||||
{canEdit}
|
||||
{canDelete}
|
||||
onClose={close}
|
||||
onIndexChange={change}
|
||||
onMakeCover={makeCover}
|
||||
onDelete={removePhoto}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,15 @@
|
||||
- Тёмная графитовая тема по умолчанию при сохранении остальных тем.
|
||||
- Просмотр фото как маршрут в каркасе, общий с публичной share-страницей.
|
||||
- Адаптивность до мобильного без потери каркаса.
|
||||
- Пер-пользовательские настройки раздела «Информация» (видимость атрибутов и подписи),
|
||||
хранимые в профиле.
|
||||
|
||||
**Non-Goals:**
|
||||
- Мультивыбор фото и массовые действия / bulk-удаление.
|
||||
- Замена роутинга на «чистые панели» без маршрутов (вариант P).
|
||||
- Новые внешние зависимости (иконки и токены вендорятся/расширяются).
|
||||
- Изменение доменных контрактов API (только добавление поля ответа и FK).
|
||||
- Порядок атрибутов и настройки на уровне альбома/фото — настройки глобальны для пользователя.
|
||||
|
||||
## Decisions
|
||||
|
||||
@@ -59,10 +62,10 @@ ON DELETE SET NULL`. БД сама чинит любой путь удалени
|
||||
безвреден: альбом вставляется с `NULL`, фото ссылается на альбом, cover выставляется позже.
|
||||
Альтернатива — чистка в `photos.Delete` (легко забыть на новых путях удаления).
|
||||
|
||||
### 5. Точки выбора обложки: панель + просмотр (T1+T2)
|
||||
### 5. Точка выбора обложки: правая панель (T1)
|
||||
Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`;
|
||||
в просмотре — действие «Сделать обложкой» для текущего фото. Всё под `roles.CanEditAlbum`.
|
||||
Hover-оверлей в сетке (T3) не берём — шум и случайные клики.
|
||||
всё под `roles.CanEditAlbum`. Действие «Сделать обложкой» в тулбаре просмотра и hover-оверлей
|
||||
в сетке (T3) не используются — шум и случайные клики.
|
||||
|
||||
### 6. Тема: сохранить темы, dark = графит и дефолт (α)
|
||||
Тёмная тема перенастраивается в графит Lightroom и становится значением по умолчанию
|
||||
@@ -101,11 +104,16 @@ filmstrip, панель метаданных/комментариев, тулб
|
||||
|
||||
### 13. Просмотр: полный экран и зум
|
||||
Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по
|
||||
`fullscreenchange`. Зум — локальные `zoomed`/`scale`: колесо меняет масштаб с привязкой к
|
||||
курсору; `Space` переключает 100% (1:1); при увеличении изображение панорамируется
|
||||
перетаскиванием и скроллбарами. Хоткеи игнорируются при фокусе в поле ввода. Зум
|
||||
сбрасывается при смене фото. Альтернатива — только `object-fit` без ручного масштаба —
|
||||
не даёт 1:1 и панорамирования.
|
||||
`fullscreenchange`. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото
|
||||
скрыты и всплывают у соответствующих кромок (панели — по касанию края, скрытие ~1 с после
|
||||
ухода; стрелки — при курсоре в 1/6 левой/правой области, скрытие ~300 мс; в зуме стрелки
|
||||
скрыты полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото:
|
||||
колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%;
|
||||
`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум
|
||||
догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование —
|
||||
перетаскиванием и скроллом. Хоткеи игнорируются при фокусе в поле ввода; зум сбрасывается при
|
||||
смене фото. Альтернатива — только `object-fit` без ручного масштаба — не даёт 1:1 и
|
||||
панорамирования.
|
||||
|
||||
### 14. Панели: ресайз и сворачиваемые разделы
|
||||
Ширина рейла и панели хранится в `localStorage`, меняется перетаскиванием разделителя
|
||||
@@ -124,6 +132,33 @@ filmstrip, панель метаданных/комментариев, тулб
|
||||
панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома
|
||||
обновляет запись в списке.
|
||||
|
||||
### 17. Настройки информации: оверрайды в профиле пользователя
|
||||
Настройки раздела «Информация» хранятся в профиле как оверрайды, а не как полный список:
|
||||
`users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида
|
||||
`{ "hidden": ["Contrast", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие ключа
|
||||
означает, что атрибут видим и подпись дефолтная, поэтому новые и незнакомые теги EXIF не
|
||||
«теряются» и не требуют миграций данных. `GET /auth/me` отдаёт `preferences` вместе с
|
||||
пользователем; сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной
|
||||
валидацией: строки и массивы строк, ограничение длины подписи/ключа и числа скрытых ключей).
|
||||
Настройки не входят в `PublicUser` (приватны). UI: `PanelSection` получает слот `actions` под
|
||||
шестерёнку; модалка `PhotoInfoSettingsDialog` строится из объединения известного каталога
|
||||
подписей и ключей текущего фото; список в панели фильтруется по оверрайдам, а скрытые
|
||||
атрибуты сворачиваются под кнопку «…» (раскрытие — временное, для текущего фото).
|
||||
Альтернативы: полный упорядоченный список в профиле (хрупок к новым тегам, нужны миграции) и
|
||||
хранение на клиенте (не переносится между устройствами) — отклонены.
|
||||
|
||||
### 18. Метаданные фото: устойчивый парсер и хранение в jsonb
|
||||
EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`)
|
||||
как плоская карта «тег → значение» всех распознаваемых библиотекой тегов. Парсер —
|
||||
`github.com/dsoprea/go-exif/v3`: он переживает нестандартные и битые блоки EXIF, на которых
|
||||
падал прежний `rwcarlsen/goexif` (реальные снимки HUAWEI давали zero-length sub-IFD и пустой
|
||||
результат). Числовые значения нормализуются (дроби → число); дата съёмки и GPS дополнительно
|
||||
пишутся в колонки `taken_at`, `latitude`/`longitude`. На клиенте теги показываются с русскими
|
||||
подписями и переводом enum. Для ранее загруженных фото предусмотрена разовая команда
|
||||
`cmd/backfill-exif`, которая тянет оригиналы из S3 (`storage.Get`) и заполняет
|
||||
`exif`/`taken_at`/координаты. Альтернатива — фиксированный набор полей — отклонена:
|
||||
пользователю нужны все атрибуты.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
||||
@@ -147,19 +182,29 @@ filmstrip, панель метаданных/комментариев, тулб
|
||||
- [Крупные файлы утяжеляют память] → лимит 100 МБ и чтение через `LimitReader`; превышение
|
||||
даёт понятную 413.
|
||||
- [Сохранённые ширина/состояние панелей] → значения ограничиваются допустимым диапазоном.
|
||||
- [Настройки в профиле: рост jsonb и приватность] → `preferences` не входят в `PublicUser`;
|
||||
серверная валидация ограничивает структуру, длину подписей и число скрытых ключей.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Миграция `000002_album_cover_fk`: `UPDATE albums SET cover_photo_id = NULL WHERE
|
||||
cover_photo_id IS NOT NULL AND cover_photo_id NOT IN (SELECT id FROM photos);` затем
|
||||
`ADD CONSTRAINT ... ON DELETE SET NULL`. Down — `DROP CONSTRAINT`.
|
||||
2. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go`
|
||||
2. Миграция `000003_photo_exif`: `ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT
|
||||
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
|
||||
3. Миграция `000004_user_preferences`: `ALTER TABLE users ADD COLUMN preferences jsonb NOT
|
||||
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
|
||||
4. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go`
|
||||
(LEFT JOIN + счётчики), `albums/service.go` (enrich + presign), `photos` (автообложка только
|
||||
если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`).
|
||||
3. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
|
||||
разделами и EXIF, модалки, `Viewer` с зумом), стор альбомов, токены и скроллбары, `types.ts`.
|
||||
4. Откат: снять constraint (down-миграция); фронтенд откатывается независимо, поле ответа
|
||||
игнорируется старым клиентом.
|
||||
если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`; извлечение EXIF в
|
||||
`photos.exif` и команда `cmd/backfill-exif`), `models.User` (+`Preferences`), `auth`
|
||||
(`GetUserByID` c `preferences`, `Me` отдаёт настройки, `PATCH /auth/me/preferences` с
|
||||
валидацией).
|
||||
5. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
|
||||
разделами и EXIF, модалки, `Viewer` с зумом), слот действий и модалка настроек атрибутов,
|
||||
стор настроек, `types.ts`.
|
||||
6. Откат: снять constraint и колонки `photos.exif`/`users.preferences` (down-миграции);
|
||||
фронтенд откатывается независимо, поля ответа игнорируются старым клиентом.
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
- **Создание альбома.** Инлайн-форма заменяется иконочной кнопкой с хинтом, открывающей
|
||||
модалку ввода названия.
|
||||
- **Обложки.** `Album` получает `cover_thumb_url`; карточки и рейл показывают реальную
|
||||
миниатюру. Выбор обложки: пикер в правой панели и действие «сделать обложкой» в
|
||||
просмотре. Автообложка пишется только когда обложка ещё не задана; FK
|
||||
миниатюру. Выбор обложки: пикер в правой панели (действие «сделать обложкой» в
|
||||
просмотре не используется). Автообложка пишется только когда обложка ещё не задана; FK
|
||||
`albums.cover_photo_id -> photos(id) ON DELETE SET NULL` чинит висячие ссылки.
|
||||
- **Просмотр как маршрут.** `Lightbox`-оверлей заменяется маршрутом
|
||||
`/albums/:id/photos/:photoId` внутри шелла: крупное фото, filmstrip снизу, панель
|
||||
@@ -31,10 +31,20 @@
|
||||
ошибкой при превышении.
|
||||
- **Переименование фото.** `PATCH /photos/:id` меняет `original_filename`; инлайн-редактор
|
||||
в правой панели, доступный автору фото и owner/admin.
|
||||
- **Просмотр.** Полноэкранный режим (кнопка и хоткей `f`), зум колесом мыши, `Space` — 100%
|
||||
с панорамированием; удаление текущего фото с модальным подтверждением; возврат к обзору.
|
||||
- **Метаданные фото (EXIF).** При загрузке система извлекает все распознаваемые EXIF-атрибуты
|
||||
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
|
||||
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
|
||||
фото есть команда бэкфилла метаданных из оригиналов.
|
||||
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
|
||||
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
|
||||
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
|
||||
удаление текущего фото с модальным подтверждением; возврат к обзору.
|
||||
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
||||
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
||||
- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую
|
||||
модалку со списком EXIF-атрибутов: переключатели видимости и пользовательские подписи.
|
||||
Скрытые атрибуты сворачиваются под элементом «…» (клик раскрывает все). Настройки хранятся
|
||||
в профиле пользователя (server-side) и применяются ко всем фото.
|
||||
- **Счётчики.** В ответе альбома — число фото и участников.
|
||||
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
|
||||
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
|
||||
@@ -53,7 +63,8 @@
|
||||
|
||||
- `albums`: отображение обложки, счётчики фото/участников и явный выбор обложки.
|
||||
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
|
||||
загрузки.
|
||||
загрузки; извлечение и хранение EXIF-метаданных.
|
||||
- `auth`: хранение пользовательских настроек отображения информации в профиле.
|
||||
|
||||
## Impact
|
||||
|
||||
@@ -63,14 +74,20 @@
|
||||
(presign → `CoverThumbURL`, обогащение списка); `internal/models` (`Album.CoverThumbURL`,
|
||||
`PhotosCount`, `MembersCount`); `internal/photos` (автообложка только при
|
||||
`cover_photo_id IS NULL`; `PATCH /photos/:id` для переименования; лимит загрузки 100 МБ с
|
||||
`413 file_too_large`; событие `photo.updated`).
|
||||
`413 file_too_large`; событие `photo.updated`; извлечение EXIF через
|
||||
`github.com/dsoprea/go-exif/v3` в `photos.exif`, миграция `000003_photo_exif`, команда
|
||||
`cmd/backfill-exif` и `storage.Get`); миграция `users.preferences`,
|
||||
`models.User.Preferences`, `internal/auth` (`Me` отдаёт настройки,
|
||||
`PATCH /auth/me/preferences` с валидацией).
|
||||
- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell`
|
||||
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
|
||||
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
|
||||
`Viewer` с полноэкранным режимом и зумом, общий стор альбомов, контрол плотности; новый
|
||||
маршрут `albums/[id]/photos/[photoId]`; новые значения токенов темы и стили скроллбаров;
|
||||
`types.ts` (+`cover_thumb_url`, счётчики, `photos_count`); новые иконки в реестре.
|
||||
- **Спеки:** `ui-workspace` (новая), `albums`, `photos` (дельты). `ui-iconography` не
|
||||
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
|
||||
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
|
||||
новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики,
|
||||
`photos_count`, `preferences`); новые иконки в реестре.
|
||||
- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не
|
||||
затрагивается — иконки по-прежнему наследуют цвет темы.
|
||||
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
|
||||
изменений API нет.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Хранение пользовательских настроек интерфейса
|
||||
Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при
|
||||
получении текущего пользователя. Настройки SHALL быть приватными для пользователя.
|
||||
Отсутствие настроек SHALL трактоваться как значения по умолчанию.
|
||||
|
||||
#### Scenario: Получение настроек
|
||||
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
|
||||
- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию)
|
||||
|
||||
#### Scenario: Сохранение настроек
|
||||
- **WHEN** пользователь сохраняет настройки отображения
|
||||
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
|
||||
|
||||
#### Scenario: Изоляция настроек
|
||||
- **WHEN** другой пользователь запрашивает свой профиль
|
||||
- **THEN** он не получает настройки первого пользователя
|
||||
|
||||
#### Scenario: Некорректные настройки
|
||||
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
|
||||
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
|
||||
@@ -41,3 +41,15 @@
|
||||
#### Scenario: Файл в пределах лимита
|
||||
- **WHEN** файл не превышает лимит
|
||||
- **THEN** система принимает multipart-загрузку и обрабатывает фото
|
||||
|
||||
### Requirement: Метаданные фото (EXIF)
|
||||
При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их
|
||||
вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор.
|
||||
|
||||
#### Scenario: Фото с метаданными
|
||||
- **WHEN** загружается фото с EXIF
|
||||
- **THEN** данные фото содержат извлечённые атрибуты (камера, экспозиция, ISO, дата съёмки и др.)
|
||||
|
||||
#### Scenario: Фото без метаданных
|
||||
- **WHEN** загружается фото без читаемых EXIF
|
||||
- **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно
|
||||
|
||||
@@ -139,7 +139,8 @@ SHALL отображаться вне каркаса, но в общей тем
|
||||
|
||||
### Requirement: Сворачиваемые разделы панели
|
||||
Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние
|
||||
сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные EXIF фото.
|
||||
сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото,
|
||||
включая извлечённые EXIF-атрибуты.
|
||||
|
||||
#### Scenario: Сворачивание раздела
|
||||
- **WHEN** пользователь нажимает заголовок раздела
|
||||
@@ -147,30 +148,47 @@ SHALL отображаться вне каркаса, но в общей тем
|
||||
|
||||
#### Scenario: Метаданные фото
|
||||
- **WHEN** открыт просмотр фото
|
||||
- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки и координаты
|
||||
- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки, координаты и извлечённые EXIF-атрибуты
|
||||
|
||||
### Requirement: Полноэкранный просмотр фотографии
|
||||
В режиме просмотра SHALL быть доступен переход в полноэкранный режим кнопкой и хоткеем `f`.
|
||||
В полноэкранном режиме верхняя и нижняя панели SHALL скрываться и показываться у
|
||||
соответствующей кромки экрана.
|
||||
|
||||
#### Scenario: Вход в полный экран
|
||||
- **WHEN** пользователь нажимает кнопку полного экрана или клавишу `f`
|
||||
- **THEN** просмотр разворачивается на весь экран
|
||||
- **THEN** просмотр разворачивается на весь экран, панели скрыты
|
||||
|
||||
#### Scenario: Панель по кромке
|
||||
- **WHEN** курсор касается верхней или нижней кромки экрана
|
||||
- **THEN** соответствующая панель показывается и скрывается примерно через секунду после ухода курсора
|
||||
|
||||
#### Scenario: Выход из полного экрана
|
||||
- **WHEN** пользователь повторно нажимает кнопку или клавишу `f`
|
||||
- **THEN** полноэкранный режим закрывается
|
||||
|
||||
### Requirement: Зум в просмотре
|
||||
Просмотр SHALL поддерживать зум: колесо мыши меняет масштаб, `Space` переключает 100%, а при
|
||||
увеличении фото SHALL панорамироваться. Зум SHALL работать и в полноэкранном режиме.
|
||||
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
||||
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
|
||||
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
|
||||
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
|
||||
работать и в полноэкранном режиме.
|
||||
|
||||
#### Scenario: Зум колесом
|
||||
#### Scenario: Зум колесом к указателю
|
||||
- **WHEN** пользователь вращает колесо мыши над фото
|
||||
- **THEN** масштаб фото меняется относительно положения курсора
|
||||
- **THEN** масштаб меняется относительно курсора, страница не прокручивается, предел — 200%
|
||||
|
||||
#### Scenario: 100% по пробелу
|
||||
#### Scenario: Пробел
|
||||
- **WHEN** пользователь нажимает `Space`
|
||||
- **THEN** фото показывается в масштабе 100% (1:1), повторное нажатие возвращает вписанный вид
|
||||
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный
|
||||
|
||||
#### Scenario: Оригинал в зуме
|
||||
- **WHEN** пользователь входит в зум
|
||||
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
||||
|
||||
#### Scenario: Стрелки соседних фото
|
||||
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
||||
- **THEN** показывается соответствующая стрелка, скрываясь примерно через 300 мс после ухода; в зуме стрелки скрыты
|
||||
|
||||
#### Scenario: Панорамирование
|
||||
- **WHEN** фото увеличено
|
||||
@@ -198,3 +216,38 @@ SHALL отображаться вне каркаса, но в общей тем
|
||||
#### Scenario: Возврат из просмотра
|
||||
- **WHEN** пользователь нажимает кнопку «назад» в режиме просмотра
|
||||
- **THEN** открывается альбом с сеткой фото
|
||||
|
||||
### Requirement: Настройки списка атрибутов информации
|
||||
Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим
|
||||
модальное окно, в котором пользователь SHALL выбирать отображаемые EXIF-атрибуты и задавать
|
||||
их подписи. Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле
|
||||
пользователя. При отсутствии настроек SHALL отображаться все доступные атрибуты.
|
||||
|
||||
#### Scenario: Открытие настроек
|
||||
- **WHEN** пользователь нажимает иконку настроек в разделе «Информация»
|
||||
- **THEN** открывается модальное окно со списком EXIF-атрибутов: переключатели видимости и поля подписей
|
||||
|
||||
#### Scenario: Сохранение настроек
|
||||
- **WHEN** пользователь скрывает атрибут, задаёт подпись и сохраняет
|
||||
- **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле
|
||||
|
||||
#### Scenario: Сброс настроек
|
||||
- **WHEN** пользователь сбрасывает настройки к значениям по умолчанию
|
||||
- **THEN** показываются все атрибуты с исходными подписями
|
||||
|
||||
### Requirement: Свёртка скрытых атрибутов
|
||||
Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент
|
||||
«…»; нажатие на него SHALL раскрывать все атрибуты текущего фото, включая скрытые. Если
|
||||
скрытых атрибутов нет, элемент «…» SHALL не отображаться.
|
||||
|
||||
#### Scenario: Скрытые атрибуты
|
||||
- **WHEN** в разделе «Информация» есть скрытые настройками атрибуты
|
||||
- **THEN** после видимых атрибутов показан элемент «…»
|
||||
|
||||
#### Scenario: Раскрытие всех атрибутов
|
||||
- **WHEN** пользователь нажимает «…»
|
||||
- **THEN** отображаются все атрибуты фото, включая скрытые
|
||||
|
||||
#### Scenario: Нет скрытых атрибутов
|
||||
- **WHEN** ни один атрибут не скрыт
|
||||
- **THEN** элемент «…» не отображается
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
- [x] 4.1 Компонент `AlbumPanel`: превью обложки, название, счётчики, переходы к участникам/настройкам/ссылкам; управление обложкой только для owner/admin. Проверка: viewer не видит управление обложкой.
|
||||
- [x] 4.2 Модальный `CoverPicker` (сетка миниатюр) + `PATCH /albums/:id` с `cover_photo_id`/`clear_cover`. Проверка: назначение и сброс обложки отражаются в рейле, карточке и панели.
|
||||
- [x] 4.3 Действие «Сделать обложкой» в тулбаре просмотра (роль-gated). Проверка: из просмотра текущее фото становится обложкой альбома.
|
||||
- [x] 4.3 Выбор обложки выполняется только из правой панели (модальный пикер); действие «Сделать обложкой» в тулбаре просмотра не используется. Проверка: обложка задаётся и сбрасывается из панели, в тулбаре просмотра кнопки нет.
|
||||
|
||||
## 5. Центр: сетка, плотность, загрузка
|
||||
|
||||
@@ -47,9 +47,22 @@
|
||||
|
||||
- [x] 8.1 Переименование фото: `PATCH /photos/:id` (права автора/owner/admin, пустое имя 400) и инлайн-редактор в правой панели; событие `photo.updated`. Проверка: переименование сохраняется, пустое имя отклоняется, чужое фото — отказ, событие приходит.
|
||||
- [x] 8.2 Удаление фото из просмотра с модальным подтверждением; переход к соседнему и обновление ленты. Проверка: после удаления лента не содержит удалённое фото.
|
||||
- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) и зум (колесо, `Space` 100%, панорамирование) в обычном и полноэкранном режиме. Проверка: хоткеи и зум работают, при вводе текста не срабатывают.
|
||||
- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) с автоскрытием верхней/нижней панелей у кромок и стрелок (1/6 края, скрытие ~300 мс, в зуме скрыты); зум колесом к указателю без прокрутки (макс 200%), `Space` возвращает из любого зума во вписанный вид, в зуме подгружается оригинал. Проверка: поведение подтверждено в браузере (автоскрытие, зум к указателю, предел 200%, подмена превью→оригинал).
|
||||
- [x] 8.4 Сворачиваемые разделы правой панели с сохранением состояния и раздел «Информация» (EXIF). Проверка: состояние сохраняется между сессиями, метаданные отображаются.
|
||||
- [x] 8.5 Ресайз левой и правой панелей перетаскиванием с сохранением ширины. Проверка: ширина восстанавливается после перезагрузки.
|
||||
- [x] 8.6 Лимит загрузки 100 МБ и понятные ошибки (клиентская проверка и `413 file_too_large`). Проверка: 60 МБ проходит разбор, превышение лимита даёт явную ошибку размера.
|
||||
- [x] 8.7 Счётчики `photos_count`/`members_count` в ответе альбома. Проверка: `GET /api/albums` и `GET /api/albums/:id` содержат счётчики.
|
||||
- [x] 8.8 Тема и хром: нейтральная тема — тёплый графит, порядок и значок переключателя, стилизация скроллбаров. Проверка: темы переключаются согласованно, скроллбары в тон темы.
|
||||
- [x] 8.9 Извлечение EXIF: парсер заменён на `github.com/dsoprea/go-exif/v3` (все распознаваемые теги, нормализация чисел), хранение в `photos.exif` (миграция `000003_photo_exif`, jsonb `'{}'` по умолчанию), отдача в API фото; дата съёмки и GPS дублируются в `taken_at`/координаты. Проверка: реальное фото HUAWEI nova 15 Pro даёт 67 атрибутов; `go test ./...`.
|
||||
- [x] 8.10 Бэкфилл метаданных существующих фото: команда `cmd/backfill-exif` + `storage.Get` + `photos.Store.BackfillEXIF` (тянет оригиналы из S3, заполняет `exif`/`taken_at`/координаты). Проверка: прогон обновил 25 фото, `taken_at` проставлен.
|
||||
- [x] 8.11 Раздел «Информация» показывает все EXIF-атрибуты с русскими подписями, переводом enum и единицами (выдержка `1/N`, `f/N`, `N мм`, EV); служебные/дублирующие теги скрыты. Проверка: Playwright на реальном фото — 9/9.
|
||||
|
||||
## 9. Настройки информации пользователя
|
||||
|
||||
- [ ] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает.
|
||||
- [ ] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется.
|
||||
- [ ] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы.
|
||||
- [ ] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`.
|
||||
- [ ] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле.
|
||||
- [ ] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы.
|
||||
- [ ] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
||||
|
||||
Reference in New Issue
Block a user