diff --git a/backend/cmd/backfill-exif/main.go b/backend/cmd/backfill-exif/main.go new file mode 100644 index 0000000..a0d9309 --- /dev/null +++ b/backend/cmd/backfill-exif/main.go @@ -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) + } + } +} diff --git a/backend/go.mod b/backend/go.mod index f371bbf..119006b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,13 +9,13 @@ require ( github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 github.com/aws/smithy-go v1.28.5 github.com/disintegration/imaging v1.6.2 + github.com/dsoprea/go-exif/v3 v3.0.1 github.com/gen2brain/webp v0.6.4 github.com/gin-gonic/gin v1.12.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.20.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.11.0 - github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd golang.org/x/crypto v0.58.0 ) @@ -37,14 +37,18 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect + github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect github.com/ebitengine/purego v0.10.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -68,4 +72,5 @@ require ( golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index c27a063..3e61305 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -68,6 +68,23 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E= +github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8= +github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk= +github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No= +github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0= +github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc= +github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc= +github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA= +github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg= +github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8= +github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8= +github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw= +github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= @@ -80,6 +97,11 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= +github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -100,6 +122,10 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU= github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM= +github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= +github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo= +github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -115,10 +141,16 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -155,8 +187,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= -github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -189,23 +221,44 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go index 168b0f4..79022a1 100644 --- a/backend/internal/albums/service.go +++ b/backend/internal/albums/service.go @@ -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) { - 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) { @@ -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) { - 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 { @@ -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 { 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) return a, nil } @@ -124,11 +150,11 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error { if role != roles.Owner { 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 } - if s.storage != nil { - _ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/") + if err := s.store.Delete(ctx, a.ID); err != nil { + return err } s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID}) return nil diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go index 3d947d8..b4cd255 100644 --- a/backend/internal/albums/store.go +++ b/backend/internal/albums/store.go @@ -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) { var a models.Album err := s.pool.QueryRow(ctx, - `SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at - FROM albums WHERE id = $1`, id). - Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt) + `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 + 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 errors.Is(err, pgx.ErrNoRows) { 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) { 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 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 ORDER BY a.created_at DESC`, userID) if err != nil { @@ -70,7 +79,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, out := []models.Album{} for rows.Next() { var a models.Album - if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); 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 } out = append(out, a) @@ -98,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error { } 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 +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 7330727..6dfdc8b 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -115,6 +115,7 @@ func (s *Server) Router() *gin.Engine { authed.Use(s.auth.Middleware()) authed.GET("/auth/me", authHandler.Me) + authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences) authed.GET("/sse/albums/:id", s.albumSSE) authed.GET("/sse/me", s.meSSE) @@ -133,8 +134,20 @@ func (s *Server) Router() *gin.Engine { authed.GET("/albums/:id/photos", photosHandler.List) authed.POST("/albums/:id/photos", photosHandler.Upload) authed.GET("/photos/:id", photosHandler.Get) + authed.PATCH("/photos/:id", photosHandler.Rename) authed.DELETE("/photos/:id", photosHandler.Delete) 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.DELETE("/photos/:id/likes", socialHandler.RemoveLike) diff --git a/backend/internal/auth/handlers.go b/backend/internal/auth/handlers.go index 3b0700e..352ec41 100644 --- a/backend/internal/auth/handlers.go +++ b/backend/internal/auth/handlers.go @@ -1,6 +1,9 @@ package auth import ( + "encoding/json" + "fmt" + "io" "net/http" "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")) 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}) } diff --git a/backend/internal/auth/store.go b/backend/internal/auth/store.go index 5b3173d..054375c 100644 --- a/backend/internal/auth/store.go +++ b/backend/internal/auth/store.go @@ -2,6 +2,7 @@ package auth import ( "context" + "encoding/json" "errors" "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) { 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)) } func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) { 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)) } func (s *Store) scanUser(row pgx.Row) (*models.User, error) { 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) { return nil, ErrNotFound } return nil, err } + if prefs != "" && prefs != "null" { + _ = json.Unmarshal([]byte(prefs), &u.Preferences) + } 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 { _, err := s.pool.Exec(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index 7456174..c6835dc 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -3,12 +3,13 @@ package models import "time" type User struct { - ID string `json:"id"` - Email string `json:"email"` - PasswordHash string `json:"-"` - Name string `json:"name"` - AvatarURL string `json:"avatar_url,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url,omitempty"` + Preferences map[string]any `json:"preferences,omitempty"` + CreatedAt time.Time `json:"created_at"` } type PublicUser struct { @@ -26,13 +27,17 @@ func (u *User) Public() PublicUser { } type Album struct { - ID string `json:"id"` - OwnerID string `json:"owner_id"` - Title string `json:"title"` - Description string `json:"description"` - CoverPhotoID *string `json:"cover_photo_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + OwnerID string `json:"owner_id"` + Title string `json:"title"` + Description string `json:"description"` + CoverPhotoID *string `json:"cover_photo_id,omitempty"` + CoverThumbKey string `json:"-"` + CoverThumbURL string `json:"cover_thumb_url,omitempty"` + 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 { @@ -71,8 +76,12 @@ type Photo struct { TakenAt *time.Time `json:"taken_at,omitempty"` Latitude *float64 `json:"latitude,omitempty"` Longitude *float64 `json:"longitude,omitempty"` - Caption string `json:"caption"` - CreatedAt time.Time `json:"created_at"` + Caption string `json:"caption"` + 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"` LikesCount int `json:"likes_count"` CommentsCount int `json:"comments_count"` @@ -83,6 +92,11 @@ type Photo struct { DownloadURL string `json:"download_url,omitempty"` } +type Tag struct { + ID string `json:"id"` + Name string `json:"name"` +} + type Comment struct { ID string `json:"id"` PhotoID string `json:"photo_id"` diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 4d0190c..c207078 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -1,6 +1,7 @@ package photos import ( + "errors" "io" "net/http" "strconv" @@ -12,7 +13,7 @@ import ( "shared-album/internal/httpx" ) -const maxUploadSize = 50 << 20 +const maxUploadSize = 100 << 20 type Handler struct { svc *Service @@ -43,7 +44,12 @@ func (h *Handler) Upload(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) form, err := c.MultipartForm() 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 } files := form.File["files"] @@ -93,6 +99,24 @@ func (h *Handler) Get(c *gin.Context) { 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) { url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c)) if err != nil { @@ -109,3 +133,123 @@ func (h *Handler) Delete(c *gin.Context) { } 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}) +} diff --git a/backend/internal/photos/pipeline.go b/backend/internal/photos/pipeline.go index 789c1a2..2ed36f6 100644 --- a/backend/internal/photos/pipeline.go +++ b/backend/internal/photos/pipeline.go @@ -2,12 +2,16 @@ package photos import ( "bytes" + "fmt" "image" + "strconv" + "strings" "time" + exif "github.com/dsoprea/go-exif/v3" + exifcommon "github.com/dsoprea/go-exif/v3/common" "github.com/disintegration/imaging" "github.com/gen2brain/webp" - "github.com/rwcarlsen/goexif/exif" ) type ImageMeta struct { @@ -29,23 +33,90 @@ func inspect(data []byte) (width, height int, ok bool) { return cfg.Width, cfg.Height, true } -func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) { - x, err := exif.Decode(bytes.NewReader(data)) - if err != nil { - return nil, nil, nil +type exifInfo struct { + TakenAt *time.Time + Latitude *float64 + Longitude *float64 + Attributes map[string]any +} + +func collectTags(ifd *exif.Ifd, dst map[string]any) { + if ifd == nil { + return } - if dt, err := x.Get(exif.DateTimeOriginal); err == nil { - if s, err := dt.StringVal(); err == nil { - if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil { - utc := t.UTC() - taken = &utc - } + for _, ite := range ifd.Entries() { + name := ite.TagName() + if name == "" || strings.HasPrefix(name, "UnknownTag") { + continue + } + if _, exists := dst[name]; exists { + continue + } + s, err := ite.FormatFirst() + if err != nil { + 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 { - lat, lon = &la, &lo + if n, err := strconv.ParseInt(base, 10, 64); err == nil { + 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) { diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 7696dce..c58bfc4 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -62,7 +62,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er if !ok { return nil, httpx.BadRequest("invalid_image", "could not decode image") } - taken, lat, lon := extractEXIF(in.Data) + meta := extractEXIF(in.Data) id := auth.NewID() ext := strings.ToLower(filepath.Ext(in.Filename)) @@ -85,9 +85,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er Width: width, Height: height, MimeType: mime, - TakenAt: taken, - Latitude: lat, - Longitude: lon, + TakenAt: meta.TakenAt, + Latitude: meta.Latitude, + Longitude: meta.Longitude, + EXIF: meta.Attributes, } if err := s.store.Create(ctx, p); err != nil { return nil, err @@ -121,7 +122,7 @@ func (s *Service) processVariants(photoID, albumID string, data []byte) { if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil { return } - s.store.SetCover(ctx, albumID, photoID) + s.store.SetCoverIfEmpty(ctx, albumID, photoID) if p, err := s.store.GetByID(ctx, photoID, ""); err == nil { if err := s.attachURLs(ctx, p); err == nil { 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 } + if p.DeletedAt != nil { + return nil, httpx.NotFound("photo_not_found", "photo not found") + } if err := s.attachURLs(ctx, p); err != nil { return nil, err } @@ -229,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot } 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 { return nil, err } @@ -250,6 +257,40 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string, 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 { p, err := s.store.GetByID(ctx, photoID, userID) if err != nil { @@ -262,23 +303,286 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error { if err != nil { return err } - own := p.UploaderID == userID - if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) { + if !canDeletePhoto(role, p.UploaderID == userID) { 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 } - 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...) + s.publishDeleted(p) + return nil +} + +func canDeletePhoto(role string, own bool) bool { + return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role)) +} + +func (s *Service) publishDeleted(p *models.Photo) { s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{ "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 } + +// 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) +} diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index 8937da9..446c4a6 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -2,12 +2,16 @@ package photos import ( "context" + "encoding/json" "errors" + "strings" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" + "shared-album/internal/auth" "shared-album/internal/models" ) @@ -19,40 +23,71 @@ type Store struct { 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, ''), 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, u.id, u.name, COALESCE(u.avatar_url, ''), (SELECT count(*) FROM likes l WHERE l.photo_id = p.id), (SELECT count(*) FROM comments c WHERE c.photo_id = p.id), - EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2)` + EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2), + COALESCE(p.exif, '{}'::jsonb)::text, 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) { var p models.Photo + var exifText, tagsText string err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, &p.ThumbKey, &p.PreviewKey, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, - &p.LikesCount, &p.CommentsCount, &p.LikedByMe) + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } + setExif(&p, exifText) + setTags(&p, tagsText) 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 { + exifJSON, err := json.Marshal(p.EXIF) + if err != nil || len(exifJSON) == 0 { + exifJSON = []byte("{}") + } return s.pool.QueryRow(ctx, `INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename, - size, width, height, mime_type, taken_at, latitude, longitude, caption) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + size, width, height, mime_type, taken_at, latitude, longitude, caption, exif) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb) RETURNING created_at`, p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename, - p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, + p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON), ).Scan(&p.CreatedAt) } @@ -85,6 +120,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) `SELECT `+photoColumns+` FROM photos p JOIN users u ON u.id = p.uploader_id 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 ($4::timestamptz IS NULL OR p.created_at > $4) 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{} 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); err != nil { + &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil { return nil, err } + setExif(&p, exifText) + setTags(&p, tagsText) out = append(out, p) } 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) if err != nil { return err @@ -122,6 +216,228 @@ func (s *Store) Delete(ctx context.Context, id string) error { return nil } -func (s *Store) SetCover(ctx context.Context, albumID, photoID string) { - _, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID) +// ListTrash returns soft-deleted photos from albums the user belongs to, plus +// 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 } diff --git a/backend/internal/repo/repo_test.go b/backend/internal/repo/repo_test.go index 14d23df..9449131 100644 --- a/backend/internal/repo/repo_test.go +++ b/backend/internal/repo/repo_test.go @@ -12,6 +12,7 @@ import ( "shared-album/internal/auth" "shared-album/internal/members" "shared-album/internal/models" + "shared-album/internal/photos" "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) { pool := testPool(t) defer pool.Close() diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index 6f700f5..19b5b93 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -122,8 +122,23 @@ func (c *Client) DeletePrefix(ctx context.Context, prefix string) error { return c.Delete(ctx, keys...) } -func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { - out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{ +func (c *Client) Get(ctx context.Context, key string) ([]byte, error) { + out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("get object %s: %w", key, err) + } + defer out.Body.Close() + data, err := io.ReadAll(out.Body) + if err != nil { + return nil, fmt.Errorf("read object %s: %w", key, err) + } + return data, nil +} + +func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(key), }, s3.WithPresignExpires(c.presignTTL)) diff --git a/backend/migrations/000002_album_cover_fk.down.sql b/backend/migrations/000002_album_cover_fk.down.sql new file mode 100644 index 0000000..c3c858f --- /dev/null +++ b/backend/migrations/000002_album_cover_fk.down.sql @@ -0,0 +1 @@ +ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk; diff --git a/backend/migrations/000002_album_cover_fk.up.sql b/backend/migrations/000002_album_cover_fk.up.sql new file mode 100644 index 0000000..130250f --- /dev/null +++ b/backend/migrations/000002_album_cover_fk.up.sql @@ -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; diff --git a/backend/migrations/000003_photo_exif.down.sql b/backend/migrations/000003_photo_exif.down.sql new file mode 100644 index 0000000..73fb840 --- /dev/null +++ b/backend/migrations/000003_photo_exif.down.sql @@ -0,0 +1 @@ +ALTER TABLE photos DROP COLUMN IF EXISTS exif; diff --git a/backend/migrations/000003_photo_exif.up.sql b/backend/migrations/000003_photo_exif.up.sql new file mode 100644 index 0000000..4b9ba65 --- /dev/null +++ b/backend/migrations/000003_photo_exif.up.sql @@ -0,0 +1 @@ +ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/backend/migrations/000004_user_preferences.down.sql b/backend/migrations/000004_user_preferences.down.sql new file mode 100644 index 0000000..109d772 --- /dev/null +++ b/backend/migrations/000004_user_preferences.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS preferences; diff --git a/backend/migrations/000004_user_preferences.up.sql b/backend/migrations/000004_user_preferences.up.sql new file mode 100644 index 0000000..d62d6ab --- /dev/null +++ b/backend/migrations/000004_user_preferences.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/backend/migrations/000005_photo_trash.down.sql b/backend/migrations/000005_photo_trash.down.sql new file mode 100644 index 0000000..efa7285 --- /dev/null +++ b/backend/migrations/000005_photo_trash.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS photos_deleted_idx; +ALTER TABLE photos DROP COLUMN IF EXISTS deleted_at; diff --git a/backend/migrations/000005_photo_trash.up.sql b/backend/migrations/000005_photo_trash.up.sql new file mode 100644 index 0000000..0e2a655 --- /dev/null +++ b/backend/migrations/000005_photo_trash.up.sql @@ -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; diff --git a/backend/migrations/000006_photo_album_nullable.down.sql b/backend/migrations/000006_photo_album_nullable.down.sql new file mode 100644 index 0000000..9fdcff6 --- /dev/null +++ b/backend/migrations/000006_photo_album_nullable.down.sql @@ -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; diff --git a/backend/migrations/000006_photo_album_nullable.up.sql b/backend/migrations/000006_photo_album_nullable.up.sql new file mode 100644 index 0000000..371002b --- /dev/null +++ b/backend/migrations/000006_photo_album_nullable.up.sql @@ -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; diff --git a/backend/migrations/000007_photo_tags.down.sql b/backend/migrations/000007_photo_tags.down.sql new file mode 100644 index 0000000..e86d7d8 --- /dev/null +++ b/backend/migrations/000007_photo_tags.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS photo_tags; +DROP TABLE IF EXISTS tags; diff --git a/backend/migrations/000007_photo_tags.up.sql b/backend/migrations/000007_photo_tags.up.sql new file mode 100644 index 0000000..0b0737f --- /dev/null +++ b/backend/migrations/000007_photo_tags.up.sql @@ -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); diff --git a/frontend/src/app.css b/frontend/src/app.css index 024fe6c..93e09fa 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -15,39 +15,45 @@ --primary-fg: 0 0% 100%; --danger: 0 84% 60%; --success: 142 71% 45%; + --chrome: 0 0% 98%; + --canvas: 0 0% 100%; --shadow: 0 0% 0% / 0.08; } -/* Тёмная */ +/* Тёмная (графит Lightroom) */ [data-theme='dark'] { - --bg: 0 0% 7%; - --bg-elevated: 0 0% 11%; - --bg-muted: 0 0% 16%; + --bg: 0 0% 9%; + --bg-elevated: 0 0% 15%; + --bg-muted: 0 0% 18%; --border: 0 0% 22%; - --text: 0 0% 96%; + --text: 0 0% 92%; --text-muted: 0 0% 65%; --text-subtle: 0 0% 45%; --primary: 217 91% 60%; --primary-fg: 0 0% 100%; --danger: 0 84% 65%; --success: 142 65% 50%; + --chrome: 0 0% 13%; + --canvas: 0 0% 9%; --shadow: 0 0% 0% / 0.4; } -/* Нейтральная (тёплая серо-бежевая) */ +/* Нейтральная (тёплый графит) */ [data-theme='neutral'] { - --bg: 40 20% 96%; - --bg-elevated: 40 22% 92%; - --bg-muted: 40 18% 87%; - --border: 40 14% 78%; - --text: 30 10% 15%; - --text-muted: 30 8% 40%; - --text-subtle: 30 6% 55%; - --primary: 25 90% 50%; + --bg: 30 12% 18%; + --bg-elevated: 30 12% 23%; + --bg-muted: 30 11% 28%; + --border: 30 9% 34%; + --text: 35 18% 93%; + --text-muted: 32 10% 68%; + --text-subtle: 30 7% 50%; + --primary: 25 90% 55%; --primary-fg: 0 0% 100%; - --danger: 0 72% 55%; - --success: 142 55% 42%; - --shadow: 30 20% 20% / 0.12; + --danger: 0 72% 62%; + --success: 142 45% 52%; + --chrome: 30 12% 15%; + --canvas: 30 12% 18%; + --shadow: 30 30% 5% / 0.45; } html, @@ -66,3 +72,34 @@ body { 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; +} diff --git a/frontend/src/app.html b/frontend/src/app.html index d32d6e1..e616e5c 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,5 +1,5 @@ - + @@ -7,7 +7,7 @@ + +{#if albumActions.renaming} + +{/if} + +{#if albumActions.deleting} + (albumActions.deleting = null)} + /> +{/if} diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte new file mode 100644 index 0000000..71e2e16 --- /dev/null +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -0,0 +1,231 @@ + + +{#snippet selectionActions()} +
+

Действия

+ +
+{/snippet} + +{#if panelPhoto} + +{:else if !photoId && selectedItems.length > 1} + {@render selectionActions()} +{:else if album && !photoId} +
+ +
+ {#if album.cover_thumb_url} + + {:else} + + + + {/if} +
+ {#if canEdit} +
+ + {#if album.cover_photo_id} + + {/if} +
+ {/if} +
+ + +

{album.title}

+ {#if album.description}

{album.description}

{/if} +

+ {album.photos_count ?? 0} фото · {album.members_count ?? 0} участников +

+ {#if error}

{error}

{/if} +
+ + + + +
+ + {#if picking} + (picking = false)} + onSelected={(updated) => (album = updated)} + /> + {/if} +{/if} + +{#if pendingDeleteSelected} + (pendingDeleteSelected = false)} + /> +{/if} diff --git a/frontend/src/lib/components/AlbumRail.svelte b/frontend/src/lib/components/AlbumRail.svelte new file mode 100644 index 0000000..3e0dcd1 --- /dev/null +++ b/frontend/src/lib/components/AlbumRail.svelte @@ -0,0 +1,151 @@ + + +
+
+ Альбомы + {#if auth.isAuthenticated} + + {/if} +
+ + {#if !auth.isAuthenticated} +

Войдите, чтобы видеть альбомы.

+ {:else if albums.loading && albums.list.length === 0} +

Загрузка…

+ {:else if albums.list.length === 0} +

Альбомов пока нет.

+ {:else} + + {/if} + + {#if auth.isAuthenticated} + + {/if} + + {#if creating} + (creating = false)} + onCreated={(album) => goto(`/albums/${album.id}`)} + /> + {/if} +
diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..1938748 --- /dev/null +++ b/frontend/src/lib/components/AppShell.svelte @@ -0,0 +1,227 @@ + + +
+
+
+ + + + Galo + +
+
+ {#if panel && panelVisible} + + {/if} + {#if auth.isAuthenticated} + + + {:else} + + + + + {/if} + + +
+
+ +
+ {#if railOpen} + + {/if} + + + + +
+ {@render children()} +
+ + {#if panel && panelVisible} + {#if panelOpen} + + {/if} + + + {/if} +
+
diff --git a/frontend/src/lib/components/Comments.svelte b/frontend/src/lib/components/Comments.svelte index 93a0acd..41a51bc 100644 --- a/frontend/src/lib/components/Comments.svelte +++ b/frontend/src/lib/components/Comments.svelte @@ -75,18 +75,20 @@ {/each} {#if auth.isAuthenticated} -
+
{/if} diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..5903918 --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/lib/components/ContextMenu.svelte b/frontend/src/lib/components/ContextMenu.svelte new file mode 100644 index 0000000..7faec7f --- /dev/null +++ b/frontend/src/lib/components/ContextMenu.svelte @@ -0,0 +1,63 @@ + + + contextMenu.close()} + onscroll={() => contextMenu.close()} + onclick={() => contextMenu.close()} + oncontextmenu={() => contextMenu.close()} +/> + +{#if contextMenu.open} + +{/if} diff --git a/frontend/src/lib/components/CoverPicker.svelte b/frontend/src/lib/components/CoverPicker.svelte new file mode 100644 index 0000000..5941fc6 --- /dev/null +++ b/frontend/src/lib/components/CoverPicker.svelte @@ -0,0 +1,114 @@ + + + + + diff --git a/frontend/src/lib/components/CreateAlbumDialog.svelte b/frontend/src/lib/components/CreateAlbumDialog.svelte new file mode 100644 index 0000000..cf3bb40 --- /dev/null +++ b/frontend/src/lib/components/CreateAlbumDialog.svelte @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend/src/lib/components/DensityControl.svelte b/frontend/src/lib/components/DensityControl.svelte new file mode 100644 index 0000000..d00ba10 --- /dev/null +++ b/frontend/src/lib/components/DensityControl.svelte @@ -0,0 +1,19 @@ + + +
+ + density.set(Number(e.currentTarget.value))} + class="accent-primary h-1 w-24 cursor-pointer" + aria-label="Размер миниатюр" + title="Размер миниатюр" + /> +
diff --git a/frontend/src/lib/components/Lightbox.svelte b/frontend/src/lib/components/Lightbox.svelte deleted file mode 100644 index 5af7be3..0000000 --- a/frontend/src/lib/components/Lightbox.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/frontend/src/lib/components/PanelSection.svelte b/frontend/src/lib/components/PanelSection.svelte new file mode 100644 index 0000000..828496b --- /dev/null +++ b/frontend/src/lib/components/PanelSection.svelte @@ -0,0 +1,52 @@ + + +
+
+ + {#if actions} +
{@render actions()}
+ {/if} +
+ {#if isOpen} +
{@render children()}
+ {/if} +
diff --git a/frontend/src/lib/components/PhotoGrid.svelte b/frontend/src/lib/components/PhotoGrid.svelte index b7bbcb6..09ad607 100644 --- a/frontend/src/lib/components/PhotoGrid.svelte +++ b/frontend/src/lib/components/PhotoGrid.svelte @@ -1,25 +1,45 @@ {#if photos.length === 0}

Пока нет фотографий.

{:else} -
    +
      {#each photos as photo, i (photo.id)}
    • + + + + + +

      + Отметьте, какие атрибуты показывать, и задайте свои подписи. Скрытые атрибуты доступны по + кнопке «…» в разделе «Информация». +

      + +
      + {#if attributes.length === 0} +

      Нет доступных атрибутов.

      + {:else} +
        + {#each attributes as key (key)} +
      • + + (labelDraft[key] = e.currentTarget.value)} + /> +
      • + {/each} +
      + {/if} +
      + +
      + +
      + + +
      +
      + + diff --git a/frontend/src/lib/components/PhotoPanel.svelte b/frontend/src/lib/components/PhotoPanel.svelte new file mode 100644 index 0000000..4172750 --- /dev/null +++ b/frontend/src/lib/components/PhotoPanel.svelte @@ -0,0 +1,452 @@ + + +
      +
      + {#if editingName} +
      + +
      + + +
      +
      + {#if nameError}

      {nameError}

      {/if} + {:else if canRename} + + {:else} +

      {photo.original_filename}

      + {/if} +
      + + + + + + +
      + {#if (photo.tags ?? []).length} +
      + {#each photo.tags ?? [] as tag (tag.id)} + + {tag.name} + + + {/each} +
      + {/if} +
      + (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())} +
      + {#if tagQuery.trim()} + + {/if} + {#each tagSuggestions as tag (tag.id)} + + {/each} +
      + {/if} +
      +
      +
      + + + {#snippet actions()} + + {/snippet} +
      +
      Автор{photo.uploader.name}
      +
      Файл{formatSize(photo.size)}
      +
      Тип{photo.mime_type}
      +
      + Разрешение + {photo.width}×{photo.height} +
      + {#each showAll ? allExifRows : visibleExifRows as row (row.key)} +
      + {row.label} + {row.value} +
      + {/each} + {#if hiddenCount > 0 && !showAll} + + {/if} + {#if photo.taken_at} +
      + Снято + {new Date(photo.taken_at).toLocaleString()} +
      + {/if} + {#if photo.latitude != null && photo.longitude != null} +
      + Координаты + {photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)} +
      + {/if} +
      + Загружено + {new Date(photo.created_at).toLocaleString()} +
      +
      +
      + + + + +
      + +{#if settingsOpen} + savePreferences({})} + onClose={() => (settingsOpen = false)} + /> +{/if} + +{#if pendingDelete} + (pendingDelete = false)} + /> +{/if} diff --git a/frontend/src/lib/components/ThemeSwitcher.svelte b/frontend/src/lib/components/ThemeSwitcher.svelte index a2dfa2e..664d35d 100644 --- a/frontend/src/lib/components/ThemeSwitcher.svelte +++ b/frontend/src/lib/components/ThemeSwitcher.svelte @@ -1,29 +1,34 @@
      {#each options as option (option.value)} - theme.set(option.value)} - /> + > + + {/each}
      diff --git a/frontend/src/lib/components/TransferDialog.svelte b/frontend/src/lib/components/TransferDialog.svelte new file mode 100644 index 0000000..f215b6f --- /dev/null +++ b/frontend/src/lib/components/TransferDialog.svelte @@ -0,0 +1,157 @@ + + + + +{#if transfer.open} + +{/if} diff --git a/frontend/src/lib/components/Uploader.svelte b/frontend/src/lib/components/Uploader.svelte index 69bd165..0de5fce 100644 --- a/frontend/src/lib/components/Uploader.svelte +++ b/frontend/src/lib/components/Uploader.svelte @@ -1,6 +1,5 @@ -
      { - e.preventDefault(); - dragging = true; - }} - ondragleave={() => (dragging = false)} - ondrop={onDrop} - ondragenter={(e) => e.preventDefault()} - role="region" - aria-label="Загрузка фотографий" -> - - -

      Перетащите фото сюда или

      - + - {#if busy} -
      +{#if busy} +
      +

      Загрузка… {progress}%

      +
      - {/if} - {#if error} -

      {error}

      - {/if} -
      +
      +{/if} +{#if error} + +{/if} diff --git a/frontend/src/lib/components/Viewer.svelte b/frontend/src/lib/components/Viewer.svelte new file mode 100644 index 0000000..0614195 --- /dev/null +++ b/frontend/src/lib/components/Viewer.svelte @@ -0,0 +1,433 @@ + + + + +
      +
      +
      + + {index + 1} / {photos.length} +
      +
      + +
      +
      + +
      + + +
      + {#if current} + {current.original_filename} + {/if} +
      + + +
      + +
      + {#each photos as photo, i (photo.id)} + + {/each} +
      +
      diff --git a/frontend/src/lib/exif.ts b/frontend/src/lib/exif.ts new file mode 100644 index 0000000..27c66db --- /dev/null +++ b/frontend/src/lib/exif.ts @@ -0,0 +1,224 @@ +export const EXIF_LABELS: Record = { + 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> = { + 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, + labels: Record = {} +): 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); diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 55b0060..f8e6966 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -40,7 +40,17 @@ export const icons = { link_off: { path: '' }, delete_forever: { path: '' }, content_copy: { path: '' }, - check: { path: '' } + check: { path: '' }, + edit: { path: '' }, + label: { path: '' }, + menu: { path: '' }, + grid_view: { path: '' }, + wallpaper: { path: '' }, + fullscreen: { path: '' }, + fullscreen_exit: { path: '' }, + contrast: { path: '' }, + check_all: { path: '' }, + uncheck_all: { path: '' } } satisfies Record; export type IconName = keyof typeof icons; diff --git a/frontend/src/lib/stores/album-actions.svelte.ts b/frontend/src/lib/stores/album-actions.svelte.ts new file mode 100644 index 0000000..1fda6c1 --- /dev/null +++ b/frontend/src/lib/stores/album-actions.svelte.ts @@ -0,0 +1,21 @@ +import type { Album } from '$lib/types'; + +class AlbumActionsStore { + renaming = $state(null); + deleting = $state(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(); diff --git a/frontend/src/lib/stores/albums.svelte.ts b/frontend/src/lib/stores/albums.svelte.ts new file mode 100644 index 0000000..38e50bc --- /dev/null +++ b/frontend/src/lib/stores/albums.svelte.ts @@ -0,0 +1,76 @@ +import { api } from '$lib/api'; +import type { Album } from '$lib/types'; +import { auth } from './auth.svelte'; + +class AlbumsStore { + list = $state([]); + 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 { + 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 { + 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 { + const data = await api<{ album: Album }>(`/albums/${id}`, { + method: 'PATCH', + body: { title } + }); + this.upsert(data.album); + return data.album; + } + + async destroy(id: string): Promise { + 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(); diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index 4e9cce8..8c7c26c 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -1,8 +1,11 @@ +import type { Preferences } from '$lib/types'; + export interface User { id: string; email: string; name: string; avatar_url?: string; + preferences?: Preferences; } 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() { this.access = null; this.refresh = null; diff --git a/frontend/src/lib/stores/context-menu.svelte.ts b/frontend/src/lib/stores/context-menu.svelte.ts new file mode 100644 index 0000000..eef451f --- /dev/null +++ b/frontend/src/lib/stores/context-menu.svelte.ts @@ -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([]); + + 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(); diff --git a/frontend/src/lib/stores/density.svelte.ts b/frontend/src/lib/stores/density.svelte.ts new file mode 100644 index 0000000..1a7c555 --- /dev/null +++ b/frontend/src/lib/stores/density.svelte.ts @@ -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(); diff --git a/frontend/src/lib/stores/selection.svelte.ts b/frontend/src/lib/stores/selection.svelte.ts new file mode 100644 index 0000000..665d593 --- /dev/null +++ b/frontend/src/lib/stores/selection.svelte.ts @@ -0,0 +1,36 @@ +import type { Photo } from '$lib/types'; + +class SelectionStore { + albumId = $state(''); + items = $state([]); + anchorId = $state(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(); diff --git a/frontend/src/lib/stores/theme.svelte.ts b/frontend/src/lib/stores/theme.svelte.ts index b432c3f..99f2ee8 100644 --- a/frontend/src/lib/stores/theme.svelte.ts +++ b/frontend/src/lib/stores/theme.svelte.ts @@ -14,13 +14,13 @@ function resolve(theme: Theme): Resolved { } class ThemeStore { - current = $state('system'); - resolved = $state('light'); + current = $state('dark'); + resolved = $state('dark'); private media: MediaQueryList | null = null; init() { 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.apply(); this.media = window.matchMedia('(prefers-color-scheme: dark)'); diff --git a/frontend/src/lib/stores/transfer.svelte.ts b/frontend/src/lib/stores/transfer.svelte.ts new file mode 100644 index 0000000..c7e37ad --- /dev/null +++ b/frontend/src/lib/stores/transfer.svelte.ts @@ -0,0 +1,25 @@ +export type TransferAction = 'move' | 'copy'; + +class TransferStore { + open = $state(false); + ids = $state([]); + targetId = $state(null); + action = $state(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(); diff --git a/frontend/src/lib/stores/trash.svelte.ts b/frontend/src/lib/stores/trash.svelte.ts new file mode 100644 index 0000000..76fc0b0 --- /dev/null +++ b/frontend/src/lib/stores/trash.svelte.ts @@ -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(); diff --git a/frontend/src/lib/stores/viewer-actions.svelte.ts b/frontend/src/lib/stores/viewer-actions.svelte.ts new file mode 100644 index 0000000..7bfa1ac --- /dev/null +++ b/frontend/src/lib/stores/viewer-actions.svelte.ts @@ -0,0 +1,13 @@ +class ViewerActions { + deleteRequested = $state(null); + + requestDelete(id: string) { + this.deleteRequested = id; + } + + clear() { + this.deleteRequested = null; + } +} + +export const viewerActions = new ViewerActions(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0f00e5d..99f9ff8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,8 +1,18 @@ +export interface PhotoInfoPreferences { + visible?: string[]; + labels?: Record; +} + +export interface Preferences { + photo_info?: PhotoInfoPreferences; +} + export interface User { id: string; email?: string; name: string; avatar_url?: string; + preferences?: Preferences; } export interface Album { @@ -11,6 +21,9 @@ export interface Album { title: string; description: string; cover_photo_id?: string | null; + cover_thumb_url?: string; + photos_count?: number; + members_count?: number; created_at: string; updated_at: string; } @@ -23,6 +36,11 @@ export interface AlbumMember { user: User; } +export interface Tag { + id: string; + name: string; +} + export interface Photo { id: string; album_id: string; @@ -36,6 +54,10 @@ export interface Photo { latitude?: number | null; longitude?: number | null; caption: string; + exif?: Record; + tags?: Tag[]; + deleted_at?: string | null; + album_title?: string; created_at: string; uploader: User; likes_count: number; diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..6f4a2ee --- /dev/null +++ b/frontend/src/routes/(app)/+layout.svelte @@ -0,0 +1,25 @@ + + + + {#snippet rail()} + + {/snippet} + {#snippet panel()} + + {/snippet} + {@render children()} + + + + diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte new file mode 100644 index 0000000..0a3ad10 --- /dev/null +++ b/frontend/src/routes/(app)/+page.svelte @@ -0,0 +1,91 @@ + + +{#if !auth.isAuthenticated} +
      +

      Общие фотоальбомы

      +

      Войдите, чтобы создавать альбомы и делиться фотографиями.

      + + + Войти + +
      +{:else} +
      +
      +

      Мои альбомы

      + +
      + + {#if albums.loading && albums.list.length === 0} +

      Загрузка…

      + {:else} + {#if albums.list.length === 0} +
      + +

      Альбомов пока нет. Создайте первый кнопкой + в панели альбомов.

      +
      + {/if} + + {/if} +
      +{/if} diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte new file mode 100644 index 0000000..4f75ee1 --- /dev/null +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -0,0 +1,384 @@ + + +{#if loading} +

      Загрузка…

      +{:else if error} +

      {error}

      +{:else} +
      { + 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} + > +
      +
      + + + +

      {album?.title ?? ''}

      +
      +
      + + {#if canUpload} + + {/if} +
      +
      + +
      + {#if canUpload && photos.length === 0} + + {:else} + { + const p = photos[i]; + if (p) goto(`/albums/${albumId}/photos/${p.id}`); + }} + /> + {/if} + + {#if nextCursor} +
      + +
      + {/if} +
      + + {#if dragging} +
      + + + Отпустите, чтобы загрузить + +
      + {/if} + + { + for (const p of created) { + if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos]; + } + loadAlbum().catch(() => {}); + }} + /> +
      +{/if} + +{#if pendingDelete} + (pendingDelete = false)} + /> +{/if} diff --git a/frontend/src/routes/albums/[id]/members/+page.svelte b/frontend/src/routes/(app)/albums/[id]/members/+page.svelte similarity index 100% rename from frontend/src/routes/albums/[id]/members/+page.svelte rename to frontend/src/routes/(app)/albums/[id]/members/+page.svelte diff --git a/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte new file mode 100644 index 0000000..ea0dc79 --- /dev/null +++ b/frontend/src/routes/(app)/albums/[id]/photos/[photoId]/+page.svelte @@ -0,0 +1,105 @@ + + +{#if loading} +

      Загрузка…

      +{:else if error} +

      {error}

      +{:else if index < 0} +
      +

      Фото не найдено.

      + К альбому +
      +{:else} +
      +
      + +
      +
      + + {#if pendingDelete} + (pendingDelete = null)} + /> + {/if} +{/if} diff --git a/frontend/src/routes/albums/[id]/settings/+page.svelte b/frontend/src/routes/(app)/albums/[id]/settings/+page.svelte similarity index 100% rename from frontend/src/routes/albums/[id]/settings/+page.svelte rename to frontend/src/routes/(app)/albums/[id]/settings/+page.svelte diff --git a/frontend/src/routes/(app)/trash/+page.svelte b/frontend/src/routes/(app)/trash/+page.svelte new file mode 100644 index 0000000..760c87a --- /dev/null +++ b/frontend/src/routes/(app)/trash/+page.svelte @@ -0,0 +1,267 @@ + + +
      +
      + +

      Корзина

      +
      + + {#if error}

      {error}

      {/if} + + {#if loading} +

      Загрузка…

      + {:else if photos.length === 0} +
      + +

      Корзина пуста.

      +
      + {:else} +
        + {#each photos as photo (photo.id)} +
      • +
        + {#if photo.thumb_url} + + {:else} + + + + {/if} +
        +
        +

        + {photo.original_filename} +

        +

        + {photo.album_title || 'Без альбома'} +

        +
        + + +
        +
        +
      • + {/each} +
      + {/if} +
      + +{#if pending} + (pending = null)} + /> +{/if} + +{#if restoring} + +{/if} diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte new file mode 100644 index 0000000..a14569d --- /dev/null +++ b/frontend/src/routes/(public)/+layout.svelte @@ -0,0 +1,21 @@ + + +
      +
      + +
      +
      + {@render children()} +
      +
      diff --git a/frontend/src/routes/invites/[token]/+page.svelte b/frontend/src/routes/(public)/invites/[token]/+page.svelte similarity index 100% rename from frontend/src/routes/invites/[token]/+page.svelte rename to frontend/src/routes/(public)/invites/[token]/+page.svelte diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte similarity index 100% rename from frontend/src/routes/login/+page.svelte rename to frontend/src/routes/(public)/login/+page.svelte diff --git a/frontend/src/routes/share/[token]/+page.svelte b/frontend/src/routes/(public)/share/[token]/+page.svelte similarity index 64% rename from frontend/src/routes/share/[token]/+page.svelte rename to frontend/src/routes/(public)/share/[token]/+page.svelte index 89e28cd..234b2be 100644 --- a/frontend/src/routes/share/[token]/+page.svelte +++ b/frontend/src/routes/(public)/share/[token]/+page.svelte @@ -3,6 +3,7 @@ import { api } from '$lib/api'; import Icon from '$lib/components/Icon.svelte'; import PhotoGrid from '$lib/components/PhotoGrid.svelte'; + import Viewer from '$lib/components/Viewer.svelte'; import type { Album, Photo } from '$lib/types'; const token = $derived($page.params.token); @@ -65,41 +66,13 @@ {/if} {#if active >= 0} - {/if} {:else} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 202a21b..4cf1a16 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,68 +1,27 @@ -
      -
      -
      - - - Galo - -
      - {#if auth.isAuthenticated} - - - {:else} - - - Войти - - {/if} - -
      -
      -
      -
      - {@render children()} -
      -
      +{@render children()} + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte deleted file mode 100644 index 0ce6b47..0000000 --- a/frontend/src/routes/+page.svelte +++ /dev/null @@ -1,107 +0,0 @@ - - -{#if !auth.isAuthenticated} -
      -

      Общие фотоальбомы

      -

      Войдите, чтобы создавать альбомы и делиться фотографиями.

      - - - Войти - -
      -{:else} -
      -

      Мои альбомы

      -
      - - -
      -
      - - {#if error}

      {error}

      {/if} - - {#if loading} -

      Загрузка…

      - {:else if albums.length === 0} -
      - -

      Альбомов пока нет. Создайте первый.

      -
      - {:else} - - {/if} -{/if} diff --git a/frontend/src/routes/albums/[id]/+page.svelte b/frontend/src/routes/albums/[id]/+page.svelte deleted file mode 100644 index f737f70..0000000 --- a/frontend/src/routes/albums/[id]/+page.svelte +++ /dev/null @@ -1,186 +0,0 @@ - - -{#if loading} -

      Загрузка…

      -{:else if error} -

      {error}

      -{:else if album} -
      -
      -

      {album.title}

      -

      {album.description}

      -
      -
      - - - Участники - - {#if role === 'owner' || role === 'admin'} - - - Настройки - - {/if} -
      -
      - - {#if canUpload} -
      - { - for (const p of created) { - if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos]; - } - }} - /> -
      - {/if} - - (lightboxIndex = i)} /> - - {#if nextCursor} -
      - -
      - {/if} - - {#if lightboxIndex >= 0} - (lightboxIndex = -1)} - onIndexChange={(i) => (lightboxIndex = i)} - /> - {/if} -{/if} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 80b45ff..4594d69 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -11,6 +11,8 @@ export default { muted: 'hsl(var(--bg-muted) / )' }, border: 'hsl(var(--border) / )', + chrome: 'hsl(var(--chrome) / )', + canvas: 'hsl(var(--canvas) / )', text: { DEFAULT: 'hsl(var(--text) / )', muted: 'hsl(var(--text-muted) / )', diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/.openspec.yaml b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/.openspec.yaml new file mode 100644 index 0000000..53f62d7 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-10 diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/design.md b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/design.md new file mode 100644 index 0000000..487baff --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/design.md @@ -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` или кастомный тултип — косметика, спеки не меняет. diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/proposal.md b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/proposal.md new file mode 100644 index 0000000..c73f2f0 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/proposal.md @@ -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 нет. diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/albums/spec.md b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/albums/spec.md new file mode 100644 index 0000000..3e0ff15 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/albums/spec.md @@ -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-ссылки удаляются diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/auth/spec.md b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/auth/spec.md new file mode 100644 index 0000000..fa14247 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/auth/spec.md @@ -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** система отклоняет запрос с ошибкой и не меняет сохранённые настройки diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/photos/spec.md b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/photos/spec.md new file mode 100644 index 0000000..00d955d --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/photos/spec.md @@ -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** метка отвязывается от фото diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/ui-workspace/spec.md b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/ui-workspace/spec.md new file mode 100644 index 0000000..1a8aca7 --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/specs/ui-workspace/spec.md @@ -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** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения diff --git a/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/tasks.md b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/tasks.md new file mode 100644 index 0000000..fbd2eeb --- /dev/null +++ b/openspec/changes/archive/2026-10-10-lightroom-workspace-ui/tasks.md @@ -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`, компактный переключатель темы). Проверка: высота `
      ` ~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). diff --git a/openspec/specs/albums/spec.md b/openspec/specs/albums/spec.md index 7c96668..2be8109 100644 --- a/openspec/specs/albums/spec.md +++ b/openspec/specs/albums/spec.md @@ -35,8 +35,47 @@ - **THEN** система сохраняет изменения ### Requirement: Удаление альбома -Владелец SHALL иметь возможность удалить альбом вместе с содержимым. +Владелец SHALL иметь возможность удалить альбом; фото альбома SHALL помещаться в корзину без +удаления объектов хранилища, а участники и share-ссылки SHALL удаляться вместе с альбомом. #### Scenario: Удаление альбома - **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** ответ содержит число фото и число участников альбома diff --git a/openspec/specs/auth/spec.md b/openspec/specs/auth/spec.md index 7f06609..e17aef3 100644 --- a/openspec/specs/auth/spec.md +++ b/openspec/specs/auth/spec.md @@ -45,3 +45,24 @@ #### Scenario: Выход - **WHEN** пользователь вызывает logout с действующим refresh-токеном - **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется + +### Requirement: Хранение пользовательских настроек интерфейса +Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при +получении текущего пользователя. Настройки SHALL быть приватными для пользователя. +Отсутствие настроек SHALL трактоваться как значения по умолчанию. + +#### Scenario: Получение настроек +- **WHEN** аутентифицированный пользователь запрашивает свой профиль +- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию) + +#### Scenario: Сохранение настроек +- **WHEN** пользователь сохраняет настройки отображения +- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние + +#### Scenario: Изоляция настроек +- **WHEN** другой пользователь запрашивает свой профиль +- **THEN** он не получает настройки первого пользователя + +#### Scenario: Некорректные настройки +- **WHEN** пользователь присылает настройки недопустимой структуры или размера +- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки diff --git a/openspec/specs/photos/spec.md b/openspec/specs/photos/spec.md index f49f173..6d6c90b 100644 --- a/openspec/specs/photos/spec.md +++ b/openspec/specs/photos/spec.md @@ -55,3 +55,125 @@ #### Scenario: Удаление чужого фото участником - **WHEN** участник с ролью member пытается удалить чужое фото - **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** метка отвязывается от фото diff --git a/openspec/specs/ui-workspace/spec.md b/openspec/specs/ui-workspace/spec.md new file mode 100644 index 0000000..e622bf3 --- /dev/null +++ b/openspec/specs/ui-workspace/spec.md @@ -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** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения