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/aws-sdk-go-v2/service/s3 v1.114.3
|
||||||
github.com/aws/smithy-go v1.28.5
|
github.com/aws/smithy-go v1.28.5
|
||||||
github.com/disintegration/imaging v1.6.2
|
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/gen2brain/webp v0.6.4
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.20.1
|
github.com/golang-migrate/migrate/v4 v4.20.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.11.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
|
golang.org/x/crypto v0.58.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,14 +37,18 @@ require (
|
|||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/ebitengine/purego v0.10.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.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-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/sys v0.49.0 // indirect
|
||||||
golang.org/x/text v0.43.0 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // 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-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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
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 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
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-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 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
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-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 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
|
||||||
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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=
|
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/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 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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=
|
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 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
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 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
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 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
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 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
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 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
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.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 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
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.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 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -51,7 +51,24 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
|
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
|
||||||
return s.store.ListForUser(ctx, userID)
|
out, err := s.store.ListForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
s.attachCover(ctx, &out[i])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachCover fills CoverThumbURL from the presigned thumbnail key, when present.
|
||||||
|
func (s *Service) attachCover(ctx context.Context, a *models.Album) {
|
||||||
|
if s.storage == nil || a == nil || a.CoverThumbKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
|
||||||
|
a.CoverThumbURL = url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
||||||
@@ -76,7 +93,12 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
|
||||||
return s.Access(ctx, albumID, userID)
|
a, role, err := s.Access(ctx, albumID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
s.attachCover(ctx, a)
|
||||||
|
return a, role, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateInput struct {
|
type UpdateInput struct {
|
||||||
@@ -112,6 +134,10 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
|
|||||||
if err := s.store.Update(ctx, a); err != nil {
|
if err := s.store.Update(ctx, a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if refreshed, err := s.store.GetByID(ctx, a.ID); err == nil {
|
||||||
|
a = refreshed
|
||||||
|
}
|
||||||
|
s.attachCover(ctx, a)
|
||||||
s.hub.Publish(albumID, "album.updated", a)
|
s.hub.Publish(albumID, "album.updated", a)
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
@@ -124,11 +150,11 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
|
|||||||
if role != roles.Owner {
|
if role != roles.Owner {
|
||||||
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
||||||
}
|
}
|
||||||
if err := s.store.Delete(ctx, a.ID); err != nil {
|
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if s.storage != nil {
|
if err := s.store.Delete(ctx, a.ID); err != nil {
|
||||||
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
|
return err
|
||||||
}
|
}
|
||||||
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
|
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -43,9 +43,14 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
|
|||||||
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
||||||
var a models.Album
|
var a models.Album
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at
|
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
|
||||||
FROM albums WHERE id = $1`, id).
|
COALESCE(p.thumb_key, ''),
|
||||||
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt)
|
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||||
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||||
|
FROM albums a
|
||||||
|
LEFT JOIN photos p ON p.id = a.cover_photo_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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -57,9 +62,13 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
|||||||
|
|
||||||
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
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
|
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
|
||||||
|
COALESCE(p.thumb_key, ''),
|
||||||
|
(SELECT count(*) FROM photos WHERE album_id = a.id),
|
||||||
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||||
FROM albums a
|
FROM albums a
|
||||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
||||||
|
LEFT JOIN photos p ON p.id = a.cover_photo_id
|
||||||
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
|
||||||
ORDER BY a.created_at DESC`, userID)
|
ORDER BY a.created_at DESC`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +79,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
|
|||||||
out := []models.Album{}
|
out := []models.Album{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.Album
|
var a models.Album
|
||||||
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
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 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
@@ -98,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrashPhotosByAlbum soft-deletes all photos of an album so they can be restored
|
||||||
|
// or permanently removed from the trash after the album is gone.
|
||||||
|
func (s *Store) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE photos SET deleted_at = now() WHERE album_id = $1 AND deleted_at IS NULL`, albumID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
authed.Use(s.auth.Middleware())
|
authed.Use(s.auth.Middleware())
|
||||||
|
|
||||||
authed.GET("/auth/me", authHandler.Me)
|
authed.GET("/auth/me", authHandler.Me)
|
||||||
|
authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences)
|
||||||
authed.GET("/sse/albums/:id", s.albumSSE)
|
authed.GET("/sse/albums/:id", s.albumSSE)
|
||||||
authed.GET("/sse/me", s.meSSE)
|
authed.GET("/sse/me", s.meSSE)
|
||||||
|
|
||||||
@@ -133,8 +134,20 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
authed.GET("/albums/:id/photos", photosHandler.List)
|
authed.GET("/albums/:id/photos", photosHandler.List)
|
||||||
authed.POST("/albums/:id/photos", photosHandler.Upload)
|
authed.POST("/albums/:id/photos", photosHandler.Upload)
|
||||||
authed.GET("/photos/:id", photosHandler.Get)
|
authed.GET("/photos/:id", photosHandler.Get)
|
||||||
|
authed.PATCH("/photos/:id", photosHandler.Rename)
|
||||||
authed.DELETE("/photos/:id", photosHandler.Delete)
|
authed.DELETE("/photos/:id", photosHandler.Delete)
|
||||||
authed.GET("/photos/:id/download", photosHandler.Download)
|
authed.GET("/photos/:id/download", photosHandler.Download)
|
||||||
|
authed.POST("/photos/:id/restore", photosHandler.Restore)
|
||||||
|
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
|
||||||
|
authed.POST("/photos/:id/tags", photosHandler.AddTag)
|
||||||
|
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
|
||||||
|
authed.GET("/tags", photosHandler.Tags)
|
||||||
|
|
||||||
|
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
|
||||||
|
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
|
||||||
|
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
|
||||||
|
authed.GET("/trash", photosHandler.Trash)
|
||||||
|
authed.GET("/trash/count", photosHandler.TrashCount)
|
||||||
|
|
||||||
authed.POST("/photos/:id/likes", socialHandler.AddLike)
|
authed.POST("/photos/:id/likes", socialHandler.AddLike)
|
||||||
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
|
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -89,5 +92,79 @@ func (h *Handler) Me(c *gin.Context) {
|
|||||||
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
|
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"user": u.Public()})
|
prefs := u.Preferences
|
||||||
|
if prefs == nil {
|
||||||
|
prefs = map[string]any{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"user": u.Public(), "preferences": prefs})
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxVisibleAttrs = 500
|
||||||
|
maxLabelEntries = 500
|
||||||
|
maxPreferenceKeyLen = 128
|
||||||
|
maxPreferenceValueLen = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
type photoInfoPreferences struct {
|
||||||
|
Visible []string `json:"visible"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type preferencesPayload struct {
|
||||||
|
PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p preferencesPayload) validate() error {
|
||||||
|
pi := p.PhotoInfo
|
||||||
|
if pi == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(pi.Visible) > maxVisibleAttrs {
|
||||||
|
return fmt.Errorf("too many visible attributes")
|
||||||
|
}
|
||||||
|
for _, k := range pi.Visible {
|
||||||
|
if k == "" || len(k) > maxPreferenceKeyLen {
|
||||||
|
return fmt.Errorf("invalid visible attribute key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pi.Labels) > maxLabelEntries {
|
||||||
|
return fmt.Errorf("too many label overrides")
|
||||||
|
}
|
||||||
|
for k, v := range pi.Labels {
|
||||||
|
if k == "" || len(k) > maxPreferenceKeyLen {
|
||||||
|
return fmt.Errorf("invalid label key")
|
||||||
|
}
|
||||||
|
if len(v) > maxPreferenceValueLen {
|
||||||
|
return fmt.Errorf("label value too long")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdatePreferences(c *gin.Context) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not read request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload preferencesPayload
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := payload.validate(); err != nil {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"preferences": payload})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -29,27 +30,45 @@ func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
|
|||||||
|
|
||||||
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||||
return s.scanUser(s.pool.QueryRow(ctx,
|
return s.scanUser(s.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
|
||||||
|
COALESCE(preferences, '{}'::jsonb)::text, created_at
|
||||||
FROM users WHERE lower(email) = lower($1)`, email))
|
FROM users WHERE lower(email) = lower($1)`, email))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
|
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
|
||||||
return s.scanUser(s.pool.QueryRow(ctx,
|
return s.scanUser(s.pool.QueryRow(ctx,
|
||||||
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
|
||||||
|
COALESCE(preferences, '{}'::jsonb)::text, created_at
|
||||||
FROM users WHERE id = $1`, id))
|
FROM users WHERE id = $1`, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
|
func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
|
||||||
var u models.User
|
var u models.User
|
||||||
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil {
|
var prefs string
|
||||||
|
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if prefs != "" && prefs != "null" {
|
||||||
|
_ = json.Unmarshal([]byte(prefs), &u.Preferences)
|
||||||
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
|
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
|
||||||
_, err := s.pool.Exec(ctx,
|
_, err := s.pool.Exec(ctx,
|
||||||
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
|
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AvatarURL string `json:"avatar_url,omitempty"`
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Preferences map[string]any `json:"preferences,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublicUser struct {
|
type PublicUser struct {
|
||||||
@@ -26,13 +27,17 @@ func (u *User) Public() PublicUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Album struct {
|
type Album struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CoverThumbKey string `json:"-"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
|
||||||
|
PhotosCount int `json:"photos_count"`
|
||||||
|
MembersCount int `json:"members_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumMember struct {
|
type AlbumMember struct {
|
||||||
@@ -71,8 +76,12 @@ type Photo struct {
|
|||||||
TakenAt *time.Time `json:"taken_at,omitempty"`
|
TakenAt *time.Time `json:"taken_at,omitempty"`
|
||||||
Latitude *float64 `json:"latitude,omitempty"`
|
Latitude *float64 `json:"latitude,omitempty"`
|
||||||
Longitude *float64 `json:"longitude,omitempty"`
|
Longitude *float64 `json:"longitude,omitempty"`
|
||||||
Caption string `json:"caption"`
|
Caption string `json:"caption"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
EXIF map[string]any `json:"exif,omitempty"`
|
||||||
|
Tags []Tag `json:"tags,omitempty"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
AlbumTitle string `json:"album_title,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Uploader PublicUser `json:"uploader"`
|
Uploader PublicUser `json:"uploader"`
|
||||||
LikesCount int `json:"likes_count"`
|
LikesCount int `json:"likes_count"`
|
||||||
CommentsCount int `json:"comments_count"`
|
CommentsCount int `json:"comments_count"`
|
||||||
@@ -83,6 +92,11 @@ type Photo struct {
|
|||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
PhotoID string `json:"photo_id"`
|
PhotoID string `json:"photo_id"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package photos
|
package photos
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
"shared-album/internal/httpx"
|
"shared-album/internal/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxUploadSize = 50 << 20
|
const maxUploadSize = 100 << 20
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
@@ -43,7 +44,12 @@ func (h *Handler) Upload(c *gin.Context) {
|
|||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
|
||||||
form, err := c.MultipartForm()
|
form, err := c.MultipartForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "expected multipart form"))
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) {
|
||||||
|
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", "files exceed the maximum upload size (100 MB)"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid multipart form"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
files := form.File["files"]
|
files := form.File["files"]
|
||||||
@@ -93,6 +99,24 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"photo": p})
|
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type renameRequest struct {
|
||||||
|
OriginalFilename string `json:"original_filename" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Rename(c *gin.Context) {
|
||||||
|
var req renameRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "original_filename is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.svc.Rename(c.Request.Context(), c.Param("id"), auth.UserID(c), req.OriginalFilename)
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Download(c *gin.Context) {
|
func (h *Handler) Download(c *gin.Context) {
|
||||||
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,3 +133,123 @@ func (h *Handler) Delete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PermanentDelete(c *gin.Context) {
|
||||||
|
if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Restore(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c), req.AlbumID)
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Trash(c *gin.Context) {
|
||||||
|
list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c))
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"photos": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) TrashCount(c *gin.Context) {
|
||||||
|
n, err := h.svc.TrashCount(c.Request.Context(), auth.UserID(c))
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"count": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Tags(c *gin.Context) {
|
||||||
|
tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AddTag(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.svc.AddTag(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Name)
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RemoveTag(c *gin.Context) {
|
||||||
|
p, err := h.svc.RemoveTag(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("tag_id"))
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"photo": p})
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchRequest struct {
|
||||||
|
IDs []string `json:"ids" binding:"required"`
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteBatch(c *gin.Context) {
|
||||||
|
var req batchRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c))
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MoveBatch(c *gin.Context) {
|
||||||
|
var req batchRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"moved": n})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CopyBatch(c *gin.Context) {
|
||||||
|
var req batchRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
|
||||||
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
|
||||||
|
if err != nil {
|
||||||
|
httpx.RespondError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"photos": created})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package photos
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
exif "github.com/dsoprea/go-exif/v3"
|
||||||
|
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gen2brain/webp"
|
"github.com/gen2brain/webp"
|
||||||
"github.com/rwcarlsen/goexif/exif"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImageMeta struct {
|
type ImageMeta struct {
|
||||||
@@ -29,23 +33,90 @@ func inspect(data []byte) (width, height int, ok bool) {
|
|||||||
return cfg.Width, cfg.Height, true
|
return cfg.Width, cfg.Height, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
|
type exifInfo struct {
|
||||||
x, err := exif.Decode(bytes.NewReader(data))
|
TakenAt *time.Time
|
||||||
if err != nil {
|
Latitude *float64
|
||||||
return nil, nil, nil
|
Longitude *float64
|
||||||
|
Attributes map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectTags(ifd *exif.Ifd, dst map[string]any) {
|
||||||
|
if ifd == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
|
for _, ite := range ifd.Entries() {
|
||||||
if s, err := dt.StringVal(); err == nil {
|
name := ite.TagName()
|
||||||
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
|
if name == "" || strings.HasPrefix(name, "UnknownTag") {
|
||||||
utc := t.UTC()
|
continue
|
||||||
taken = &utc
|
}
|
||||||
}
|
if _, exists := dst[name]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, err := ite.FormatFirst()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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 la, lo, err := x.LatLong(); err == nil {
|
if n, err := strconv.ParseInt(base, 10, 64); err == nil {
|
||||||
lat, lon = &la, &lo
|
return n
|
||||||
}
|
}
|
||||||
return taken, lat, lon
|
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()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
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, preview []byte, err error) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
||||||
}
|
}
|
||||||
taken, lat, lon := extractEXIF(in.Data)
|
meta := extractEXIF(in.Data)
|
||||||
|
|
||||||
id := auth.NewID()
|
id := auth.NewID()
|
||||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
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,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
MimeType: mime,
|
MimeType: mime,
|
||||||
TakenAt: taken,
|
TakenAt: meta.TakenAt,
|
||||||
Latitude: lat,
|
Latitude: meta.Latitude,
|
||||||
Longitude: lon,
|
Longitude: meta.Longitude,
|
||||||
|
EXIF: meta.Attributes,
|
||||||
}
|
}
|
||||||
if err := s.store.Create(ctx, p); err != nil {
|
if err := s.store.Create(ctx, p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -121,7 +122,7 @@ func (s *Service) processVariants(photoID, albumID string, data []byte) {
|
|||||||
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
|
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.store.SetCover(ctx, albumID, photoID)
|
s.store.SetCoverIfEmpty(ctx, albumID, photoID)
|
||||||
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
|
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
|
||||||
if err := s.attachURLs(ctx, p); err == nil {
|
if err := s.attachURLs(ctx, p); err == nil {
|
||||||
s.hub.Publish(albumID, "photo.created", p)
|
s.hub.Publish(albumID, "photo.created", p)
|
||||||
@@ -206,6 +207,9 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if p.DeletedAt != nil {
|
||||||
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
if err := s.attachURLs(ctx, p); err != nil {
|
if err := s.attachURLs(ctx, p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -229,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if p.DeletedAt != nil {
|
||||||
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -250,6 +257,40 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string,
|
|||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) Rename(ctx context.Context, photoID, userID, filename string) (*models.Photo, error) {
|
||||||
|
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
own := p.UploaderID == userID
|
||||||
|
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
|
||||||
|
return nil, httpx.Forbidden("forbidden", "you cannot rename this photo")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(filename)
|
||||||
|
if name == "" {
|
||||||
|
return nil, httpx.BadRequest("invalid_filename", "filename is required")
|
||||||
|
}
|
||||||
|
if len(name) > 255 {
|
||||||
|
name = name[:255]
|
||||||
|
}
|
||||||
|
if err := s.store.UpdateFilename(ctx, photoID, name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.OriginalFilename = name
|
||||||
|
if err := s.attachURLs(ctx, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.hub.Publish(p.AlbumID, "photo.updated", p)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
||||||
p, err := s.store.GetByID(ctx, photoID, userID)
|
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -262,23 +303,286 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
own := p.UploaderID == userID
|
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||||
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
|
|
||||||
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||||
}
|
}
|
||||||
if err := s.store.Delete(ctx, photoID); err != nil {
|
if p.DeletedAt != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
keys := []string{p.StorageKey}
|
s.publishDeleted(p)
|
||||||
if p.ThumbKey != "" {
|
return nil
|
||||||
keys = append(keys, p.ThumbKey)
|
}
|
||||||
}
|
|
||||||
if p.PreviewKey != "" {
|
func canDeletePhoto(role string, own bool) bool {
|
||||||
keys = append(keys, p.PreviewKey)
|
return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))
|
||||||
}
|
}
|
||||||
_ = s.storage.Delete(ctx, keys...)
|
|
||||||
|
func (s *Service) publishDeleted(p *models.Photo) {
|
||||||
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
|
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
|
||||||
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
|
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete.
|
||||||
|
func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) {
|
||||||
|
deleted := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := s.Delete(ctx, id, userID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore returns a photo from the trash back to its album.
|
||||||
|
// Restore returns a photo from the trash back to an album. When targetAlbumID is
|
||||||
|
// empty the photo goes back to its own album; if that album is gone, an
|
||||||
|
// album_required error is returned so the client can prompt for a target.
|
||||||
|
func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID string) (*models.Photo, error) {
|
||||||
|
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetAlbumID != "" {
|
||||||
|
_, role, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !roles.CanUpload(role) {
|
||||||
|
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||||
|
}
|
||||||
|
if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.AlbumID = targetAlbumID
|
||||||
|
p.DeletedAt = nil
|
||||||
|
} else {
|
||||||
|
if p.AlbumID == "" {
|
||||||
|
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
|
||||||
|
}
|
||||||
|
exists, err := s.store.AlbumExists(ctx, p.AlbumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
|
||||||
|
}
|
||||||
|
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||||
|
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
|
||||||
|
}
|
||||||
|
if p.DeletedAt != nil {
|
||||||
|
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.DeletedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.attachURLs(ctx, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.AlbumID != "" {
|
||||||
|
s.hub.Publish(p.AlbumID, "photo.created", p)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermanentDelete removes the photo row; stored objects are deleted only when no
|
||||||
|
// other album references the same storage key.
|
||||||
|
func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error {
|
||||||
|
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !canDeletePhoto(role, p.UploaderID == userID) {
|
||||||
|
return httpx.Forbidden("forbidden", "you cannot delete this photo")
|
||||||
|
}
|
||||||
|
if err := s.store.HardDelete(ctx, photoID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.AlbumID != "" {
|
||||||
|
s.publishDeleted(p)
|
||||||
|
}
|
||||||
|
if n, err := s.store.StorageKeyRefCount(ctx, p.StorageKey); err == nil && n == 0 {
|
||||||
|
keys := []string{p.StorageKey}
|
||||||
|
if p.ThumbKey != "" {
|
||||||
|
keys = append(keys, p.ThumbKey)
|
||||||
|
}
|
||||||
|
if p.PreviewKey != "" {
|
||||||
|
keys = append(keys, p.PreviewKey)
|
||||||
|
}
|
||||||
|
_ = s.storage.Delete(ctx, keys...)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveBatch moves photos to another album (metadata only; stored objects untouched).
|
||||||
|
func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) {
|
||||||
|
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !roles.CanUpload(targetRole) {
|
||||||
|
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||||
|
}
|
||||||
|
moved := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
p, err := s.store.GetByID(ctx, id, userID)
|
||||||
|
if err != nil || p.DeletedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldAlbum := p.AlbumID
|
||||||
|
if oldAlbum == targetAlbumID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, srcRole, err := s.albums.Access(ctx, oldAlbum, userID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !canDeletePhoto(srcRole, p.UploaderID == userID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.AlbumID = targetAlbumID
|
||||||
|
s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
|
||||||
|
"id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID,
|
||||||
|
})
|
||||||
|
if err := s.attachURLs(ctx, p); err == nil {
|
||||||
|
s.hub.Publish(targetAlbumID, "photo.created", p)
|
||||||
|
}
|
||||||
|
moved++
|
||||||
|
}
|
||||||
|
return moved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyBatch clones photos into another album (metadata only; stored objects shared).
|
||||||
|
func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) {
|
||||||
|
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !roles.CanUpload(targetRole) {
|
||||||
|
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||||
|
}
|
||||||
|
out := []models.Photo{}
|
||||||
|
for _, id := range ids {
|
||||||
|
src, err := s.store.GetByID(ctx, id, userID)
|
||||||
|
if err != nil || src.DeletedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newID := auth.NewID()
|
||||||
|
if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clone, err := s.store.GetByID(ctx, newID, userID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.attachURLs(ctx, clone); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID)
|
||||||
|
s.hub.Publish(targetAlbumID, "photo.created", clone)
|
||||||
|
out = append(out, *clone)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trash lists soft-deleted photos from all albums the user belongs to.
|
||||||
|
func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||||
|
out, err := s.store.ListTrash(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
_ = s.attachURLs(ctx, &out[i])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrashCount returns the number of photos in the user's trash.
|
||||||
|
func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
|
||||||
|
return s.store.TrashCount(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags lists the user's tags, optionally filtered by a search query.
|
||||||
|
func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
||||||
|
return s.store.ListTags(ctx, userID, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taggablePhoto(ctx context.Context, photoID, userID string) (*models.Photo, error) {
|
||||||
|
p, err := s.store.GetByID(ctx, photoID, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.DeletedAt != nil {
|
||||||
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
|
if p.AlbumID == "" {
|
||||||
|
return nil, httpx.NotFound("photo_not_found", "photo not found")
|
||||||
|
}
|
||||||
|
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
|
||||||
|
func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
|
||||||
|
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag, err := s.store.EnsureTag(ctx, userID, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, httpx.BadRequest("invalid_tag", "tag name is required")
|
||||||
|
}
|
||||||
|
if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.Get(ctx, photoID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTag detaches one of the user's tags from a photo.
|
||||||
|
func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
|
||||||
|
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, httpx.NotFound("tag_not_found", "tag not found")
|
||||||
|
}
|
||||||
|
if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.Get(ctx, photoID, userID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package photos
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"shared-album/internal/auth"
|
||||||
"shared-album/internal/models"
|
"shared-album/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,40 +23,71 @@ type Store struct {
|
|||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||||
|
|
||||||
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
|
||||||
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
||||||
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
||||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||||
(SELECT count(*) FROM comments c WHERE c.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, p.deleted_at,
|
||||||
|
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
|
||||||
|
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||||
|
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
|
||||||
|
|
||||||
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||||
var p models.Photo
|
var p models.Photo
|
||||||
|
var exifText, tagsText string
|
||||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.PreviewKey,
|
&p.ThumbKey, &p.PreviewKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe)
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
setExif(&p, exifText)
|
||||||
|
setTags(&p, tagsText)
|
||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setTags(p *models.Photo, raw string) {
|
||||||
|
if raw == "" || raw == "null" || raw == "[]" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tags []models.Tag
|
||||||
|
if err := json.Unmarshal([]byte(raw), &tags); err == nil && len(tags) > 0 {
|
||||||
|
p.Tags = tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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,
|
return s.pool.QueryRow(ctx,
|
||||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
|
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
|
||||||
size, width, height, mime_type, taken_at, latitude, longitude, caption)
|
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)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
|
||||||
RETURNING created_at`,
|
RETURNING created_at`,
|
||||||
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
|
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)
|
).Scan(&p.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +120,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
|||||||
`SELECT `+photoColumns+`
|
`SELECT `+photoColumns+`
|
||||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||||
WHERE p.album_id = $1
|
WHERE p.album_id = $1
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
||||||
AND ($4::timestamptz IS NULL OR p.created_at > $4)
|
AND ($4::timestamptz IS NULL OR p.created_at > $4)
|
||||||
ORDER BY p.created_at DESC, p.id DESC
|
ORDER BY p.created_at DESC, p.id DESC
|
||||||
@@ -98,20 +134,78 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
|||||||
out := []models.Photo{}
|
out := []models.Photo{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p models.Photo
|
var p models.Photo
|
||||||
|
var exifText, tagsText string
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.PreviewKey,
|
&p.ThumbKey, &p.PreviewKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe); err != nil {
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
setExif(&p, exifText)
|
||||||
|
setTags(&p, tagsText)
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error {
|
||||||
|
var tag pgconn.CommandTag
|
||||||
|
var err error
|
||||||
|
if deleted {
|
||||||
|
tag, err = s.pool.Exec(ctx,
|
||||||
|
`UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||||
|
} else {
|
||||||
|
tag, err = s.pool.Exec(ctx,
|
||||||
|
`UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone creates a new photo row that references the same stored objects as src.
|
||||||
|
func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
|
||||||
|
exifJSON, err := json.Marshal(src.EXIF)
|
||||||
|
if err != nil || len(exifJSON) == 0 {
|
||||||
|
exifJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
_, err = s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
|
||||||
|
original_filename, 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,$15,$16::jsonb)`,
|
||||||
|
id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
|
||||||
|
src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
|
||||||
|
src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
|
||||||
|
func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HardDelete permanently removes the photo row.
|
||||||
|
func (s *Store) HardDelete(ctx context.Context, id string) error {
|
||||||
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -122,6 +216,228 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
|
// ListTrash returns soft-deleted photos from albums the user belongs to, plus
|
||||||
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID)
|
// their own photos whose album no longer exists.
|
||||||
|
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT `+photoColumns+`, COALESCE(a.title, '')
|
||||||
|
FROM photos p
|
||||||
|
JOIN users u ON u.id = p.uploader_id
|
||||||
|
LEFT JOIN albums a ON a.id = p.album_id
|
||||||
|
WHERE p.deleted_at IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
||||||
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
||||||
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||||
|
)
|
||||||
|
ORDER BY p.deleted_at DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
userID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []models.Photo{}
|
||||||
|
for rows.Next() {
|
||||||
|
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.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, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
setExif(&p, exifText)
|
||||||
|
setTags(&p, tagsText)
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrashCount counts soft-deleted photos accessible to the user.
|
||||||
|
func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM photos p
|
||||||
|
LEFT JOIN albums a ON a.id = p.album_id
|
||||||
|
WHERE p.deleted_at IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
||||||
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
||||||
|
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||||
|
)`, userID).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
|
||||||
|
func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
|
||||||
|
tag, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumExists reports whether an album row exists.
|
||||||
|
func (s *Store) AlbumExists(ctx context.Context, albumID string) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, name FROM tags
|
||||||
|
WHERE user_id = $1 AND ($2 = '' OR name ILIKE '%' || $2 || '%')
|
||||||
|
ORDER BY lower(name) LIMIT 50`, userID, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []models.Tag{}
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Tag
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureTag returns the user's tag with the given name, creating it if needed.
|
||||||
|
func (s *Store) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || len(name) > 64 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
var t models.Tag
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
||||||
|
Scan(&t.ID, &t.Name)
|
||||||
|
if err == nil {
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
id := auth.NewID()
|
||||||
|
err = s.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO tags (id, user_id, name) VALUES ($1,$2,$3) RETURNING id, name`, id, userID, name).
|
||||||
|
Scan(&t.ID, &t.Name)
|
||||||
|
if err != nil {
|
||||||
|
if e2 := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
||||||
|
Scan(&t.ID, &t.Name); e2 == nil {
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AttachTag(ctx context.Context, photoID, tagID string) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, photoID, tagID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DetachTag(ctx context.Context, photoID, tagID string) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
|
||||||
|
var ok bool
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
|
||||||
|
return ok, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
||||||
|
_, _ = s.pool.Exec(ctx,
|
||||||
|
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
||||||
|
albumID, photoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"shared-album/internal/auth"
|
"shared-album/internal/auth"
|
||||||
"shared-album/internal/members"
|
"shared-album/internal/members"
|
||||||
"shared-album/internal/models"
|
"shared-album/internal/models"
|
||||||
|
"shared-album/internal/photos"
|
||||||
"shared-album/internal/sharelinks"
|
"shared-album/internal/sharelinks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,6 +124,71 @@ func TestAlbumRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlbumCoverRepository(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
defer pool.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
store := albums.NewStore(pool)
|
||||||
|
|
||||||
|
owner := newUser(t, ctx, pool, "CoverOwner")
|
||||||
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
|
||||||
|
|
||||||
|
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Cover"}
|
||||||
|
if err := store.Create(ctx, a); err != nil {
|
||||||
|
t.Fatalf("create album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
insertPhoto := func(id, thumbKey string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
id, a.ID, owner.ID, "albums/"+a.ID+"/original/"+id+".jpg", thumbKey); err != nil {
|
||||||
|
t.Fatalf("insert photo: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
photo1 := auth.NewID()
|
||||||
|
insertPhoto(photo1, "albums/"+a.ID+"/thumb/"+photo1+".webp")
|
||||||
|
|
||||||
|
got, err := store.GetByID(ctx, a.ID)
|
||||||
|
if err != nil || got.CoverThumbKey != "" {
|
||||||
|
t.Fatalf("expected empty cover key before set: %v %q", err, got.CoverThumbKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := photos.NewStore(pool)
|
||||||
|
ps.SetCoverIfEmpty(ctx, a.ID, photo1)
|
||||||
|
got, _ = store.GetByID(ctx, a.ID)
|
||||||
|
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
|
||||||
|
t.Fatalf("cover not set: %+v", got.CoverPhotoID)
|
||||||
|
}
|
||||||
|
if want := "albums/" + a.ID + "/thumb/" + photo1 + ".webp"; got.CoverThumbKey != want {
|
||||||
|
t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
photo2 := auth.NewID()
|
||||||
|
insertPhoto(photo2, "thumb-2")
|
||||||
|
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
|
||||||
|
got, _ = store.GetByID(ctx, a.ID)
|
||||||
|
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
|
||||||
|
t.Fatalf("existing cover was overridden: %+v", got.CoverPhotoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo1); err != nil {
|
||||||
|
t.Fatalf("delete cover photo: %v", err)
|
||||||
|
}
|
||||||
|
got, _ = store.GetByID(ctx, a.ID)
|
||||||
|
if got.CoverPhotoID != nil {
|
||||||
|
t.Fatalf("cover not cleared after delete: %+v", got.CoverPhotoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
|
||||||
|
list, err := store.ListForUser(ctx, owner.ID)
|
||||||
|
if err != nil || len(list) != 1 || list[0].CoverThumbKey != "thumb-2" {
|
||||||
|
t.Fatalf("list cover key: %v %+v", err, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMemberRepository(t *testing.T) {
|
func TestMemberRepository(t *testing.T) {
|
||||||
pool := testPool(t)
|
pool := testPool(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|||||||
@@ -122,8 +122,23 @@ func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
|
|||||||
return c.Delete(ctx, keys...)
|
return c.Delete(ctx, keys...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) {
|
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
||||||
out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
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),
|
Bucket: aws.String(c.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
}, s3.WithPresignExpires(c.presignTTL))
|
}, s3.WithPresignExpires(c.presignTTL))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Clear any dangling cover references before adding the constraint.
|
||||||
|
UPDATE albums
|
||||||
|
SET cover_photo_id = NULL
|
||||||
|
WHERE cover_photo_id IS NOT NULL
|
||||||
|
AND cover_photo_id NOT IN (SELECT id FROM photos);
|
||||||
|
|
||||||
|
ALTER TABLE albums
|
||||||
|
ADD CONSTRAINT albums_cover_photo_fk
|
||||||
|
FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS preferences;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS photos_deleted_idx;
|
||||||
|
ALTER TABLE photos DROP COLUMN IF EXISTS deleted_at;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE photos ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||||
|
CREATE INDEX IF NOT EXISTS photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
|
||||||
|
DELETE FROM photos WHERE album_id IS NULL;
|
||||||
|
ALTER TABLE photos ALTER COLUMN album_id SET NOT NULL;
|
||||||
|
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
|
||||||
|
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
|
||||||
|
ALTER TABLE photos ALTER COLUMN album_id DROP NOT NULL;
|
||||||
|
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
|
||||||
|
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS photo_tags;
|
||||||
|
DROP TABLE IF EXISTS tags;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tags_user_name_idx ON tags (user_id, lower(name));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_tags (
|
||||||
|
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
tag_id text NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (photo_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS photo_tags_tag_idx ON photo_tags (tag_id);
|
||||||
+54
-17
@@ -15,39 +15,45 @@
|
|||||||
--primary-fg: 0 0% 100%;
|
--primary-fg: 0 0% 100%;
|
||||||
--danger: 0 84% 60%;
|
--danger: 0 84% 60%;
|
||||||
--success: 142 71% 45%;
|
--success: 142 71% 45%;
|
||||||
|
--chrome: 0 0% 98%;
|
||||||
|
--canvas: 0 0% 100%;
|
||||||
--shadow: 0 0% 0% / 0.08;
|
--shadow: 0 0% 0% / 0.08;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Тёмная */
|
/* Тёмная (графит Lightroom) */
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--bg: 0 0% 7%;
|
--bg: 0 0% 9%;
|
||||||
--bg-elevated: 0 0% 11%;
|
--bg-elevated: 0 0% 15%;
|
||||||
--bg-muted: 0 0% 16%;
|
--bg-muted: 0 0% 18%;
|
||||||
--border: 0 0% 22%;
|
--border: 0 0% 22%;
|
||||||
--text: 0 0% 96%;
|
--text: 0 0% 92%;
|
||||||
--text-muted: 0 0% 65%;
|
--text-muted: 0 0% 65%;
|
||||||
--text-subtle: 0 0% 45%;
|
--text-subtle: 0 0% 45%;
|
||||||
--primary: 217 91% 60%;
|
--primary: 217 91% 60%;
|
||||||
--primary-fg: 0 0% 100%;
|
--primary-fg: 0 0% 100%;
|
||||||
--danger: 0 84% 65%;
|
--danger: 0 84% 65%;
|
||||||
--success: 142 65% 50%;
|
--success: 142 65% 50%;
|
||||||
|
--chrome: 0 0% 13%;
|
||||||
|
--canvas: 0 0% 9%;
|
||||||
--shadow: 0 0% 0% / 0.4;
|
--shadow: 0 0% 0% / 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Нейтральная (тёплая серо-бежевая) */
|
/* Нейтральная (тёплый графит) */
|
||||||
[data-theme='neutral'] {
|
[data-theme='neutral'] {
|
||||||
--bg: 40 20% 96%;
|
--bg: 30 12% 18%;
|
||||||
--bg-elevated: 40 22% 92%;
|
--bg-elevated: 30 12% 23%;
|
||||||
--bg-muted: 40 18% 87%;
|
--bg-muted: 30 11% 28%;
|
||||||
--border: 40 14% 78%;
|
--border: 30 9% 34%;
|
||||||
--text: 30 10% 15%;
|
--text: 35 18% 93%;
|
||||||
--text-muted: 30 8% 40%;
|
--text-muted: 32 10% 68%;
|
||||||
--text-subtle: 30 6% 55%;
|
--text-subtle: 30 7% 50%;
|
||||||
--primary: 25 90% 50%;
|
--primary: 25 90% 55%;
|
||||||
--primary-fg: 0 0% 100%;
|
--primary-fg: 0 0% 100%;
|
||||||
--danger: 0 72% 55%;
|
--danger: 0 72% 62%;
|
||||||
--success: 142 55% 42%;
|
--success: 142 45% 52%;
|
||||||
--shadow: 30 20% 20% / 0.12;
|
--chrome: 30 12% 15%;
|
||||||
|
--canvas: 30 12% 18%;
|
||||||
|
--shadow: 30 30% 5% / 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -66,3 +72,34 @@ body {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Скроллбары в тон темы */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--border)) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--border));
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: hsl(var(--text-subtle));
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru" data-theme="light">
|
<html lang="ru" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var stored = localStorage.getItem('theme') || 'system';
|
var stored = localStorage.getItem('theme') || 'dark';
|
||||||
var resolved = stored;
|
var resolved = stored;
|
||||||
if (stored === 'system') {
|
if (stored === 'system') {
|
||||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
export const portal: Action<HTMLElement> = (node) => {
|
||||||
|
document.body.appendChild(node);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
+38
-1
@@ -1,5 +1,6 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { auth, type Tokens, type User } from './stores/auth.svelte';
|
import { auth, type Tokens, type User } from './stores/auth.svelte';
|
||||||
|
import type { Photo, Preferences, Tag } from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -97,5 +98,41 @@ export const authApi = {
|
|||||||
}),
|
}),
|
||||||
logout: (refresh_token: string) =>
|
logout: (refresh_token: string) =>
|
||||||
api('/auth/logout', { method: 'POST', body: { refresh_token } }),
|
api('/auth/logout', { method: 'POST', body: { refresh_token } }),
|
||||||
me: () => api<{ user: User }>('/auth/me')
|
me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'),
|
||||||
|
savePreferences: (preferences: Preferences) =>
|
||||||
|
api<{ preferences: Preferences }>('/auth/me/preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: preferences
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const photosApi = {
|
||||||
|
deleteBatch: (ids: string[]) =>
|
||||||
|
api<{ deleted: number }>('/photo-batch/delete', { method: 'POST', body: { ids } }),
|
||||||
|
moveBatch: (ids: string[], albumId: string) =>
|
||||||
|
api<{ moved: number }>('/photo-batch/move', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ids, album_id: albumId }
|
||||||
|
}),
|
||||||
|
copyBatch: (ids: string[], albumId: string) =>
|
||||||
|
api<{ photos: Photo[] }>('/photo-batch/copy', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { ids, album_id: albumId }
|
||||||
|
}),
|
||||||
|
trash: () => api<{ photos: Photo[] }>('/trash'),
|
||||||
|
trashCount: () => api<{ count: number }>('/trash/count'),
|
||||||
|
restore: (id: string, albumId?: string) =>
|
||||||
|
api<{ photo: Photo }>(`/photos/${id}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: albumId ? { album_id: albumId } : {}
|
||||||
|
}),
|
||||||
|
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }),
|
||||||
|
addTag: (id: string, name: string) =>
|
||||||
|
api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
|
||||||
|
removeTag: (id: string, tagId: string) =>
|
||||||
|
api<{ photo: Photo }>(`/photos/${id}/tags/${tagId}`, { method: 'DELETE' })
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tagsApi = {
|
||||||
|
list: (q = '') => api<{ tags: Tag[] }>(`/tags${q ? `?q=${encodeURIComponent(q)}` : ''}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { albumActions } from '$lib/stores/album-actions.svelte';
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const a = albumActions.renaming;
|
||||||
|
if (a) {
|
||||||
|
title = a.title;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function autofocus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
node.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRename(ev: Event) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const a = albumActions.renaming;
|
||||||
|
if (!a || !title.trim() || busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await albums.rename(a.id, title.trim());
|
||||||
|
albumActions.renaming = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const a = albumActions.deleting;
|
||||||
|
if (!a) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await albums.destroy(a.id);
|
||||||
|
albumActions.deleting = null;
|
||||||
|
if ($page.params.id === a.id) await goto('/');
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if albumActions.renaming}
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Переименовать альбом"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy) albumActions.renaming = null;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && !busy) albumActions.renaming = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
|
||||||
|
onsubmit={saveRename}
|
||||||
|
>
|
||||||
|
<h2 class="text-text text-lg font-semibold">Переименовать альбом</h2>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-text-muted mb-1 block text-sm">Название</span>
|
||||||
|
<input
|
||||||
|
bind:value={title}
|
||||||
|
use:autofocus
|
||||||
|
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={() => (albumActions.renaming = null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !title.trim()}
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Icon name="check" class="size-4" />
|
||||||
|
{busy ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if albumActions.deleting}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить альбом?"
|
||||||
|
message="Альбом «{albumActions.deleting.title}» будет удалён."
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onClose={() => (albumActions.deleting = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, photosApi } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import CoverPicker from './CoverPicker.svelte';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
|
import PanelSection from './PanelSection.svelte';
|
||||||
|
import PhotoPanel from './PhotoPanel.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
const albumId = $derived($page.params.id ?? '');
|
||||||
|
const photoId = $derived($page.params.photoId ?? '');
|
||||||
|
|
||||||
|
let album = $state<Album | null>(null);
|
||||||
|
let role = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let picking = $state(false);
|
||||||
|
|
||||||
|
let photo = $state<Photo | null>(null);
|
||||||
|
|
||||||
|
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
|
||||||
|
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
|
||||||
|
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
|
||||||
|
|
||||||
|
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||||
|
|
||||||
|
let pendingDeleteSelected = $state(false);
|
||||||
|
let deletingSelected = $state(false);
|
||||||
|
|
||||||
|
async function deleteSelected() {
|
||||||
|
deletingSelected = true;
|
||||||
|
try {
|
||||||
|
await photosApi.deleteBatch(selection.ids);
|
||||||
|
selection.clear();
|
||||||
|
pendingDeleteSelected = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
deletingSelected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSelected() {
|
||||||
|
const first = selectedItems[0];
|
||||||
|
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRenamed(updated: Photo) {
|
||||||
|
photo = updated;
|
||||||
|
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleted() {
|
||||||
|
selection.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
error = '';
|
||||||
|
if (!id) {
|
||||||
|
album = null;
|
||||||
|
role = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api<{ album: Album; role: string }>(`/albums/${id}`)
|
||||||
|
.then((data) => {
|
||||||
|
album = data.album;
|
||||||
|
role = data.role;
|
||||||
|
albums.upsert(data.album);
|
||||||
|
})
|
||||||
|
.catch((e) => (error = (e as Error).message));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = photoId;
|
||||||
|
if (!id) {
|
||||||
|
photo = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api<{ photo: Photo }>(`/photos/${id}`)
|
||||||
|
.then((data) => (photo = data.photo))
|
||||||
|
.catch((e) => (error = (e as Error).message));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resetCover() {
|
||||||
|
if (!album) return;
|
||||||
|
try {
|
||||||
|
album = await albums.setCover(album.id, null);
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLink =
|
||||||
|
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet selectionActions()}
|
||||||
|
<div class="border-border border-b p-4">
|
||||||
|
<p class="text-text-subtle mb-1 text-xs font-semibold tracking-wide uppercase">Действия</p>
|
||||||
|
<nav class="space-y-0.5">
|
||||||
|
<button type="button" class={navLink} onclick={openSelected}>
|
||||||
|
<Icon name="image" class="size-4" />
|
||||||
|
Открыть (просмотр)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={navLink}
|
||||||
|
onclick={() => transfer.start(selection.ids, null, 'move')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow_back" class="size-4" />
|
||||||
|
Переместить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={navLink}
|
||||||
|
onclick={() => transfer.start(selection.ids, null, 'copy')}
|
||||||
|
>
|
||||||
|
<Icon name="content_copy" class="size-4" />
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
||||||
|
onclick={() => (pendingDeleteSelected = true)}
|
||||||
|
>
|
||||||
|
<Icon name="delete" class="size-4" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if panelPhoto}
|
||||||
|
<PhotoPanel
|
||||||
|
photo={panelPhoto}
|
||||||
|
{role}
|
||||||
|
viewer={!!photoId}
|
||||||
|
onOpen={openSelected}
|
||||||
|
onRenamed={onRenamed}
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
/>
|
||||||
|
{:else if !photoId && selectedItems.length > 1}
|
||||||
|
{@render selectionActions()}
|
||||||
|
{:else if album && !photoId}
|
||||||
|
<div>
|
||||||
|
<PanelSection id="album-cover" title="Обложка">
|
||||||
|
<div class="bg-bg-muted border-border aspect-video overflow-hidden rounded-lg border">
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<span class="flex h-full w-full items-center justify-center">
|
||||||
|
<Icon name="image" class="size-10 text-text-subtle" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if canEdit}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
|
||||||
|
onclick={() => (picking = true)}
|
||||||
|
>
|
||||||
|
<Icon name="wallpaper" class="size-4" />
|
||||||
|
Выбрать обложку
|
||||||
|
</button>
|
||||||
|
{#if album.cover_photo_id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center rounded-lg px-3 py-1.5 text-sm"
|
||||||
|
onclick={resetCover}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection id="album-info" title="Информация">
|
||||||
|
<h2 class="text-text text-lg font-semibold">{album.title}</h2>
|
||||||
|
{#if album.description}<p class="text-text-muted text-sm">{album.description}</p>{/if}
|
||||||
|
<p class="text-text-subtle mt-1 text-xs">
|
||||||
|
{album.photos_count ?? 0} фото · {album.members_count ?? 0} участников
|
||||||
|
</p>
|
||||||
|
{#if error}<p class="text-danger mt-2 text-sm">{error}</p>{/if}
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection id="album-nav" title="Навигация">
|
||||||
|
<nav class="space-y-0.5" aria-label="Навигация по альбому">
|
||||||
|
<a class={navLink} href="/albums/{album.id}/members">
|
||||||
|
<Icon name="group" class="size-4" />
|
||||||
|
Участники
|
||||||
|
</a>
|
||||||
|
{#if canEdit}
|
||||||
|
<a class={navLink} href="/albums/{album.id}/settings">
|
||||||
|
<Icon name="settings" class="size-4" />
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a class={navLink} href="/albums/{album.id}/settings#share">
|
||||||
|
<Icon name="add_link" class="size-4" />
|
||||||
|
Ссылки для просмотра
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</PanelSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if picking}
|
||||||
|
<CoverPicker
|
||||||
|
albumId={album.id}
|
||||||
|
onClose={() => (picking = false)}
|
||||||
|
onSelected={(updated) => (album = updated)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingDeleteSelected}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить выбранные фото?"
|
||||||
|
message="{selectedItems.length} фото будут перемещены в корзину."
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={deletingSelected}
|
||||||
|
onConfirm={deleteSelected}
|
||||||
|
onClose={() => (pendingDeleteSelected = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import CreateAlbumDialog from './CreateAlbumDialog.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
|
import { albumActions } from '$lib/stores/album-actions.svelte';
|
||||||
|
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||||
|
import { trash } from '$lib/stores/trash.svelte';
|
||||||
|
import type { Album } from '$lib/types';
|
||||||
|
|
||||||
|
const activeId = $derived($page.params.id ?? '');
|
||||||
|
const onTrash = $derived($page.url.pathname === '/trash');
|
||||||
|
let creating = $state(false);
|
||||||
|
let dropId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function onDragOver(ev: DragEvent, albumId: string) {
|
||||||
|
if (!ev.dataTransfer?.types.includes('application/x-galo-photos')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = 'move';
|
||||||
|
dropId = albumId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(albumId: string) {
|
||||||
|
if (dropId === albumId) dropId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(ev: DragEvent, albumId: string) {
|
||||||
|
dropId = null;
|
||||||
|
const raw = ev.dataTransfer?.getData('application/x-galo-photos');
|
||||||
|
if (!raw) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(raw) as string[];
|
||||||
|
transfer.start(ids, albumId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenu(ev: MouseEvent, album: Album) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
|
||||||
|
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
|
||||||
|
];
|
||||||
|
contextMenu.show(ev.clientX, ev.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
$page.url.pathname;
|
||||||
|
if (auth.isAuthenticated) trash.load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="border-border flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<span class="text-text-muted text-xs font-semibold tracking-wide uppercase">Альбомы</span>
|
||||||
|
{#if auth.isAuthenticated}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||||
|
aria-label="Новый альбом"
|
||||||
|
title="Новый альбом"
|
||||||
|
onclick={() => (creating = true)}
|
||||||
|
>
|
||||||
|
<Icon name="add" class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !auth.isAuthenticated}
|
||||||
|
<p class="text-text-subtle p-3 text-sm">Войдите, чтобы видеть альбомы.</p>
|
||||||
|
{:else if albums.loading && albums.list.length === 0}
|
||||||
|
<p class="text-text-subtle p-3 text-sm">Загрузка…</p>
|
||||||
|
{:else if albums.list.length === 0}
|
||||||
|
<p class="text-text-subtle p-3 text-sm">Альбомов пока нет.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||||
|
{#each albums.list as album (album.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/albums/{album.id}"
|
||||||
|
class="flex items-center gap-2.5 rounded-md border px-2 py-1.5 text-sm transition-colors {album.id ===
|
||||||
|
activeId
|
||||||
|
? 'bg-bg-muted text-text'
|
||||||
|
: 'text-text-muted hover:bg-bg-muted hover:text-text'} {dropId === album.id
|
||||||
|
? 'border-primary ring-primary ring-2'
|
||||||
|
: 'border-transparent'}"
|
||||||
|
aria-current={album.id === activeId ? 'page' : undefined}
|
||||||
|
ondragover={(e) => onDragOver(e, album.id)}
|
||||||
|
ondragleave={() => onDragLeave(album.id)}
|
||||||
|
ondrop={(e) => onDrop(e, album.id)}
|
||||||
|
oncontextmenu={(e) => onContextMenu(e, album)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||||
|
>
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img
|
||||||
|
src={album.cover_thumb_url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Icon name="image" class="size-4 text-text-subtle" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{album.title}</span>
|
||||||
|
<span class="text-text-subtle ml-auto shrink-0 text-xs">{album.photos_count ?? 0}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if auth.isAuthenticated}
|
||||||
|
<div class="border-border mt-auto border-t p-2">
|
||||||
|
<a
|
||||||
|
href="/trash"
|
||||||
|
class="flex items-center gap-2.5 rounded-md border px-2 py-1.5 text-sm transition-colors {onTrash
|
||||||
|
? 'bg-bg-muted text-text border-transparent'
|
||||||
|
: 'text-text-muted hover:bg-bg-muted hover:text-text border-transparent'}"
|
||||||
|
aria-current={onTrash ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||||
|
>
|
||||||
|
<Icon name="delete" class="size-4 text-text-subtle" />
|
||||||
|
</span>
|
||||||
|
<span class="truncate">Корзина</span>
|
||||||
|
<span class="text-text-subtle ml-auto shrink-0 text-xs">{trash.count}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if creating}
|
||||||
|
<CreateAlbumDialog
|
||||||
|
onClose={() => (creating = false)}
|
||||||
|
onCreated={(album) => goto(`/albums/${album.id}`)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { authApi } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import ThemeSwitcher from './ThemeSwitcher.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
rail?: Snippet;
|
||||||
|
panel?: Snippet;
|
||||||
|
panelVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, rail, panel, panelVisible = true }: Props = $props();
|
||||||
|
|
||||||
|
let railOpen = $state(false);
|
||||||
|
let panelOpen = $state(false);
|
||||||
|
|
||||||
|
const RAIL = { min: 176, max: 480, key: 'rail-width', def: 240 };
|
||||||
|
const PANEL = { min: 220, max: 560, key: 'panel-width', def: 288 };
|
||||||
|
|
||||||
|
let railWidth = $state(RAIL.def);
|
||||||
|
let panelWidth = $state(PANEL.def);
|
||||||
|
|
||||||
|
let isFullscreen = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10);
|
||||||
|
if (r >= RAIL.min && r <= RAIL.max) railWidth = r;
|
||||||
|
const p = parseInt(localStorage.getItem(PANEL.key) ?? '', 10);
|
||||||
|
if (p >= PANEL.min && p <= PANEL.max) panelWidth = p;
|
||||||
|
|
||||||
|
const onFs = () => (isFullscreen = !!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onFs);
|
||||||
|
onFs();
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFs);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) await document.exitFullscreen();
|
||||||
|
else await document.documentElement.requestFullscreen();
|
||||||
|
} catch {
|
||||||
|
/* недоступно */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(side: 'left' | 'right', ev: PointerEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const cfg = side === 'left' ? RAIL : PANEL;
|
||||||
|
const startX = ev.clientX;
|
||||||
|
const startW = side === 'left' ? railWidth : panelWidth;
|
||||||
|
const prevCursor = document.body.style.cursor;
|
||||||
|
const prevSelect = document.body.style.userSelect;
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
const move = (e: PointerEvent) => {
|
||||||
|
const dx = e.clientX - startX;
|
||||||
|
const raw = side === 'left' ? startW + dx : startW - dx;
|
||||||
|
const v = Math.min(cfg.max, Math.max(cfg.min, raw));
|
||||||
|
if (side === 'left') railWidth = v;
|
||||||
|
else panelWidth = v;
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
document.body.style.cursor = prevCursor;
|
||||||
|
document.body.style.userSelect = prevSelect;
|
||||||
|
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudge(side: 'left' | 'right', ev: KeyboardEvent) {
|
||||||
|
const cfg = side === 'left' ? RAIL : PANEL;
|
||||||
|
let delta = 0;
|
||||||
|
if (ev.key === 'ArrowLeft') delta = side === 'left' ? -16 : 16;
|
||||||
|
else if (ev.key === 'ArrowRight') delta = side === 'left' ? 16 : -16;
|
||||||
|
else return;
|
||||||
|
ev.preventDefault();
|
||||||
|
if (side === 'left') railWidth = Math.min(cfg.max, Math.max(cfg.min, railWidth + delta));
|
||||||
|
else panelWidth = Math.min(cfg.max, Math.max(cfg.min, panelWidth + delta));
|
||||||
|
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle =
|
||||||
|
'bg-transparent hover:bg-primary/40 focus-visible:bg-primary/40 hidden w-1 shrink-0 cursor-col-resize p-0 transition-colors outline-none';
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
if (auth.refresh) {
|
||||||
|
await authApi.logout(auth.refresh).catch(() => {});
|
||||||
|
}
|
||||||
|
auth.clear();
|
||||||
|
goto('/login');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-canvas text-text flex h-screen flex-col overflow-hidden">
|
||||||
|
<header
|
||||||
|
class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-2 py-0.5 sm:px-3"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1 lg:hidden"
|
||||||
|
aria-label="Альбомы"
|
||||||
|
title="Альбомы"
|
||||||
|
onclick={() => (railOpen = true)}
|
||||||
|
>
|
||||||
|
<Icon name="menu" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<a href="/" class="inline-flex items-center gap-1.5 truncate text-sm font-semibold">
|
||||||
|
<Icon name="photo_camera" class="size-4" />
|
||||||
|
Galo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 sm:gap-1.5">
|
||||||
|
{#if panel && panelVisible}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1 xl:hidden"
|
||||||
|
aria-label="Панель"
|
||||||
|
title="Панель"
|
||||||
|
onclick={() => (panelOpen = true)}
|
||||||
|
>
|
||||||
|
<Icon name="wallpaper" class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if auth.isAuthenticated}
|
||||||
|
<span class="text-text-muted hidden text-xs sm:inline">{auth.user?.name ?? ''}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
|
||||||
|
onclick={logout}
|
||||||
|
>
|
||||||
|
<Icon name="logout" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Выйти</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Icon name="login" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">Войти</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<ThemeSwitcher />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||||
|
aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'}
|
||||||
|
title={isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'}
|
||||||
|
aria-pressed={isFullscreen}
|
||||||
|
onclick={toggleFullscreen}
|
||||||
|
>
|
||||||
|
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="relative flex min-h-0 flex-1">
|
||||||
|
{#if railOpen}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
aria-label="Закрыть панель альбомов"
|
||||||
|
onclick={() => (railOpen = false)}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
<aside
|
||||||
|
class="border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 -translate-x-full overflow-y-auto border-r transition-transform lg:static lg:z-auto lg:w-[var(--rail-w)] lg:translate-x-0 {railOpen
|
||||||
|
? 'translate-x-0'
|
||||||
|
: ''}"
|
||||||
|
style="--rail-w: {railWidth}px"
|
||||||
|
aria-label="Альбомы"
|
||||||
|
>
|
||||||
|
{#if rail}{@render rail()}{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="{handle} lg:block"
|
||||||
|
aria-label="Изменить ширину панели альбомов"
|
||||||
|
title="Потяните, чтобы изменить ширину"
|
||||||
|
onpointerdown={(e) => startResize('left', e)}
|
||||||
|
onkeydown={(e) => nudge('left', e)}
|
||||||
|
></button>
|
||||||
|
|
||||||
|
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if panel && panelVisible}
|
||||||
|
{#if panelOpen}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fixed inset-0 z-30 bg-black/50 xl:hidden"
|
||||||
|
aria-label="Закрыть панель"
|
||||||
|
onclick={() => (panelOpen = false)}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="{handle} xl:block"
|
||||||
|
aria-label="Изменить ширину боковой панели"
|
||||||
|
title="Потяните, чтобы изменить ширину"
|
||||||
|
onpointerdown={(e) => startResize('right', e)}
|
||||||
|
onkeydown={(e) => nudge('right', e)}
|
||||||
|
></button>
|
||||||
|
<aside
|
||||||
|
class="border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 translate-x-full overflow-y-auto border-l transition-transform xl:static xl:z-auto xl:w-[var(--panel-w)] xl:translate-x-0 {panelOpen
|
||||||
|
? 'translate-x-0'
|
||||||
|
: ''}"
|
||||||
|
style="--panel-w: {panelWidth}px"
|
||||||
|
aria-label="Панель"
|
||||||
|
>
|
||||||
|
{@render panel()}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -75,18 +75,20 @@
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if auth.isAuthenticated}
|
{#if auth.isAuthenticated}
|
||||||
<form class="mt-2 flex gap-2" onsubmit={submit}>
|
<form class="relative mt-2" onsubmit={submit}>
|
||||||
<input
|
<input
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
placeholder="Комментарий…"
|
placeholder="Комментарий…"
|
||||||
class="border-border bg-bg text-text flex-1 rounded-lg border px-3 py-1.5 text-sm"
|
class="border-border bg-bg text-text w-full rounded-lg border py-1.5 pr-10 pl-3 text-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
|
class="text-primary hover:bg-bg-muted absolute top-1/2 right-1 inline-flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 disabled:opacity-50"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
aria-label="Отправить"
|
||||||
|
title="Отправить"
|
||||||
|
disabled={!text.trim()}
|
||||||
>
|
>
|
||||||
<Icon name="send" class="size-4" />
|
<Icon name="send" class="size-4" />
|
||||||
Отправить
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
busy?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Удалить',
|
||||||
|
busy = false,
|
||||||
|
onConfirm,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
|
>
|
||||||
|
<div class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5">
|
||||||
|
<h2 class="text-text text-lg font-semibold">{title}</h2>
|
||||||
|
{#if message}<p class="text-text-muted break-words text-sm">{message}</p>{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
class="bg-danger text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
onclick={onConfirm}
|
||||||
|
>
|
||||||
|
<Icon name="delete" class="size-4" />
|
||||||
|
{busy ? 'Удаление…' : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import { contextMenu } from '$lib/stores/context-menu.svelte';
|
||||||
|
|
||||||
|
const width = 200;
|
||||||
|
const itemH = 34;
|
||||||
|
|
||||||
|
const style = $derived(
|
||||||
|
(() => {
|
||||||
|
const pad = 8;
|
||||||
|
let x = contextMenu.x;
|
||||||
|
let y = contextMenu.y;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
x = Math.min(x, window.innerWidth - width - pad);
|
||||||
|
y = Math.min(y, window.innerHeight - contextMenu.items.length * itemH - pad);
|
||||||
|
}
|
||||||
|
return `left: ${Math.max(pad, x)}px; top: ${Math.max(pad, y)}px; min-width: ${width}px;`;
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
function run(item: (typeof contextMenu.items)[number]) {
|
||||||
|
contextMenu.close();
|
||||||
|
item.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape') contextMenu.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={onKey}
|
||||||
|
onresize={() => contextMenu.close()}
|
||||||
|
onscroll={() => contextMenu.close()}
|
||||||
|
onclick={() => contextMenu.close()}
|
||||||
|
oncontextmenu={() => contextMenu.close()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if contextMenu.open}
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="border-border bg-bg-elevated fixed z-[70] overflow-hidden rounded-lg border py-1 shadow-lg"
|
||||||
|
style={style}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
oncontextmenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{#each contextMenu.items as item (item.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm {item.danger
|
||||||
|
? 'text-danger hover:bg-bg-muted'
|
||||||
|
: 'text-text hover:bg-bg-muted'}"
|
||||||
|
onclick={() => run(item)}
|
||||||
|
>
|
||||||
|
<Icon name={item.icon} class="size-4" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
albumId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelected?: (album: Album) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { albumId, onClose, onSelected }: Props = $props();
|
||||||
|
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`)
|
||||||
|
.then((data) => (photos = data.photos))
|
||||||
|
.catch((e) => (error = (e as Error).message))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function choose(photo: Photo) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const album = await albums.setCover(albumId, photo.id);
|
||||||
|
onSelected?.(album);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Выбор обложки"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
|
>
|
||||||
|
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border">
|
||||||
|
<div class="border-border flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<h2 class="font-semibold">Выбрать обложку</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
title="Закрыть"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
{#if error}<p class="text-danger mb-3 text-sm">{error}</p>{/if}
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-text-muted text-sm">Загрузка…</p>
|
||||||
|
{:else if photos.length === 0}
|
||||||
|
<p class="text-text-muted text-sm">В альбоме нет фотографий.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5">
|
||||||
|
{#each photos as photo (photo.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
class="border-border bg-bg-muted block aspect-square w-full overflow-hidden rounded-lg border hover:border-primary disabled:opacity-50"
|
||||||
|
onclick={() => choose(photo)}
|
||||||
|
>
|
||||||
|
{#if photo.thumb_url}
|
||||||
|
<img
|
||||||
|
src={photo.thumb_url}
|
||||||
|
alt={photo.original_filename}
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
|
||||||
|
обработка…
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import type { Album } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated?: (album: Album) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onClose, onCreated }: Props = $props();
|
||||||
|
|
||||||
|
let title = $state('');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
async function submit(ev: Event) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!title.trim() || busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const album = await albums.create(title.trim());
|
||||||
|
onCreated?.(album);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function autofocus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Новый альбом"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
|
||||||
|
onsubmit={submit}
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold">Новый альбом</h2>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-text-muted mb-1 block text-sm">Название</span>
|
||||||
|
<input
|
||||||
|
bind:value={title}
|
||||||
|
use:autofocus
|
||||||
|
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
|
||||||
|
placeholder="Например, Отпуск"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !title.trim()}
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Icon name="add" class="size-4" />
|
||||||
|
{busy ? 'Создание…' : 'Создать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon name="grid_view" class="text-text-subtle size-4" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="8"
|
||||||
|
step="1"
|
||||||
|
value={density.cols}
|
||||||
|
oninput={(e) => density.set(Number(e.currentTarget.value))}
|
||||||
|
class="accent-primary h-1 w-24 cursor-pointer"
|
||||||
|
aria-label="Размер миниатюр"
|
||||||
|
title="Размер миниатюр"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import type { Photo } from '$lib/types';
|
|
||||||
import Comments from './Comments.svelte';
|
|
||||||
import Icon from './Icon.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
photos: Photo[];
|
|
||||||
index: number;
|
|
||||||
onClose: () => void;
|
|
||||||
onIndexChange: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { photos, index, onClose, onIndexChange }: Props = $props();
|
|
||||||
|
|
||||||
let fresh = $state<Photo | null>(null);
|
|
||||||
let touchX = 0;
|
|
||||||
|
|
||||||
const current = $derived(photos[index]);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = current?.id;
|
|
||||||
fresh = null;
|
|
||||||
if (!id) return;
|
|
||||||
api<{ photo: Photo }>(`/photos/${id}`)
|
|
||||||
.then((data) => (fresh = data.photo))
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
function prev() {
|
|
||||||
if (index > 0) onIndexChange(index - 1);
|
|
||||||
}
|
|
||||||
function next() {
|
|
||||||
if (index < photos.length - 1) onIndexChange(index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(ev: KeyboardEvent) {
|
|
||||||
if (ev.key === 'Escape') onClose();
|
|
||||||
if (ev.key === 'ArrowLeft') prev();
|
|
||||||
if (ev.key === 'ArrowRight') next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTouchStart(ev: TouchEvent) {
|
|
||||||
touchX = ev.changedTouches[0].clientX;
|
|
||||||
}
|
|
||||||
function onTouchEnd(ev: TouchEvent) {
|
|
||||||
const dx = ev.changedTouches[0].clientX - touchX;
|
|
||||||
if (dx > 50) prev();
|
|
||||||
else if (dx < -50) next();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bg-bg/95 fixed inset-0 z-50 flex flex-col"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
tabindex="-1"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) onClose();
|
|
||||||
}}
|
|
||||||
onkeydown={onKey}
|
|
||||||
ontouchstart={onTouchStart}
|
|
||||||
ontouchend={onTouchEnd}
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between p-3">
|
|
||||||
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{#if fresh?.download_url}
|
|
||||||
<a
|
|
||||||
href={fresh.download_url}
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<Icon name="download" class="size-4" />
|
|
||||||
Скачать
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onClose}
|
|
||||||
aria-label="Закрыть"
|
|
||||||
title="Закрыть"
|
|
||||||
class="border-border text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
|
|
||||||
>
|
|
||||||
<Icon name="close" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-1 items-center justify-center overflow-hidden px-2 pb-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center px-2"
|
|
||||||
aria-label="Предыдущее фото"
|
|
||||||
title="Предыдущее фото"
|
|
||||||
onclick={prev}
|
|
||||||
disabled={index === 0}
|
|
||||||
>
|
|
||||||
<Icon name="chevron_left" class="size-8" />
|
|
||||||
</button>
|
|
||||||
{#if current}
|
|
||||||
<img
|
|
||||||
src={fresh?.preview_url ?? fresh?.original_url ?? current.preview_url ?? current.original_url}
|
|
||||||
alt={current.original_filename}
|
|
||||||
class="max-h-[75vh] max-w-full rounded-lg object-contain"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center px-2"
|
|
||||||
aria-label="Следующее фото"
|
|
||||||
title="Следующее фото"
|
|
||||||
onclick={next}
|
|
||||||
disabled={index === photos.length - 1}
|
|
||||||
>
|
|
||||||
<Icon name="chevron_right" class="size-8" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if current}
|
|
||||||
<div class="border-border bg-bg-elevated max-h-56 overflow-y-auto border-t p-3">
|
|
||||||
<p class="text-text text-sm">
|
|
||||||
{current.uploader.name} · {current.original_filename}
|
|
||||||
</p>
|
|
||||||
<Comments photoId={current.id} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
open?: boolean;
|
||||||
|
actions?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, title, open = true, actions, children }: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let isOpen = $state(open);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const v = localStorage.getItem(`panel-section-${id}`);
|
||||||
|
if (v === '0') isOpen = false;
|
||||||
|
else if (v === '1') isOpen = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
localStorage.setItem(`panel-section-${id}`, isOpen ? '1' : '0');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="border-border border-b">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text flex flex-1 items-center justify-between gap-2 px-4 py-2.5 text-left"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onclick={toggle}
|
||||||
|
>
|
||||||
|
<span class="text-xs font-semibold tracking-wide uppercase">{title}</span>
|
||||||
|
<Icon
|
||||||
|
name="expand_more"
|
||||||
|
class="size-4 shrink-0 transition-transform {isOpen ? 'rotate-180' : ''}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{#if actions}
|
||||||
|
<div class="flex items-center pr-3">{@render actions()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="px-4 pb-4">{@render children()}</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -1,25 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
|
selectedIds?: string[];
|
||||||
|
onSelect?: (index: number, event: MouseEvent) => void;
|
||||||
onOpen?: (index: number) => void;
|
onOpen?: (index: number) => void;
|
||||||
|
onDragStart?: (index: number, event: DragEvent) => void;
|
||||||
|
onContextMenu?: (index: number, event: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { photos, onOpen }: Props = $props();
|
let { photos, selectedIds = [], onSelect, onOpen, onDragStart, onContextMenu }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const isSelected = (id: string) => selectedIds.includes(id);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if photos.length === 0}
|
{#if photos.length === 0}
|
||||||
<p class="text-text-muted p-8 text-center">Пока нет фотографий.</p>
|
<p class="text-text-muted p-8 text-center">Пока нет фотографий.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
<ul
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||||
|
>
|
||||||
{#each photos as photo, i (photo.id)}
|
{#each photos as photo, i (photo.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border"
|
draggable={isSelected(photo.id)}
|
||||||
onclick={() => onOpen?.(i)}
|
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border {isSelected(
|
||||||
|
photo.id
|
||||||
|
)
|
||||||
|
? 'ring-primary ring-2'
|
||||||
|
: ''}"
|
||||||
|
aria-pressed={isSelected(photo.id)}
|
||||||
|
onclick={(e) => onSelect?.(i, e)}
|
||||||
|
ondblclick={() => onOpen?.(i)}
|
||||||
|
ondragstart={(e) => onDragStart?.(i, e)}
|
||||||
|
oncontextmenu={(e) => onContextMenu?.(i, e)}
|
||||||
>
|
>
|
||||||
{#if photo.thumb_url}
|
{#if photo.thumb_url}
|
||||||
<img
|
<img
|
||||||
@@ -27,7 +47,7 @@
|
|||||||
alt={photo.original_filename}
|
alt={photo.original_filename}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
class="pointer-events-none h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
|
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import type { Preferences, PhotoInfoPreferences } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
attributes: string[];
|
||||||
|
defaults: Record<string, string>;
|
||||||
|
visible: string[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
busy?: boolean;
|
||||||
|
onSave: (prefs: Preferences) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
attributes,
|
||||||
|
defaults,
|
||||||
|
visible,
|
||||||
|
labels,
|
||||||
|
busy = false,
|
||||||
|
onSave,
|
||||||
|
onReset,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let checked = $state<Set<string>>(new Set(visible));
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let labelDraft = $state<Record<string, string>>({ ...labels });
|
||||||
|
|
||||||
|
function isChecked(key: string): boolean {
|
||||||
|
return checked.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChecked(key: string, value: boolean) {
|
||||||
|
const next = new Set(checked);
|
||||||
|
if (value) next.add(key);
|
||||||
|
else next.delete(key);
|
||||||
|
checked = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAll() {
|
||||||
|
checked = new Set(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAll() {
|
||||||
|
checked = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const cleanLabels: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(labelDraft)) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed) cleanLabels[key] = trimmed;
|
||||||
|
}
|
||||||
|
const photo_info: PhotoInfoPreferences = {
|
||||||
|
visible: attributes.filter((key) => checked.has(key))
|
||||||
|
};
|
||||||
|
if (Object.keys(cleanLabels).length) photo_info.labels = cleanLabels;
|
||||||
|
onSave({ photo_info });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape' && !busy) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Настройки атрибутов"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy) onClose();
|
||||||
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
|
>
|
||||||
|
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-lg flex-col rounded-xl border">
|
||||||
|
<div class="border-border flex items-center justify-between border-b px-5 py-3">
|
||||||
|
<h2 class="text-text text-base font-semibold">Настройки атрибутов</h2>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
|
||||||
|
aria-label="Отметить все"
|
||||||
|
title="Отметить все"
|
||||||
|
onclick={selectAll}
|
||||||
|
>
|
||||||
|
<Icon name="check_all" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
|
||||||
|
aria-label="Снять все"
|
||||||
|
title="Снять все"
|
||||||
|
onclick={deselectAll}
|
||||||
|
>
|
||||||
|
<Icon name="uncheck_all" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
<Icon name="close" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-text-muted px-5 pt-3 text-xs">
|
||||||
|
Отметьте, какие атрибуты показывать, и задайте свои подписи. Скрытые атрибуты доступны по
|
||||||
|
кнопке «…» в разделе «Информация».
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
|
||||||
|
{#if attributes.length === 0}
|
||||||
|
<p class="text-text-subtle py-4 text-sm">Нет доступных атрибутов.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1.5">
|
||||||
|
{#each attributes as key (key)}
|
||||||
|
<li class="flex items-center gap-3">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-primary size-4"
|
||||||
|
checked={isChecked(key)}
|
||||||
|
onchange={(e) => setChecked(key, e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="text-text w-44 shrink-0 truncate text-sm">{defaults[key] ?? key}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="border-border bg-bg text-text min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
|
||||||
|
placeholder={defaults[key] ?? key}
|
||||||
|
maxlength="128"
|
||||||
|
value={labelDraft[key] ?? ''}
|
||||||
|
oninput={(e) => (labelDraft[key] = e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-border flex items-center justify-between gap-2 border-t px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted rounded-lg border px-3 py-1.5 text-xs"
|
||||||
|
onclick={onReset}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
onclick={save}
|
||||||
|
>
|
||||||
|
{busy ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api, photosApi, tagsApi } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import Comments from './Comments.svelte';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
|
import PanelSection from './PanelSection.svelte';
|
||||||
|
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
|
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||||
|
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
|
||||||
|
import type { Photo, Preferences, Tag } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photo: Photo;
|
||||||
|
role: string;
|
||||||
|
viewer?: boolean;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onRenamed?: (photo: Photo) => void;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props();
|
||||||
|
|
||||||
|
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||||
|
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
|
||||||
|
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
|
||||||
|
|
||||||
|
let editingName = $state(false);
|
||||||
|
let nameDraft = $state('');
|
||||||
|
let savingName = $state(false);
|
||||||
|
let nameError = $state('');
|
||||||
|
|
||||||
|
let pendingDelete = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
let settingsOpen = $state(false);
|
||||||
|
let showAll = $state(false);
|
||||||
|
let savingPrefs = $state(false);
|
||||||
|
|
||||||
|
const prefs = $derived(auth.preferences);
|
||||||
|
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
|
||||||
|
const visiblePrefs = $derived(prefs.photo_info?.visible);
|
||||||
|
const effectiveVisible = $derived(
|
||||||
|
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
|
||||||
|
);
|
||||||
|
const exif = $derived((photo.exif ?? {}) as Record<string, unknown>);
|
||||||
|
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
|
||||||
|
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
|
||||||
|
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
|
||||||
|
const attributeKeys = $derived(
|
||||||
|
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
|
||||||
|
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// сброс временного состояния при смене фото
|
||||||
|
photo.id;
|
||||||
|
editingName = false;
|
||||||
|
nameError = '';
|
||||||
|
showAll = false;
|
||||||
|
tagQuery = '';
|
||||||
|
tagOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
let tagQuery = $state('');
|
||||||
|
let tagOptions = $state<Tag[]>([]);
|
||||||
|
let tagOpen = $state(false);
|
||||||
|
let tagBusy = $state(false);
|
||||||
|
|
||||||
|
const attachedTagIds = $derived(new Set((photo.tags ?? []).map((t) => t.id)));
|
||||||
|
const tagSuggestions = $derived(
|
||||||
|
tagOptions.filter((t) => !attachedTagIds.has(t.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const q = tagQuery;
|
||||||
|
if (!tagOpen) return;
|
||||||
|
const handle = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
tagOptions = (await tagsApi.list(q)).tags;
|
||||||
|
} catch {
|
||||||
|
tagOptions = [];
|
||||||
|
}
|
||||||
|
}, 180);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addTag(name: string) {
|
||||||
|
const n = name.trim();
|
||||||
|
if (!n || tagBusy) return;
|
||||||
|
tagBusy = true;
|
||||||
|
try {
|
||||||
|
const res = await photosApi.addTag(photo.id, n);
|
||||||
|
onRenamed?.(res.photo);
|
||||||
|
tagQuery = '';
|
||||||
|
tagOpen = false;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
tagBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(tagId: string) {
|
||||||
|
try {
|
||||||
|
const res = await photosApi.removeTag(photo.id, tagId);
|
||||||
|
onRenamed?.(res.photo);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
nameDraft = photo.original_filename;
|
||||||
|
nameError = '';
|
||||||
|
editingName = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName(ev: Event) {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!nameDraft.trim()) return;
|
||||||
|
savingName = true;
|
||||||
|
nameError = '';
|
||||||
|
try {
|
||||||
|
const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { original_filename: nameDraft.trim() }
|
||||||
|
});
|
||||||
|
onRenamed?.(data.photo);
|
||||||
|
editingName = false;
|
||||||
|
} catch (e) {
|
||||||
|
nameError = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
savingName = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autofocus(node: HTMLInputElement) {
|
||||||
|
node.focus();
|
||||||
|
node.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (!bytes) return '—';
|
||||||
|
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
|
||||||
|
let v = bytes;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePreferences(next: Preferences) {
|
||||||
|
savingPrefs = true;
|
||||||
|
try {
|
||||||
|
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: next
|
||||||
|
});
|
||||||
|
auth.setPreferences(res.preferences ?? {});
|
||||||
|
showAll = false;
|
||||||
|
settingsOpen = false;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
savingPrefs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete() {
|
||||||
|
if (viewer) {
|
||||||
|
viewerActions.requestDelete(photo.id);
|
||||||
|
} else {
|
||||||
|
pendingDelete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await photosApi.deleteBatch([photo.id]);
|
||||||
|
pendingDelete = false;
|
||||||
|
onDeleted?.();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLink =
|
||||||
|
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
|
||||||
|
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="p-4">
|
||||||
|
{#if editingName}
|
||||||
|
<form class="relative" onsubmit={saveName}>
|
||||||
|
<input
|
||||||
|
bind:value={nameDraft}
|
||||||
|
use:autofocus
|
||||||
|
aria-label="Имя файла"
|
||||||
|
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
|
||||||
|
/>
|
||||||
|
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-primary hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1 disabled:opacity-50"
|
||||||
|
aria-label="Сохранить"
|
||||||
|
title="Сохранить"
|
||||||
|
disabled={savingName || !nameDraft.trim()}
|
||||||
|
>
|
||||||
|
<Icon name="check" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||||
|
aria-label="Отмена"
|
||||||
|
title="Отмена"
|
||||||
|
onclick={() => (editingName = false)}
|
||||||
|
>
|
||||||
|
<Icon name="close" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{#if nameError}<p class="text-danger mt-1 text-xs">{nameError}</p>{/if}
|
||||||
|
{:else if canRename}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text hover:text-primary block w-full truncate text-left text-sm font-semibold"
|
||||||
|
title="Переименовать"
|
||||||
|
onclick={startEdit}
|
||||||
|
>
|
||||||
|
{photo.original_filename}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<h2 class="text-text truncate text-sm font-semibold">{photo.original_filename}</h2>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PanelSection id="photo-actions" title="Действия">
|
||||||
|
<nav class="space-y-0.5">
|
||||||
|
{#if !viewer}
|
||||||
|
<button type="button" class={navLink} onclick={() => onOpen?.()}>
|
||||||
|
<Icon name="image" class="size-4" />
|
||||||
|
Открыть (просмотр)
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if photo.download_url}
|
||||||
|
<a class={navLink} href={photo.download_url} target="_blank" rel="noreferrer" download>
|
||||||
|
<Icon name="download" class="size-4" />
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={navLink}
|
||||||
|
onclick={() => transfer.start([photo.id], null, 'move')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow_back" class="size-4" />
|
||||||
|
Переместить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={navLink}
|
||||||
|
onclick={() => transfer.start([photo.id], null, 'copy')}
|
||||||
|
>
|
||||||
|
<Icon name="content_copy" class="size-4" />
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
{#if canDelete}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
||||||
|
onclick={requestDelete}
|
||||||
|
>
|
||||||
|
<Icon name="delete" class="size-4" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection id="photo-tags" title="Метки">
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#if (photo.tags ?? []).length}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each photo.tags ?? [] as tag (tag.id)}
|
||||||
|
<span
|
||||||
|
class="border-border bg-bg-muted text-text inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-danger inline-flex"
|
||||||
|
aria-label="Убрать метку {tag.name}"
|
||||||
|
title="Убрать"
|
||||||
|
onclick={() => removeTag(tag.id)}
|
||||||
|
>
|
||||||
|
<Icon name="close" class="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
bind:value={tagQuery}
|
||||||
|
onfocus={() => (tagOpen = true)}
|
||||||
|
oninput={() => (tagOpen = true)}
|
||||||
|
onblur={() => setTimeout(() => (tagOpen = false), 150)}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(tagQuery);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
tagOpen = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Добавить метку…"
|
||||||
|
maxlength="64"
|
||||||
|
aria-label="Метки фото"
|
||||||
|
class="border-border bg-bg text-text w-full rounded-md border px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
{#if tagOpen && (tagSuggestions.length > 0 || tagQuery.trim())}
|
||||||
|
<div
|
||||||
|
class="border-border bg-bg-elevated absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
{#if tagQuery.trim()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(tagQuery);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="add" class="size-4" />
|
||||||
|
Создать «{tagQuery.trim()}»
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#each tagSuggestions as tag (tag.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(tag.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="label" class="size-4 text-text-subtle" />
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection id="photo-info" title="Информация">
|
||||||
|
{#snippet actions()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||||
|
aria-label="Настройки атрибутов"
|
||||||
|
title="Настройки атрибутов"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
settingsOpen = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="settings" class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
<dl class="space-y-1">
|
||||||
|
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
|
||||||
|
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
|
||||||
|
<div class={metaRow}><span>Тип</span><span class="text-text">{photo.mime_type}</span></div>
|
||||||
|
<div class={metaRow}>
|
||||||
|
<span>Разрешение</span>
|
||||||
|
<span class="text-text">{photo.width}×{photo.height}</span>
|
||||||
|
</div>
|
||||||
|
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
|
||||||
|
<div class={metaRow}>
|
||||||
|
<span>{row.label}</span>
|
||||||
|
<span class="text-text">{row.value}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if hiddenCount > 0 && !showAll}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
|
||||||
|
aria-label="Показать все атрибуты"
|
||||||
|
title="Показать все атрибуты"
|
||||||
|
onclick={() => (showAll = true)}
|
||||||
|
>
|
||||||
|
…
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if photo.taken_at}
|
||||||
|
<div class={metaRow}>
|
||||||
|
<span>Снято</span>
|
||||||
|
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if photo.latitude != null && photo.longitude != null}
|
||||||
|
<div class={metaRow}>
|
||||||
|
<span>Координаты</span>
|
||||||
|
<span class="text-text">{photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class={metaRow}>
|
||||||
|
<span>Загружено</span>
|
||||||
|
<span class="text-text">{new Date(photo.created_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection id="photo-comments" title="Комментарии">
|
||||||
|
<Comments photoId={photo.id} />
|
||||||
|
</PanelSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if settingsOpen}
|
||||||
|
<PhotoInfoSettingsDialog
|
||||||
|
attributes={attributeKeys}
|
||||||
|
defaults={EXIF_LABELS}
|
||||||
|
visible={[...effectiveVisible]}
|
||||||
|
labels={prefs.photo_info?.labels ?? {}}
|
||||||
|
busy={savingPrefs}
|
||||||
|
onSave={savePreferences}
|
||||||
|
onReset={() => savePreferences({})}
|
||||||
|
onClose={() => (settingsOpen = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить фото?"
|
||||||
|
message={photo.original_filename}
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={deleting}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onClose={() => (pendingDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import IconButton from './IconButton.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import type { IconName } from '$lib/icons/registry';
|
import type { IconName } from '$lib/icons/registry';
|
||||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||||
|
|
||||||
const options: { value: Theme; label: string; icon: IconName }[] = [
|
const options: { value: Theme; label: string; icon: IconName }[] = [
|
||||||
{ value: 'light', label: 'Light', icon: 'light_mode' },
|
|
||||||
{ value: 'dark', label: 'Dark', icon: 'dark_mode' },
|
{ value: 'dark', label: 'Dark', icon: 'dark_mode' },
|
||||||
{ value: 'neutral', label: 'Neutral', icon: 'palette' },
|
{ value: 'neutral', label: 'Neutral', icon: 'contrast' },
|
||||||
|
{ value: 'light', label: 'Light', icon: 'light_mode' },
|
||||||
{ value: 'system', label: 'System', icon: 'brightness_auto' }
|
{ value: 'system', label: 'System', icon: 'brightness_auto' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1"
|
class="border-border bg-bg-elevated inline-flex items-center gap-0 rounded-md border p-0.5"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Theme"
|
aria-label="Theme"
|
||||||
>
|
>
|
||||||
{#each options as option (option.value)}
|
{#each options as option (option.value)}
|
||||||
<IconButton
|
<button
|
||||||
name={option.icon}
|
type="button"
|
||||||
label={option.label}
|
class="inline-flex items-center justify-center rounded p-0.5 transition-colors {theme.current ===
|
||||||
active={theme.current === option.value}
|
option.value
|
||||||
pressed={theme.current === option.value}
|
? 'bg-primary text-primary-fg'
|
||||||
class="px-2 py-1 text-sm"
|
: 'text-text-muted hover:bg-bg-muted hover:text-text'}"
|
||||||
|
aria-label={option.label}
|
||||||
|
title={option.label}
|
||||||
|
aria-pressed={theme.current === option.value}
|
||||||
onclick={() => theme.set(option.value)}
|
onclick={() => theme.set(option.value)}
|
||||||
/>
|
>
|
||||||
|
<Icon name={option.icon} class="size-4" />
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
|
import { transfer, type TransferAction } from '$lib/stores/transfer.svelte';
|
||||||
|
|
||||||
|
let targetId = $state('');
|
||||||
|
let action = $state<TransferAction>('move');
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (transfer.open) {
|
||||||
|
targetId = transfer.targetId ?? '';
|
||||||
|
action = transfer.action ?? 'move';
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (transfer.open) albums.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!targetId || busy) return;
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const body = { ids: transfer.ids, album_id: targetId };
|
||||||
|
if (action === 'move') await api('/photo-batch/move', { method: 'POST', body });
|
||||||
|
else await api('/photo-batch/copy', { method: 'POST', body });
|
||||||
|
selection.clear();
|
||||||
|
transfer.close();
|
||||||
|
albums.load();
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === 'Escape' && !busy) transfer.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
|
{#if transfer.open}
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Перенос или копирование"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy) transfer.close();
|
||||||
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
|
>
|
||||||
|
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
|
||||||
|
<div class="border-border flex items-center justify-between border-b px-5 py-3">
|
||||||
|
<h2 class="text-text text-base font-semibold">
|
||||||
|
{action === 'move' ? 'Переместить' : 'Копировать'} {transfer.ids.length} фото
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
onclick={() => transfer.close()}
|
||||||
|
>
|
||||||
|
<Icon name="close" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||||
|
<div class="border-border inline-flex rounded-lg border p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-3 py-1.5 text-sm {action === 'move'
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'text-text-muted hover:text-text'}"
|
||||||
|
onclick={() => (action = 'move')}
|
||||||
|
>
|
||||||
|
Переместить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-3 py-1.5 text-sm {action === 'copy'
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'text-text-muted hover:text-text'}"
|
||||||
|
onclick={() => (action = 'copy')}
|
||||||
|
>
|
||||||
|
Копировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-text-muted mb-1.5 text-xs">Альбом</p>
|
||||||
|
{#if albums.list.length === 0}
|
||||||
|
<p class="text-text-subtle text-sm">Нет доступных альбомов.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="max-h-56 space-y-1 overflow-y-auto">
|
||||||
|
{#each albums.list as album (album.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
|
||||||
|
album.id
|
||||||
|
? 'border-primary bg-bg-muted text-text'
|
||||||
|
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
|
||||||
|
aria-pressed={targetId === album.id}
|
||||||
|
onclick={() => (targetId = album.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-bg-muted border-border flex size-8 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||||
|
>
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<Icon name="image" class="size-4 text-text-subtle" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{album.title}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={() => transfer.close()}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !targetId}
|
||||||
|
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
onclick={confirm}
|
||||||
|
>
|
||||||
|
{busy ? 'Обработка…' : action === 'move' ? 'Переместить' : 'Копировать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import Icon from './Icon.svelte';
|
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -10,39 +9,35 @@
|
|||||||
|
|
||||||
let { albumId, onUploaded }: Props = $props();
|
let { albumId, onUploaded }: Props = $props();
|
||||||
|
|
||||||
let dragging = $state(false);
|
let fileInput: HTMLInputElement | undefined = $state();
|
||||||
let progress = $state(0);
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let progress = $state(0);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
function pick() {
|
const MAX_SIZE = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function pick() {
|
||||||
fileInput?.click();
|
fileInput?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileInput: HTMLInputElement | undefined = $state();
|
export function upload(files: FileList | File[]) {
|
||||||
|
const list = Array.from(files);
|
||||||
function onDrop(ev: DragEvent) {
|
if (list.length === 0) return;
|
||||||
ev.preventDefault();
|
const tooBig = list.filter((f) => f.size > MAX_SIZE);
|
||||||
dragging = false;
|
if (tooBig.length > 0) {
|
||||||
if (ev.dataTransfer?.files?.length) {
|
error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Максимум 100 МБ.`;
|
||||||
upload(ev.dataTransfer.files);
|
return;
|
||||||
|
}
|
||||||
|
const total = list.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
if (total > MAX_SIZE) {
|
||||||
|
error = 'Суммарный размер файлов превышает 100 МБ. Загрузите меньшими порциями.';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function onChange(ev: Event) {
|
|
||||||
const target = ev.target as HTMLInputElement;
|
|
||||||
if (target.files?.length) upload(target.files);
|
|
||||||
target.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function upload(files: FileList) {
|
|
||||||
error = '';
|
error = '';
|
||||||
busy = true;
|
busy = true;
|
||||||
progress = 0;
|
progress = 0;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const file of Array.from(files)) {
|
for (const file of list) form.append('files', file);
|
||||||
form.append('files', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', `/api/albums/${albumId}/photos`);
|
xhr.open('POST', `/api/albums/${albumId}/photos`);
|
||||||
@@ -53,6 +48,7 @@
|
|||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
busy = false;
|
busy = false;
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
progress = 0;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(xhr.responseText);
|
const data = JSON.parse(xhr.responseText);
|
||||||
onUploaded?.(data.photos ?? []);
|
onUploaded?.(data.photos ?? []);
|
||||||
@@ -73,48 +69,40 @@
|
|||||||
};
|
};
|
||||||
xhr.send(form);
|
xhr.send(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onChange(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement;
|
||||||
|
if (input.files?.length) upload(input.files);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<input
|
||||||
class="border-border rounded-xl border-2 border-dashed p-6 text-center transition-colors {dragging
|
bind:this={fileInput}
|
||||||
? 'bg-bg-muted border-primary'
|
type="file"
|
||||||
: 'bg-bg-elevated'}"
|
accept="image/*"
|
||||||
ondragover={(e) => {
|
multiple
|
||||||
e.preventDefault();
|
class="hidden"
|
||||||
dragging = true;
|
onchange={onChange}
|
||||||
}}
|
/>
|
||||||
ondragleave={() => (dragging = false)}
|
|
||||||
ondrop={onDrop}
|
|
||||||
ondragenter={(e) => e.preventDefault()}
|
|
||||||
role="region"
|
|
||||||
aria-label="Загрузка фотографий"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={fileInput}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
class="hidden"
|
|
||||||
onchange={onChange}
|
|
||||||
/>
|
|
||||||
<Icon name="cloud_upload" class="text-text-subtle mx-auto mb-3 size-10" />
|
|
||||||
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={pick}
|
|
||||||
disabled={busy}
|
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Icon name="add_photo_alternate" class="size-4" />
|
|
||||||
{busy ? 'Загрузка…' : 'Выбрать файлы'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if busy}
|
{#if busy}
|
||||||
<div class="bg-bg-muted mt-4 h-2 w-full overflow-hidden rounded">
|
<div
|
||||||
|
class="bg-bg-elevated border-border fixed right-4 bottom-4 z-50 w-64 rounded-lg border p-3 shadow-lg"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p class="text-text mb-2 text-sm">Загрузка… {progress}%</p>
|
||||||
|
<div class="bg-bg-muted h-1.5 w-full overflow-hidden rounded">
|
||||||
<div class="bg-primary h-full transition-all" style="width: {progress}%"></div>
|
<div class="bg-primary h-full transition-all" style="width: {progress}%"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
{#if error}
|
{/if}
|
||||||
<p class="text-danger mt-3 text-sm">{error}</p>
|
{#if error}
|
||||||
{/if}
|
<div
|
||||||
</div>
|
class="bg-bg-elevated border-danger text-danger fixed right-4 bottom-4 z-50 rounded-lg border p-3 text-sm"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||||
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
|
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photos: Photo[];
|
||||||
|
index: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onIndexChange: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { photos, index, onClose, onIndexChange }: Props = $props();
|
||||||
|
|
||||||
|
let fresh = $state<Photo | null>(null);
|
||||||
|
let touchX = 0;
|
||||||
|
|
||||||
|
const current = $derived(photos[index]);
|
||||||
|
|
||||||
|
const MAX_ZOOM = 2;
|
||||||
|
|
||||||
|
let viewerEl: HTMLDivElement | undefined = $state();
|
||||||
|
let scroller: HTMLDivElement | undefined = $state();
|
||||||
|
let imgEl: HTMLImageElement | undefined = $state();
|
||||||
|
let topEl: HTMLDivElement | undefined = $state();
|
||||||
|
let bottomEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
let isFullscreen = $state(false);
|
||||||
|
let zoomed = $state(false);
|
||||||
|
let scale = $state(1);
|
||||||
|
let hiResLoaded = $state(false);
|
||||||
|
|
||||||
|
let showTop = $state(false);
|
||||||
|
let showBottom = $state(false);
|
||||||
|
let showLeft = $state(false);
|
||||||
|
let showRight = $state(false);
|
||||||
|
const EDGE = 8;
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let startL = 0;
|
||||||
|
let startT = 0;
|
||||||
|
|
||||||
|
const previewSrc = $derived(
|
||||||
|
fresh?.preview_url ?? fresh?.original_url ?? current?.preview_url ?? current?.original_url ?? ''
|
||||||
|
);
|
||||||
|
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
|
||||||
|
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = current?.id;
|
||||||
|
fresh = null;
|
||||||
|
zoomed = false;
|
||||||
|
scale = 1;
|
||||||
|
hiResLoaded = false;
|
||||||
|
if (!id) return;
|
||||||
|
api<{ photo: Photo }>(`/photos/${id}`)
|
||||||
|
.then((data) => (fresh = data.photo))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// In zoom mode, preload the full-resolution original and swap it in once loaded.
|
||||||
|
$effect(() => {
|
||||||
|
if (!zoomed || hiResLoaded) return;
|
||||||
|
const url = fullSrc;
|
||||||
|
if (!url) return;
|
||||||
|
const im = new Image();
|
||||||
|
let alive = true;
|
||||||
|
im.onload = () => {
|
||||||
|
if (alive) hiResLoaded = true;
|
||||||
|
};
|
||||||
|
im.src = url;
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
im.onload = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const el = scroller;
|
||||||
|
if (!el) return;
|
||||||
|
const handler = (e: WheelEvent) => onWheel(e);
|
||||||
|
el.addEventListener('wheel', handler, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const onFs = () => {
|
||||||
|
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
|
||||||
|
resetBars();
|
||||||
|
};
|
||||||
|
document.addEventListener('fullscreenchange', onFs);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFs);
|
||||||
|
});
|
||||||
|
|
||||||
|
const topClass = $derived(
|
||||||
|
`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 bg-transparent px-2 py-0.5 transition-transform duration-200 ${isFullscreen && !showTop ? '-translate-y-full' : 'translate-y-0'}`
|
||||||
|
);
|
||||||
|
const bottomClass = $derived(
|
||||||
|
isFullscreen
|
||||||
|
? `border-border bg-chrome absolute inset-x-0 bottom-0 z-30 flex gap-2 overflow-x-auto border-t p-2 transition-transform duration-200 ${showBottom ? 'translate-y-0' : 'translate-y-full'}`
|
||||||
|
: 'border-border bg-chrome flex shrink-0 gap-2 overflow-x-auto border-t p-2'
|
||||||
|
);
|
||||||
|
const arrowPos =
|
||||||
|
'absolute top-1/2 z-20 inline-flex -translate-y-1/2 items-center justify-center rounded-full bg-black/40 p-1.5 text-text backdrop-blur transition-opacity duration-200 hover:text-white';
|
||||||
|
const arrowVisibility = (shown: boolean, disabled: boolean, isZoomed: boolean) =>
|
||||||
|
isZoomed || !shown ? 'pointer-events-none opacity-0' : disabled ? 'opacity-40' : 'opacity-100';
|
||||||
|
const leftDisabled = $derived(index === 0 || zoomed);
|
||||||
|
const rightDisabled = $derived(index === photos.length - 1 || zoomed);
|
||||||
|
const leftBtnClass = $derived(
|
||||||
|
`${arrowPos} left-2 ${arrowVisibility(showLeft, leftDisabled, zoomed)}`
|
||||||
|
);
|
||||||
|
const rightBtnClass = $derived(
|
||||||
|
`${arrowPos} right-2 ${arrowVisibility(showRight, rightDisabled, zoomed)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
function resetBars() {
|
||||||
|
showTop = false;
|
||||||
|
showBottom = false;
|
||||||
|
showLeft = false;
|
||||||
|
showRight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inRect(el: HTMLElement | undefined, x: number, y: number): boolean {
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWinMouseMove(e: MouseEvent) {
|
||||||
|
const r = viewerEl?.getBoundingClientRect();
|
||||||
|
if (r) {
|
||||||
|
const zoneX = r.width / 6;
|
||||||
|
const insideY = e.clientY >= r.top && e.clientY <= r.bottom;
|
||||||
|
showLeft = !zoomed && insideY && e.clientX >= r.left && e.clientX <= r.left + zoneX;
|
||||||
|
showRight = !zoomed && insideY && e.clientX <= r.right && e.clientX >= r.right - zoneX;
|
||||||
|
}
|
||||||
|
if (!isFullscreen) return;
|
||||||
|
const nearTop = e.clientY <= EDGE;
|
||||||
|
const nearBottom = e.clientY >= window.innerHeight - EDGE;
|
||||||
|
showTop = nearTop || inRect(topEl, e.clientX, e.clientY);
|
||||||
|
showBottom = nearBottom || inRect(bottomEl, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseWidth(): number {
|
||||||
|
return fresh?.width || current?.width || imgEl?.naturalWidth || 0;
|
||||||
|
}
|
||||||
|
function baseHeight(): number {
|
||||||
|
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (index > 0) onIndexChange(index - 1);
|
||||||
|
}
|
||||||
|
function next() {
|
||||||
|
if (index < photos.length - 1) onIndexChange(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleActual() {
|
||||||
|
if (zoomed) {
|
||||||
|
zoomed = false;
|
||||||
|
scale = 1;
|
||||||
|
if (scroller) {
|
||||||
|
scroller.scrollLeft = 0;
|
||||||
|
scroller.scrollTop = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
zoomed = true;
|
||||||
|
scale = 1;
|
||||||
|
tick().then(() => {
|
||||||
|
if (!scroller) return;
|
||||||
|
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
|
||||||
|
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(ev: WheelEvent) {
|
||||||
|
if (!current || !scroller || !imgEl) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const natW = baseWidth();
|
||||||
|
const natH = baseHeight();
|
||||||
|
if (!natW || !natH) return;
|
||||||
|
|
||||||
|
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||||
|
const fitScale = Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 1);
|
||||||
|
const curScale = zoomed ? scale : fitScale;
|
||||||
|
const newScale = Math.min(MAX_ZOOM, Math.max(fitScale, curScale * factor));
|
||||||
|
if (Math.abs(newScale - curScale) < 1e-4) return;
|
||||||
|
|
||||||
|
const r = imgEl.getBoundingClientRect();
|
||||||
|
const fx = (ev.clientX - r.left) / r.width;
|
||||||
|
const fy = (ev.clientY - r.top) / r.height;
|
||||||
|
|
||||||
|
zoomed = true;
|
||||||
|
scale = newScale;
|
||||||
|
|
||||||
|
tick().then(() => {
|
||||||
|
if (!scroller) return;
|
||||||
|
const sr = scroller.getBoundingClientRect();
|
||||||
|
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
|
||||||
|
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(ev: PointerEvent) {
|
||||||
|
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
|
||||||
|
dragging = true;
|
||||||
|
startX = ev.clientX;
|
||||||
|
startY = ev.clientY;
|
||||||
|
startL = scroller.scrollLeft;
|
||||||
|
startT = scroller.scrollTop;
|
||||||
|
try {
|
||||||
|
scroller.setPointerCapture(ev.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onPointerMove(ev: PointerEvent) {
|
||||||
|
if (!dragging || !scroller) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
scroller.scrollLeft = startL - (ev.clientX - startX);
|
||||||
|
scroller.scrollTop = startT - (ev.clientY - startY);
|
||||||
|
}
|
||||||
|
function onPointerUp(ev: PointerEvent) {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
try {
|
||||||
|
scroller?.releasePointerCapture(ev.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleViewerFullscreen() {
|
||||||
|
if (!viewerEl) return;
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === viewerEl) await document.exitFullscreen();
|
||||||
|
else await viewerEl.requestFullscreen();
|
||||||
|
} catch {
|
||||||
|
/* недоступно */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTyping(): boolean {
|
||||||
|
const el = document.activeElement as HTMLElement | null;
|
||||||
|
if (!el) return false;
|
||||||
|
const tag = el.tagName;
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(ev: KeyboardEvent) {
|
||||||
|
if (isTyping()) return;
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
if (!document.fullscreenElement) onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.code === 'Space') {
|
||||||
|
ev.preventDefault();
|
||||||
|
toggleActual();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
|
||||||
|
ev.preventDefault();
|
||||||
|
toggleViewerFullscreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (zoomed) return;
|
||||||
|
if (ev.key === 'ArrowLeft') prev();
|
||||||
|
else if (ev.key === 'ArrowRight') next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(ev: TouchEvent) {
|
||||||
|
touchX = ev.changedTouches[0].clientX;
|
||||||
|
}
|
||||||
|
function onTouchEnd(ev: TouchEvent) {
|
||||||
|
if (zoomed) return;
|
||||||
|
const dx = ev.changedTouches[0].clientX - touchX;
|
||||||
|
if (dx > 50) prev();
|
||||||
|
else if (dx < -50) next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenu(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const p = current;
|
||||||
|
if (!p) return;
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
...(p.download_url
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Скачать',
|
||||||
|
icon: 'download' as const,
|
||||||
|
run: () => window.open(p.download_url, '_blank')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: 'Переместить',
|
||||||
|
icon: 'arrow_back',
|
||||||
|
run: () => transfer.start([p.id], null, 'move')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Копировать',
|
||||||
|
icon: 'content_copy',
|
||||||
|
run: () => transfer.start([p.id], null, 'copy')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Удалить',
|
||||||
|
icon: 'delete',
|
||||||
|
danger: true,
|
||||||
|
run: () => viewerActions.requestDelete(p.id)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
contextMenu.show(e.clientX, e.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn =
|
||||||
|
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
||||||
|
|
||||||
|
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
|
||||||
|
<div bind:this={topEl} class={topClass}>
|
||||||
|
<div class="pointer-events-auto flex items-center gap-1">
|
||||||
|
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||||
|
<Icon name="arrow_back" class="size-4" />
|
||||||
|
</button>
|
||||||
|
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pointer-events-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={btn}
|
||||||
|
aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'}
|
||||||
|
title={isFullscreen
|
||||||
|
? 'Выйти из полноэкранного просмотра (f)'
|
||||||
|
: 'Полноэкранный просмотр (f)'}
|
||||||
|
aria-pressed={isFullscreen}
|
||||||
|
onclick={toggleViewerFullscreen}
|
||||||
|
>
|
||||||
|
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex min-h-0 flex-1 items-stretch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={leftBtnClass}
|
||||||
|
aria-label="Предыдущее фото"
|
||||||
|
title="Предыдущее фото"
|
||||||
|
onclick={prev}
|
||||||
|
disabled={leftDisabled}
|
||||||
|
>
|
||||||
|
<Icon name="chevron_left" class="size-8" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={scroller}
|
||||||
|
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
|
||||||
|
? 'cursor-grab overflow-auto'
|
||||||
|
: 'overflow-hidden'}"
|
||||||
|
role="group"
|
||||||
|
aria-label="Просмотр фото"
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
onpointercancel={onPointerUp}
|
||||||
|
oncontextmenu={onContextMenu}
|
||||||
|
ontouchstart={onTouchStart}
|
||||||
|
ontouchend={onTouchEnd}
|
||||||
|
>
|
||||||
|
{#if current}
|
||||||
|
<img
|
||||||
|
bind:this={imgEl}
|
||||||
|
src={displaySrc}
|
||||||
|
alt={current.original_filename}
|
||||||
|
draggable="false"
|
||||||
|
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
|
||||||
|
class="m-auto block {zoomed
|
||||||
|
? 'max-w-none shrink-0 select-none'
|
||||||
|
: 'max-h-full max-w-full object-contain'}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={rightBtnClass}
|
||||||
|
aria-label="Следующее фото"
|
||||||
|
title="Следующее фото"
|
||||||
|
onclick={next}
|
||||||
|
disabled={rightDisabled}
|
||||||
|
>
|
||||||
|
<Icon name="chevron_right" class="size-8" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div bind:this={bottomEl} class={bottomClass} aria-label="Лента миниатюр">
|
||||||
|
{#each photos as photo, i (photo.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-16 w-16 shrink-0 overflow-hidden rounded border {i === index
|
||||||
|
? 'border-primary'
|
||||||
|
: 'border-border'}"
|
||||||
|
onclick={() => onIndexChange(i)}
|
||||||
|
aria-label="Фото {i + 1}"
|
||||||
|
aria-current={i === index ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{#if photo.thumb_url}
|
||||||
|
<img
|
||||||
|
src={photo.thumb_url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="bg-bg-muted block h-full w-full"></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
export 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: 'Описание'
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export 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: 'Некалиброванное' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatExifValue(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExifRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXIF_BASE: string[] = [
|
||||||
|
'Make',
|
||||||
|
'Model',
|
||||||
|
'LensModel',
|
||||||
|
'ExposureTime',
|
||||||
|
'FNumber',
|
||||||
|
'ISOSpeedRatings',
|
||||||
|
'FocalLength',
|
||||||
|
'FocalLengthIn35mmFilm'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildExifRows(
|
||||||
|
exif: Record<string, unknown>,
|
||||||
|
labels: Record<string, string> = {}
|
||||||
|
): ExifRow[] {
|
||||||
|
return Object.entries(exif)
|
||||||
|
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
|
||||||
|
.map(([k, v]) => ({
|
||||||
|
key: k,
|
||||||
|
label: labels[k] ?? EXIF_LABELS[k] ?? k,
|
||||||
|
value: formatExifValue(k, v)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXIF_CATALOG: string[] = Object.keys(EXIF_LABELS);
|
||||||
@@ -40,7 +40,17 @@ export const icons = {
|
|||||||
link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' },
|
link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' },
|
||||||
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
|
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
|
||||||
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
|
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
|
||||||
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' }
|
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' },
|
||||||
|
edit: { path: '<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83l3.75 3.75z"/>' },
|
||||||
|
label: { path: '<path fill="currentColor" d="M17.63 5.84C17.27 5.33 16.67 5 16 5H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h11c.67 0 1.27-.33 1.63-.84L22 12z"/>' },
|
||||||
|
menu: { path: '<path fill="currentColor" d="M3 18v-2h18v2zm0-5v-2h18v2zm0-5V6h18v2z"/>' },
|
||||||
|
grid_view: { path: '<path fill="currentColor" d="M3 3v8h8V3zm10 0v8h8V3zM3 13v8h8v-8zm10 0v8h8v-8z"/>' },
|
||||||
|
wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' },
|
||||||
|
fullscreen: { path: '<path fill="currentColor" d="M7 14H5v5h5v-2H7zm-2-4h2V7h3V5H5zm12 7h-3v2h5v-5h-2zM14 5v2h3v3h2V5z"/>' },
|
||||||
|
fullscreen_exit: { path: '<path fill="currentColor" d="M5 16h3v3h2v-5H5zm3-8H5v2h5V5H8zm6 11h2v-3h3v-2h-5zm2-11v3h-2v2h5V5z"/>' },
|
||||||
|
contrast: { path: '<path fill="currentColor" fill-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4a8 8 0 0 1 0 16z"/>' },
|
||||||
|
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
|
||||||
|
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' }
|
||||||
} satisfies Record<string, IconDef>;
|
} satisfies Record<string, IconDef>;
|
||||||
|
|
||||||
export type IconName = keyof typeof icons;
|
export type IconName = keyof typeof icons;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Album } from '$lib/types';
|
||||||
|
|
||||||
|
class AlbumActionsStore {
|
||||||
|
renaming = $state<Album | null>(null);
|
||||||
|
deleting = $state<Album | null>(null);
|
||||||
|
|
||||||
|
openRename(album: Album) {
|
||||||
|
this.renaming = album;
|
||||||
|
}
|
||||||
|
|
||||||
|
openDelete(album: Album) {
|
||||||
|
this.deleting = album;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.renaming = null;
|
||||||
|
this.deleting = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const albumActions = new AlbumActionsStore();
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { Album } from '$lib/types';
|
||||||
|
import { auth } from './auth.svelte';
|
||||||
|
|
||||||
|
class AlbumsStore {
|
||||||
|
list = $state<Album[]>([]);
|
||||||
|
loading = $state(false);
|
||||||
|
loaded = $state(false);
|
||||||
|
error = $state('');
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
this.list = [];
|
||||||
|
this.loaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await api<{ albums: Album[] }>('/albums');
|
||||||
|
this.list = data.albums;
|
||||||
|
this.loaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(title: string): Promise<Album> {
|
||||||
|
const data = await api<{ album: Album }>('/albums', { method: 'POST', body: { title } });
|
||||||
|
this.list = [data.album, ...this.list];
|
||||||
|
return data.album;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCover(albumId: string, coverPhotoId: string | null): Promise<Album> {
|
||||||
|
const body = coverPhotoId ? { cover_photo_id: coverPhotoId } : { clear_cover: true };
|
||||||
|
const data = await api<{ album: Album }>(`/albums/${albumId}`, { method: 'PATCH', body });
|
||||||
|
this.upsert(data.album);
|
||||||
|
return data.album;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename(id: string, title: string): Promise<Album> {
|
||||||
|
const data = await api<{ album: Album }>(`/albums/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { title }
|
||||||
|
});
|
||||||
|
this.upsert(data.album);
|
||||||
|
return data.album;
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(id: string): Promise<void> {
|
||||||
|
await api(`/albums/${id}`, { method: 'DELETE' });
|
||||||
|
this.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(album: Album) {
|
||||||
|
const i = this.list.findIndex((a) => a.id === album.id);
|
||||||
|
if (i === -1) {
|
||||||
|
this.list = [album, ...this.list];
|
||||||
|
} else {
|
||||||
|
this.list = this.list.map((a) => (a.id === album.id ? album : a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
this.list = this.list.filter((a) => a.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.list = [];
|
||||||
|
this.loaded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const albums = new AlbumsStore();
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import type { Preferences } from '$lib/types';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
|
preferences?: Preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tokens {
|
export interface Tokens {
|
||||||
@@ -56,6 +59,18 @@ class AuthStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get preferences(): Preferences {
|
||||||
|
return this.user?.preferences ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreferences(preferences: Preferences) {
|
||||||
|
if (!this.user) return;
|
||||||
|
this.user = { ...this.user, preferences };
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(this.user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.access = null;
|
this.access = null;
|
||||||
this.refresh = null;
|
this.refresh = null;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { IconName } from '$lib/icons/registry';
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
label: string;
|
||||||
|
icon: IconName;
|
||||||
|
danger?: boolean;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContextMenuStore {
|
||||||
|
open = $state(false);
|
||||||
|
x = $state(0);
|
||||||
|
y = $state(0);
|
||||||
|
items = $state<ContextMenuItem[]>([]);
|
||||||
|
|
||||||
|
show(x: number, y: number, items: ContextMenuItem[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.items = items;
|
||||||
|
this.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.open = false;
|
||||||
|
this.items = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contextMenu = new ContextMenuStore();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const KEY = 'grid-cols';
|
||||||
|
const MIN = 2;
|
||||||
|
const MAX = 8;
|
||||||
|
|
||||||
|
class DensityStore {
|
||||||
|
cols = $state(5);
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const v = parseInt(localStorage.getItem(KEY) ?? '', 10);
|
||||||
|
if (v >= MIN && v <= MAX) this.cols = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(value: number) {
|
||||||
|
this.cols = Math.min(MAX, Math.max(MIN, Math.round(value)));
|
||||||
|
if (typeof window !== 'undefined') localStorage.setItem(KEY, String(this.cols));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const density = new DensityStore();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
class SelectionStore {
|
||||||
|
albumId = $state('');
|
||||||
|
items = $state<Photo[]>([]);
|
||||||
|
anchorId = $state<string | null>(null);
|
||||||
|
|
||||||
|
get ids(): string[] {
|
||||||
|
return this.items.map((p) => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get count(): number {
|
||||||
|
return this.items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: string): boolean {
|
||||||
|
return this.items.some((p) => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(albumId: string, items: Photo[]) {
|
||||||
|
this.albumId = albumId;
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(photo: Photo) {
|
||||||
|
this.items = this.items.map((p) => (p.id === photo.id ? photo : p));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.albumId = '';
|
||||||
|
this.items = [];
|
||||||
|
this.anchorId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selection = new SelectionStore();
|
||||||
@@ -14,13 +14,13 @@ function resolve(theme: Theme): Resolved {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ThemeStore {
|
class ThemeStore {
|
||||||
current = $state<Theme>('system');
|
current = $state<Theme>('dark');
|
||||||
resolved = $state<Resolved>('light');
|
resolved = $state<Resolved>('dark');
|
||||||
private media: MediaQueryList | null = null;
|
private media: MediaQueryList | null = null;
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const stored = (localStorage.getItem(KEY) as Theme | null) ?? 'system';
|
const stored = (localStorage.getItem(KEY) as Theme | null) ?? 'dark';
|
||||||
this.current = stored;
|
this.current = stored;
|
||||||
this.apply();
|
this.apply();
|
||||||
this.media = window.matchMedia('(prefers-color-scheme: dark)');
|
this.media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type TransferAction = 'move' | 'copy';
|
||||||
|
|
||||||
|
class TransferStore {
|
||||||
|
open = $state(false);
|
||||||
|
ids = $state<string[]>([]);
|
||||||
|
targetId = $state<string | null>(null);
|
||||||
|
action = $state<TransferAction | null>(null);
|
||||||
|
|
||||||
|
start(ids: string[], targetId: string | null = null, action: TransferAction | null = null) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
this.ids = ids;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.action = action;
|
||||||
|
this.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.open = false;
|
||||||
|
this.ids = [];
|
||||||
|
this.targetId = null;
|
||||||
|
this.action = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transfer = new TransferStore();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { photosApi } from '$lib/api';
|
||||||
|
import { auth } from './auth.svelte';
|
||||||
|
|
||||||
|
class TrashStore {
|
||||||
|
count = $state(0);
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
this.count = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.count = (await photosApi.trashCount()).count;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trash = new TrashStore();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
class ViewerActions {
|
||||||
|
deleteRequested = $state<string | null>(null);
|
||||||
|
|
||||||
|
requestDelete(id: string) {
|
||||||
|
this.deleteRequested = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.deleteRequested = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewerActions = new ViewerActions();
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
|
export interface PhotoInfoPreferences {
|
||||||
|
visible?: string[];
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Preferences {
|
||||||
|
photo_info?: PhotoInfoPreferences;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
|
preferences?: Preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Album {
|
export interface Album {
|
||||||
@@ -11,6 +21,9 @@ export interface Album {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
cover_photo_id?: string | null;
|
cover_photo_id?: string | null;
|
||||||
|
cover_thumb_url?: string;
|
||||||
|
photos_count?: number;
|
||||||
|
members_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -23,6 +36,11 @@ export interface AlbumMember {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Photo {
|
export interface Photo {
|
||||||
id: string;
|
id: string;
|
||||||
album_id: string;
|
album_id: string;
|
||||||
@@ -36,6 +54,10 @@ export interface Photo {
|
|||||||
latitude?: number | null;
|
latitude?: number | null;
|
||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
caption: string;
|
caption: string;
|
||||||
|
exif?: Record<string, unknown>;
|
||||||
|
tags?: Tag[];
|
||||||
|
deleted_at?: string | null;
|
||||||
|
album_title?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
uploader: User;
|
uploader: User;
|
||||||
likes_count: number;
|
likes_count: number;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import AppShell from '$lib/components/AppShell.svelte';
|
||||||
|
import AlbumRail from '$lib/components/AlbumRail.svelte';
|
||||||
|
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
|
||||||
|
import TransferDialog from '$lib/components/TransferDialog.svelte';
|
||||||
|
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
const showPanel = $derived($page.url.pathname !== '/');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AppShell panelVisible={showPanel}>
|
||||||
|
{#snippet rail()}
|
||||||
|
<AlbumRail />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet panel()}
|
||||||
|
<AlbumPanel />
|
||||||
|
{/snippet}
|
||||||
|
{@render children()}
|
||||||
|
</AppShell>
|
||||||
|
|
||||||
|
<TransferDialog />
|
||||||
|
<AlbumActionsDialogs />
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import DensityControl from '$lib/components/DensityControl.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import { albumActions } from '$lib/stores/album-actions.svelte';
|
||||||
|
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||||
|
import type { Album } from '$lib/types';
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const items: ContextMenuItem[] = [
|
||||||
|
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
|
||||||
|
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
|
||||||
|
];
|
||||||
|
contextMenu.show(ev.clientX, ev.clientY, items);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !auth.isAuthenticated}
|
||||||
|
<div class="py-16 text-center">
|
||||||
|
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
|
||||||
|
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
|
||||||
|
>
|
||||||
|
<Icon name="login" class="size-5" />
|
||||||
|
Войти
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-4 sm:p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<h1 class="text-xl font-semibold">Мои альбомы</h1>
|
||||||
|
<DensityControl />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if albums.loading && albums.list.length === 0}
|
||||||
|
<p class="text-text-muted">Загрузка…</p>
|
||||||
|
{:else}
|
||||||
|
{#if albums.list.length === 0}
|
||||||
|
<div class="text-text-muted mb-4 flex flex-col items-center gap-3 py-10">
|
||||||
|
<Icon name="photo_library" class="size-16 text-text-subtle" />
|
||||||
|
<p>Альбомов пока нет. Создайте первый кнопкой <span class="text-text">+</span> в панели альбомов.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ul
|
||||||
|
class="grid gap-3"
|
||||||
|
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
||||||
|
>
|
||||||
|
{#each albums.list as album (album.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/albums/{album.id}"
|
||||||
|
class="group block"
|
||||||
|
oncontextmenu={(e) => onAlbumContextMenu(e, album)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
|
||||||
|
>
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img
|
||||||
|
src={album.cover_thumb_url}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="flex h-full w-full items-center justify-center">
|
||||||
|
<Icon name="image" class="size-12 text-text-subtle" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<h2 class="text-text truncate text-sm font-medium">{album.title}</h2>
|
||||||
|
<p class="text-text-subtle text-xs">{album.photos_count ?? 0} фото</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api, photosApi } from '$lib/api';
|
||||||
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import Uploader from '$lib/components/Uploader.svelte';
|
||||||
|
import DensityControl from '$lib/components/DensityControl.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
|
import { transfer } from '$lib/stores/transfer.svelte';
|
||||||
|
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||||
|
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
const albumId = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
|
let album = $state<Album | null>(null);
|
||||||
|
let role = $state('');
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let nextCursor = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let dragging = $state(false);
|
||||||
|
let pendingDelete = $state(false);
|
||||||
|
let deletingMenu = $state(false);
|
||||||
|
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
|
||||||
|
|
||||||
|
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
||||||
|
|
||||||
|
async function loadAlbum() {
|
||||||
|
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
|
||||||
|
album = data.album;
|
||||||
|
role = data.role;
|
||||||
|
albums.upsert(data.album);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotos() {
|
||||||
|
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
||||||
|
`/albums/${albumId}/photos`
|
||||||
|
);
|
||||||
|
photos = data.photos;
|
||||||
|
nextCursor = data.next_cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!nextCursor) return;
|
||||||
|
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
||||||
|
`/albums/${albumId}/photos?cursor=${encodeURIComponent(nextCursor)}`
|
||||||
|
);
|
||||||
|
photos = [...photos, ...data.photos];
|
||||||
|
nextCursor = data.next_cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
selection.clear();
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
Promise.all([loadAlbum(), loadPhotos()])
|
||||||
|
.catch((e) => (error = (e as Error).message))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
let client: SSEClient | null = null;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
if (!auth.access) return;
|
||||||
|
client = new SSEClient({
|
||||||
|
path: `/api/sse/albums/${id}`,
|
||||||
|
onEvent: handleEvent,
|
||||||
|
onReconnect: () => loadPhotos()
|
||||||
|
});
|
||||||
|
client.connect(auth.access);
|
||||||
|
return () => {
|
||||||
|
client?.close();
|
||||||
|
client = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleEvent(ev: SSEEvent) {
|
||||||
|
const data = ev.data as Record<string, unknown>;
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'photo.created': {
|
||||||
|
const photo = data as unknown as Photo;
|
||||||
|
if (photos.some((p) => p.id === photo.id)) {
|
||||||
|
photos = photos.map((p) => (p.id === photo.id ? photo : p));
|
||||||
|
} else {
|
||||||
|
photos = [photo, ...photos];
|
||||||
|
}
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'photo.deleted': {
|
||||||
|
photos = photos.filter((p) => p.id !== data.id);
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'photo.updated': {
|
||||||
|
const photo = data as unknown as Photo;
|
||||||
|
photos = photos.map((p) => (p.id === photo.id ? photo : p));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'like.added': {
|
||||||
|
photos = photos.map((p) =>
|
||||||
|
p.id === data.photo_id
|
||||||
|
? {
|
||||||
|
...p,
|
||||||
|
likes_count: p.likes_count + 1,
|
||||||
|
liked_by_me: data.user_id === auth.user?.id || p.liked_by_me
|
||||||
|
}
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'like.removed': {
|
||||||
|
photos = photos.map((p) =>
|
||||||
|
p.id === data.photo_id
|
||||||
|
? { ...p, likes_count: Math.max(0, p.likes_count - 1), liked_by_me: false }
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'comment.created': {
|
||||||
|
const comment = data as unknown as { photo_id: string };
|
||||||
|
photos = photos.map((p) =>
|
||||||
|
p.id === comment.photo_id ? { ...p, comments_count: p.comments_count + 1 } : p
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'album.updated': {
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPhoto(i: number, e: MouseEvent) {
|
||||||
|
const photo = photos[i];
|
||||||
|
if (!photo) return;
|
||||||
|
const cur = selection.albumId === albumId ? selection.items : [];
|
||||||
|
if (e.shiftKey && selection.anchorId && selection.albumId === albumId) {
|
||||||
|
const a = photos.findIndex((p) => p.id === selection.anchorId);
|
||||||
|
if (a >= 0) {
|
||||||
|
const lo = Math.min(a, i);
|
||||||
|
const hi = Math.max(a, i);
|
||||||
|
selection.set(albumId, photos.slice(lo, hi + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const exists = cur.some((p) => p.id === photo.id);
|
||||||
|
selection.set(
|
||||||
|
albumId,
|
||||||
|
exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo]
|
||||||
|
);
|
||||||
|
selection.anchorId = photo.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selection.set(albumId, [photo]);
|
||||||
|
selection.anchorId = photo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragPhoto(i: number, e: DragEvent) {
|
||||||
|
const photo = photos[i];
|
||||||
|
if (!photo) return;
|
||||||
|
if (!(selection.albumId === albumId && selection.has(photo.id))) {
|
||||||
|
selection.set(albumId, [photo]);
|
||||||
|
selection.anchorId = photo.id;
|
||||||
|
}
|
||||||
|
const ids = selection.ids;
|
||||||
|
e.dataTransfer?.setData('application/x-galo-photos', JSON.stringify(ids));
|
||||||
|
e.dataTransfer?.setData('text/plain', `${ids.length} фото`);
|
||||||
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(ev: DragEvent) {
|
||||||
|
ev.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
if (canUpload && ev.dataTransfer?.files?.length) {
|
||||||
|
uploader?.upload(ev.dataTransfer.files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenuForPhoto(i: number, e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const photo = photos[i];
|
||||||
|
if (!photo) return;
|
||||||
|
const inSel = selection.albumId === albumId && selection.has(photo.id);
|
||||||
|
const multi = inSel && selection.count > 1;
|
||||||
|
if (!inSel) {
|
||||||
|
selection.set(albumId, [photo]);
|
||||||
|
selection.anchorId = photo.id;
|
||||||
|
}
|
||||||
|
const openFirst = () => {
|
||||||
|
const first = selection.albumId === albumId ? selection.items[0] : photo;
|
||||||
|
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
|
||||||
|
};
|
||||||
|
let items: ContextMenuItem[];
|
||||||
|
if (multi) {
|
||||||
|
const ids = selection.ids;
|
||||||
|
items = [
|
||||||
|
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
|
||||||
|
{ label: 'Переместить', icon: 'arrow_back', run: () => transfer.start(ids, null, 'move') },
|
||||||
|
{ label: 'Копировать', icon: 'content_copy', run: () => transfer.start(ids, null, 'copy') },
|
||||||
|
{ label: 'Удалить', icon: 'delete', danger: true, run: () => (pendingDelete = true) }
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
items = [
|
||||||
|
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
|
||||||
|
...(photo.download_url
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Скачать',
|
||||||
|
icon: 'download' as const,
|
||||||
|
run: () => window.open(photo.download_url, '_blank')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
label: 'Переместить',
|
||||||
|
icon: 'arrow_back',
|
||||||
|
run: () => transfer.start([photo.id], null, 'move')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Копировать',
|
||||||
|
icon: 'content_copy',
|
||||||
|
run: () => transfer.start([photo.id], null, 'copy')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Удалить',
|
||||||
|
icon: 'delete',
|
||||||
|
danger: true,
|
||||||
|
run: () => {
|
||||||
|
selection.set(albumId, [photo]);
|
||||||
|
selection.anchorId = photo.id;
|
||||||
|
pendingDelete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
contextMenu.show(e.clientX, e.clientY, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmMenuDelete() {
|
||||||
|
deletingMenu = true;
|
||||||
|
try {
|
||||||
|
await photosApi.deleteBatch(selection.ids);
|
||||||
|
selection.clear();
|
||||||
|
pendingDelete = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
deletingMenu = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-text-muted p-4">Загрузка…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-danger p-4">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="relative flex min-h-full flex-col"
|
||||||
|
role="region"
|
||||||
|
aria-label="Альбом"
|
||||||
|
ondragover={(e) => {
|
||||||
|
if (!canUpload) return;
|
||||||
|
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) dragging = false;
|
||||||
|
}}
|
||||||
|
ondrop={onDrop}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="border-border bg-chrome sticky top-0 z-10 flex items-center justify-between gap-3 border-b px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex shrink-0 items-center justify-center rounded-md p-1.5"
|
||||||
|
aria-label="Ко всем альбомам"
|
||||||
|
title="Ко всем альбомам"
|
||||||
|
>
|
||||||
|
<Icon name="arrow_back" class="size-5" />
|
||||||
|
</a>
|
||||||
|
<h1 class="text-text truncate text-sm font-medium">{album?.title ?? ''}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<DensityControl />
|
||||||
|
{#if canUpload}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5"
|
||||||
|
aria-label="Загрузить фото"
|
||||||
|
title="Загрузить фото"
|
||||||
|
onclick={() => uploader?.pick()}
|
||||||
|
>
|
||||||
|
<Icon name="cloud_upload" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-3 sm:p-4">
|
||||||
|
{#if canUpload && photos.length === 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text-muted hover:border-primary flex w-full flex-col items-center gap-3 rounded-xl border-2 border-dashed p-12 text-center"
|
||||||
|
onclick={() => uploader?.pick()}
|
||||||
|
>
|
||||||
|
<Icon name="cloud_upload" class="size-10 text-text-subtle" />
|
||||||
|
<span>Перетащите фото сюда или выберите файлы</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<PhotoGrid
|
||||||
|
{photos}
|
||||||
|
selectedIds={selection.albumId === albumId ? selection.ids : []}
|
||||||
|
onSelect={selectPhoto}
|
||||||
|
onDragStart={dragPhoto}
|
||||||
|
onContextMenu={openMenuForPhoto}
|
||||||
|
onOpen={(i) => {
|
||||||
|
const p = photos[i];
|
||||||
|
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if nextCursor}
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={loadMore}
|
||||||
|
>
|
||||||
|
<Icon name="expand_more" class="size-4" />
|
||||||
|
Показать ещё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if dragging}
|
||||||
|
<div
|
||||||
|
class="bg-bg/80 border-primary pointer-events-none fixed inset-3 z-40 flex items-center justify-center rounded-xl border-2 border-dashed"
|
||||||
|
>
|
||||||
|
<span class="text-text inline-flex items-center gap-2 text-lg">
|
||||||
|
<Icon name="cloud_upload" class="size-6" />
|
||||||
|
Отпустите, чтобы загрузить
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Uploader
|
||||||
|
bind:this={uploader}
|
||||||
|
{albumId}
|
||||||
|
onUploaded={(created) => {
|
||||||
|
for (const p of created) {
|
||||||
|
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
|
||||||
|
}
|
||||||
|
loadAlbum().catch(() => {});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить выбранные фото?"
|
||||||
|
message="{selection.count} фото будут перемещены в корзину."
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={deletingMenu}
|
||||||
|
onConfirm={confirmMenuDelete}
|
||||||
|
onClose={() => (pendingDelete = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Viewer from '$lib/components/Viewer.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||||
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
|
const albumId = $derived($page.params.id ?? '');
|
||||||
|
const photoId = $derived($page.params.photoId ?? '');
|
||||||
|
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let pendingDelete = $state<Photo | null>(null);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
const index = $derived(photos.findIndex((p) => p.id === photoId));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = albumId;
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
Promise.all([
|
||||||
|
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
|
||||||
|
api<{ album: Album; role: string }>(`/albums/${id}`)
|
||||||
|
])
|
||||||
|
.then(([p]) => {
|
||||||
|
photos = p.photos;
|
||||||
|
})
|
||||||
|
.catch((e) => (error = (e as Error).message))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
goto(`/albums/${albumId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function change(i: number) {
|
||||||
|
const p = photos[i];
|
||||||
|
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = viewerActions.deleteRequested;
|
||||||
|
if (!id) return;
|
||||||
|
const photo = photos.find((p) => p.id === id);
|
||||||
|
if (photo) pendingDelete = photo;
|
||||||
|
viewerActions.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
const photo = pendingDelete;
|
||||||
|
if (!photo) return;
|
||||||
|
deleting = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await api(`/photos/${photo.id}`, { method: 'DELETE' });
|
||||||
|
pendingDelete = null;
|
||||||
|
const remaining = photos.filter((p) => p.id !== photo.id);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
const next = remaining[Math.min(Math.max(index, 0), remaining.length - 1)];
|
||||||
|
await goto(`/albums/${albumId}/photos/${next.id}`, {
|
||||||
|
replaceState: true,
|
||||||
|
noScroll: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await goto(`/albums/${albumId}`);
|
||||||
|
}
|
||||||
|
photos = remaining;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-text-muted p-4">Загрузка…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-danger p-4">{error}</p>
|
||||||
|
{:else if index < 0}
|
||||||
|
<div class="text-text-muted p-6 text-center">
|
||||||
|
<p class="mb-3">Фото не найдено.</p>
|
||||||
|
<a href="/albums/{albumId}" class="text-primary">К альбому</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<Viewer {photos} {index} onClose={close} onIndexChange={change} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pendingDelete}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить фото?"
|
||||||
|
message={pendingDelete.original_filename}
|
||||||
|
busy={deleting}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onClose={() => (pendingDelete = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import { portal } from '$lib/actions/portal';
|
||||||
|
import { photosApi, ApiError } from '$lib/api';
|
||||||
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
let photos = $state<Photo[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let pending = $state<Photo | null>(null);
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
let restoring = $state<Photo | null>(null);
|
||||||
|
let targetId = $state('');
|
||||||
|
let createNew = $state(false);
|
||||||
|
let newTitle = $state('');
|
||||||
|
let restoreBusy = $state(false);
|
||||||
|
let restoreError = $state('');
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
photos = (await photosApi.trash()).photos;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (restoring && !albums.loaded) albums.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function restore(p: Photo) {
|
||||||
|
try {
|
||||||
|
await photosApi.restore(p.id);
|
||||||
|
photos = photos.filter((x) => x.id !== p.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.code === 'album_required') {
|
||||||
|
restoring = p;
|
||||||
|
targetId = '';
|
||||||
|
createNew = false;
|
||||||
|
newTitle = '';
|
||||||
|
restoreError = '';
|
||||||
|
} else {
|
||||||
|
error = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRestore() {
|
||||||
|
const photo = restoring;
|
||||||
|
if (!photo) return;
|
||||||
|
restoreBusy = true;
|
||||||
|
restoreError = '';
|
||||||
|
try {
|
||||||
|
let id = targetId;
|
||||||
|
if (createNew) {
|
||||||
|
if (!newTitle.trim()) return;
|
||||||
|
const album = await albums.create(newTitle.trim());
|
||||||
|
id = album.id;
|
||||||
|
}
|
||||||
|
if (!id) return;
|
||||||
|
await photosApi.restore(photo.id, id);
|
||||||
|
photos = photos.filter((x) => x.id !== photo.id);
|
||||||
|
restoring = null;
|
||||||
|
} catch (e) {
|
||||||
|
restoreError = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
restoreBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!pending) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await photosApi.permanentDelete(pending.id);
|
||||||
|
photos = photos.filter((x) => x.id !== pending?.id);
|
||||||
|
pending = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-4 sm:p-6">
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<Icon name="delete" class="size-5 text-text-muted" />
|
||||||
|
<h1 class="text-xl font-semibold">Корзина</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}<p class="text-danger mb-3 text-sm">{error}</p>{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-text-muted">Загрузка…</p>
|
||||||
|
{:else if photos.length === 0}
|
||||||
|
<div class="text-text-muted flex flex-col items-center gap-3 py-16">
|
||||||
|
<Icon name="delete" class="size-16 text-text-subtle" />
|
||||||
|
<p>Корзина пуста.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{#each photos as photo (photo.id)}
|
||||||
|
<li class="border-border bg-bg-muted overflow-hidden rounded-lg border">
|
||||||
|
<div class="bg-bg-muted aspect-square overflow-hidden">
|
||||||
|
{#if photo.thumb_url}
|
||||||
|
<img src={photo.thumb_url} alt="" class="h-full w-full object-cover" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="flex h-full w-full items-center justify-center">
|
||||||
|
<Icon name="image" class="size-8 text-text-subtle" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5 p-2">
|
||||||
|
<p class="text-text truncate text-xs" title={photo.original_filename}>
|
||||||
|
{photo.original_filename}
|
||||||
|
</p>
|
||||||
|
<p class="text-text-subtle truncate text-xs">
|
||||||
|
{photo.album_title || 'Без альбома'}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted inline-flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-xs"
|
||||||
|
onclick={() => restore(photo)}
|
||||||
|
>
|
||||||
|
<Icon name="arrow_back" class="size-3.5" />
|
||||||
|
Вернуть
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-danger hover:bg-bg-muted inline-flex items-center justify-center rounded-md border px-2 py-1"
|
||||||
|
aria-label="Удалить навсегда"
|
||||||
|
title="Удалить навсегда"
|
||||||
|
onclick={() => (pending = photo)}
|
||||||
|
>
|
||||||
|
<Icon name="delete_forever" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if pending}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Удалить навсегда?"
|
||||||
|
message="Фото будет удалено без возможности восстановления. Файлы хранилища удаляются, если на них нет ссылок в других альбомах."
|
||||||
|
confirmLabel="Удалить навсегда"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={remove}
|
||||||
|
onClose={() => (pending = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if restoring}
|
||||||
|
<div
|
||||||
|
use:portal
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Восстановление фото"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !restoreBusy) restoring = null;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && !restoreBusy) restoring = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
|
||||||
|
<div class="border-border flex items-center justify-between border-b px-5 py-3">
|
||||||
|
<h2 class="text-text text-base font-semibold">Восстановить фото</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
onclick={() => (restoring = null)}
|
||||||
|
>
|
||||||
|
<Icon name="close" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||||
|
<p class="text-text-muted text-xs">
|
||||||
|
Исходный альбом удалён. Выберите альбом или создайте новый.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" class="accent-primary size-4" bind:checked={createNew} />
|
||||||
|
Создать новый альбом
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if createNew}
|
||||||
|
<input
|
||||||
|
bind:value={newTitle}
|
||||||
|
placeholder="Название альбома"
|
||||||
|
maxlength="120"
|
||||||
|
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
{:else if albums.list.length === 0}
|
||||||
|
<p class="text-text-subtle text-sm">Нет альбомов — создайте новый.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="max-h-56 space-y-1 overflow-y-auto">
|
||||||
|
{#each albums.list as album (album.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
|
||||||
|
album.id
|
||||||
|
? 'border-primary bg-bg-muted text-text'
|
||||||
|
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
|
||||||
|
aria-pressed={targetId === album.id}
|
||||||
|
onclick={() => (targetId = album.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-bg-muted border-border flex size-8 shrink-0 items-center justify-center overflow-hidden rounded border"
|
||||||
|
>
|
||||||
|
{#if album.cover_thumb_url}
|
||||||
|
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<Icon name="image" class="size-4 text-text-subtle" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{album.title}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if restoreError}<p class="text-danger text-sm">{restoreError}</p>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||||
|
onclick={() => (restoring = null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={restoreBusy || (createNew ? !newTitle.trim() : !targetId)}
|
||||||
|
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||||
|
onclick={confirmRestore}
|
||||||
|
>
|
||||||
|
{restoreBusy ? 'Восстановление…' : 'Восстановить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bg-canvas text-text min-h-screen">
|
||||||
|
<header class="border-border bg-chrome border-b">
|
||||||
|
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon name="photo_camera" />
|
||||||
|
Galo
|
||||||
|
</a>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="mx-auto max-w-6xl px-4 py-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
+7
-34
@@ -3,6 +3,7 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
|
import Viewer from '$lib/components/Viewer.svelte';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
const token = $derived($page.params.token);
|
const token = $derived($page.params.token);
|
||||||
@@ -65,41 +66,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active >= 0}
|
{#if active >= 0}
|
||||||
<div
|
<div class="bg-canvas fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label="Просмотр">
|
||||||
class="bg-bg/95 fixed inset-0 z-50 flex items-center justify-center"
|
<Viewer
|
||||||
role="dialog"
|
{photos}
|
||||||
tabindex="-1"
|
index={active}
|
||||||
onkeydown={(e) => {
|
onClose={() => (active = -1)}
|
||||||
if (e.key === 'Escape') active = -1;
|
onIndexChange={(i) => (active = i)}
|
||||||
}}
|
|
||||||
onclick={() => (active = -1)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={photos[active].preview_url ?? photos[active].original_url}
|
|
||||||
alt={photos[active].original_filename}
|
|
||||||
class="max-h-[90vh] max-w-full object-contain"
|
|
||||||
/>
|
/>
|
||||||
<div class="absolute top-4 right-4 flex gap-2">
|
|
||||||
<a
|
|
||||||
href={photos[active].download_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
class="border-border bg-bg-elevated text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Icon name="download" class="size-4" />
|
|
||||||
Скачать
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Закрыть"
|
|
||||||
title="Закрыть"
|
|
||||||
class="border-border bg-bg-elevated text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
|
|
||||||
onclick={() => (active = -1)}
|
|
||||||
>
|
|
||||||
<Icon name="close" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -1,68 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
import IconSprite from '$lib/components/IconSprite.svelte';
|
import IconSprite from '$lib/components/IconSprite.svelte';
|
||||||
|
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { theme } from '$lib/stores/theme.svelte';
|
import { theme } from '$lib/stores/theme.svelte';
|
||||||
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import { authApi } from '$lib/api';
|
import { authApi } from '$lib/api';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
theme.init();
|
theme.init();
|
||||||
|
density.init();
|
||||||
if (auth.isAuthenticated && !auth.user) {
|
if (auth.isAuthenticated && !auth.user) {
|
||||||
authApi
|
authApi
|
||||||
.me()
|
.me()
|
||||||
.then((data) => auth.setUser(data.user))
|
.then((data) => auth.setUser({ ...data.user, preferences: data.preferences ?? {} }))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
if (auth.refresh) {
|
|
||||||
await authApi.logout(auth.refresh).catch(() => {});
|
|
||||||
}
|
|
||||||
auth.clear();
|
|
||||||
goto('/login');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<IconSprite />
|
<IconSprite />
|
||||||
<div class="bg-bg text-text min-h-screen">
|
{@render children()}
|
||||||
<header class="border-border bg-bg-elevated border-b">
|
<ContextMenu />
|
||||||
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
|
||||||
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<Icon name="photo_camera" />
|
|
||||||
Galo
|
|
||||||
</a>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
{#if auth.isAuthenticated}
|
|
||||||
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
|
||||||
onclick={logout}
|
|
||||||
>
|
|
||||||
<Icon name="logout" class="size-4" />
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href="/login"
|
|
||||||
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
|
||||||
>
|
|
||||||
<Icon name="login" class="size-4" />
|
|
||||||
Войти
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<ThemeSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
|
||||||
{@render children()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
|
||||||
import type { Album } from '$lib/types';
|
|
||||||
|
|
||||||
let albums = $state<Album[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let title = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
if (!auth.isAuthenticated) {
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await api<{ albums: Album[] }>('/albums');
|
|
||||||
albums = data.albums;
|
|
||||||
} catch (e) {
|
|
||||||
error = (e as Error).message;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function create(ev: Event) {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (!title.trim()) return;
|
|
||||||
try {
|
|
||||||
const data = await api<{ album: Album }>('/albums', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { title }
|
|
||||||
});
|
|
||||||
albums = [data.album, ...albums];
|
|
||||||
title = '';
|
|
||||||
} catch (e) {
|
|
||||||
error = (e as Error).message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !auth.isAuthenticated}
|
|
||||||
<div class="py-16 text-center">
|
|
||||||
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
|
|
||||||
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
|
|
||||||
<a
|
|
||||||
href="/login"
|
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
|
|
||||||
>
|
|
||||||
<Icon name="login" class="size-5" />
|
|
||||||
Войти
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
|
||||||
<h1 class="text-2xl font-bold">Мои альбомы</h1>
|
|
||||||
<form class="flex gap-2" onsubmit={create}>
|
|
||||||
<input
|
|
||||||
bind:value={title}
|
|
||||||
placeholder="Название альбома"
|
|
||||||
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
<Icon name="add" class="size-4" />
|
|
||||||
Создать
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="text-text-muted">Загрузка…</p>
|
|
||||||
{:else if albums.length === 0}
|
|
||||||
<div class="text-text-muted flex flex-col items-center gap-3 py-12">
|
|
||||||
<Icon name="photo_library" class="size-16 text-text-subtle" />
|
|
||||||
<p>Альбомов пока нет. Создайте первый.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each albums as album (album.id)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/albums/{album.id}"
|
|
||||||
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
|
|
||||||
>
|
|
||||||
<div class="bg-bg-muted flex aspect-video items-center justify-center">
|
|
||||||
<Icon name="image" class="size-16 text-text-subtle" />
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<h2 class="text-text font-semibold">{album.title}</h2>
|
|
||||||
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
|
||||||
import Lightbox from '$lib/components/Lightbox.svelte';
|
|
||||||
import Uploader from '$lib/components/Uploader.svelte';
|
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
|
||||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
|
||||||
import type { Album, Photo } from '$lib/types';
|
|
||||||
|
|
||||||
const albumId = $derived($page.params.id ?? '');
|
|
||||||
|
|
||||||
let album = $state<Album | null>(null);
|
|
||||||
let role = $state('');
|
|
||||||
let photos = $state<Photo[]>([]);
|
|
||||||
let nextCursor = $state('');
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let lightboxIndex = $state(-1);
|
|
||||||
|
|
||||||
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
|
||||||
|
|
||||||
async function loadAlbum() {
|
|
||||||
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
|
|
||||||
album = data.album;
|
|
||||||
role = data.role;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPhotos() {
|
|
||||||
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
|
||||||
`/albums/${albumId}/photos`
|
|
||||||
);
|
|
||||||
photos = data.photos;
|
|
||||||
nextCursor = data.next_cursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (!nextCursor) return;
|
|
||||||
const data = await api<{ photos: Photo[]; next_cursor: string }>(
|
|
||||||
`/albums/${albumId}/photos?cursor=${encodeURIComponent(nextCursor)}`
|
|
||||||
);
|
|
||||||
photos = [...photos, ...data.photos];
|
|
||||||
nextCursor = data.next_cursor;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = albumId;
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
Promise.all([loadAlbum(), loadPhotos()])
|
|
||||||
.catch((e) => (error = (e as Error).message))
|
|
||||||
.finally(() => (loading = false));
|
|
||||||
});
|
|
||||||
|
|
||||||
let client: SSEClient | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const id = albumId;
|
|
||||||
if (!auth.access) return;
|
|
||||||
client = new SSEClient({
|
|
||||||
path: `/api/sse/albums/${id}`,
|
|
||||||
onEvent: handleEvent,
|
|
||||||
onReconnect: () => loadPhotos()
|
|
||||||
});
|
|
||||||
client.connect(auth.access);
|
|
||||||
return () => {
|
|
||||||
client?.close();
|
|
||||||
client = null;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleEvent(ev: SSEEvent) {
|
|
||||||
const data = ev.data as Record<string, unknown>;
|
|
||||||
switch (ev.type) {
|
|
||||||
case 'photo.created': {
|
|
||||||
const photo = data as unknown as Photo;
|
|
||||||
if (photos.some((p) => p.id === photo.id)) {
|
|
||||||
photos = photos.map((p) => (p.id === photo.id ? photo : p));
|
|
||||||
} else {
|
|
||||||
photos = [photo, ...photos];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'photo.deleted': {
|
|
||||||
photos = photos.filter((p) => p.id !== data.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'like.added': {
|
|
||||||
photos = photos.map((p) =>
|
|
||||||
p.id === data.photo_id
|
|
||||||
? { ...p, likes_count: p.likes_count + 1, liked_by_me: data.user_id === auth.user?.id || p.liked_by_me }
|
|
||||||
: p
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'like.removed': {
|
|
||||||
photos = photos.map((p) =>
|
|
||||||
p.id === data.photo_id
|
|
||||||
? { ...p, likes_count: Math.max(0, p.likes_count - 1), liked_by_me: false }
|
|
||||||
: p
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'comment.created': {
|
|
||||||
const comment = data as unknown as { photo_id: string };
|
|
||||||
photos = photos.map((p) =>
|
|
||||||
p.id === comment.photo_id ? { ...p, comments_count: p.comments_count + 1 } : p
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'album.updated': {
|
|
||||||
loadAlbum().catch(() => {});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<p class="text-text-muted">Загрузка…</p>
|
|
||||||
{:else if error}
|
|
||||||
<p class="text-danger">{error}</p>
|
|
||||||
{:else if album}
|
|
||||||
<div class="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">{album.title}</h1>
|
|
||||||
<p class="text-text-muted">{album.description}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<a
|
|
||||||
href="/albums/{albumId}/members"
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<Icon name="group" class="size-4" />
|
|
||||||
Участники
|
|
||||||
</a>
|
|
||||||
{#if role === 'owner' || role === 'admin'}
|
|
||||||
<a
|
|
||||||
href="/albums/{albumId}/settings"
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<Icon name="settings" class="size-4" />
|
|
||||||
Настройки
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if canUpload}
|
|
||||||
<div class="mb-6">
|
|
||||||
<Uploader
|
|
||||||
{albumId}
|
|
||||||
onUploaded={(created) => {
|
|
||||||
for (const p of created) {
|
|
||||||
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<PhotoGrid {photos} onOpen={(i) => (lightboxIndex = i)} />
|
|
||||||
|
|
||||||
{#if nextCursor}
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
|
|
||||||
onclick={loadMore}
|
|
||||||
>
|
|
||||||
<Icon name="expand_more" class="size-4" />
|
|
||||||
Показать ещё
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if lightboxIndex >= 0}
|
|
||||||
<Lightbox
|
|
||||||
{photos}
|
|
||||||
index={lightboxIndex}
|
|
||||||
onClose={() => (lightboxIndex = -1)}
|
|
||||||
onIndexChange={(i) => (lightboxIndex = i)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
@@ -11,6 +11,8 @@ export default {
|
|||||||
muted: 'hsl(var(--bg-muted) / <alpha-value>)'
|
muted: 'hsl(var(--bg-muted) / <alpha-value>)'
|
||||||
},
|
},
|
||||||
border: 'hsl(var(--border) / <alpha-value>)',
|
border: 'hsl(var(--border) / <alpha-value>)',
|
||||||
|
chrome: 'hsl(var(--chrome) / <alpha-value>)',
|
||||||
|
canvas: 'hsl(var(--canvas) / <alpha-value>)',
|
||||||
text: {
|
text: {
|
||||||
DEFAULT: 'hsl(var(--text) / <alpha-value>)',
|
DEFAULT: 'hsl(var(--text) / <alpha-value>)',
|
||||||
muted: 'hsl(var(--text-muted) / <alpha-value>)',
|
muted: 'hsl(var(--text-muted) / <alpha-value>)',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-10
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
См. proposal.md — Why. Текущее состояние, которое формирует подход:
|
||||||
|
|
||||||
|
- `albums.cover_photo_id` уже существует (`migrations/000001_init.up.sql`), `PATCH /albums/:id`
|
||||||
|
принимает `cover_photo_id`/`clear_cover` (`albums/handlers.go:55`), но список и `GET`
|
||||||
|
отдают только id — URL миниатюры негде взять, поэтому карточки рисуют иконку-заглушку.
|
||||||
|
- `photos.Service.attachURLs` уже пресигнит `ThumbKey`/`PreviewKey` (`photos/service.go:132`),
|
||||||
|
а `albums.Service` держит `storage *storage.Client` — приём с presigned URL доступен и там.
|
||||||
|
- Фоновая обработка загруженного фото безусловно зовёт `photos.Store.SetCover`
|
||||||
|
(`photos/service.go:124`), перезаписывая выбор пользователя.
|
||||||
|
- FK на `cover_photo_id` нет; удаление фото-обложки оставляет висячий id.
|
||||||
|
- Фронтенд: один плоский `+layout.svelte` (топбар + `max-w-6xl` + центр), лайтбокс —
|
||||||
|
оверлей, на `share/[token]` свой инлайн-лайтбокс. Темы: light/dark/neutral/system, дефолт
|
||||||
|
`system` (`stores/theme.svelte.ts`).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Постоянный каркас с рейлом и контекстной панелью, центр = текущий маршрут (Q).
|
||||||
|
- Обложки доезжают до UI; явный выбор + разумный дефолт; корректное удаление.
|
||||||
|
- Тёмная графитовая тема по умолчанию при сохранении остальных тем.
|
||||||
|
- Просмотр фото как маршрут в каркасе, общий с публичной share-страницей.
|
||||||
|
- Адаптивность до мобильного без потери каркаса.
|
||||||
|
- Пер-пользовательские настройки раздела «Информация» (видимость атрибутов и подписи),
|
||||||
|
хранимые в профиле.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Мультивыбор фото и массовые действия / bulk-удаление.
|
||||||
|
- Замена роутинга на «чистые панели» без маршрутов (вариант P).
|
||||||
|
- Новые внешние зависимости (иконки и токены вендорятся/расширяются).
|
||||||
|
- Изменение доменных контрактов API (только добавление поля ответа и FK).
|
||||||
|
- Порядок атрибутов и настройки на уровне альбома/фото — настройки глобальны для пользователя.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Каркас: группы маршрутов, центр = маршрут
|
||||||
|
`+layout.svelte` разбивается на группы: `(app)` — каркас с топбаром, левым рейлом и правой
|
||||||
|
панелью, и `(public)` — только токены. Центр `(app)` рендерит текущий маршрут; рейл живёт
|
||||||
|
между навигациями.
|
||||||
|
Альтернатива — один layout с условиями по `$page.route.id` (проще, но разрастается) или P
|
||||||
|
(панели вместо маршрутов) — отклонена: ломает deeplink и историю.
|
||||||
|
|
||||||
|
### 2. Обложка: LEFT JOIN + presign (A)
|
||||||
|
`albums.Store` добавляет `LEFT JOIN photos p ON p.id = a.cover_photo_id` и отдаёт
|
||||||
|
`cover_thumb_key`; `albums.Service` пресигнит его в `Album.CoverThumbURL`. Один запрос и один
|
||||||
|
presign на альбом; висячий id безопасно даёт `NULL`. Альтернативы: N+1 с `GET /photos/:id`
|
||||||
|
(chatty) и выделенный thumb-эндпоинт (расходится с текущим паттерном presigned URL).
|
||||||
|
|
||||||
|
### 3. Автообложка: писать только если пусто
|
||||||
|
`processVariants` вызывает `SetCover` только при `cover_photo_id IS NULL`. Выбор пользователя
|
||||||
|
не слетает, дефолт появляется с первым фото. Альтернатива — дефолт на чтении (COALESCE) —
|
||||||
|
отклонена в пользу простоты записи/чтения; осознанно принимаем, что в пачке побеждает
|
||||||
|
фото, обработанное первым.
|
||||||
|
|
||||||
|
### 4. Целостность обложки: FK ON DELETE SET NULL (M)
|
||||||
|
Миграция `000002_album_cover_fk`: сперва обнуляет уже висячие `cover_photo_id`, затем
|
||||||
|
`ALTER TABLE albums ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id)
|
||||||
|
ON DELETE SET NULL`. БД сама чинит любой путь удаления. Циклический FK `albums <-> photos`
|
||||||
|
безвреден: альбом вставляется с `NULL`, фото ссылается на альбом, cover выставляется позже.
|
||||||
|
Альтернатива — чистка в `photos.Delete` (легко забыть на новых путях удаления).
|
||||||
|
|
||||||
|
### 5. Точка выбора обложки: правая панель (T1)
|
||||||
|
Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`;
|
||||||
|
всё под `roles.CanEditAlbum`. Действие «Сделать обложкой» в тулбаре просмотра и hover-оверлей
|
||||||
|
в сетке (T3) не используются — шум и случайные клики.
|
||||||
|
|
||||||
|
### 6. Тема: сохранить темы, dark = графит и дефолт (α)
|
||||||
|
Тёмная тема перенастраивается в графит Lightroom и становится значением по умолчанию
|
||||||
|
(`theme.current = 'dark'` при отсутствии сохранённого выбора). Добавляются аддитивные токены
|
||||||
|
`--chrome` (топбар/рейл/панель) и `--canvas` (центр), чтобы «утопить» контент между панелями.
|
||||||
|
`light`/`neutral`/`system` остаются. Спека `ui-iconography` не затрагивается.
|
||||||
|
|
||||||
|
### 7. Адаптив: drawer + sheet + горизонтальная лента (M1)
|
||||||
|
Порог рабочего стола — `lg`/`md`. Ниже: рейл — off-canvas drawer по кнопке, правая панель —
|
||||||
|
сворачиваемая секция/bottom-sheet, filmstrip — горизонтальный скролл. Публичные экраны
|
||||||
|
получают ту же мобильную логику просмотра без рейла/панели.
|
||||||
|
|
||||||
|
### 8. Плотность сетки, без выбора (D)
|
||||||
|
Регулятор размера миниатюр в тулбаре сетки управляет `grid-template-columns` (пресеты или
|
||||||
|
`minmax`), значение хранится в `localStorage`. Мультивыбор/bulk — вне объёма.
|
||||||
|
|
||||||
|
### 9. Просмотр как маршрут (L2)
|
||||||
|
Новый маршрут `albums/[id]/photos/[photoId]` рендерит общий компонент `Viewer` (крупное фото,
|
||||||
|
filmstrip, панель метаданных/комментариев, тулбар). `share/[token]` переиспользует `Viewer`
|
||||||
|
без рейла/панели. Навигация синхронизирует маршрут и filmstrip; клавиши ←/→/Esc.
|
||||||
|
|
||||||
|
### 10. Загрузка из тулбара (U1)
|
||||||
|
Иконочная кнопка загрузки (роль-gated) открывает файловый диалог; `dragover` на весь альбом
|
||||||
|
показывает полноэкранный оверлей; пустой альбом показывает центральную зону-подсказку.
|
||||||
|
Прогресс — компактная полоса/тост вместо крупного блока.
|
||||||
|
|
||||||
|
### 11. Создание альбома через модалку
|
||||||
|
Кнопка `+` с хинтом (в рейле и/или топбаре) открывает модалку с полем названия; `POST
|
||||||
|
/albums`, созданный альбом становится активным. Инлайн-форма удаляется.
|
||||||
|
|
||||||
|
### 12. Переименование фото: `PATCH /photos/:id`
|
||||||
|
Имя файла меняется через новый эндпоинт; права — как у удаления (`CanDeleteAnyPhoto` или
|
||||||
|
автор своего фото). Пустое имя отклоняется, длина ограничивается 255 символами, изменение
|
||||||
|
транслируется событием `photo.updated`. UI — инлайн-редактор в правой панели. Альтернатива —
|
||||||
|
редактировать `caption` вместо имени — отклонена: пользователь просил имя файла.
|
||||||
|
|
||||||
|
### 13. Просмотр: полный экран и зум
|
||||||
|
Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по
|
||||||
|
`fullscreenchange`. `f`/кнопка переключают именно полноэкранный режим вьюера: если активен
|
||||||
|
полноэкранный режим приложения, `f` входит в просмотр фото вложенным режимом и не выключает
|
||||||
|
режим приложения. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото
|
||||||
|
всплывают у соответствующих кромок без таймеров — показываются и скрываются сразу по позиции
|
||||||
|
курсора (панели — по касанию края; стрелки — при курсоре в 1/6 левой/правой области, только
|
||||||
|
внутри границ вьюера, поэтому при уходе в боковые панели они скрыты; в зуме стрелки скрыты
|
||||||
|
полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото:
|
||||||
|
колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%;
|
||||||
|
`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум
|
||||||
|
догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование —
|
||||||
|
перетаскиванием и скроллом. Хоткеи игнорируются при фокусе в поле ввода; зум сбрасывается при
|
||||||
|
смене фото. Альтернатива — только `object-fit` без ручного масштаба — не даёт 1:1 и
|
||||||
|
панорамирования.
|
||||||
|
|
||||||
|
### 14. Панели: ресайз и сворачиваемые разделы
|
||||||
|
Ширина рейла и панели хранится в `localStorage`, меняется перетаскиванием разделителя
|
||||||
|
(и стрелками с клавиатуры), ширина подаётся CSS-переменной на десктопных брейкпоинтах.
|
||||||
|
Разделы панели — компонент `PanelSection` со сворачиванием и сохранением состояния.
|
||||||
|
Альтернатива — фиксированные ширины и всегда развёрнутые секции — отклонена: не отвечает
|
||||||
|
запросу.
|
||||||
|
|
||||||
|
### 15. Лимит загрузки
|
||||||
|
Общий лимит тела запроса поднят до 100 МБ; при `http.MaxBytesError` возвращается
|
||||||
|
`413 file_too_large` с понятным сообщением вместо маскировки под ошибку разбора multipart.
|
||||||
|
Клиент заранее проверяет размер каждого файла и суммарный.
|
||||||
|
|
||||||
|
### 16. Счётчики альбома и событие обновления фото
|
||||||
|
`photos_count`/`members_count` считаются подзапросами в тех же `ListForUser`/`GetByID`, чтобы
|
||||||
|
панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома
|
||||||
|
обновляет запись в списке.
|
||||||
|
|
||||||
|
### 17. Настройки информации: явный список видимых в профиле пользователя
|
||||||
|
Настройки раздела «Информация» хранятся в профиле как явный список видимых атрибутов:
|
||||||
|
`users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида
|
||||||
|
`{ "visible": ["Make", "Model", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие
|
||||||
|
секции `photo_info` означает базовый набор по умолчанию (камера, объектив, выдержка,
|
||||||
|
диафрагма, ISO, фокусное расстояние) — не «все атрибуты». Список `visible` хранится явно,
|
||||||
|
поэтому различимо состояние «ничего не выбрано»; новые и незнакомые теги не «теряются» (их
|
||||||
|
можно включить в диалоге). `GET /auth/me` отдаёт `preferences` вместе с пользователем;
|
||||||
|
сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной валидацией:
|
||||||
|
строки и массивы строк, ограничение длины подписи/ключа и числа записей). Пустой объект
|
||||||
|
(`{}`) удаляет настройки, возвращая базовый набор. Настройки не входят в `PublicUser`
|
||||||
|
(приватны). UI: `PanelSection` получает слот `actions` под шестерёнку; модалка
|
||||||
|
`PhotoInfoSettingsDialog` строится из объединения известного каталога подписей и ключей
|
||||||
|
текущего фото и даёт действия «отметить все»/«снять все» и «Сбросить»; список в панели
|
||||||
|
фильтруется по сохранённым видимым атрибутам, а остальные сворачиваются под кнопку «…»
|
||||||
|
(раскрытие — временное, для текущего фото). Альтернатива с оверрайдами `hidden` (пусто = все
|
||||||
|
видимы) отклонена: не даёт базовый набор по умолчанию.
|
||||||
|
|
||||||
|
### 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`/координаты. Альтернатива — фиксированный набор полей — отклонена:
|
||||||
|
пользователю нужны все атрибуты.
|
||||||
|
|
||||||
|
### 19. Выбор фото в сетке через общий стор
|
||||||
|
Выбор фото в сетке и контекстная панель живут в разных местах дерева (страница маршрута и слот
|
||||||
|
`AppShell`), поэтому выбранное фото хранится в общем сторе `selection` (`albumId` + `photo`).
|
||||||
|
Одиночный клик в `PhotoGrid` записывает выбор (рамка `ring` + `aria-pressed`), и панель
|
||||||
|
показывает сведения о выбранном фото; двойной клик открывает маршрут просмотра. Панель
|
||||||
|
использует `panelPhoto = photoId ? routePhoto : selectedPhoto`, поэтому в просмотре приоритет у
|
||||||
|
маршрута, а в альбоме — у выбора; выбор сбрасывается при смене альбома. Альтернатива — хранить
|
||||||
|
выбор в URL (query) — избыточно для локального выделения.
|
||||||
|
|
||||||
|
### 20. Корзина: мягкое удаление и подсчёт ссылок
|
||||||
|
Удаление фото — мягкое (`photos.deleted_at`, миграция `000005_photo_trash`): списки и просмотр
|
||||||
|
исключают такие фото, `GET /trash` отдаёт их по альбомам пользователя (владелец или участник).
|
||||||
|
Восстановление снимает `deleted_at`. Окончательное удаление удаляет запись, и только если
|
||||||
|
после этого `storage_key` больше нигде не встречается (копии в других альбомах разделяют
|
||||||
|
объект), удаляются объекты S3 (оригинал, thumb, preview). Альтернатива — удалять S3 сразу —
|
||||||
|
порвала бы копии.
|
||||||
|
|
||||||
|
### 21. Перенос и копирование — только метаданные
|
||||||
|
Перенос меняет `photos.album_id`; копирование создаёт новую запись с тем же `storage_key`,
|
||||||
|
thumb/preview и метаданными (клон). Объекты S3 не дублируются, поэтому «ссылки в любых
|
||||||
|
альбомах» — это строки photos с общим `storage_key`. Права: удаление в источнике (автор или
|
||||||
|
owner/admin) и загрузка в цель (member+).
|
||||||
|
|
||||||
|
### 22. Мультивыбор и трансфер через общие сторы
|
||||||
|
Выбор — стор `selection` (набор фото + anchor); Ctrl/Cmd — toggle, Shift — диапазон по
|
||||||
|
индексам текущей сетки. Действия инициируют диалог: стор `transfer` хранит ids, необязательный
|
||||||
|
целевой альбом и действие; глобальный `TransferDialog` (портал) выбирает альбом и
|
||||||
|
перенос/копию. Перетаскивание с сетки на альбом рейла передаёт ids через `dataTransfer` типа
|
||||||
|
`application/x-galo-photos`; дроп-цель — элементы рейла. Обработчик загрузки показывает оверлей
|
||||||
|
только для типа `Files`, поэтому при переносе фото зона загрузки не появляется.
|
||||||
|
|
||||||
|
### 23. Рейл: вход в корзину и скрытие панели
|
||||||
|
В рейле альбомов последним элементом списка добавлена «Корзина» (иконка корзины), ведущая на
|
||||||
|
маршрут `/trash` со списком удалённых фото и действиями «Вернуть»/«Удалить навсегда». Пустая
|
||||||
|
правая панель на обзоре скрыта: `AppShell` получает `panelVisible`, который layout выключает на
|
||||||
|
корневом маршруте.
|
||||||
|
|
||||||
|
### 24. Компактный верхний хром
|
||||||
|
Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к
|
||||||
|
минимуму: узкие вертикальные отступы (`py-0.5`/`py-1`), иконочные органы управления без рамок,
|
||||||
|
иконки `size-4`, компактный переключатель темы. Верхняя панель просмотра — прозрачный оверлей
|
||||||
|
(`absolute`, без фона и рамки, `pointer-events-none` с активными кнопками), поэтому не занимает
|
||||||
|
место в раскладке и увеличивает область фото. Функциональность (меню, панель, темы, полный
|
||||||
|
экран, навигация, скачивание, удаление) сохраняется.
|
||||||
|
|
||||||
|
### 25. Действия в режиме просмотра — в правой панели
|
||||||
|
Скачивание и удаление убраны из верхней панели просмотра и перенесены в правую панель, в раздел
|
||||||
|
«Действия», вместе с «Переместить» и «Копировать». Скачивание — ссылка на presigned оригинал;
|
||||||
|
перенос/копирование — через `transfer`/`TransferDialog`; удаление инициируется стором
|
||||||
|
`viewerActions`, а обрабатывает его страница просмотра (та же логика перехода к соседнему фото
|
||||||
|
после удаления).
|
||||||
|
|
||||||
|
### 26. Единая панель фото
|
||||||
|
Содержимое правой панели для одного фото (имя файла, «Действия», «Информация», «Комментарии»)
|
||||||
|
вынесено в общий компонент `PhotoPanel`, который переиспользуется и в режиме просмотра
|
||||||
|
(`viewer=true`), и при одиночном выборе фото в сетке — это одна и та же панель, а не копия.
|
||||||
|
Отличие только в наборе действий: в просмотре нет «Открыть», а удаление идёт через стор
|
||||||
|
`viewerActions` (обрабатывает страница просмотра с переходом к соседнему фото); при выборе в
|
||||||
|
сетке удаление выполняет сам компонент и очищает выбор. Мультивыбор (>1) показывает только
|
||||||
|
раздел «Действия».
|
||||||
|
|
||||||
|
### 27. Контекстное меню фото
|
||||||
|
Правый клик по фото в сетке и по изображению в просмотре открывает контекстное меню с теми же
|
||||||
|
пунктами, что и раздел «Действия». Меню — общий стор `contextMenu` (`x`, `y`, items) и глобальный
|
||||||
|
компонент `ContextMenu` (портал), закрывается по клику вне, `Escape`, скроллу и ресайзу.
|
||||||
|
Пункты формируются на местах: в сетке — «Открыть (просмотр)», «Скачать», «Переместить»,
|
||||||
|
«Копировать», «Удалить» (для мультивыбора без «Скачать»); в просмотре — без «Открыть».
|
||||||
|
|
||||||
|
### 28. Удаление альбома и восстановление фото
|
||||||
|
Удаление альбома не должно терять файлы: перед удалением альбома его фото мягко удаляются
|
||||||
|
(`deleted_at`), а FK `photos.album_id` меняется на `ON DELETE SET NULL` (колонка nullable,
|
||||||
|
миграция `000006`) — после удаления альбома фото остаются строками с `album_id = NULL` в
|
||||||
|
корзине. Объекты S3 не удаляются (каскадный `DeletePrefix` убран: он ломал бы копии в других
|
||||||
|
альбомах). `GET /trash` включает такие «без альбома» фото загрузчика. Восстановление с
|
||||||
|
указанием целевого альбома (`POST /photos/:id/restore {album_id}`) переносит фото туда; без
|
||||||
|
цели и без альбома возвращается ошибка `album_required`, и клиент предлагает выбрать альбом или
|
||||||
|
создать новый.
|
||||||
|
|
||||||
|
### 29. Контекстное меню альбома и счётчики в рейле
|
||||||
|
Правый клик по альбому (в рейле и на обзоре) открывает меню «Переименовать»/«Удалить» через
|
||||||
|
общий стор `contextMenu`; диалоги переименования и подтверждения удаления — глобальный
|
||||||
|
`AlbumActionsDialogs` (стор `albumActions`). В рейле у каждого альбома показано число фото
|
||||||
|
(`album.photos_count`), у корзины — число удалённых фото (лёгкий `GET /trash/count`, стор
|
||||||
|
`trash`).
|
||||||
|
|
||||||
|
### 30. Метки фото (область — пользователь)
|
||||||
|
Метки принадлежат пользователю: таблицы `tags` (`user_id`, уникальность по `lower(name)`) и
|
||||||
|
`photo_tags` (миграция `000007`). Фото отдаёт метки текущего пользователя (подзапрос
|
||||||
|
`json_agg` в `photoColumns` по `$2`-viewer). Эндпоинты: `GET /tags?q=` (поиск по меткам
|
||||||
|
пользователя), `POST /photos/:id/tags {name}` (создаёт метку при необходимости и привязывает,
|
||||||
|
возвращает обновлённое фото), `DELETE /photos/:id/tags/:tag_id`. UI — раздел «Метки» в
|
||||||
|
`PhotoPanel`: чипы с удалением и поле с автодополнением (поиск + «Создать «…»»). Права: метку
|
||||||
|
можно ставить на фото альбома, доступного пользователю.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
||||||
|
при необходимости кэшировать URL на короткий TTL.
|
||||||
|
- [Presigned URL обложки истекает при долго открытой странице] → URL обновляется при загрузке
|
||||||
|
списка; при ошибке загрузки картинки показывать плейсхолдер.
|
||||||
|
- [Циклический FK albums↔photos] → миграция обнуляет висячие id до создания constraint;
|
||||||
|
каскадное удаление альбома обрабатывается БД (SET NULL по уже удаляемой строке безвреден).
|
||||||
|
- [Реструктуризация `+layout` задевает все маршруты] → выносить каркас инкрементально, группы
|
||||||
|
`(app)`/`(public)` в одном шаге, публичные страницы регрессионно проверяем.
|
||||||
|
- [Добавление токенов темы задевает все экраны] → токены аддитивны, старые классы продолжают
|
||||||
|
работать; проверка триад тем.
|
||||||
|
- [Пачка загрузок: обложкой становится первое обработанное фото] → принято; дальнейшие
|
||||||
|
загрузки обложку не трогают, пользователь может задать явно.
|
||||||
|
- [Дополнительное состояние в маршруте просмотра (filmstrip, клавиатура)] → единый компонент
|
||||||
|
`Viewer` с props и общим источником фото.
|
||||||
|
- [Зум/панорамирование конфликтует с навигацией и прокруткой] → при увеличении отключаем
|
||||||
|
стрелки и свайп; колесо над областью фото перехватывается с `preventDefault`.
|
||||||
|
- [Глобальные хоткеи при вводе текста] → игнорируем `f`/`Space`/стрелки, когда фокус в
|
||||||
|
`input`/`textarea`/`contenteditable`.
|
||||||
|
- [Крупные файлы утяжеляют память] → лимит 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. Миграция `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`; извлечение 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
|
||||||
|
|
||||||
|
- Набор пресетов регулятора плотности (сколько ступеней) — решается при реализации, спеки не
|
||||||
|
меняет.
|
||||||
|
- Хинты: нативный `title` или кастомный тултип — косметика, спеки не меняет.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Сейчас интерфейс — это центрированный «маркетинговый» лейаут: список альбомов с
|
||||||
|
инлайн-формой создания, серая заглушка вместо обложек, отдельные страницы «Участники» и
|
||||||
|
«Настройки», лайтбокс-оверлей. Обложки в бэкенде уже есть (`albums.cover_photo_id`,
|
||||||
|
`PATCH /albums/:id`), но до UI не доходят. Пользователь хочет полноценный «рабочий стол»
|
||||||
|
в духе Adobe Lightroom: постоянный рейл альбомов, контекстная панель, плотная сетка,
|
||||||
|
режим просмотра как часть рабочего стола, обложки с явным выбором и единый тёмный
|
||||||
|
графитовый хром.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Рабочий стол (шелл).** Группа `(app)` с постоянным левым рейлом альбомов, топбаром и
|
||||||
|
контекстной правой панелью. Публичные маршруты (`/login`, `/invites/[token]`,
|
||||||
|
`/share/[token]`) остаются вне шелла, но в тех же токенах.
|
||||||
|
- **Создание альбома.** Инлайн-форма заменяется иконочной кнопкой с хинтом, открывающей
|
||||||
|
модалку ввода названия.
|
||||||
|
- **Обложки.** `Album` получает `cover_thumb_url`; карточки и рейл показывают реальную
|
||||||
|
миниатюру. Выбор обложки: пикер в правой панели (действие «сделать обложкой» в
|
||||||
|
просмотре не используется). Автообложка пишется только когда обложка ещё не задана; FK
|
||||||
|
`albums.cover_photo_id -> photos(id) ON DELETE SET NULL` чинит висячие ссылки.
|
||||||
|
- **Просмотр как маршрут.** `Lightbox`-оверлей заменяется маршрутом
|
||||||
|
`/albums/:id/photos/:photoId` внутри шелла: крупное фото, filmstrip снизу, панель
|
||||||
|
метаданных/комментариев. `share/[token]` переиспользует тот же компонент.
|
||||||
|
- **Плотность сетки.** Слайдер размера миниатюр в тулбаре (состояние в localStorage).
|
||||||
|
Мультивыбор и массовые действия — вне объёма.
|
||||||
|
- **Загрузка.** Крупный dropzone заменяется кнопкой в тулбаре, полноэкранным
|
||||||
|
drag-overlay и dropzone-намёком только в пустом альбоме. Лимит — 100 МБ с понятной
|
||||||
|
ошибкой при превышении.
|
||||||
|
- **Переименование фото.** `PATCH /photos/:id` меняет `original_filename`; инлайн-редактор
|
||||||
|
в правой панели, доступный автору фото и owner/admin.
|
||||||
|
- **Метаданные фото (EXIF).** При загрузке система извлекает все распознаваемые EXIF-атрибуты
|
||||||
|
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
|
||||||
|
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
|
||||||
|
фото есть команда бэкфилла метаданных из оригиналов.
|
||||||
|
- **Метки фото.** Пользователь присваивает фото свои метки: создаёт новые и выбирает из списка
|
||||||
|
с поиском; метки показываются и снимаются в разделе «Метки» панели фото.
|
||||||
|
- **Контекстные меню.** Правый клик по фото (в сетке и в просмотре) открывает меню с пунктами
|
||||||
|
раздела «Действия»; правый клик по альбому (в рейле и на обзоре) — меню «Переименовать»/
|
||||||
|
«Удалить».
|
||||||
|
- **Удаление альбома.** Удаление альбома помещает его фото в корзину, не удаляя объекты
|
||||||
|
хранилища; при восстановлении фото без альбома предлагается выбрать существующий альбом или
|
||||||
|
создать новый.
|
||||||
|
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
|
||||||
|
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
|
||||||
|
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
|
||||||
|
действия просмотра (скачать, удалить, перенести, копировать) — в правой панели, в разделе
|
||||||
|
«Действия»; удаление с модальным подтверждением; возврат к обзору.
|
||||||
|
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
||||||
|
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
||||||
|
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
|
||||||
|
- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую
|
||||||
|
модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять
|
||||||
|
все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…»
|
||||||
|
(клик раскрывает все). Настройки хранятся в профиле пользователя (server-side), применяются
|
||||||
|
ко всем фото, а при отсутствии настроек показывается базовый набор; «Сбросить» удаляет
|
||||||
|
настройки из профиля.
|
||||||
|
- **Мультивыбор и операции.** Ctrl/Shift-выбор нескольких фото в сетке; в правой панели —
|
||||||
|
раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»;
|
||||||
|
перетаскивание выделенных фото на альбом в рейле открывает диалог перенос/копирование.
|
||||||
|
Перенос и копирование меняют только метаданные (объекты S3 не трогаются). Любое удаление
|
||||||
|
помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других
|
||||||
|
альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на
|
||||||
|
обзоре убрана пустая правая панель.
|
||||||
|
- **Счётчики.** В ответе альбома — число фото и участников; в рейле у каждого альбома показано
|
||||||
|
число фото, у корзины — число удалённых фото.
|
||||||
|
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
|
||||||
|
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
|
||||||
|
системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы.
|
||||||
|
Верхняя панель приложения и верхняя панель режима просмотра — компактные (минимум высоты).
|
||||||
|
- **Адаптив.** На узких экранах рейл становится off-canvas drawer, правая панель —
|
||||||
|
bottom-sheet, filmstrip — горизонтальным скроллом.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `ui-workspace`: рабочий стол приложения — шелл, рейл альбомов, контекстная панель,
|
||||||
|
режим просмотра как маршрут, адаптивное поведение и тёмный хром.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `albums`: отображение обложки, счётчики фото/участников, явный выбор обложки и удаление в
|
||||||
|
корзину.
|
||||||
|
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
|
||||||
|
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование, корзина и метки.
|
||||||
|
- `auth`: хранение пользовательских настроек отображения информации в профиле.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Backend:** миграция FK `albums.cover_photo_id -> photos(id) ON DELETE SET NULL`;
|
||||||
|
`internal/albums/store.go` (LEFT JOIN `photos` → `cover_thumb_key` и счётчики
|
||||||
|
`photos_count`/`members_count` в `ListForUser`/`GetByID`); `internal/albums/service.go`
|
||||||
|
(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`; извлечение 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` с валидацией); миграции `000005_photo_trash` (мягкое удаление),
|
||||||
|
`000006_photo_album_nullable` (FK `photos.album_id` → `ON DELETE SET NULL`, удаление альбома
|
||||||
|
в корзину), `000007_photo_tags` (метки); `GET /trash`/`GET /trash/count`,
|
||||||
|
`POST /photos/:id/restore` (c `album_id`), `DELETE /photos/:id/permanent`,
|
||||||
|
`POST /photo-batch/{delete,move,copy}`, `GET /tags`, `POST/DELETE /photos/:id/tags`.
|
||||||
|
- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell`
|
||||||
|
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
|
||||||
|
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
|
||||||
|
`Viewer` с полноэкранным режимом и зумом, общий стор альбомов, контрол плотности; новый
|
||||||
|
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
|
||||||
|
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
|
||||||
|
новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики,
|
||||||
|
`photos_count`, `preferences`, `tags`); общие компоненты `PhotoPanel`, `ContextMenu`,
|
||||||
|
`TransferDialog`, `AlbumActionsDialogs`; маршрут `/trash`; сторы `selection`, `transfer`,
|
||||||
|
`contextMenu`, `albumActions`, `trash`, `viewerActions`; новые иконки в реестре.
|
||||||
|
- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не
|
||||||
|
затрагивается — иконки по-прежнему наследуют цвет темы.
|
||||||
|
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
|
||||||
|
изменений API нет.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Отображение обложки альбома
|
||||||
|
Данные альбома SHALL включать URL миниатюры обложки, когда обложка задана, чтобы клиенты
|
||||||
|
могли показать её в списке, рейле и карточке.
|
||||||
|
|
||||||
|
#### Scenario: Обложка задана
|
||||||
|
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
|
||||||
|
- **THEN** ответ содержит URL миниатюры обложки
|
||||||
|
|
||||||
|
#### Scenario: Обложка отсутствует
|
||||||
|
- **WHEN** у альбома нет обложки или фото обложки удалено
|
||||||
|
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
|
||||||
|
|
||||||
|
### Requirement: Выбор обложки
|
||||||
|
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
|
||||||
|
обложку; после сброса обложка определяется заново по умолчанию.
|
||||||
|
|
||||||
|
#### Scenario: Назначение обложки
|
||||||
|
- **WHEN** владелец выбирает фото альбома как обложку
|
||||||
|
- **THEN** система сохраняет выбор и отражает его в списке и карточках
|
||||||
|
|
||||||
|
#### Scenario: Сброс обложки
|
||||||
|
- **WHEN** админ сбрасывает обложку
|
||||||
|
- **THEN** система удаляет явный выбор, и обложка возвращается к значению по умолчанию
|
||||||
|
|
||||||
|
#### Scenario: Нет прав на смену обложки
|
||||||
|
- **WHEN** участник без права редактирования пытается сменить обложку
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
### Requirement: Счётчики альбома
|
||||||
|
Данные альбома SHALL включать количество фото и участников, чтобы панель и карточки могли
|
||||||
|
показать их без отдельных запросов.
|
||||||
|
|
||||||
|
#### Scenario: Счётчики в ответе
|
||||||
|
- **WHEN** клиент запрашивает список альбомов или альбом по id
|
||||||
|
- **THEN** ответ содержит число фото и число участников альбома
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Удаление альбома
|
||||||
|
Владелец SHALL иметь возможность удалить альбом; фото альбома SHALL помещаться в корзину без
|
||||||
|
удаления объектов хранилища, а участники и share-ссылки SHALL удаляться вместе с альбомом.
|
||||||
|
|
||||||
|
#### Scenario: Удаление альбома
|
||||||
|
- **WHEN** владелец удаляет альбом
|
||||||
|
- **THEN** фото альбома попадают в корзину без привязки к альбому и файлы сохраняются, участники и share-ссылки удаляются
|
||||||
@@ -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** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Обложка по умолчанию
|
||||||
|
При обработке загруженного фото система SHALL назначать его обложкой альбома только если
|
||||||
|
обложка ещё не задана.
|
||||||
|
|
||||||
|
#### Scenario: Первое фото
|
||||||
|
- **WHEN** фото обработано, и у альбома нет обложки
|
||||||
|
- **THEN** система назначает это фото обложкой
|
||||||
|
|
||||||
|
#### Scenario: Обложка уже задана
|
||||||
|
- **WHEN** фото обработано, а обложка уже задана
|
||||||
|
- **THEN** система не меняет обложку
|
||||||
|
|
||||||
|
### Requirement: Переименование фото
|
||||||
|
Автор фото, владелец и админ альбома SHALL иметь возможность изменить имя файла фото;
|
||||||
|
остальные SHALL получать отказ, а пустое имя SHALL отклоняться.
|
||||||
|
|
||||||
|
#### Scenario: Переименование автором
|
||||||
|
- **WHEN** автор меняет имя файла своего фото на непустое
|
||||||
|
- **THEN** система сохраняет новое имя и транслирует изменение подписчикам альбома
|
||||||
|
|
||||||
|
#### Scenario: Пустое имя
|
||||||
|
- **WHEN** пользователь подтверждает пустое имя файла
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
#### Scenario: Нет прав
|
||||||
|
- **WHEN** участник без прав переименовывает чужое фото
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
### Requirement: Ограничение размера загрузки
|
||||||
|
Система SHALL ограничивать размер загружаемых файлов и SHALL отвечать понятной ошибкой при
|
||||||
|
превышении лимита, а не ошибкой разбора формы.
|
||||||
|
|
||||||
|
#### Scenario: Превышение лимита
|
||||||
|
- **WHEN** загружаемый файл превышает максимальный размер
|
||||||
|
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
|
||||||
|
|
||||||
|
#### Scenario: Файл в пределах лимита
|
||||||
|
- **WHEN** файл не превышает лимит
|
||||||
|
- **THEN** система принимает multipart-загрузку и обрабатывает фото
|
||||||
|
|
||||||
|
### Requirement: Метаданные фото (EXIF)
|
||||||
|
При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их
|
||||||
|
вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор.
|
||||||
|
|
||||||
|
#### Scenario: Фото с метаданными
|
||||||
|
- **WHEN** загружается фото с EXIF
|
||||||
|
- **THEN** данные фото содержат извлечённые атрибуты (камера, экспозиция, ISO, дата съёмки и др.)
|
||||||
|
|
||||||
|
#### Scenario: Фото без метаданных
|
||||||
|
- **WHEN** загружается фото без читаемых EXIF
|
||||||
|
- **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно
|
||||||
|
|
||||||
|
### Requirement: Перенос и копирование фото между альбомами
|
||||||
|
Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь
|
||||||
|
возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только
|
||||||
|
метаданные и SHALL не изменять объекты хранилища.
|
||||||
|
|
||||||
|
#### Scenario: Перенос
|
||||||
|
- **WHEN** пользователь переносит фото в другой альбом
|
||||||
|
- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты S3 не меняются
|
||||||
|
|
||||||
|
#### Scenario: Копирование
|
||||||
|
- **WHEN** пользователь копирует фото в другой альбом
|
||||||
|
- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты S3
|
||||||
|
|
||||||
|
### Requirement: Корзина фото
|
||||||
|
Удаление фото SHALL помещать его в корзину (мягкое удаление). Восстановление SHALL возвращать
|
||||||
|
фото в его альбом. Окончательное удаление SHALL удалять запись, а объекты хранилища SHALL
|
||||||
|
удаляться только когда на них не остаётся ссылок ни в одном альбоме.
|
||||||
|
|
||||||
|
#### Scenario: Удаление в корзину
|
||||||
|
- **WHEN** пользователь удаляет фото
|
||||||
|
- **THEN** фото пропадает из альбома и появляется в корзине
|
||||||
|
|
||||||
|
#### Scenario: Восстановление
|
||||||
|
- **WHEN** пользователь восстанавливает фото из корзины
|
||||||
|
- **THEN** фото возвращается в свой альбом
|
||||||
|
|
||||||
|
#### Scenario: Окончательное удаление без ссылок
|
||||||
|
- **WHEN** пользователь удаляет фото из корзины навсегда, и на те же объекты нет ссылок в других альбомах
|
||||||
|
- **THEN** запись и объекты хранилища удаляются
|
||||||
|
|
||||||
|
#### Scenario: Окончательное удаление при наличии ссылок
|
||||||
|
- **WHEN** на объект хранилища ссылается копия в другом альбоме
|
||||||
|
- **THEN** запись удаляется, а объект хранилища сохраняется
|
||||||
|
|
||||||
|
### Requirement: Удаление альбома и восстановление фото без альбома
|
||||||
|
Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при
|
||||||
|
восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий
|
||||||
|
альбом или создать новый.
|
||||||
|
|
||||||
|
#### Scenario: Удаление альбома
|
||||||
|
- **WHEN** владелец удаляет альбом
|
||||||
|
- **THEN** его фото оказываются в корзине без привязки к альбому, файлы сохраняются
|
||||||
|
|
||||||
|
#### Scenario: Восстановление без альбома
|
||||||
|
- **WHEN** пользователь восстанавливает фото, у которого нет альбома
|
||||||
|
- **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него
|
||||||
|
|
||||||
|
### Requirement: Метки фото
|
||||||
|
Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и
|
||||||
|
выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься.
|
||||||
|
|
||||||
|
#### Scenario: Создание метки
|
||||||
|
- **WHEN** пользователь вводит новую метку и подтверждает
|
||||||
|
- **THEN** метка создаётся и привязывается к фото
|
||||||
|
|
||||||
|
#### Scenario: Выбор из списка с поиском
|
||||||
|
- **WHEN** пользователь вводит часть названия метки
|
||||||
|
- **THEN** показываются подходящие метки пользователя для выбора
|
||||||
|
|
||||||
|
#### Scenario: Снятие метки
|
||||||
|
- **WHEN** пользователь убирает метку с фото
|
||||||
|
- **THEN** метка отвязывается от фото
|
||||||
+384
@@ -0,0 +1,384 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Рабочий стол веб-приложения: постоянный каркас с рейлом альбомов, контекстной панелью,
|
||||||
|
режимом просмотра фото и адаптивным поведением, обеспечивающий цельный «lightroom»-вид.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Рабочий стол приложения
|
||||||
|
Аутентифицированные экраны SHALL отображаться внутри постоянного каркаса с топбаром, левым
|
||||||
|
рейлом альбомов и контекстной правой панелью. Публичные экраны (вход, инвайт, share-ссылка)
|
||||||
|
SHALL отображаться вне каркаса, но в общей теме оформления.
|
||||||
|
|
||||||
|
#### Scenario: Внутренний экран
|
||||||
|
- **WHEN** аутентифицированный пользователь открывает список альбомов или альбом
|
||||||
|
- **THEN** экран отрисован внутри каркаса с рейлом и панелью, рейл не перезагружается при переходе
|
||||||
|
|
||||||
|
#### Scenario: Публичный экран
|
||||||
|
- **WHEN** пользователь открывает публичную share-ссылку без входа
|
||||||
|
- **THEN** экран отрисован без рейла и контекстной панели, но в тех же цветовых токенах
|
||||||
|
|
||||||
|
### Requirement: Рейл альбомов
|
||||||
|
Рейл SHALL постоянно показывать список доступных альбомов с их обложками и отмечать
|
||||||
|
активный альбом.
|
||||||
|
|
||||||
|
#### Scenario: Активный альбом
|
||||||
|
- **WHEN** пользователь находится в альбоме
|
||||||
|
- **THEN** соответствующий элемент рейла выделен, остальные показывают обложки или плейсхолдер
|
||||||
|
|
||||||
|
### Requirement: Создание альбома
|
||||||
|
Создание альбома SHALL выполняться через иконочную кнопку с подсказкой, открывающую
|
||||||
|
модальное окно ввода названия, вместо инлайн-формы.
|
||||||
|
|
||||||
|
#### Scenario: Создание через модалку
|
||||||
|
- **WHEN** пользователь нажимает кнопку создания, вводит название и подтверждает
|
||||||
|
- **THEN** система создаёт альбом и делает его активным
|
||||||
|
|
||||||
|
#### Scenario: Пустое название
|
||||||
|
- **WHEN** пользователь подтверждает пустое название
|
||||||
|
- **THEN** создание не выполняется
|
||||||
|
|
||||||
|
### Requirement: Контекстная панель альбома
|
||||||
|
Правая панель SHALL показывать превью обложки, название, счётчики фото и участников,
|
||||||
|
управление обложкой и переходы к участникам, настройкам и ссылкам. Элементы управления
|
||||||
|
обложкой SHALL быть доступны только owner и admin.
|
||||||
|
|
||||||
|
#### Scenario: Владелец альбома
|
||||||
|
- **WHEN** owner открывает альбом
|
||||||
|
- **THEN** панель показывает управление обложкой и переходы к участникам и настройкам
|
||||||
|
|
||||||
|
#### Scenario: Наблюдатель
|
||||||
|
- **WHEN** viewer открывает альбом
|
||||||
|
- **THEN** панель не показывает управление обложкой
|
||||||
|
|
||||||
|
### Requirement: Режим просмотра фотографии
|
||||||
|
Просмотр фото SHALL быть отдельным маршрутом внутри каркаса с крупным изображением,
|
||||||
|
лентой миниатюр снизу и панелью метаданных и комментариев. Тот же режим SHALL
|
||||||
|
использоваться на публичных share-страницах.
|
||||||
|
|
||||||
|
#### Scenario: Открытие фото
|
||||||
|
- **WHEN** пользователь выбирает фото в сетке
|
||||||
|
- **THEN** открывается маршрут фото в каркасе, рейл и панель остаются доступны, адрес отражает фото
|
||||||
|
|
||||||
|
#### Scenario: Навигация в просмотре
|
||||||
|
- **WHEN** пользователь использует стрелки влево/вправо или ленту миниатюр
|
||||||
|
- **THEN** показывается соседнее фото, адрес обновляется
|
||||||
|
|
||||||
|
#### Scenario: Публичный просмотр
|
||||||
|
- **WHEN** посетитель share-ссылки открывает фото
|
||||||
|
- **THEN** используется тот же режим просмотра без рейла и панели
|
||||||
|
|
||||||
|
### Requirement: Плотность сетки
|
||||||
|
Сетка SHALL давать пользователю управлять размером миниатюр, и выбранный размер SHALL
|
||||||
|
сохраняться между сессиями.
|
||||||
|
|
||||||
|
#### Scenario: Изменение размера
|
||||||
|
- **WHEN** пользователь двигает регулятор плотности
|
||||||
|
- **THEN** размер миниатюр меняется, значение сохраняется и восстанавливается при следующем открытии
|
||||||
|
|
||||||
|
### Requirement: Загрузка фотографий из рабочего стола
|
||||||
|
Загрузка SHALL запускаться действием в тулбаре; перетаскивание файлов над альбомом SHALL
|
||||||
|
показывать оверлей и загружать их; пустой альбом SHALL показывать подсказку-зону загрузки.
|
||||||
|
Загрузка SHALL быть доступна только ролям с правом загрузки.
|
||||||
|
|
||||||
|
#### Scenario: Перетаскивание
|
||||||
|
- **WHEN** пользователь с правом загрузки перетаскивает файлы над альбомом
|
||||||
|
- **THEN** показывается оверлей загрузки, и файлы загружаются
|
||||||
|
|
||||||
|
#### Scenario: Пустой альбом
|
||||||
|
- **WHEN** пользователь открывает альбом без фото и имеет право загрузки
|
||||||
|
- **THEN** в центре показана зона-подсказка загрузки
|
||||||
|
|
||||||
|
### Requirement: Темы и графитовый хром
|
||||||
|
Тёмная тема SHALL использовать графитовый хром Lightroom и SHALL применяться по умолчанию;
|
||||||
|
нейтральная тема SHALL быть тёплым графитом; остальные темы SHALL оставаться доступными, а
|
||||||
|
скроллбары SHALL быть стилизованы в цвет темы.
|
||||||
|
|
||||||
|
#### Scenario: Первый вход
|
||||||
|
- **WHEN** пользователь впервые открывает приложение без сохранённой темы
|
||||||
|
- **THEN** применяется тёмная графитовая тема
|
||||||
|
|
||||||
|
#### Scenario: Смена темы
|
||||||
|
- **WHEN** пользователь выбирает другую тему
|
||||||
|
- **THEN** интерфейс, иконки и скроллбары меняются согласованно, и выбор сохраняется
|
||||||
|
|
||||||
|
#### Scenario: Нейтральная тема
|
||||||
|
- **WHEN** пользователь выбирает нейтральную тему
|
||||||
|
- **THEN** применяется тёплый графитовый хром, отличный от холодной тёмной темы
|
||||||
|
|
||||||
|
### Requirement: Адаптивность рабочего стола
|
||||||
|
На узких экранах рейл SHALL становиться выдвижной панелью, контекстная панель —
|
||||||
|
сворачиваемой секцией, а лента миниатюр — горизонтально прокручиваемой.
|
||||||
|
|
||||||
|
#### Scenario: Узкий экран
|
||||||
|
- **WHEN** ширина окна меньше порога рабочего стола
|
||||||
|
- **THEN** рейл скрыт за кнопкой открытия, панель сворачиваема, лента прокручивается горизонтально
|
||||||
|
|
||||||
|
### Requirement: Доступность рабочего стола
|
||||||
|
Иконочные органы управления каркаса SHALL иметь доступные имена, панели SHALL быть
|
||||||
|
достижимы с клавиатуры с видимым фокусом, анимации SHALL учитывать
|
||||||
|
предпочтение уменьшенного движения.
|
||||||
|
|
||||||
|
#### Scenario: Клавиатурная навигация
|
||||||
|
- **WHEN** пользователь работает только с клавиатуры
|
||||||
|
- **THEN** органы управления каркаса достижимы, доступные имена присутствуют, фокус виден
|
||||||
|
|
||||||
|
### Requirement: Изменение ширины панелей
|
||||||
|
Пользователь SHALL иметь возможность менять ширину левой и правой панелей перетаскиванием, а
|
||||||
|
выбранная ширина SHALL сохраняться между сессиями.
|
||||||
|
|
||||||
|
#### Scenario: Перетаскивание разделителя
|
||||||
|
- **WHEN** пользователь тянет разделитель у панели на рабочем столе
|
||||||
|
- **THEN** панель меняет ширину в допустимых пределах
|
||||||
|
|
||||||
|
#### Scenario: Сохранение ширины
|
||||||
|
- **WHEN** пользователь перезагружает приложение
|
||||||
|
- **THEN** восстановленные панели имеют сохранённую ширину
|
||||||
|
|
||||||
|
### Requirement: Сворачиваемые разделы панели
|
||||||
|
Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние
|
||||||
|
сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото,
|
||||||
|
включая извлечённые EXIF-атрибуты.
|
||||||
|
|
||||||
|
#### Scenario: Сворачивание раздела
|
||||||
|
- **WHEN** пользователь нажимает заголовок раздела
|
||||||
|
- **THEN** содержимое раздела скрывается или показывается, состояние сохраняется
|
||||||
|
|
||||||
|
#### Scenario: Метаданные фото
|
||||||
|
- **WHEN** открыт просмотр фото
|
||||||
|
- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки, координаты и извлечённые EXIF-атрибуты
|
||||||
|
|
||||||
|
### Requirement: Полноэкранный просмотр фотографии
|
||||||
|
В режиме просмотра SHALL быть доступен переход в полноэкранный режим кнопкой и хоткеем `f`.
|
||||||
|
В полноэкранном режиме верхняя и нижняя панели SHALL скрываться и показываться у
|
||||||
|
соответствующей кромки экрана.
|
||||||
|
|
||||||
|
#### Scenario: Вход в полный экран
|
||||||
|
- **WHEN** пользователь нажимает кнопку полного экрана или клавишу `f`
|
||||||
|
- **THEN** просмотр разворачивается на весь экран, панели скрыты
|
||||||
|
|
||||||
|
#### Scenario: Панель по кромке
|
||||||
|
- **WHEN** курсор касается верхней или нижней кромки экрана
|
||||||
|
- **THEN** соответствующая панель показывается, а при уходе курсора скрывается сразу
|
||||||
|
|
||||||
|
#### Scenario: Выход из полного экрана
|
||||||
|
- **WHEN** пользователь повторно нажимает кнопку или клавишу `f`
|
||||||
|
- **THEN** полноэкранный режим закрывается
|
||||||
|
|
||||||
|
#### Scenario: Полноэкранный режим приложения
|
||||||
|
- **WHEN** включён полноэкранный режим приложения, и пользователь нажимает `f`
|
||||||
|
- **THEN** открывается полноэкранный просмотр фото (вложенно), а полноэкранный режим приложения не выключается
|
||||||
|
|
||||||
|
### Requirement: Зум в просмотре
|
||||||
|
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
||||||
|
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
|
||||||
|
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
|
||||||
|
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
|
||||||
|
работать и в полноэкранном режиме.
|
||||||
|
|
||||||
|
#### Scenario: Зум колесом к указателю
|
||||||
|
- **WHEN** пользователь вращает колесо мыши над фото
|
||||||
|
- **THEN** масштаб меняется относительно курсора, страница не прокручивается, предел — 200%
|
||||||
|
|
||||||
|
#### Scenario: Пробел
|
||||||
|
- **WHEN** пользователь нажимает `Space`
|
||||||
|
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный
|
||||||
|
|
||||||
|
#### Scenario: Оригинал в зуме
|
||||||
|
- **WHEN** пользователь входит в зум
|
||||||
|
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
||||||
|
|
||||||
|
#### Scenario: Стрелки соседних фото
|
||||||
|
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
||||||
|
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
||||||
|
|
||||||
|
#### Scenario: Панорамирование
|
||||||
|
- **WHEN** фото увеличено
|
||||||
|
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
|
||||||
|
|
||||||
|
### Requirement: Удаление фото из просмотра
|
||||||
|
Автор фото, владелец и админ альбома SHALL иметь возможность удалить текущее фото из режима
|
||||||
|
просмотра; удаление SHALL подтверждаться диалогом.
|
||||||
|
|
||||||
|
#### Scenario: Подтверждение удаления
|
||||||
|
- **WHEN** пользователь с правом удаления нажимает «Удалить»
|
||||||
|
- **THEN** открывается диалог подтверждения, и после подтверждения фото удаляется
|
||||||
|
|
||||||
|
#### Scenario: Лента после удаления
|
||||||
|
- **WHEN** фото удалено
|
||||||
|
- **THEN** просмотр переходит к соседнему фото, а лента миниатюр обновляется без удалённого
|
||||||
|
|
||||||
|
### Requirement: Возврат к обзору
|
||||||
|
Из альбома и из режима просмотра SHALL быть доступен возврат к обзору альбомов.
|
||||||
|
|
||||||
|
#### Scenario: Возврат из альбома
|
||||||
|
- **WHEN** пользователь нажимает кнопку «назад» слева от названия альбома
|
||||||
|
- **THEN** открывается обзор альбомов
|
||||||
|
|
||||||
|
#### Scenario: Возврат из просмотра
|
||||||
|
- **WHEN** пользователь нажимает кнопку «назад» в режиме просмотра
|
||||||
|
- **THEN** открывается альбом с сеткой фото
|
||||||
|
|
||||||
|
### Requirement: Настройки списка атрибутов информации
|
||||||
|
Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим
|
||||||
|
модальное окно со списком EXIF-атрибутов, где пользователь SHALL отмечать отображаемые
|
||||||
|
атрибуты и задавать их подписи. В окне SHALL быть доступны действия «отметить все» и
|
||||||
|
«снять все». Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле
|
||||||
|
пользователя. При отсутствии сохранённых настроек SHALL отображаться базовый набор атрибутов
|
||||||
|
(камера, объектив, выдержка, диафрагма, ISO, фокусное расстояние).
|
||||||
|
|
||||||
|
#### Scenario: Открытие настроек
|
||||||
|
- **WHEN** пользователь нажимает иконку настроек в разделе «Информация»
|
||||||
|
- **THEN** открывается модальное окно со списком EXIF-атрибутов: галочки видимости и поля подписей, а также действия «отметить все»/«снять все»
|
||||||
|
|
||||||
|
#### Scenario: Значения по умолчанию
|
||||||
|
- **WHEN** у пользователя нет сохранённых настроек
|
||||||
|
- **THEN** в разделе «Информация» показан базовый набор атрибутов, а остальные доступны по элементу «…»
|
||||||
|
|
||||||
|
#### Scenario: Сохранение настроек
|
||||||
|
- **WHEN** пользователь отмечает атрибуты, задаёт подпись и сохраняет
|
||||||
|
- **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле
|
||||||
|
|
||||||
|
#### Scenario: Сброс настроек
|
||||||
|
- **WHEN** пользователь сбрасывает настройки
|
||||||
|
- **THEN** сохранённые настройки удаляются из профиля, и отображается базовый набор с исходными подписями
|
||||||
|
|
||||||
|
### Requirement: Свёртка скрытых атрибутов
|
||||||
|
Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент
|
||||||
|
«…»; нажатие на него SHALL раскрывать все атрибуты текущего фото, включая скрытые. Если
|
||||||
|
скрытых атрибутов нет, элемент «…» SHALL не отображаться.
|
||||||
|
|
||||||
|
#### Scenario: Скрытые атрибуты
|
||||||
|
- **WHEN** в разделе «Информация» есть скрытые настройками атрибуты
|
||||||
|
- **THEN** после видимых атрибутов показан элемент «…»
|
||||||
|
|
||||||
|
#### Scenario: Раскрытие всех атрибутов
|
||||||
|
- **WHEN** пользователь нажимает «…»
|
||||||
|
- **THEN** отображаются все атрибуты фото, включая скрытые
|
||||||
|
|
||||||
|
#### Scenario: Нет скрытых атрибутов
|
||||||
|
- **WHEN** ни один атрибут не скрыт
|
||||||
|
- **THEN** элемент «…» не отображается
|
||||||
|
|
||||||
|
### Requirement: Выбор фото в сетке
|
||||||
|
В режиме альбома одиночный выбор фото SHALL отмечать его рамкой и показывать его сведения в
|
||||||
|
контекстной панели, не открывая просмотр; двойной выбор SHALL открывать режим просмотра фото.
|
||||||
|
|
||||||
|
#### Scenario: Одиночный выбор
|
||||||
|
- **WHEN** пользователь кликает по фото в сетке
|
||||||
|
- **THEN** фото отмечено рамкой, правая панель показывает сведения об этом фото, просмотр не открывается
|
||||||
|
|
||||||
|
#### Scenario: Двойной выбор
|
||||||
|
- **WHEN** пользователь дважды кликает по фото в сетке
|
||||||
|
- **THEN** открывается режим просмотра этого фото
|
||||||
|
|
||||||
|
#### Scenario: Смена альбома
|
||||||
|
- **WHEN** пользователь переходит в другой альбом
|
||||||
|
- **THEN** отметка выбора сбрасывается
|
||||||
|
|
||||||
|
### Requirement: Мультивыбор фото в сетке
|
||||||
|
В режиме альбома пользователь SHALL выбирать несколько фото: обычный клик — одно фото,
|
||||||
|
Ctrl/Cmd-клик — добавить или убрать, Shift-клик — диапазон. Выбранные фото SHALL быть отмечены.
|
||||||
|
|
||||||
|
#### Scenario: Мультивыбор
|
||||||
|
- **WHEN** пользователь кликает по фото с зажатым Ctrl/Cmd или Shift
|
||||||
|
- **THEN** соответствующие фото отмечены, а панель показывает количество выбранных
|
||||||
|
|
||||||
|
### Requirement: Действия с выбранными фото
|
||||||
|
Когда выбрано одно или несколько фото, правая панель SHALL показывать раздел «Действия» со
|
||||||
|
строками-пунктами (иконка + подпись) «Открыть (просмотр)», «Переместить», «Копировать» и
|
||||||
|
«Удалить»; удаление SHALL подтверждаться диалогом.
|
||||||
|
|
||||||
|
#### Scenario: Действия
|
||||||
|
- **WHEN** выбрано одно или несколько фото
|
||||||
|
- **THEN** панель показывает раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать» и «Удалить»
|
||||||
|
|
||||||
|
### Requirement: Перетаскивание выбранных фото на альбом
|
||||||
|
Выбранные фото SHALL перетаскиваться на альбом в рейле; при отпускании SHALL открываться
|
||||||
|
диалог выбора переноса или копирования с указанием целевого альбома. Во время такого
|
||||||
|
перетаскивания зона загрузки файлов SHALL не показываться.
|
||||||
|
|
||||||
|
#### Scenario: Перетаскивание на альбом
|
||||||
|
- **WHEN** пользователь перетаскивает выделенные фото на альбом в рейле и отпускает
|
||||||
|
- **THEN** открывается диалог с выбором переноса/копирования и целевым альбомом
|
||||||
|
|
||||||
|
### Requirement: Корзина в списке альбомов
|
||||||
|
Рейл альбомов SHALL содержать вход в корзину последним элементом списка (с иконкой корзины).
|
||||||
|
Экран обзора альбомов SHALL не показывать пустую правую панель.
|
||||||
|
|
||||||
|
#### Scenario: Вход в корзину
|
||||||
|
- **WHEN** пользователь открывает приложение
|
||||||
|
- **THEN** в левом рейле последним элементом списка показана «Корзина» с иконкой корзины
|
||||||
|
|
||||||
|
#### Scenario: Обзор без панели
|
||||||
|
- **WHEN** пользователь открывает обзор альбомов
|
||||||
|
- **THEN** правая панель не отображается
|
||||||
|
|
||||||
|
### Requirement: Действия в режиме просмотра
|
||||||
|
В режиме просмотра правая панель SHALL содержать раздел «Действия» со строками-пунктами
|
||||||
|
(иконка + подпись) «Скачать», «Переместить», «Копировать» и «Удалить» (удаление — по правам).
|
||||||
|
Верхняя панель просмотра SHALL не дублировать скачивание и удаление.
|
||||||
|
|
||||||
|
#### Scenario: Действия в просмотре
|
||||||
|
- **WHEN** открыт режим просмотра фото
|
||||||
|
- **THEN** раздел «Действия» содержит «Скачать», «Переместить», «Копировать» и «Удалить», а в верхней панели этих кнопок нет
|
||||||
|
|
||||||
|
#### Scenario: Удаление из панели
|
||||||
|
- **WHEN** пользователь удаляет фото из раздела «Действия»
|
||||||
|
- **THEN** открывается подтверждение, и после удаления просмотр переходит к соседнему фото
|
||||||
|
|
||||||
|
### Requirement: Контекстное меню фото
|
||||||
|
Правый клик по фото в сетке и по изображению в режиме просмотра SHALL открывать контекстное
|
||||||
|
меню с пунктами раздела «Действия» («Скачать», «Переместить», «Копировать», «Удалить»; в сетке
|
||||||
|
также «Открыть (просмотр)»), выполняющими те же действия.
|
||||||
|
|
||||||
|
#### Scenario: Меню в сетке
|
||||||
|
- **WHEN** пользователь кликает правой кнопкой по фото в сетке
|
||||||
|
- **THEN** открывается меню с пунктами «Открыть (просмотр)», «Скачать», «Переместить», «Копировать», «Удалить»
|
||||||
|
|
||||||
|
#### Scenario: Меню в просмотре
|
||||||
|
- **WHEN** пользователь кликает правой кнопкой по изображению в режиме просмотра
|
||||||
|
- **THEN** открывается меню с пунктами «Скачать», «Переместить», «Копировать», «Удалить»
|
||||||
|
|
||||||
|
### Requirement: Контекстное меню альбома
|
||||||
|
Правый клик по альбому в рейле и по карточке на обзоре SHALL открывать меню с пунктами
|
||||||
|
«Переименовать» и «Удалить»; переименование SHALL открывать диалог, удаление — подтверждение.
|
||||||
|
|
||||||
|
#### Scenario: Меню альбома
|
||||||
|
- **WHEN** пользователь кликает правой кнопкой по альбому
|
||||||
|
- **THEN** открывается меню с «Переименовать» и «Удалить»
|
||||||
|
|
||||||
|
#### Scenario: Удаление альбома
|
||||||
|
- **WHEN** пользователь подтверждает удаление альбома
|
||||||
|
- **THEN** альбом удаляется, а его фото попадают в корзину
|
||||||
|
|
||||||
|
### Requirement: Счётчики в рейле
|
||||||
|
Рейл SHALL показывать число фото у каждого альбома и число элементов в корзине.
|
||||||
|
|
||||||
|
#### Scenario: Счётчики
|
||||||
|
- **WHEN** пользователь смотрит на рейл альбомов
|
||||||
|
- **THEN** у каждого альбома показано число фото, а у корзины — число удалённых фото
|
||||||
|
|
||||||
|
### Requirement: Метки в панели фото
|
||||||
|
Панель фото SHALL содержать раздел «Метки» с чипами меток фото и полем добавления, дающим
|
||||||
|
поиск по меткам пользователя и создание новых.
|
||||||
|
|
||||||
|
#### Scenario: Раздел «Метки»
|
||||||
|
- **WHEN** открыт просмотр или выбран один фото в сетке
|
||||||
|
- **THEN** в панели есть раздел «Метки» с текущими метками и полем добавления с поиском
|
||||||
|
|
||||||
|
### Requirement: Компактный верхний хром
|
||||||
|
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
|
||||||
|
(узкие отступы, иконочные органы управления), сохраняя все действия доступными. Верхняя панель
|
||||||
|
режима просмотра SHALL быть прозрачным оверлеем и SHALL не занимать место в раскладке,
|
||||||
|
увеличивая область фото.
|
||||||
|
|
||||||
|
#### Scenario: Компактные верхние панели
|
||||||
|
- **WHEN** открыт список альбомов или режим просмотра фото
|
||||||
|
- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны
|
||||||
|
|
||||||
|
#### Scenario: Прозрачная панель просмотра
|
||||||
|
- **WHEN** открыт режим просмотра фото
|
||||||
|
- **THEN** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Backend: обложка и целостность
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить миграцию `000002_album_cover_fk` (up/down): обнулить висячие `cover_photo_id`, затем `ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL`. Проверка: `make migrate` применяется, `\d albums` показывает constraint, down-миграция снимает его.
|
||||||
|
- [x] 1.2 Добавить `CoverThumbURL` в `models.Album` (json `cover_thumb_url,omitempty`). Проверка: `cd backend && go build ./...` проходит.
|
||||||
|
- [x] 1.3 В `albums/store.go` добавить `LEFT JOIN photos p ON p.id = a.cover_photo_id` и выбирать `p.thumb_key AS cover_thumb_key` в `ListForUser` и `GetByID`. Проверка: запрос возвращает `cover_thumb_key` для альбома с обложкой и `NULL` без неё.
|
||||||
|
- [x] 1.4 В `albums/service.go` пресигнить `cover_thumb_key` в `CoverThumbURL` в `List` и `Get`. Проверка: `GET /api/albums` в ответе содержит `cover_thumb_url` у альбома с обложкой.
|
||||||
|
- [x] 1.5 В `photos/service.go` вызывать `SetCover` только при `cover_photo_id IS NULL`. Проверка: загрузка двух фото в новый альбом оставляет обложкой первое; после удаления фото-обложки cover становится `NULL`, следующая загрузка ставит новый.
|
||||||
|
- [x] 1.6 Покрыть тестами store-join обложки и автообложку; проверить `cd backend && go test ./...`.
|
||||||
|
|
||||||
|
## 2. Frontend: темы и каркас
|
||||||
|
|
||||||
|
- [x] 2.1 Добавить токены `--chrome`/`--canvas`, перенастроить `dark` в графит и сделать его дефолтом (при отсутствии сохранённого выбора). Проверка: `cd frontend && npm run check` проходит, все три темы визуально согласованы.
|
||||||
|
- [x] 2.2 Добавить `cover_thumb_url` в `lib/types.ts` и недостающие иконки (обложка, плотность/меню) в `lib/icons/registry.ts`. Проверка: `npm run check` без ошибок типов.
|
||||||
|
- [x] 2.3 Разбить `+layout.svelte` на группы `(app)`/`(public)` и ввести `AppShell` (топбар, слот рейла, слот панели, центр = маршрут). Проверка: навигация между альбомами не перезагружает рейл; `/login`, `/invites/[token]`, `/share/[token]` рендерятся без каркаса.
|
||||||
|
- [x] 2.4 Реализовать адаптивный каркас: рейл → drawer, панель → сворачиваемая секция, лента → горизонтальный скролл на узкой ширине. Проверка: при ширине меньше порога каркас управляем без горизонтального overflow.
|
||||||
|
|
||||||
|
## 3. Рейл и создание альбома
|
||||||
|
|
||||||
|
- [x] 3.1 Компонент `AlbumRail`: список альбомов с обложками, плейсхолдер, подсветка активного. Проверка: активный альбом выделен, остальные показывают обложку/плейсхолдер.
|
||||||
|
- [x] 3.2 Кнопка создания `+` с хинтом и модалка ввода названия (замена инлайн-формы). Проверка: создание альбома через модалку, новый альбом становится активным; пустое название не создаёт альбом.
|
||||||
|
|
||||||
|
## 4. Контекстная панель и обложки
|
||||||
|
|
||||||
|
- [x] 4.1 Компонент `AlbumPanel`: превью обложки, название, счётчики, переходы к участникам/настройкам/ссылкам; управление обложкой только для owner/admin. Проверка: viewer не видит управление обложкой.
|
||||||
|
- [x] 4.2 Модальный `CoverPicker` (сетка миниатюр) + `PATCH /albums/:id` с `cover_photo_id`/`clear_cover`. Проверка: назначение и сброс обложки отражаются в рейле, карточке и панели.
|
||||||
|
- [x] 4.3 Выбор обложки выполняется только из правой панели (модальный пикер); действие «Сделать обложкой» в тулбаре просмотра не используется. Проверка: обложка задаётся и сбрасывается из панели, в тулбаре просмотра кнопки нет.
|
||||||
|
|
||||||
|
## 5. Центр: сетка, плотность, загрузка
|
||||||
|
|
||||||
|
- [x] 5.1 Сетка альбомов edge-to-edge с реальными обложками вместо карточек-заглушек. Проверка: карточки показывают `cover_thumb_url`, при отсутствии — плейсхолдер.
|
||||||
|
- [x] 5.2 Регулятор плотности миниатюр в тулбаре сетки с сохранением в `localStorage`. Проверка: размер меняется и восстанавливается после перезагрузки.
|
||||||
|
- [x] 5.3 Загрузка: иконочная кнопка в тулбаре, полноэкранный drag-overlay, зона-подсказка в пустом альбоме; прогресс компактной полосой. Проверка: перетаскивание и выбор файлов загружают фото, пустой альбом показывает подсказку.
|
||||||
|
|
||||||
|
## 6. Просмотр как маршрут
|
||||||
|
|
||||||
|
- [x] 6.1 Маршрут `albums/[id]/photos/[photoId]` и компонент `Viewer` (крупное фото, filmstrip, панель метаданных/комментариев, тулбар, клавиши ←/→/Esc). Проверка: переход из сетки открывает маршрут, адрес отражает фото, навигация синхронна с filmstrip.
|
||||||
|
- [x] 6.2 Переиспользовать `Viewer` на `share/[token]` вместо инлайн-лайтбокса. Проверка: публичный просмотр использует тот же компонент без рейла/панели.
|
||||||
|
|
||||||
|
## 7. Интеграция
|
||||||
|
|
||||||
|
- [x] 7.1 Пройти сценарий: создать альбом → загрузить фото → задать обложку → открыть просмотр → открыть share-ссылку; проверить поведение на десктопе и узком экране.
|
||||||
|
- [x] 7.2 Проверить `make build` (сборка фронтенда в `backend/static`, статический Go-бинарник) и `openspec validate "lightroom-workspace-ui"`.
|
||||||
|
|
||||||
|
## 8. Дополнительно реализовано (после планирования)
|
||||||
|
|
||||||
|
- [x] 8.1 Переименование фото: `PATCH /photos/:id` (права автора/owner/admin, пустое имя 400) и инлайн-редактор в правой панели; событие `photo.updated`. Проверка: переименование сохраняется, пустое имя отклоняется, чужое фото — отказ, событие приходит.
|
||||||
|
- [x] 8.2 Удаление фото из просмотра с модальным подтверждением; переход к соседнему и обновление ленты. Проверка: после удаления лента не содержит удалённое фото.
|
||||||
|
- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) с автоскрытием верхней/нижней панелей у кромок и стрелок (1/6 края, в зуме скрыты); поведение без таймеров — панели и стрелки показываются/скрываются сразу, стрелки только в границах вьюера; зум колесом к указателю без прокрутки (макс 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. Настройки информации пользователя
|
||||||
|
|
||||||
|
- [x] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает.
|
||||||
|
- [x] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется.
|
||||||
|
- [x] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы.
|
||||||
|
- [x] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`.
|
||||||
|
- [x] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле.
|
||||||
|
- [x] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы.
|
||||||
|
- [x] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
||||||
|
- [x] 9.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12).
|
||||||
|
- [x] 9.9 Выбор фото в сетке: одиночный клик выделяет фото рамкой и показывает его сведения в правой панели, двойной — открывает просмотр; стор `selection`, сброс при смене альбома. Проверка: Playwright — выделение без навигации, панель показывает инфо, двойной клик открывает просмотр (5/5).
|
||||||
|
- [x] 9.10 Вьюер и модалки: убраны таймеры автоскрытия (панели/стрелки мгновенно), стрелки появляются только в границах вьюера, `f`/кнопка переключают вложенный fullscreen вьюера (не выключают режим приложения); модалки рендерятся порталом в `document.body` (центрирование во вьюпорте). Проверка: Playwright — стрелки 4/4, fullscreen+f 6/6, центрирование 2/2.
|
||||||
|
- [x] 9.11 Корзина: миграция `000005_photo_trash` (`deleted_at`), мягкое удаление (`DELETE /photos/:id`), `GET /trash`, `POST /photos/:id/restore`, `DELETE /photos/:id/permanent` с удалением объектов S3 только при отсутствии ссылок на `storage_key`. Проверка: удаление → в корзине, восстановление возвращает, копия сохраняет объект.
|
||||||
|
- [x] 9.12 Перенос/копирование: `POST /photo-batch/{delete,move,copy}`, клон записи с общими объектами S3 (метаданные), события SSE для источника и цели. Проверка: копия даёт N фото в цели, перенос убирает из источника.
|
||||||
|
- [x] 9.13 Фронтенд: стор `selection` (Ctrl/Shift-мультивыбор), действия в панели «Удалить/Переместить/Копировать», drag выделенных на альбом рейла, глобальный `TransferDialog`. Проверка: Playwright — мультивыбор и copy 9/9, drag+move 4/4.
|
||||||
|
- [x] 9.14 Рейл: вход «Корзина» последним элементом списка с иконкой, скрытие пустой правой панели (`panelVisible`), маршрут `/trash` с восстановлением и окончательным удалением. Проверка: в рейле есть вход в корзину, на обзоре нет правой панели.
|
||||||
|
- [x] 9.15 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
||||||
|
- [x] 9.16 Раздел «Действия» в панели — пункты-надписи с иконками (не кнопки): «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»; надпись «Выбрано:» убрана. Проверка: Playwright — «Открыть (просмотр)» открывает просмотр, раздел присутствует (4/4).
|
||||||
|
- [x] 9.17 Компактный верхний хром: верхняя панель приложения и верхняя панель режима просмотра сведены к минимуму (узкие отступы, иконочные кнопки `size-4`, компактный переключатель темы). Проверка: высота `<header>` ~31px, верхней панели просмотра ~29px; `npm run check`.
|
||||||
|
- [x] 9.18 Действия просмотра перенесены в правую панель: в разделе «Действия» — «Скачать», «Переместить», «Копировать», «Удалить»; из верхней панели просмотра скачивание и удаление убраны; удаление через стор `viewerActions` и страницу просмотра. Проверка: Playwright — верх без скачать/удалить, панель с действиями, удаление из панели переходит к соседнему фото (9/9).
|
||||||
|
- [x] 9.19 Единая панель фото: содержимое для одного фото вынесено в общий компонент `PhotoPanel`, переиспользуемый в просмотре и при одиночном выборе в сетке (не копия); мультивыбор — только «Действия». Проверка: Playwright — просмотр 9/9, выбор 5/5, мультивыбор 9/9; `npm run check`.
|
||||||
|
- [x] 9.20 Контекстное меню фото (правая кнопка) в сетке и просмотре с пунктами из «Действий»; общий стор `contextMenu` и глобальный `ContextMenu`. Проверка: Playwright — меню в сетке (5 пунктов), меню в просмотре (4 пункта), «Открыть» из меню и «Переместить» → диалог (12/12).
|
||||||
|
- [x] 9.21 Контекстное меню альбома («Переименовать»/«Удалить») в рейле и на обзоре; диалоги в `AlbumActionsDialogs`. Проверка: Playwright — меню, переименование, удаление (4/4).
|
||||||
|
- [x] 9.22 Удаление альбома помещает фото в корзину без потери файлов (FK `photos.album_id` → `ON DELETE SET NULL`, миграция `000006`); восстановление без альбома предлагает выбрать/создать альбом (`album_required` + `POST /photos/:id/restore {album_id}`). Проверка: Playwright — удалить альбом → фото в корзине, диалог восстановления, фото в новом альбоме (7/7).
|
||||||
|
- [x] 9.23 Счётчики в рейле: число фото у альбома и число элементов корзины (`GET /trash/count`, стор `trash`); обновление счётчика альбома после загрузки/удаления. Проверка: Playwright — альбом 2, корзина 0→1 (3/3).
|
||||||
|
- [x] 9.24 Метки фото (область — пользователь): таблицы `tags`/`photo_tags` (миграция `000007`), `GET /tags?q=`, `POST /photos/:id/tags`, `DELETE /photos/:id/tags/:tag_id`, метки в ответе фото; раздел «Метки» в панели с чипами, поиском и созданием. Проверка: API — создание/поиск/снятие; Playwright — создание, снятие, выбор из поиска (4/4).
|
||||||
@@ -35,8 +35,47 @@
|
|||||||
- **THEN** система сохраняет изменения
|
- **THEN** система сохраняет изменения
|
||||||
|
|
||||||
### Requirement: Удаление альбома
|
### Requirement: Удаление альбома
|
||||||
Владелец SHALL иметь возможность удалить альбом вместе с содержимым.
|
Владелец SHALL иметь возможность удалить альбом; фото альбома SHALL помещаться в корзину без
|
||||||
|
удаления объектов хранилища, а участники и share-ссылки SHALL удаляться вместе с альбомом.
|
||||||
|
|
||||||
#### Scenario: Удаление альбома
|
#### Scenario: Удаление альбома
|
||||||
- **WHEN** владелец удаляет альбом
|
- **WHEN** владелец удаляет альбом
|
||||||
- **THEN** система удаляет альбом, его фото, участников и share-ссылки
|
- **THEN** фото альбома попадают в корзину без привязки к альбому и файлы сохраняются, участники и share-ссылки удаляются
|
||||||
|
|
||||||
|
### Requirement: Отображение обложки альбома
|
||||||
|
Данные альбома SHALL включать URL миниатюры обложки, когда обложка задана, чтобы клиенты
|
||||||
|
могли показать её в списке, рейле и карточке.
|
||||||
|
|
||||||
|
#### Scenario: Обложка задана
|
||||||
|
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
|
||||||
|
- **THEN** ответ содержит URL миниатюры обложки
|
||||||
|
|
||||||
|
#### Scenario: Обложка отсутствует
|
||||||
|
- **WHEN** у альбома нет обложки или фото обложки удалено
|
||||||
|
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Выбор обложки
|
||||||
|
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
|
||||||
|
обложку; после сброса обложка определяется заново по умолчанию.
|
||||||
|
|
||||||
|
#### Scenario: Назначение обложки
|
||||||
|
- **WHEN** владелец выбирает фото альбома как обложку
|
||||||
|
- **THEN** система сохраняет выбор и отражает его в списке и карточках
|
||||||
|
|
||||||
|
#### Scenario: Сброс обложки
|
||||||
|
- **WHEN** админ сбрасывает обложку
|
||||||
|
- **THEN** система удаляет явный выбор, и обложка возвращается к значению по умолчанию
|
||||||
|
|
||||||
|
#### Scenario: Нет прав на смену обложки
|
||||||
|
- **WHEN** участник без права редактирования пытается сменить обложку
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Счётчики альбома
|
||||||
|
Данные альбома SHALL включать количество фото и участников, чтобы панель и карточки могли
|
||||||
|
показать их без отдельных запросов.
|
||||||
|
|
||||||
|
#### Scenario: Счётчики в ответе
|
||||||
|
- **WHEN** клиент запрашивает список альбомов или альбом по id
|
||||||
|
- **THEN** ответ содержит число фото и число участников альбома
|
||||||
|
|||||||
@@ -45,3 +45,24 @@
|
|||||||
#### Scenario: Выход
|
#### Scenario: Выход
|
||||||
- **WHEN** пользователь вызывает logout с действующим refresh-токеном
|
- **WHEN** пользователь вызывает logout с действующим refresh-токеном
|
||||||
- **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется
|
- **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется
|
||||||
|
|
||||||
|
### Requirement: Хранение пользовательских настроек интерфейса
|
||||||
|
Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при
|
||||||
|
получении текущего пользователя. Настройки SHALL быть приватными для пользователя.
|
||||||
|
Отсутствие настроек SHALL трактоваться как значения по умолчанию.
|
||||||
|
|
||||||
|
#### Scenario: Получение настроек
|
||||||
|
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
|
||||||
|
- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию)
|
||||||
|
|
||||||
|
#### Scenario: Сохранение настроек
|
||||||
|
- **WHEN** пользователь сохраняет настройки отображения
|
||||||
|
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
|
||||||
|
|
||||||
|
#### Scenario: Изоляция настроек
|
||||||
|
- **WHEN** другой пользователь запрашивает свой профиль
|
||||||
|
- **THEN** он не получает настройки первого пользователя
|
||||||
|
|
||||||
|
#### Scenario: Некорректные настройки
|
||||||
|
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
|
||||||
|
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
|
||||||
|
|||||||
@@ -55,3 +55,125 @@
|
|||||||
#### Scenario: Удаление чужого фото участником
|
#### Scenario: Удаление чужого фото участником
|
||||||
- **WHEN** участник с ролью member пытается удалить чужое фото
|
- **WHEN** участник с ролью member пытается удалить чужое фото
|
||||||
- **THEN** система отклоняет запрос
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
### Requirement: Обложка по умолчанию
|
||||||
|
При обработке загруженного фото система SHALL назначать его обложкой альбома только если
|
||||||
|
обложка ещё не задана.
|
||||||
|
|
||||||
|
#### Scenario: Первое фото
|
||||||
|
- **WHEN** фото обработано, и у альбома нет обложки
|
||||||
|
- **THEN** система назначает это фото обложкой
|
||||||
|
|
||||||
|
#### Scenario: Обложка уже задана
|
||||||
|
- **WHEN** фото обработано, а обложка уже задана
|
||||||
|
- **THEN** система не меняет обложку
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Переименование фото
|
||||||
|
Автор фото, владелец и админ альбома SHALL иметь возможность изменить имя файла фото;
|
||||||
|
остальные SHALL получать отказ, а пустое имя SHALL отклоняться.
|
||||||
|
|
||||||
|
#### Scenario: Переименование автором
|
||||||
|
- **WHEN** автор меняет имя файла своего фото на непустое
|
||||||
|
- **THEN** система сохраняет новое имя и транслирует изменение подписчикам альбома
|
||||||
|
|
||||||
|
#### Scenario: Пустое имя
|
||||||
|
- **WHEN** пользователь подтверждает пустое имя файла
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
#### Scenario: Нет прав
|
||||||
|
- **WHEN** участник без прав переименовывает чужое фото
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Ограничение размера загрузки
|
||||||
|
Система SHALL ограничивать размер загружаемых файлов и SHALL отвечать понятной ошибкой при
|
||||||
|
превышении лимита, а не ошибкой разбора формы.
|
||||||
|
|
||||||
|
#### Scenario: Превышение лимита
|
||||||
|
- **WHEN** загружаемый файл превышает максимальный размер
|
||||||
|
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
|
||||||
|
|
||||||
|
#### Scenario: Файл в пределах лимита
|
||||||
|
- **WHEN** файл не превышает лимит
|
||||||
|
- **THEN** система принимает multipart-загрузку и обрабатывает фото
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Метаданные фото (EXIF)
|
||||||
|
При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их
|
||||||
|
вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор.
|
||||||
|
|
||||||
|
#### Scenario: Фото с метаданными
|
||||||
|
- **WHEN** загружается фото с EXIF
|
||||||
|
- **THEN** данные фото содержат извлечённые атрибуты (камера, экспозиция, ISO, дата съёмки и др.)
|
||||||
|
|
||||||
|
#### Scenario: Фото без метаданных
|
||||||
|
- **WHEN** загружается фото без читаемых EXIF
|
||||||
|
- **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Перенос и копирование фото между альбомами
|
||||||
|
Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь
|
||||||
|
возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только
|
||||||
|
метаданные и SHALL не изменять объекты хранилища.
|
||||||
|
|
||||||
|
#### Scenario: Перенос
|
||||||
|
- **WHEN** пользователь переносит фото в другой альбом
|
||||||
|
- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты S3 не меняются
|
||||||
|
|
||||||
|
#### Scenario: Копирование
|
||||||
|
- **WHEN** пользователь копирует фото в другой альбом
|
||||||
|
- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты S3
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Корзина фото
|
||||||
|
Удаление фото SHALL помещать его в корзину (мягкое удаление). Восстановление SHALL возвращать
|
||||||
|
фото в его альбом. Окончательное удаление SHALL удалять запись, а объекты хранилища SHALL
|
||||||
|
удаляться только когда на них не остаётся ссылок ни в одном альбоме.
|
||||||
|
|
||||||
|
#### Scenario: Удаление в корзину
|
||||||
|
- **WHEN** пользователь удаляет фото
|
||||||
|
- **THEN** фото пропадает из альбома и появляется в корзине
|
||||||
|
|
||||||
|
#### Scenario: Восстановление
|
||||||
|
- **WHEN** пользователь восстанавливает фото из корзины
|
||||||
|
- **THEN** фото возвращается в свой альбом
|
||||||
|
|
||||||
|
#### Scenario: Окончательное удаление без ссылок
|
||||||
|
- **WHEN** пользователь удаляет фото из корзины навсегда, и на те же объекты нет ссылок в других альбомах
|
||||||
|
- **THEN** запись и объекты хранилища удаляются
|
||||||
|
|
||||||
|
#### Scenario: Окончательное удаление при наличии ссылок
|
||||||
|
- **WHEN** на объект хранилища ссылается копия в другом альбоме
|
||||||
|
- **THEN** запись удаляется, а объект хранилища сохраняется
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Удаление альбома и восстановление фото без альбома
|
||||||
|
Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при
|
||||||
|
восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий
|
||||||
|
альбом или создать новый.
|
||||||
|
|
||||||
|
#### Scenario: Удаление альбома
|
||||||
|
- **WHEN** владелец удаляет альбом
|
||||||
|
- **THEN** его фото оказываются в корзине без привязки к альбому, файлы сохраняются
|
||||||
|
|
||||||
|
#### Scenario: Восстановление без альбома
|
||||||
|
- **WHEN** пользователь восстанавливает фото, у которого нет альбома
|
||||||
|
- **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: Метки фото
|
||||||
|
Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и
|
||||||
|
выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься.
|
||||||
|
|
||||||
|
#### Scenario: Создание метки
|
||||||
|
- **WHEN** пользователь вводит новую метку и подтверждает
|
||||||
|
- **THEN** метка создаётся и привязывается к фото
|
||||||
|
|
||||||
|
#### Scenario: Выбор из списка с поиском
|
||||||
|
- **WHEN** пользователь вводит часть названия метки
|
||||||
|
- **THEN** показываются подходящие метки пользователя для выбора
|
||||||
|
|
||||||
|
#### Scenario: Снятие метки
|
||||||
|
- **WHEN** пользователь убирает метку с фото
|
||||||
|
- **THEN** метка отвязывается от фото
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# ui-workspace Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Рабочий стол веб-приложения: постоянный каркас с рейлом альбомов, контекстной панелью,
|
||||||
|
режимом просмотра фото и адаптивным поведением, обеспечивающий цельный «lightroom»-вид.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Рабочий стол приложения
|
||||||
|
Аутентифицированные экраны SHALL отображаться внутри постоянного каркаса с топбаром, левым
|
||||||
|
рейлом альбомов и контекстной правой панелью. Публичные экраны (вход, инвайт, share-ссылка)
|
||||||
|
SHALL отображаться вне каркаса, но в общей теме оформления.
|
||||||
|
|
||||||
|
#### Scenario: Внутренний экран
|
||||||
|
- **WHEN** аутентифицированный пользователь открывает список альбомов или альбом
|
||||||
|
- **THEN** экран отрисован внутри каркаса с рейлом и панелью, рейл не перезагружается при переходе
|
||||||
|
|
||||||
|
#### Scenario: Публичный экран
|
||||||
|
- **WHEN** пользователь открывает публичную share-ссылку без входа
|
||||||
|
- **THEN** экран отрисован без рейла и контекстной панели, но в тех же цветовых токенах
|
||||||
|
|
||||||
|
### Requirement: Рейл альбомов
|
||||||
|
Рейл SHALL постоянно показывать список доступных альбомов с их обложками и отмечать
|
||||||
|
активный альбом.
|
||||||
|
|
||||||
|
#### Scenario: Активный альбом
|
||||||
|
- **WHEN** пользователь находится в альбоме
|
||||||
|
- **THEN** соответствующий элемент рейла выделен, остальные показывают обложки или плейсхолдер
|
||||||
|
|
||||||
|
### Requirement: Создание альбома
|
||||||
|
Создание альбома SHALL выполняться через иконочную кнопку с подсказкой, открывающую
|
||||||
|
модальное окно ввода названия, вместо инлайн-формы.
|
||||||
|
|
||||||
|
#### Scenario: Создание через модалку
|
||||||
|
- **WHEN** пользователь нажимает кнопку создания, вводит название и подтверждает
|
||||||
|
- **THEN** система создаёт альбом и делает его активным
|
||||||
|
|
||||||
|
#### Scenario: Пустое название
|
||||||
|
- **WHEN** пользователь подтверждает пустое название
|
||||||
|
- **THEN** создание не выполняется
|
||||||
|
|
||||||
|
### Requirement: Контекстная панель альбома
|
||||||
|
Правая панель SHALL показывать превью обложки, название, счётчики фото и участников,
|
||||||
|
управление обложкой и переходы к участникам, настройкам и ссылкам. Элементы управления
|
||||||
|
обложкой SHALL быть доступны только owner и admin.
|
||||||
|
|
||||||
|
#### Scenario: Владелец альбома
|
||||||
|
- **WHEN** owner открывает альбом
|
||||||
|
- **THEN** панель показывает управление обложкой и переходы к участникам и настройкам
|
||||||
|
|
||||||
|
#### Scenario: Наблюдатель
|
||||||
|
- **WHEN** viewer открывает альбом
|
||||||
|
- **THEN** панель не показывает управление обложкой
|
||||||
|
|
||||||
|
### Requirement: Режим просмотра фотографии
|
||||||
|
Просмотр фото SHALL быть отдельным маршрутом внутри каркаса с крупным изображением,
|
||||||
|
лентой миниатюр снизу и панелью метаданных и комментариев. Тот же режим SHALL
|
||||||
|
использоваться на публичных share-страницах.
|
||||||
|
|
||||||
|
#### Scenario: Открытие фото
|
||||||
|
- **WHEN** пользователь выбирает фото в сетке
|
||||||
|
- **THEN** открывается маршрут фото в каркасе, рейл и панель остаются доступны, адрес отражает фото
|
||||||
|
|
||||||
|
#### Scenario: Навигация в просмотре
|
||||||
|
- **WHEN** пользователь использует стрелки влево/вправо или ленту миниатюр
|
||||||
|
- **THEN** показывается соседнее фото, адрес обновляется
|
||||||
|
|
||||||
|
#### Scenario: Публичный просмотр
|
||||||
|
- **WHEN** посетитель share-ссылки открывает фото
|
||||||
|
- **THEN** используется тот же режим просмотра без рейла и панели
|
||||||
|
|
||||||
|
### Requirement: Плотность сетки
|
||||||
|
Сетка SHALL давать пользователю управлять размером миниатюр, и выбранный размер SHALL
|
||||||
|
сохраняться между сессиями.
|
||||||
|
|
||||||
|
#### Scenario: Изменение размера
|
||||||
|
- **WHEN** пользователь двигает регулятор плотности
|
||||||
|
- **THEN** размер миниатюр меняется, значение сохраняется и восстанавливается при следующем открытии
|
||||||
|
|
||||||
|
### Requirement: Загрузка фотографий из рабочего стола
|
||||||
|
Загрузка SHALL запускаться действием в тулбаре; перетаскивание файлов над альбомом SHALL
|
||||||
|
показывать оверлей и загружать их; пустой альбом SHALL показывать подсказку-зону загрузки.
|
||||||
|
Загрузка SHALL быть доступна только ролям с правом загрузки.
|
||||||
|
|
||||||
|
#### Scenario: Перетаскивание
|
||||||
|
- **WHEN** пользователь с правом загрузки перетаскивает файлы над альбомом
|
||||||
|
- **THEN** показывается оверлей загрузки, и файлы загружаются
|
||||||
|
|
||||||
|
#### Scenario: Пустой альбом
|
||||||
|
- **WHEN** пользователь открывает альбом без фото и имеет право загрузки
|
||||||
|
- **THEN** в центре показана зона-подсказка загрузки
|
||||||
|
|
||||||
|
### Requirement: Темы и графитовый хром
|
||||||
|
Тёмная тема SHALL использовать графитовый хром Lightroom и SHALL применяться по умолчанию;
|
||||||
|
нейтральная тема SHALL быть тёплым графитом; остальные темы SHALL оставаться доступными, а
|
||||||
|
скроллбары SHALL быть стилизованы в цвет темы.
|
||||||
|
|
||||||
|
#### Scenario: Первый вход
|
||||||
|
- **WHEN** пользователь впервые открывает приложение без сохранённой темы
|
||||||
|
- **THEN** применяется тёмная графитовая тема
|
||||||
|
|
||||||
|
#### Scenario: Смена темы
|
||||||
|
- **WHEN** пользователь выбирает другую тему
|
||||||
|
- **THEN** интерфейс, иконки и скроллбары меняются согласованно, и выбор сохраняется
|
||||||
|
|
||||||
|
#### Scenario: Нейтральная тема
|
||||||
|
- **WHEN** пользователь выбирает нейтральную тему
|
||||||
|
- **THEN** применяется тёплый графитовый хром, отличный от холодной тёмной темы
|
||||||
|
|
||||||
|
### Requirement: Адаптивность рабочего стола
|
||||||
|
На узких экранах рейл SHALL становиться выдвижной панелью, контекстная панель —
|
||||||
|
сворачиваемой секцией, а лента миниатюр — горизонтально прокручиваемой.
|
||||||
|
|
||||||
|
#### Scenario: Узкий экран
|
||||||
|
- **WHEN** ширина окна меньше порога рабочего стола
|
||||||
|
- **THEN** рейл скрыт за кнопкой открытия, панель сворачиваема, лента прокручивается горизонтально
|
||||||
|
|
||||||
|
### Requirement: Доступность рабочего стола
|
||||||
|
Иконочные органы управления каркаса SHALL иметь доступные имена, панели SHALL быть
|
||||||
|
достижимы с клавиатуры с видимым фокусом, анимации SHALL учитывать
|
||||||
|
предпочтение уменьшенного движения.
|
||||||
|
|
||||||
|
#### Scenario: Клавиатурная навигация
|
||||||
|
- **WHEN** пользователь работает только с клавиатуры
|
||||||
|
- **THEN** органы управления каркаса достижимы, доступные имена присутствуют, фокус виден
|
||||||
|
|
||||||
|
### Requirement: Изменение ширины панелей
|
||||||
|
Пользователь SHALL иметь возможность менять ширину левой и правой панелей перетаскиванием, а
|
||||||
|
выбранная ширина SHALL сохраняться между сессиями.
|
||||||
|
|
||||||
|
#### Scenario: Перетаскивание разделителя
|
||||||
|
- **WHEN** пользователь тянет разделитель у панели на рабочем столе
|
||||||
|
- **THEN** панель меняет ширину в допустимых пределах
|
||||||
|
|
||||||
|
#### Scenario: Сохранение ширины
|
||||||
|
- **WHEN** пользователь перезагружает приложение
|
||||||
|
- **THEN** восстановленные панели имеют сохранённую ширину
|
||||||
|
|
||||||
|
### Requirement: Сворачиваемые разделы панели
|
||||||
|
Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние
|
||||||
|
сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото,
|
||||||
|
включая извлечённые EXIF-атрибуты.
|
||||||
|
|
||||||
|
#### Scenario: Сворачивание раздела
|
||||||
|
- **WHEN** пользователь нажимает заголовок раздела
|
||||||
|
- **THEN** содержимое раздела скрывается или показывается, состояние сохраняется
|
||||||
|
|
||||||
|
#### Scenario: Метаданные фото
|
||||||
|
- **WHEN** открыт просмотр фото
|
||||||
|
- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки, координаты и извлечённые EXIF-атрибуты
|
||||||
|
|
||||||
|
### Requirement: Полноэкранный просмотр фотографии
|
||||||
|
В режиме просмотра SHALL быть доступен переход в полноэкранный режим кнопкой и хоткеем `f`.
|
||||||
|
В полноэкранном режиме верхняя и нижняя панели SHALL скрываться и показываться у
|
||||||
|
соответствующей кромки экрана.
|
||||||
|
|
||||||
|
#### Scenario: Вход в полный экран
|
||||||
|
- **WHEN** пользователь нажимает кнопку полного экрана или клавишу `f`
|
||||||
|
- **THEN** просмотр разворачивается на весь экран, панели скрыты
|
||||||
|
|
||||||
|
#### Scenario: Панель по кромке
|
||||||
|
- **WHEN** курсор касается верхней или нижней кромки экрана
|
||||||
|
- **THEN** соответствующая панель показывается, а при уходе курсора скрывается сразу
|
||||||
|
|
||||||
|
#### Scenario: Выход из полного экрана
|
||||||
|
- **WHEN** пользователь повторно нажимает кнопку или клавишу `f`
|
||||||
|
- **THEN** полноэкранный режим закрывается
|
||||||
|
|
||||||
|
#### Scenario: Полноэкранный режим приложения
|
||||||
|
- **WHEN** включён полноэкранный режим приложения, и пользователь нажимает `f`
|
||||||
|
- **THEN** открывается полноэкранный просмотр фото (вложенно), а полноэкранный режим приложения не выключается
|
||||||
|
|
||||||
|
### Requirement: Зум в просмотре
|
||||||
|
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
||||||
|
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
|
||||||
|
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
|
||||||
|
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
|
||||||
|
работать и в полноэкранном режиме.
|
||||||
|
|
||||||
|
#### Scenario: Зум колесом к указателю
|
||||||
|
- **WHEN** пользователь вращает колесо мыши над фото
|
||||||
|
- **THEN** масштаб меняется относительно курсора, страница не прокручивается, предел — 200%
|
||||||
|
|
||||||
|
#### Scenario: Пробел
|
||||||
|
- **WHEN** пользователь нажимает `Space`
|
||||||
|
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный
|
||||||
|
|
||||||
|
#### Scenario: Оригинал в зуме
|
||||||
|
- **WHEN** пользователь входит в зум
|
||||||
|
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
|
||||||
|
|
||||||
|
#### Scenario: Стрелки соседних фото
|
||||||
|
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
||||||
|
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
||||||
|
|
||||||
|
#### Scenario: Панорамирование
|
||||||
|
- **WHEN** фото увеличено
|
||||||
|
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
|
||||||
|
|
||||||
|
### Requirement: Удаление фото из просмотра
|
||||||
|
Автор фото, владелец и админ альбома SHALL иметь возможность удалить текущее фото из режима
|
||||||
|
просмотра; удаление SHALL подтверждаться диалогом.
|
||||||
|
|
||||||
|
#### Scenario: Подтверждение удаления
|
||||||
|
- **WHEN** пользователь с правом удаления нажимает «Удалить»
|
||||||
|
- **THEN** открывается диалог подтверждения, и после подтверждения фото удаляется
|
||||||
|
|
||||||
|
#### Scenario: Лента после удаления
|
||||||
|
- **WHEN** фото удалено
|
||||||
|
- **THEN** просмотр переходит к соседнему фото, а лента миниатюр обновляется без удалённого
|
||||||
|
|
||||||
|
### Requirement: Возврат к обзору
|
||||||
|
Из альбома и из режима просмотра SHALL быть доступен возврат к обзору альбомов.
|
||||||
|
|
||||||
|
#### Scenario: Возврат из альбома
|
||||||
|
- **WHEN** пользователь нажимает кнопку «назад» слева от названия альбома
|
||||||
|
- **THEN** открывается обзор альбомов
|
||||||
|
|
||||||
|
#### Scenario: Возврат из просмотра
|
||||||
|
- **WHEN** пользователь нажимает кнопку «назад» в режиме просмотра
|
||||||
|
- **THEN** открывается альбом с сеткой фото
|
||||||
|
|
||||||
|
### Requirement: Настройки списка атрибутов информации
|
||||||
|
Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим
|
||||||
|
модальное окно со списком EXIF-атрибутов, где пользователь SHALL отмечать отображаемые
|
||||||
|
атрибуты и задавать их подписи. В окне SHALL быть доступны действия «отметить все» и
|
||||||
|
«снять все». Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле
|
||||||
|
пользователя. При отсутствии сохранённых настроек SHALL отображаться базовый набор атрибутов
|
||||||
|
(камера, объектив, выдержка, диафрагма, ISO, фокусное расстояние).
|
||||||
|
|
||||||
|
#### Scenario: Открытие настроек
|
||||||
|
- **WHEN** пользователь нажимает иконку настроек в разделе «Информация»
|
||||||
|
- **THEN** открывается модальное окно со списком EXIF-атрибутов: галочки видимости и поля подписей, а также действия «отметить все»/«снять все»
|
||||||
|
|
||||||
|
#### Scenario: Значения по умолчанию
|
||||||
|
- **WHEN** у пользователя нет сохранённых настроек
|
||||||
|
- **THEN** в разделе «Информация» показан базовый набор атрибутов, а остальные доступны по элементу «…»
|
||||||
|
|
||||||
|
#### Scenario: Сохранение настроек
|
||||||
|
- **WHEN** пользователь отмечает атрибуты, задаёт подпись и сохраняет
|
||||||
|
- **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле
|
||||||
|
|
||||||
|
#### Scenario: Сброс настроек
|
||||||
|
- **WHEN** пользователь сбрасывает настройки
|
||||||
|
- **THEN** сохранённые настройки удаляются из профиля, и отображается базовый набор с исходными подписями
|
||||||
|
|
||||||
|
### Requirement: Свёртка скрытых атрибутов
|
||||||
|
Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент
|
||||||
|
«…»; нажатие на него SHALL раскрывать все атрибуты текущего фото, включая скрытые. Если
|
||||||
|
скрытых атрибутов нет, элемент «…» SHALL не отображаться.
|
||||||
|
|
||||||
|
#### Scenario: Скрытые атрибуты
|
||||||
|
- **WHEN** в разделе «Информация» есть скрытые настройками атрибуты
|
||||||
|
- **THEN** после видимых атрибутов показан элемент «…»
|
||||||
|
|
||||||
|
#### Scenario: Раскрытие всех атрибутов
|
||||||
|
- **WHEN** пользователь нажимает «…»
|
||||||
|
- **THEN** отображаются все атрибуты фото, включая скрытые
|
||||||
|
|
||||||
|
#### Scenario: Нет скрытых атрибутов
|
||||||
|
- **WHEN** ни один атрибут не скрыт
|
||||||
|
- **THEN** элемент «…» не отображается
|
||||||
|
|
||||||
|
### Requirement: Выбор фото в сетке
|
||||||
|
В режиме альбома одиночный выбор фото SHALL отмечать его рамкой и показывать его сведения в
|
||||||
|
контекстной панели, не открывая просмотр; двойной выбор SHALL открывать режим просмотра фото.
|
||||||
|
|
||||||
|
#### Scenario: Одиночный выбор
|
||||||
|
- **WHEN** пользователь кликает по фото в сетке
|
||||||
|
- **THEN** фото отмечено рамкой, правая панель показывает сведения об этом фото, просмотр не открывается
|
||||||
|
|
||||||
|
#### Scenario: Двойной выбор
|
||||||
|
- **WHEN** пользователь дважды кликает по фото в сетке
|
||||||
|
- **THEN** открывается режим просмотра этого фото
|
||||||
|
|
||||||
|
#### Scenario: Смена альбома
|
||||||
|
- **WHEN** пользователь переходит в другой альбом
|
||||||
|
- **THEN** отметка выбора сбрасывается
|
||||||
|
|
||||||
|
### Requirement: Мультивыбор фото в сетке
|
||||||
|
В режиме альбома пользователь SHALL выбирать несколько фото: обычный клик — одно фото,
|
||||||
|
Ctrl/Cmd-клик — добавить или убрать, Shift-клик — диапазон. Выбранные фото SHALL быть отмечены.
|
||||||
|
|
||||||
|
#### Scenario: Мультивыбор
|
||||||
|
- **WHEN** пользователь кликает по фото с зажатым Ctrl/Cmd или Shift
|
||||||
|
- **THEN** соответствующие фото отмечены, а панель показывает количество выбранных
|
||||||
|
|
||||||
|
### Requirement: Действия с выбранными фото
|
||||||
|
Когда выбрано одно или несколько фото, правая панель SHALL показывать раздел «Действия» со
|
||||||
|
строками-пунктами (иконка + подпись) «Открыть (просмотр)», «Переместить», «Копировать» и
|
||||||
|
«Удалить»; удаление SHALL подтверждаться диалогом.
|
||||||
|
|
||||||
|
#### Scenario: Действия
|
||||||
|
- **WHEN** выбрано одно или несколько фото
|
||||||
|
- **THEN** панель показывает раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать» и «Удалить»
|
||||||
|
|
||||||
|
### Requirement: Перетаскивание выбранных фото на альбом
|
||||||
|
Выбранные фото SHALL перетаскиваться на альбом в рейле; при отпускании SHALL открываться
|
||||||
|
диалог выбора переноса или копирования с указанием целевого альбома. Во время такого
|
||||||
|
перетаскивания зона загрузки файлов SHALL не показываться.
|
||||||
|
|
||||||
|
#### Scenario: Перетаскивание на альбом
|
||||||
|
- **WHEN** пользователь перетаскивает выделенные фото на альбом в рейле и отпускает
|
||||||
|
- **THEN** открывается диалог с выбором переноса/копирования и целевым альбомом
|
||||||
|
|
||||||
|
### Requirement: Корзина в списке альбомов
|
||||||
|
Рейл альбомов SHALL содержать вход в корзину последним элементом списка (с иконкой корзины).
|
||||||
|
Экран обзора альбомов SHALL не показывать пустую правую панель.
|
||||||
|
|
||||||
|
#### Scenario: Вход в корзину
|
||||||
|
- **WHEN** пользователь открывает приложение
|
||||||
|
- **THEN** в левом рейле последним элементом списка показана «Корзина» с иконкой корзины
|
||||||
|
|
||||||
|
#### Scenario: Обзор без панели
|
||||||
|
- **WHEN** пользователь открывает обзор альбомов
|
||||||
|
- **THEN** правая панель не отображается
|
||||||
|
|
||||||
|
### Requirement: Действия в режиме просмотра
|
||||||
|
В режиме просмотра правая панель SHALL содержать раздел «Действия» со строками-пунктами
|
||||||
|
(иконка + подпись) «Скачать», «Переместить», «Копировать» и «Удалить» (удаление — по правам).
|
||||||
|
Верхняя панель просмотра SHALL не дублировать скачивание и удаление.
|
||||||
|
|
||||||
|
#### Scenario: Действия в просмотре
|
||||||
|
- **WHEN** открыт режим просмотра фото
|
||||||
|
- **THEN** раздел «Действия» содержит «Скачать», «Переместить», «Копировать» и «Удалить», а в верхней панели этих кнопок нет
|
||||||
|
|
||||||
|
#### Scenario: Удаление из панели
|
||||||
|
- **WHEN** пользователь удаляет фото из раздела «Действия»
|
||||||
|
- **THEN** открывается подтверждение, и после удаления просмотр переходит к соседнему фото
|
||||||
|
|
||||||
|
### Requirement: Контекстное меню фото
|
||||||
|
Правый клик по фото в сетке и по изображению в режиме просмотра SHALL открывать контекстное
|
||||||
|
меню с пунктами раздела «Действия» («Скачать», «Переместить», «Копировать», «Удалить»; в сетке
|
||||||
|
также «Открыть (просмотр)»), выполняющими те же действия.
|
||||||
|
|
||||||
|
#### Scenario: Меню в сетке
|
||||||
|
- **WHEN** пользователь кликает правой кнопкой по фото в сетке
|
||||||
|
- **THEN** открывается меню с пунктами «Открыть (просмотр)», «Скачать», «Переместить», «Копировать», «Удалить»
|
||||||
|
|
||||||
|
#### Scenario: Меню в просмотре
|
||||||
|
- **WHEN** пользователь кликает правой кнопкой по изображению в режиме просмотра
|
||||||
|
- **THEN** открывается меню с пунктами «Скачать», «Переместить», «Копировать», «Удалить»
|
||||||
|
|
||||||
|
### Requirement: Контекстное меню альбома
|
||||||
|
Правый клик по альбому в рейле и по карточке на обзоре SHALL открывать меню с пунктами
|
||||||
|
«Переименовать» и «Удалить»; переименование SHALL открывать диалог, удаление — подтверждение.
|
||||||
|
|
||||||
|
#### Scenario: Меню альбома
|
||||||
|
- **WHEN** пользователь кликает правой кнопкой по альбому
|
||||||
|
- **THEN** открывается меню с «Переименовать» и «Удалить»
|
||||||
|
|
||||||
|
#### Scenario: Удаление альбома
|
||||||
|
- **WHEN** пользователь подтверждает удаление альбома
|
||||||
|
- **THEN** альбом удаляется, а его фото попадают в корзину
|
||||||
|
|
||||||
|
### Requirement: Счётчики в рейле
|
||||||
|
Рейл SHALL показывать число фото у каждого альбома и число элементов в корзине.
|
||||||
|
|
||||||
|
#### Scenario: Счётчики
|
||||||
|
- **WHEN** пользователь смотрит на рейл альбомов
|
||||||
|
- **THEN** у каждого альбома показано число фото, а у корзины — число удалённых фото
|
||||||
|
|
||||||
|
### Requirement: Метки в панели фото
|
||||||
|
Панель фото SHALL содержать раздел «Метки» с чипами меток фото и полем добавления, дающим
|
||||||
|
поиск по меткам пользователя и создание новых.
|
||||||
|
|
||||||
|
#### Scenario: Раздел «Метки»
|
||||||
|
- **WHEN** открыт просмотр или выбран один фото в сетке
|
||||||
|
- **THEN** в панели есть раздел «Метки» с текущими метками и полем добавления с поиском
|
||||||
|
|
||||||
|
### Requirement: Компактный верхний хром
|
||||||
|
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
|
||||||
|
(узкие отступы, иконочные органы управления), сохраняя все действия доступными. Верхняя панель
|
||||||
|
режима просмотра SHALL быть прозрачным оверлеем и SHALL не занимать место в раскладке,
|
||||||
|
увеличивая область фото.
|
||||||
|
|
||||||
|
#### Scenario: Компактные верхние панели
|
||||||
|
- **WHEN** открыт список альбомов или режим просмотра фото
|
||||||
|
- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны
|
||||||
|
|
||||||
|
#### Scenario: Прозрачная панель просмотра
|
||||||
|
- **WHEN** открыт режим просмотра фото
|
||||||
|
- **THEN** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения
|
||||||
Reference in New Issue
Block a user