feat: Lightroom-style workspace UI #2

Merged
iu merged 18 commits from feature/lightroom-workspace-ui into develop 2026-10-10 19:56:23 +00:00
90 changed files with 6981 additions and 690 deletions
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"bufio"
"context"
"log/slog"
"os"
"strings"
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/photos"
"shared-album/internal/storage"
)
func main() {
loadDotEnv(".env")
loadDotEnv("../.env")
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "error", err)
os.Exit(1)
}
ctx := context.Background()
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
slog.Error("database connect failed", "error", err)
os.Exit(1)
}
defer pool.Close()
store, err := storage.New(ctx, cfg.S3)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
}
photoStore := photos.NewStore(pool)
updated, err := photoStore.BackfillEXIF(ctx, store.Get)
if err != nil {
slog.Error("backfill failed", "error", err)
os.Exit(1)
}
slog.Info("exif backfill complete", "updated", updated)
}
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
}
+6 -1
View File
@@ -9,13 +9,13 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
github.com/aws/smithy-go v1.28.5 github.com/aws/smithy-go v1.28.5
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/webp v0.6.4 github.com/gen2brain/webp v0.6.4
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.20.1 github.com/golang-migrate/migrate/v4 v4.20.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
) )
@@ -37,14 +37,18 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/ebitengine/purego v0.10.1 // indirect github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -68,4 +72,5 @@ require (
golang.org/x/sys v0.49.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )
+55 -2
View File
@@ -68,6 +68,23 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No=
github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0=
github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc=
github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc=
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -80,6 +97,11 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -100,6 +122,10 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU= github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM= github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -115,10 +141,16 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -155,8 +187,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -189,23 +221,44 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+31 -5
View File
@@ -51,7 +51,24 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
} }
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) { func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
return s.store.ListForUser(ctx, userID) out, err := s.store.ListForUser(ctx, userID)
if err != nil {
return nil, err
}
for i := range out {
s.attachCover(ctx, &out[i])
}
return out, nil
}
// attachCover fills CoverThumbURL from the presigned thumbnail key, when present.
func (s *Service) attachCover(ctx context.Context, a *models.Album) {
if s.storage == nil || a == nil || a.CoverThumbKey == "" {
return
}
if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
a.CoverThumbURL = url
}
} }
func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) { func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
@@ -76,7 +93,12 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
} }
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) { func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
return s.Access(ctx, albumID, userID) a, role, err := s.Access(ctx, albumID, userID)
if err != nil {
return nil, "", err
}
s.attachCover(ctx, a)
return a, role, nil
} }
type UpdateInput struct { type UpdateInput struct {
@@ -112,6 +134,10 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
if err := s.store.Update(ctx, a); err != nil { if err := s.store.Update(ctx, a); err != nil {
return nil, err return nil, err
} }
if refreshed, err := s.store.GetByID(ctx, a.ID); err == nil {
a = refreshed
}
s.attachCover(ctx, a)
s.hub.Publish(albumID, "album.updated", a) s.hub.Publish(albumID, "album.updated", a)
return a, nil return a, nil
} }
@@ -124,11 +150,11 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
if role != roles.Owner { if role != roles.Owner {
return httpx.Forbidden("forbidden", "only the owner can delete the album") return httpx.Forbidden("forbidden", "only the owner can delete the album")
} }
if err := s.store.Delete(ctx, a.ID); err != nil { if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
return err return err
} }
if s.storage != nil { if err := s.store.Delete(ctx, a.ID); err != nil {
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/") return err
} }
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID}) s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
return nil return nil
+22 -5
View File
@@ -43,9 +43,14 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) { func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
var a models.Album var a models.Album
err := s.pool.QueryRow(ctx, err := s.pool.QueryRow(ctx,
`SELECT id, owner_id, title, description, cover_photo_id, created_at, updated_at `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
FROM albums WHERE id = $1`, id). COALESCE(p.thumb_key, ''),
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt) (SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a
LEFT JOIN photos p ON p.id = a.cover_photo_id
WHERE a.id = $1`, id).
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -57,9 +62,13 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) { func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.pool.Query(ctx, rows, err := s.pool.Query(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
COALESCE(p.thumb_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1 LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
LEFT JOIN photos p ON p.id = a.cover_photo_id
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID) ORDER BY a.created_at DESC`, userID)
if err != nil { if err != nil {
@@ -70,7 +79,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
out := []models.Album{} out := []models.Album{}
for rows.Next() { for rows.Next() {
var a models.Album var a models.Album
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt); err != nil { if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
return nil, err return nil, err
} }
out = append(out, a) out = append(out, a)
@@ -98,3 +107,11 @@ func (s *Store) Delete(ctx context.Context, id string) error {
} }
return nil return nil
} }
// TrashPhotosByAlbum soft-deletes all photos of an album so they can be restored
// or permanently removed from the trash after the album is gone.
func (s *Store) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
_, err := s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = now() WHERE album_id = $1 AND deleted_at IS NULL`, albumID)
return err
}
+13
View File
@@ -115,6 +115,7 @@ func (s *Server) Router() *gin.Engine {
authed.Use(s.auth.Middleware()) authed.Use(s.auth.Middleware())
authed.GET("/auth/me", authHandler.Me) authed.GET("/auth/me", authHandler.Me)
authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences)
authed.GET("/sse/albums/:id", s.albumSSE) authed.GET("/sse/albums/:id", s.albumSSE)
authed.GET("/sse/me", s.meSSE) authed.GET("/sse/me", s.meSSE)
@@ -133,8 +134,20 @@ func (s *Server) Router() *gin.Engine {
authed.GET("/albums/:id/photos", photosHandler.List) authed.GET("/albums/:id/photos", photosHandler.List)
authed.POST("/albums/:id/photos", photosHandler.Upload) authed.POST("/albums/:id/photos", photosHandler.Upload)
authed.GET("/photos/:id", photosHandler.Get) authed.GET("/photos/:id", photosHandler.Get)
authed.PATCH("/photos/:id", photosHandler.Rename)
authed.DELETE("/photos/:id", photosHandler.Delete) authed.DELETE("/photos/:id", photosHandler.Delete)
authed.GET("/photos/:id/download", photosHandler.Download) authed.GET("/photos/:id/download", photosHandler.Download)
authed.POST("/photos/:id/restore", photosHandler.Restore)
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
authed.POST("/photos/:id/tags", photosHandler.AddTag)
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
authed.GET("/tags", photosHandler.Tags)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
authed.GET("/trash", photosHandler.Trash)
authed.GET("/trash/count", photosHandler.TrashCount)
authed.POST("/photos/:id/likes", socialHandler.AddLike) authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike) authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
+78 -1
View File
@@ -1,6 +1,9 @@
package auth package auth
import ( import (
"encoding/json"
"fmt"
"io"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -89,5 +92,79 @@ func (h *Handler) Me(c *gin.Context) {
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found")) httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
return return
} }
c.JSON(http.StatusOK, gin.H{"user": u.Public()}) prefs := u.Preferences
if prefs == nil {
prefs = map[string]any{}
}
c.JSON(http.StatusOK, gin.H{"user": u.Public(), "preferences": prefs})
}
const (
maxVisibleAttrs = 500
maxLabelEntries = 500
maxPreferenceKeyLen = 128
maxPreferenceValueLen = 128
)
type photoInfoPreferences struct {
Visible []string `json:"visible"`
Labels map[string]string `json:"labels,omitempty"`
}
type preferencesPayload struct {
PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"`
}
func (p preferencesPayload) validate() error {
pi := p.PhotoInfo
if pi == nil {
return nil
}
if len(pi.Visible) > maxVisibleAttrs {
return fmt.Errorf("too many visible attributes")
}
for _, k := range pi.Visible {
if k == "" || len(k) > maxPreferenceKeyLen {
return fmt.Errorf("invalid visible attribute key")
}
}
if len(pi.Labels) > maxLabelEntries {
return fmt.Errorf("too many label overrides")
}
for k, v := range pi.Labels {
if k == "" || len(k) > maxPreferenceKeyLen {
return fmt.Errorf("invalid label key")
}
if len(v) > maxPreferenceValueLen {
return fmt.Errorf("label value too long")
}
}
return nil
}
func (h *Handler) UpdatePreferences(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not read request body"))
return
}
var payload preferencesPayload
if err := json.Unmarshal(body, &payload); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object"))
return
}
if err := payload.validate(); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error()))
return
}
raw, err := json.Marshal(payload)
if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
return
}
if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"preferences": payload})
} }
+22 -3
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -29,27 +30,45 @@ func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx, return s.scanUser(s.pool.QueryRow(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'::jsonb)::text, created_at
FROM users WHERE lower(email) = lower($1)`, email)) FROM users WHERE lower(email) = lower($1)`, email))
} }
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) { func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx, return s.scanUser(s.pool.QueryRow(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'::jsonb)::text, created_at
FROM users WHERE id = $1`, id)) FROM users WHERE id = $1`, id))
} }
func (s *Store) scanUser(row pgx.Row) (*models.User, error) { func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
var u models.User var u models.User
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil { var prefs string
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return nil, err return nil, err
} }
if prefs != "" && prefs != "null" {
_ = json.Unmarshal([]byte(prefs), &u.Preferences)
}
return &u, nil return &u, nil
} }
func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
tag, err := s.pool.Exec(ctx,
`UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error { func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.pool.Exec(ctx, _, err := s.pool.Exec(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
+29 -15
View File
@@ -3,12 +3,13 @@ package models
import "time" import "time"
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"` AvatarURL string `json:"avatar_url,omitempty"`
CreatedAt time.Time `json:"created_at"` Preferences map[string]any `json:"preferences,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
type PublicUser struct { type PublicUser struct {
@@ -26,13 +27,17 @@ func (u *User) Public() PublicUser {
} }
type Album struct { type Album struct {
ID string `json:"id"` ID string `json:"id"`
OwnerID string `json:"owner_id"` OwnerID string `json:"owner_id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
CoverPhotoID *string `json:"cover_photo_id,omitempty"` CoverPhotoID *string `json:"cover_photo_id,omitempty"`
CreatedAt time.Time `json:"created_at"` CoverThumbKey string `json:"-"`
UpdatedAt time.Time `json:"updated_at"` CoverThumbURL string `json:"cover_thumb_url,omitempty"`
PhotosCount int `json:"photos_count"`
MembersCount int `json:"members_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type AlbumMember struct { type AlbumMember struct {
@@ -71,8 +76,12 @@ type Photo struct {
TakenAt *time.Time `json:"taken_at,omitempty"` TakenAt *time.Time `json:"taken_at,omitempty"`
Latitude *float64 `json:"latitude,omitempty"` Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"` Longitude *float64 `json:"longitude,omitempty"`
Caption string `json:"caption"` Caption string `json:"caption"`
CreatedAt time.Time `json:"created_at"` EXIF map[string]any `json:"exif,omitempty"`
Tags []Tag `json:"tags,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
AlbumTitle string `json:"album_title,omitempty"`
CreatedAt time.Time `json:"created_at"`
Uploader PublicUser `json:"uploader"` Uploader PublicUser `json:"uploader"`
LikesCount int `json:"likes_count"` LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"` CommentsCount int `json:"comments_count"`
@@ -83,6 +92,11 @@ type Photo struct {
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
} }
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Comment struct { type Comment struct {
ID string `json:"id"` ID string `json:"id"`
PhotoID string `json:"photo_id"` PhotoID string `json:"photo_id"`
+146 -2
View File
@@ -1,6 +1,7 @@
package photos package photos
import ( import (
"errors"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -12,7 +13,7 @@ import (
"shared-album/internal/httpx" "shared-album/internal/httpx"
) )
const maxUploadSize = 50 << 20 const maxUploadSize = 100 << 20
type Handler struct { type Handler struct {
svc *Service svc *Service
@@ -43,7 +44,12 @@ func (h *Handler) Upload(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
form, err := c.MultipartForm() form, err := c.MultipartForm()
if err != nil { if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "expected multipart form")) var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", "files exceed the maximum upload size (100 MB)"))
return
}
httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid multipart form"))
return return
} }
files := form.File["files"] files := form.File["files"]
@@ -93,6 +99,24 @@ func (h *Handler) Get(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photo": p}) c.JSON(http.StatusOK, gin.H{"photo": p})
} }
type renameRequest struct {
OriginalFilename string `json:"original_filename" binding:"required"`
}
func (h *Handler) Rename(c *gin.Context) {
var req renameRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "original_filename is required"))
return
}
p, err := h.svc.Rename(c.Request.Context(), c.Param("id"), auth.UserID(c), req.OriginalFilename)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) Download(c *gin.Context) { func (h *Handler) Download(c *gin.Context) {
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c)) url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil { if err != nil {
@@ -109,3 +133,123 @@ func (h *Handler) Delete(c *gin.Context) {
} }
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (h *Handler) PermanentDelete(c *gin.Context) {
if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) Restore(c *gin.Context) {
var req struct {
AlbumID string `json:"album_id"`
}
_ = c.ShouldBindJSON(&req)
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) Trash(c *gin.Context) {
list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": list})
}
func (h *Handler) TrashCount(c *gin.Context) {
n, err := h.svc.TrashCount(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"count": n})
}
func (h *Handler) Tags(c *gin.Context) {
tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
func (h *Handler) AddTag(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required"))
return
}
p, err := h.svc.AddTag(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Name)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) RemoveTag(c *gin.Context) {
p, err := h.svc.RemoveTag(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("tag_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
type batchRequest struct {
IDs []string `json:"ids" binding:"required"`
AlbumID string `json:"album_id"`
}
func (h *Handler) DeleteBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required"))
return
}
n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
func (h *Handler) MoveBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
return
}
n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"moved": n})
}
func (h *Handler) CopyBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
return
}
created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": created})
}
+85 -14
View File
@@ -2,12 +2,16 @@ package photos
import ( import (
"bytes" "bytes"
"fmt"
"image" "image"
"strconv"
"strings"
"time" "time"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/gen2brain/webp" "github.com/gen2brain/webp"
"github.com/rwcarlsen/goexif/exif"
) )
type ImageMeta struct { type ImageMeta struct {
@@ -29,23 +33,90 @@ func inspect(data []byte) (width, height int, ok bool) {
return cfg.Width, cfg.Height, true return cfg.Width, cfg.Height, true
} }
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) { type exifInfo struct {
x, err := exif.Decode(bytes.NewReader(data)) TakenAt *time.Time
if err != nil { Latitude *float64
return nil, nil, nil Longitude *float64
Attributes map[string]any
}
func collectTags(ifd *exif.Ifd, dst map[string]any) {
if ifd == nil {
return
} }
if dt, err := x.Get(exif.DateTimeOriginal); err == nil { for _, ite := range ifd.Entries() {
if s, err := dt.StringVal(); err == nil { name := ite.TagName()
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil { if name == "" || strings.HasPrefix(name, "UnknownTag") {
utc := t.UTC() continue
taken = &utc }
} if _, exists := dst[name]; exists {
continue
}
s, err := ite.FormatFirst()
if err != nil {
continue
}
s = strings.TrimSpace(strings.TrimRight(s, "\x00"))
if s == "" {
continue
}
dst[name] = normalizeExifValue(s)
}
for _, child := range ifd.Children() {
collectTags(child, dst)
}
}
func normalizeExifValue(s string) any {
base := strings.TrimSuffix(s, "...")
if i := strings.IndexByte(base, '/'); i > 0 {
num, err1 := strconv.ParseInt(strings.TrimSpace(base[:i]), 10, 64)
den, err2 := strconv.ParseInt(strings.TrimSpace(base[i+1:]), 10, 64)
if err1 == nil && err2 == nil && den != 0 {
return float64(num) / float64(den)
} }
} }
if la, lo, err := x.LatLong(); err == nil { if n, err := strconv.ParseInt(base, 10, 64); err == nil {
lat, lon = &la, &lo return n
} }
return taken, lat, lon if f, err := strconv.ParseFloat(base, 64); err == nil {
return f
}
return s
}
func extractEXIF(data []byte) exifInfo {
out := exifInfo{Attributes: map[string]any{}}
raw, err := exif.SearchAndExtractExif(data)
if err != nil {
return out
}
im, err := exifcommon.NewIfdMappingWithStandard()
if err != nil {
return out
}
ti := exif.NewTagIndex()
_, index, err := exif.Collect(im, ti, raw)
if err != nil || index.RootIfd == nil {
return out
}
collectTags(index.RootIfd, out.Attributes)
if v, ok := out.Attributes["DateTimeOriginal"]; ok {
if t, err := time.Parse("2006:01:02 15:04:05", fmt.Sprintf("%v", v)); err == nil {
utc := t.UTC()
out.TakenAt = &utc
}
}
if gi, err := index.RootIfd.GpsInfo(); err == nil {
lat := gi.Latitude.Decimal()
lon := gi.Longitude.Decimal()
out.Latitude, out.Longitude = &lat, &lon
}
return out
}
func ExtractEXIFAttributes(data []byte) map[string]any {
return extractEXIF(data).Attributes
} }
func buildVariants(data []byte) (thumb, preview []byte, err error) { func buildVariants(data []byte) (thumb, preview []byte, err error) {
+320 -16
View File
@@ -62,7 +62,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
if !ok { if !ok {
return nil, httpx.BadRequest("invalid_image", "could not decode image") return nil, httpx.BadRequest("invalid_image", "could not decode image")
} }
taken, lat, lon := extractEXIF(in.Data) meta := extractEXIF(in.Data)
id := auth.NewID() id := auth.NewID()
ext := strings.ToLower(filepath.Ext(in.Filename)) ext := strings.ToLower(filepath.Ext(in.Filename))
@@ -85,9 +85,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
Width: width, Width: width,
Height: height, Height: height,
MimeType: mime, MimeType: mime,
TakenAt: taken, TakenAt: meta.TakenAt,
Latitude: lat, Latitude: meta.Latitude,
Longitude: lon, Longitude: meta.Longitude,
EXIF: meta.Attributes,
} }
if err := s.store.Create(ctx, p); err != nil { if err := s.store.Create(ctx, p); err != nil {
return nil, err return nil, err
@@ -121,7 +122,7 @@ func (s *Service) processVariants(photoID, albumID string, data []byte) {
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil { if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
return return
} }
s.store.SetCover(ctx, albumID, photoID) s.store.SetCoverIfEmpty(ctx, albumID, photoID)
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil { if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
if err := s.attachURLs(ctx, p); err == nil { if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(albumID, "photo.created", p) s.hub.Publish(albumID, "photo.created", p)
@@ -206,6 +207,9 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
} }
return nil, err return nil, err
} }
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if err := s.attachURLs(ctx, p); err != nil { if err := s.attachURLs(ctx, p); err != nil {
return nil, err return nil, err
} }
@@ -229,6 +233,9 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot
} }
return nil, err return nil, err
} }
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil { if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err return nil, err
} }
@@ -250,6 +257,40 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string,
return url, nil return url, nil
} }
func (s *Service) Rename(ctx context.Context, photoID, userID, filename string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
}
own := p.UploaderID == userID
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
return nil, httpx.Forbidden("forbidden", "you cannot rename this photo")
}
name := strings.TrimSpace(filename)
if name == "" {
return nil, httpx.BadRequest("invalid_filename", "filename is required")
}
if len(name) > 255 {
name = name[:255]
}
if err := s.store.UpdateFilename(ctx, photoID, name); err != nil {
return nil, err
}
p.OriginalFilename = name
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
s.hub.Publish(p.AlbumID, "photo.updated", p)
return p, nil
}
func (s *Service) Delete(ctx context.Context, photoID, userID string) error { func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
p, err := s.store.GetByID(ctx, photoID, userID) p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil { if err != nil {
@@ -262,23 +303,286 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
if err != nil { if err != nil {
return err return err
} }
own := p.UploaderID == userID if !canDeletePhoto(role, p.UploaderID == userID) {
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
return httpx.Forbidden("forbidden", "you cannot delete this photo") return httpx.Forbidden("forbidden", "you cannot delete this photo")
} }
if err := s.store.Delete(ctx, photoID); err != nil { if p.DeletedAt != nil {
return nil
}
if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
return err return err
} }
keys := []string{p.StorageKey} s.publishDeleted(p)
if p.ThumbKey != "" { return nil
keys = append(keys, p.ThumbKey) }
}
if p.PreviewKey != "" { func canDeletePhoto(role string, own bool) bool {
keys = append(keys, p.PreviewKey) return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))
} }
_ = s.storage.Delete(ctx, keys...)
func (s *Service) publishDeleted(p *models.Photo) {
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{ s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID, "id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
}) })
}
// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete.
func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) {
deleted := 0
for _, id := range ids {
if err := s.Delete(ctx, id, userID); err != nil {
continue
}
deleted++
}
return deleted, nil
}
// Restore returns a photo from the trash back to its album.
// Restore returns a photo from the trash back to an album. When targetAlbumID is
// empty the photo goes back to its own album; if that album is gone, an
// album_required error is returned so the client can prompt for a target.
func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if targetAlbumID != "" {
_, role, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return nil, err
}
if !roles.CanUpload(role) {
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
return nil, err
}
p.AlbumID = targetAlbumID
p.DeletedAt = nil
} else {
if p.AlbumID == "" {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
exists, err := s.store.AlbumExists(ctx, p.AlbumID)
if err != nil {
return nil, err
}
if !exists {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
}
if p.DeletedAt != nil {
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
return nil, err
}
}
p.DeletedAt = nil
}
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
if p.AlbumID != "" {
s.hub.Publish(p.AlbumID, "photo.created", p)
}
return p, nil
}
// PermanentDelete removes the photo row; stored objects are deleted only when no
// other album references the same storage key.
func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("photo_not_found", "photo not found")
}
return err
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return err
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return httpx.Forbidden("forbidden", "you cannot delete this photo")
}
if err := s.store.HardDelete(ctx, photoID); err != nil {
return err
}
if p.AlbumID != "" {
s.publishDeleted(p)
}
if n, err := s.store.StorageKeyRefCount(ctx, p.StorageKey); err == nil && n == 0 {
keys := []string{p.StorageKey}
if p.ThumbKey != "" {
keys = append(keys, p.ThumbKey)
}
if p.PreviewKey != "" {
keys = append(keys, p.PreviewKey)
}
_ = s.storage.Delete(ctx, keys...)
}
return nil return nil
} }
// MoveBatch moves photos to another album (metadata only; stored objects untouched).
func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) {
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return 0, err
}
if !roles.CanUpload(targetRole) {
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
moved := 0
for _, id := range ids {
p, err := s.store.GetByID(ctx, id, userID)
if err != nil || p.DeletedAt != nil {
continue
}
oldAlbum := p.AlbumID
if oldAlbum == targetAlbumID {
continue
}
_, srcRole, err := s.albums.Access(ctx, oldAlbum, userID)
if err != nil {
continue
}
if !canDeletePhoto(srcRole, p.UploaderID == userID) {
continue
}
if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
continue
}
p.AlbumID = targetAlbumID
s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
"id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID,
})
if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(targetAlbumID, "photo.created", p)
}
moved++
}
return moved, nil
}
// CopyBatch clones photos into another album (metadata only; stored objects shared).
func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) {
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return nil, err
}
if !roles.CanUpload(targetRole) {
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
out := []models.Photo{}
for _, id := range ids {
src, err := s.store.GetByID(ctx, id, userID)
if err != nil || src.DeletedAt != nil {
continue
}
if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil {
continue
}
newID := auth.NewID()
if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil {
continue
}
clone, err := s.store.GetByID(ctx, newID, userID)
if err != nil {
continue
}
if err := s.attachURLs(ctx, clone); err != nil {
continue
}
s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID)
s.hub.Publish(targetAlbumID, "photo.created", clone)
out = append(out, *clone)
}
return out, nil
}
// Trash lists soft-deleted photos from all albums the user belongs to.
func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) {
out, err := s.store.ListTrash(ctx, userID)
if err != nil {
return nil, err
}
for i := range out {
_ = s.attachURLs(ctx, &out[i])
}
return out, nil
}
// TrashCount returns the number of photos in the user's trash.
func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
return s.store.TrashCount(ctx, userID)
}
// Tags lists the user's tags, optionally filtered by a search query.
func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
return s.store.ListTags(ctx, userID, q)
}
func (s *Service) taggablePhoto(ctx context.Context, photoID, userID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if p.AlbumID == "" {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
return p, nil
}
// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
tag, err := s.store.EnsureTag(ctx, userID, name)
if err != nil {
return nil, httpx.BadRequest("invalid_tag", "tag name is required")
}
if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}
// RemoveTag detaches one of the user's tags from a photo.
func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
if err != nil {
return nil, err
}
if !owned {
return nil, httpx.NotFound("tag_not_found", "tag not found")
}
if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}
+326 -10
View File
@@ -2,12 +2,16 @@ package photos
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"strings"
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth"
"shared-album/internal/models" "shared-album/internal/models"
) )
@@ -19,40 +23,71 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key, const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''), COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
p.original_filename, p.size, p.width, p.height, p.mime_type, p.original_filename, p.size, p.width, p.height, p.mime_type,
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
u.id, u.name, COALESCE(u.avatar_url, ''), u.id, u.name, COALESCE(u.avatar_url, ''),
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id), (SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id), (SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2)` EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at,
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
func scanPhoto(row pgx.Row) (*models.Photo, error) { func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo var p models.Photo
var exifText, tagsText string
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey, &p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe) &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return nil, err return nil, err
} }
setExif(&p, exifText)
setTags(&p, tagsText)
return &p, nil return &p, nil
} }
func setTags(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "[]" {
return
}
var tags []models.Tag
if err := json.Unmarshal([]byte(raw), &tags); err == nil && len(tags) > 0 {
p.Tags = tags
}
}
func setExif(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "{}" {
return
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 {
p.EXIF = m
}
}
func (s *Store) Create(ctx context.Context, p *models.Photo) error { func (s *Store) Create(ctx context.Context, p *models.Photo) error {
exifJSON, err := json.Marshal(p.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
return s.pool.QueryRow(ctx, return s.pool.QueryRow(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename, `INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
size, width, height, mime_type, taken_at, latitude, longitude, caption) size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
RETURNING created_at`, RETURNING created_at`,
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename, p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
).Scan(&p.CreatedAt) ).Scan(&p.CreatedAt)
} }
@@ -85,6 +120,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
`SELECT `+photoColumns+` `SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.album_id = $1 WHERE p.album_id = $1
AND p.deleted_at IS NULL
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3)) AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
AND ($4::timestamptz IS NULL OR p.created_at > $4) AND ($4::timestamptz IS NULL OR p.created_at > $4)
ORDER BY p.created_at DESC, p.id DESC ORDER BY p.created_at DESC, p.id DESC
@@ -98,20 +134,78 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
out := []models.Photo{} out := []models.Photo{}
for rows.Next() { for rows.Next() {
var p models.Photo var p models.Photo
var exifText, tagsText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey, &p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe); err != nil { &p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
return nil, err return nil, err
} }
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p) out = append(out, p)
} }
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) Delete(ctx context.Context, id string) error { func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error {
var tag pgconn.CommandTag
var err error
if deleted {
tag, err = s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
} else {
tag, err = s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
}
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Clone creates a new photo row that references the same stored objects as src.
func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
exifJSON, err := json.Marshal(src.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
_, err = s.pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`,
id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
return err
}
// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
return n, err
}
// HardDelete permanently removes the photo row.
func (s *Store) HardDelete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id) tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil { if err != nil {
return err return err
@@ -122,6 +216,228 @@ func (s *Store) Delete(ctx context.Context, id string) error {
return nil return nil
} }
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) { // ListTrash returns soft-deleted photos from albums the user belongs to, plus
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1`, albumID, photoID) // their own photos whose album no longer exists.
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`, COALESCE(a.title, '')
FROM photos p
JOIN users u ON u.id = p.uploader_id
LEFT JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)
ORDER BY p.deleted_at DESC
LIMIT 200`,
userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText, tagsText string
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
}
// TrashCount counts soft-deleted photos accessible to the user.
func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
var n int
err := s.pool.QueryRow(ctx,
`SELECT count(*) FROM photos p
LEFT JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)`, userID).Scan(&n)
return n, err
}
// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AlbumExists reports whether an album row exists.
func (s *Store) AlbumExists(ctx context.Context, albumID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
return ok, err
}
func (s *Store) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, name FROM tags
WHERE user_id = $1 AND ($2 = '' OR name ILIKE '%' || $2 || '%')
ORDER BY lower(name) LIMIT 50`, userID, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Tag{}
for rows.Next() {
var t models.Tag
if err := rows.Scan(&t.ID, &t.Name); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// EnsureTag returns the user's tag with the given name, creating it if needed.
func (s *Store) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 64 {
return nil, ErrNotFound
}
var t models.Tag
err := s.pool.QueryRow(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name)
if err == nil {
return &t, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
id := auth.NewID()
err = s.pool.QueryRow(ctx,
`INSERT INTO tags (id, user_id, name) VALUES ($1,$2,$3) RETURNING id, name`, id, userID, name).
Scan(&t.ID, &t.Name)
if err != nil {
if e2 := s.pool.QueryRow(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name); e2 == nil {
return &t, nil
}
return nil, err
}
return &t, nil
}
func (s *Store) AttachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, photoID, tagID)
return err
}
func (s *Store) DetachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
return err
}
func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
return ok, err
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx,
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
albumID, photoID)
}
func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type backfillRow struct {
ID string
StorageKey string
}
func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, storage_key FROM photos
WHERE exif IS NULL OR exif = '{}'::jsonb
ORDER BY created_at`)
if err != nil {
return 0, err
}
var pending []backfillRow
for rows.Next() {
var r backfillRow
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
rows.Close()
return 0, err
}
pending = append(pending, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
updated := 0
for _, r := range pending {
data, err := fetch(ctx, r.StorageKey)
if err != nil {
continue
}
meta := extractEXIF(data)
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
continue
}
attrs, err := json.Marshal(meta.Attributes)
if err != nil || len(attrs) == 0 {
attrs = []byte("{}")
}
_, err = s.pool.Exec(ctx,
`UPDATE photos SET exif = $2::jsonb,
taken_at = COALESCE(taken_at, $3),
latitude = COALESCE(latitude, $4),
longitude = COALESCE(longitude, $5)
WHERE id = $1`,
r.ID, string(attrs), meta.TakenAt, meta.Latitude, meta.Longitude)
if err != nil {
continue
}
updated++
}
return updated, nil
} }
+66
View File
@@ -12,6 +12,7 @@ import (
"shared-album/internal/auth" "shared-album/internal/auth"
"shared-album/internal/members" "shared-album/internal/members"
"shared-album/internal/models" "shared-album/internal/models"
"shared-album/internal/photos"
"shared-album/internal/sharelinks" "shared-album/internal/sharelinks"
) )
@@ -123,6 +124,71 @@ func TestAlbumRepository(t *testing.T) {
} }
} }
func TestAlbumCoverRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
store := albums.NewStore(pool)
owner := newUser(t, ctx, pool, "CoverOwner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Cover"}
if err := store.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
insertPhoto := func(id, thumbKey string) {
t.Helper()
if _, err := pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key)
VALUES ($1, $2, $3, $4, $5)`,
id, a.ID, owner.ID, "albums/"+a.ID+"/original/"+id+".jpg", thumbKey); err != nil {
t.Fatalf("insert photo: %v", err)
}
}
photo1 := auth.NewID()
insertPhoto(photo1, "albums/"+a.ID+"/thumb/"+photo1+".webp")
got, err := store.GetByID(ctx, a.ID)
if err != nil || got.CoverThumbKey != "" {
t.Fatalf("expected empty cover key before set: %v %q", err, got.CoverThumbKey)
}
ps := photos.NewStore(pool)
ps.SetCoverIfEmpty(ctx, a.ID, photo1)
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
t.Fatalf("cover not set: %+v", got.CoverPhotoID)
}
if want := "albums/" + a.ID + "/thumb/" + photo1 + ".webp"; got.CoverThumbKey != want {
t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, want)
}
photo2 := auth.NewID()
insertPhoto(photo2, "thumb-2")
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
t.Fatalf("existing cover was overridden: %+v", got.CoverPhotoID)
}
if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo1); err != nil {
t.Fatalf("delete cover photo: %v", err)
}
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("cover not cleared after delete: %+v", got.CoverPhotoID)
}
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
list, err := store.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 || list[0].CoverThumbKey != "thumb-2" {
t.Fatalf("list cover key: %v %+v", err, list)
}
}
func TestMemberRepository(t *testing.T) { func TestMemberRepository(t *testing.T) {
pool := testPool(t) pool := testPool(t)
defer pool.Close() defer pool.Close()
+17 -2
View File
@@ -122,8 +122,23 @@ func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
return c.Delete(ctx, keys...) return c.Delete(ctx, keys...)
} }
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{ out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("get object %s: %w", key, err)
}
defer out.Body.Close()
data, err := io.ReadAll(out.Body)
if err != nil {
return nil, fmt.Errorf("read object %s: %w", key, err)
}
return data, nil
}
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket), Bucket: aws.String(c.bucket),
Key: aws.String(key), Key: aws.String(key),
}, s3.WithPresignExpires(c.presignTTL)) }, s3.WithPresignExpires(c.presignTTL))
@@ -0,0 +1 @@
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk;
@@ -0,0 +1,9 @@
-- Clear any dangling cover references before adding the constraint.
UPDATE albums
SET cover_photo_id = NULL
WHERE cover_photo_id IS NOT NULL
AND cover_photo_id NOT IN (SELECT id FROM photos);
ALTER TABLE albums
ADD CONSTRAINT albums_cover_photo_fk
FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL;
@@ -0,0 +1 @@
ALTER TABLE photos DROP COLUMN IF EXISTS exif;
@@ -0,0 +1 @@
ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS preferences;
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS photos_deleted_idx;
ALTER TABLE photos DROP COLUMN IF EXISTS deleted_at;
@@ -0,0 +1,2 @@
ALTER TABLE photos ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
CREATE INDEX IF NOT EXISTS photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL;
@@ -0,0 +1,5 @@
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
DELETE FROM photos WHERE album_id IS NULL;
ALTER TABLE photos ALTER COLUMN album_id SET NOT NULL;
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE;
@@ -0,0 +1,4 @@
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
ALTER TABLE photos ALTER COLUMN album_id DROP NOT NULL;
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL;
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS photo_tags;
DROP TABLE IF EXISTS tags;
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS tags (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_user_name_idx ON tags (user_id, lower(name));
CREATE TABLE IF NOT EXISTS photo_tags (
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
tag_id text NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (photo_id, tag_id)
);
CREATE INDEX IF NOT EXISTS photo_tags_tag_idx ON photo_tags (tag_id);
+54 -17
View File
@@ -15,39 +15,45 @@
--primary-fg: 0 0% 100%; --primary-fg: 0 0% 100%;
--danger: 0 84% 60%; --danger: 0 84% 60%;
--success: 142 71% 45%; --success: 142 71% 45%;
--chrome: 0 0% 98%;
--canvas: 0 0% 100%;
--shadow: 0 0% 0% / 0.08; --shadow: 0 0% 0% / 0.08;
} }
/* Тёмная */ /* Тёмная (графит Lightroom) */
[data-theme='dark'] { [data-theme='dark'] {
--bg: 0 0% 7%; --bg: 0 0% 9%;
--bg-elevated: 0 0% 11%; --bg-elevated: 0 0% 15%;
--bg-muted: 0 0% 16%; --bg-muted: 0 0% 18%;
--border: 0 0% 22%; --border: 0 0% 22%;
--text: 0 0% 96%; --text: 0 0% 92%;
--text-muted: 0 0% 65%; --text-muted: 0 0% 65%;
--text-subtle: 0 0% 45%; --text-subtle: 0 0% 45%;
--primary: 217 91% 60%; --primary: 217 91% 60%;
--primary-fg: 0 0% 100%; --primary-fg: 0 0% 100%;
--danger: 0 84% 65%; --danger: 0 84% 65%;
--success: 142 65% 50%; --success: 142 65% 50%;
--chrome: 0 0% 13%;
--canvas: 0 0% 9%;
--shadow: 0 0% 0% / 0.4; --shadow: 0 0% 0% / 0.4;
} }
/* Нейтральная (тёплая серо-бежевая) */ /* Нейтральная (тёплый графит) */
[data-theme='neutral'] { [data-theme='neutral'] {
--bg: 40 20% 96%; --bg: 30 12% 18%;
--bg-elevated: 40 22% 92%; --bg-elevated: 30 12% 23%;
--bg-muted: 40 18% 87%; --bg-muted: 30 11% 28%;
--border: 40 14% 78%; --border: 30 9% 34%;
--text: 30 10% 15%; --text: 35 18% 93%;
--text-muted: 30 8% 40%; --text-muted: 32 10% 68%;
--text-subtle: 30 6% 55%; --text-subtle: 30 7% 50%;
--primary: 25 90% 50%; --primary: 25 90% 55%;
--primary-fg: 0 0% 100%; --primary-fg: 0 0% 100%;
--danger: 0 72% 55%; --danger: 0 72% 62%;
--success: 142 55% 42%; --success: 142 45% 52%;
--shadow: 30 20% 20% / 0.12; --chrome: 30 12% 15%;
--canvas: 30 12% 18%;
--shadow: 30 30% 5% / 0.45;
} }
html, html,
@@ -66,3 +72,34 @@ body {
transition: none; transition: none;
} }
} }
/* Скроллбары в тон темы */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border: 2px solid transparent;
border-radius: 9999px;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--text-subtle));
background-clip: padding-box;
}
::-webkit-scrollbar-corner {
background: transparent;
}
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="ru" data-theme="light"> <html lang="ru" data-theme="dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -7,7 +7,7 @@
<script> <script>
(function () { (function () {
try { try {
var stored = localStorage.getItem('theme') || 'system'; var stored = localStorage.getItem('theme') || 'dark';
var resolved = stored; var resolved = stored;
if (stored === 'system') { if (stored === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
+10
View File
@@ -0,0 +1,10 @@
import type { Action } from 'svelte/action';
export const portal: Action<HTMLElement> = (node) => {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
};
+38 -1
View File
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { auth, type Tokens, type User } from './stores/auth.svelte'; import { auth, type Tokens, type User } from './stores/auth.svelte';
import type { Photo, Preferences, Tag } from './types';
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@@ -97,5 +98,41 @@ export const authApi = {
}), }),
logout: (refresh_token: string) => logout: (refresh_token: string) =>
api('/auth/logout', { method: 'POST', body: { refresh_token } }), api('/auth/logout', { method: 'POST', body: { refresh_token } }),
me: () => api<{ user: User }>('/auth/me') me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'),
savePreferences: (preferences: Preferences) =>
api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH',
body: preferences
})
};
export const photosApi = {
deleteBatch: (ids: string[]) =>
api<{ deleted: number }>('/photo-batch/delete', { method: 'POST', body: { ids } }),
moveBatch: (ids: string[], albumId: string) =>
api<{ moved: number }>('/photo-batch/move', {
method: 'POST',
body: { ids, album_id: albumId }
}),
copyBatch: (ids: string[], albumId: string) =>
api<{ photos: Photo[] }>('/photo-batch/copy', {
method: 'POST',
body: { ids, album_id: albumId }
}),
trash: () => api<{ photos: Photo[] }>('/trash'),
trashCount: () => api<{ count: number }>('/trash/count'),
restore: (id: string, albumId?: string) =>
api<{ photo: Photo }>(`/photos/${id}/restore`, {
method: 'POST',
body: albumId ? { album_id: albumId } : {}
}),
permanentDelete: (id: string) => api(`/photos/${id}/permanent`, { method: 'DELETE' }),
addTag: (id: string, name: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags`, { method: 'POST', body: { name } }),
removeTag: (id: string, tagId: string) =>
api<{ photo: Photo }>(`/photos/${id}/tags/${tagId}`, { method: 'DELETE' })
};
export const tagsApi = {
list: (q = '') => api<{ tags: Tag[] }>(`/tags${q ? `?q=${encodeURIComponent(q)}` : ''}`)
}; };
@@ -0,0 +1,119 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { portal } from '$lib/actions/portal';
import { albums } from '$lib/stores/albums.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
let title = $state('');
let busy = $state(false);
let error = $state('');
$effect(() => {
const a = albumActions.renaming;
if (a) {
title = a.title;
error = '';
}
});
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
async function saveRename(ev: Event) {
ev.preventDefault();
const a = albumActions.renaming;
if (!a || !title.trim() || busy) return;
busy = true;
error = '';
try {
await albums.rename(a.id, title.trim());
albumActions.renaming = null;
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
async function confirmDelete() {
const a = albumActions.deleting;
if (!a) return;
busy = true;
error = '';
try {
await albums.destroy(a.id);
albumActions.deleting = null;
if ($page.params.id === a.id) await goto('/');
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
{#if albumActions.renaming}
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Переименовать альбом"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) albumActions.renaming = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !busy) albumActions.renaming = null;
}}
>
<form
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
onsubmit={saveRename}
>
<h2 class="text-text text-lg font-semibold">Переименовать альбом</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Название</span>
<input
bind:value={title}
use:autofocus
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
/>
</label>
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
<div class="flex justify-end gap-2">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={() => (albumActions.renaming = null)}
>
Отмена
</button>
<button
type="submit"
disabled={busy || !title.trim()}
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
<Icon name="check" class="size-4" />
{busy ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</form>
</div>
{/if}
{#if albumActions.deleting}
<ConfirmDialog
title="Удалить альбом?"
message="Альбом «{albumActions.deleting.title}» будет удалён."
confirmLabel="Удалить"
busy={busy}
onConfirm={confirmDelete}
onClose={() => (albumActions.deleting = null)}
/>
{/if}
@@ -0,0 +1,231 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import CoverPicker from './CoverPicker.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoPanel from './PhotoPanel.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
let album = $state<Album | null>(null);
let role = $state('');
let error = $state('');
let picking = $state(false);
let photo = $state<Photo | null>(null);
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
const canEdit = $derived(role === 'owner' || role === 'admin');
let pendingDeleteSelected = $state(false);
let deletingSelected = $state(false);
async function deleteSelected() {
deletingSelected = true;
try {
await photosApi.deleteBatch(selection.ids);
selection.clear();
pendingDeleteSelected = false;
} catch (e) {
error = (e as Error).message;
} finally {
deletingSelected = false;
}
}
function openSelected() {
const first = selectedItems[0];
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
}
function onRenamed(updated: Photo) {
photo = updated;
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
}
function onDeleted() {
selection.clear();
}
$effect(() => {
const id = albumId;
error = '';
if (!id) {
album = null;
role = '';
return;
}
api<{ album: Album; role: string }>(`/albums/${id}`)
.then((data) => {
album = data.album;
role = data.role;
albums.upsert(data.album);
})
.catch((e) => (error = (e as Error).message));
});
$effect(() => {
const id = photoId;
if (!id) {
photo = null;
return;
}
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (photo = data.photo))
.catch((e) => (error = (e as Error).message));
});
async function resetCover() {
if (!album) return;
try {
album = await albums.setCover(album.id, null);
} catch (e) {
error = (e as Error).message;
}
}
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
</script>
{#snippet selectionActions()}
<div class="border-border border-b p-4">
<p class="text-text-subtle mb-1 text-xs font-semibold tracking-wide uppercase">Действия</p>
<nav class="space-y-0.5">
<button type="button" class={navLink} onclick={openSelected}>
<Icon name="image" class="size-4" />
Открыть (просмотр)
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start(selection.ids, null, 'move')}
>
<Icon name="arrow_back" class="size-4" />
Переместить
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start(selection.ids, null, 'copy')}
>
<Icon name="content_copy" class="size-4" />
Копировать
</button>
<button
type="button"
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
onclick={() => (pendingDeleteSelected = true)}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
</nav>
</div>
{/snippet}
{#if panelPhoto}
<PhotoPanel
photo={panelPhoto}
{role}
viewer={!!photoId}
onOpen={openSelected}
onRenamed={onRenamed}
onDeleted={onDeleted}
/>
{:else if !photoId && selectedItems.length > 1}
{@render selectionActions()}
{:else if album && !photoId}
<div>
<PanelSection id="album-cover" title="Обложка">
<div class="bg-bg-muted border-border aspect-video overflow-hidden rounded-lg border">
{#if album.cover_thumb_url}
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
{:else}
<span class="flex h-full w-full items-center justify-center">
<Icon name="image" class="size-10 text-text-subtle" />
</span>
{/if}
</div>
{#if canEdit}
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
class="border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
onclick={() => (picking = true)}
>
<Icon name="wallpaper" class="size-4" />
Выбрать обложку
</button>
{#if album.cover_photo_id}
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center rounded-lg px-3 py-1.5 text-sm"
onclick={resetCover}
>
Сбросить
</button>
{/if}
</div>
{/if}
</PanelSection>
<PanelSection id="album-info" title="Информация">
<h2 class="text-text text-lg font-semibold">{album.title}</h2>
{#if album.description}<p class="text-text-muted text-sm">{album.description}</p>{/if}
<p class="text-text-subtle mt-1 text-xs">
{album.photos_count ?? 0} фото · {album.members_count ?? 0} участников
</p>
{#if error}<p class="text-danger mt-2 text-sm">{error}</p>{/if}
</PanelSection>
<PanelSection id="album-nav" title="Навигация">
<nav class="space-y-0.5" aria-label="Навигация по альбому">
<a class={navLink} href="/albums/{album.id}/members">
<Icon name="group" class="size-4" />
Участники
</a>
{#if canEdit}
<a class={navLink} href="/albums/{album.id}/settings">
<Icon name="settings" class="size-4" />
Настройки
</a>
<a class={navLink} href="/albums/{album.id}/settings#share">
<Icon name="add_link" class="size-4" />
Ссылки для просмотра
</a>
{/if}
</nav>
</PanelSection>
</div>
{#if picking}
<CoverPicker
albumId={album.id}
onClose={() => (picking = false)}
onSelected={(updated) => (album = updated)}
/>
{/if}
{/if}
{#if pendingDeleteSelected}
<ConfirmDialog
title="Удалить выбранные фото?"
message="{selectedItems.length} фото будут перемещены в корзину."
confirmLabel="Удалить"
busy={deletingSelected}
onConfirm={deleteSelected}
onClose={() => (pendingDeleteSelected = false)}
/>
{/if}
@@ -0,0 +1,151 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Icon from './Icon.svelte';
import CreateAlbumDialog from './CreateAlbumDialog.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { trash } from '$lib/stores/trash.svelte';
import type { Album } from '$lib/types';
const activeId = $derived($page.params.id ?? '');
const onTrash = $derived($page.url.pathname === '/trash');
let creating = $state(false);
let dropId = $state<string | null>(null);
function onDragOver(ev: DragEvent, albumId: string) {
if (!ev.dataTransfer?.types.includes('application/x-galo-photos')) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
dropId = albumId;
}
function onDragLeave(albumId: string) {
if (dropId === albumId) dropId = null;
}
function onDrop(ev: DragEvent, albumId: string) {
dropId = null;
const raw = ev.dataTransfer?.getData('application/x-galo-photos');
if (!raw) return;
ev.preventDefault();
try {
const ids = JSON.parse(raw) as string[];
transfer.start(ids, albumId);
} catch {
/* ignore */
}
}
function onContextMenu(ev: MouseEvent, album: Album) {
ev.preventDefault();
ev.stopPropagation();
const items: ContextMenuItem[] = [
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
];
contextMenu.show(ev.clientX, ev.clientY, items);
}
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
$effect(() => {
$page.url.pathname;
if (auth.isAuthenticated) trash.load();
});
</script>
<div class="flex h-full flex-col">
<div class="border-border flex items-center justify-between border-b px-3 py-2">
<span class="text-text-muted text-xs font-semibold tracking-wide uppercase">Альбомы</span>
{#if auth.isAuthenticated}
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label="Новый альбом"
title="Новый альбом"
onclick={() => (creating = true)}
>
<Icon name="add" class="size-4" />
</button>
{/if}
</div>
{#if !auth.isAuthenticated}
<p class="text-text-subtle p-3 text-sm">Войдите, чтобы видеть альбомы.</p>
{:else if albums.loading && albums.list.length === 0}
<p class="text-text-subtle p-3 text-sm">Загрузка…</p>
{:else if albums.list.length === 0}
<p class="text-text-subtle p-3 text-sm">Альбомов пока нет.</p>
{:else}
<ul class="flex-1 space-y-0.5 overflow-y-auto p-2">
{#each albums.list as album (album.id)}
<li>
<a
href="/albums/{album.id}"
class="flex items-center gap-2.5 rounded-md border px-2 py-1.5 text-sm transition-colors {album.id ===
activeId
? 'bg-bg-muted text-text'
: 'text-text-muted hover:bg-bg-muted hover:text-text'} {dropId === album.id
? 'border-primary ring-primary ring-2'
: 'border-transparent'}"
aria-current={album.id === activeId ? 'page' : undefined}
ondragover={(e) => onDragOver(e, album.id)}
ondragleave={() => onDragLeave(album.id)}
ondrop={(e) => onDrop(e, album.id)}
oncontextmenu={(e) => onContextMenu(e, album)}
>
<span
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
>
{#if album.cover_thumb_url}
<img
src={album.cover_thumb_url}
alt=""
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<Icon name="image" class="size-4 text-text-subtle" />
{/if}
</span>
<span class="truncate">{album.title}</span>
<span class="text-text-subtle ml-auto shrink-0 text-xs">{album.photos_count ?? 0}</span>
</a>
</li>
{/each}
</ul>
{/if}
{#if auth.isAuthenticated}
<div class="border-border mt-auto border-t p-2">
<a
href="/trash"
class="flex items-center gap-2.5 rounded-md border px-2 py-1.5 text-sm transition-colors {onTrash
? 'bg-bg-muted text-text border-transparent'
: 'text-text-muted hover:bg-bg-muted hover:text-text border-transparent'}"
aria-current={onTrash ? 'page' : undefined}
>
<span
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
>
<Icon name="delete" class="size-4 text-text-subtle" />
</span>
<span class="truncate">Корзина</span>
<span class="text-text-subtle ml-auto shrink-0 text-xs">{trash.count}</span>
</a>
</div>
{/if}
{#if creating}
<CreateAlbumDialog
onClose={() => (creating = false)}
onCreated={(album) => goto(`/albums/${album.id}`)}
/>
{/if}
</div>
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authApi } from '$lib/api';
import Icon from './Icon.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
rail?: Snippet;
panel?: Snippet;
panelVisible?: boolean;
}
let { children, rail, panel, panelVisible = true }: Props = $props();
let railOpen = $state(false);
let panelOpen = $state(false);
const RAIL = { min: 176, max: 480, key: 'rail-width', def: 240 };
const PANEL = { min: 220, max: 560, key: 'panel-width', def: 288 };
let railWidth = $state(RAIL.def);
let panelWidth = $state(PANEL.def);
let isFullscreen = $state(false);
onMount(() => {
const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10);
if (r >= RAIL.min && r <= RAIL.max) railWidth = r;
const p = parseInt(localStorage.getItem(PANEL.key) ?? '', 10);
if (p >= PANEL.min && p <= PANEL.max) panelWidth = p;
const onFs = () => (isFullscreen = !!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFs);
onFs();
return () => document.removeEventListener('fullscreenchange', onFs);
});
async function toggleFullscreen() {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await document.documentElement.requestFullscreen();
} catch {
/* недоступно */
}
}
function startResize(side: 'left' | 'right', ev: PointerEvent) {
ev.preventDefault();
const cfg = side === 'left' ? RAIL : PANEL;
const startX = ev.clientX;
const startW = side === 'left' ? railWidth : panelWidth;
const prevCursor = document.body.style.cursor;
const prevSelect = document.body.style.userSelect;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const move = (e: PointerEvent) => {
const dx = e.clientX - startX;
const raw = side === 'left' ? startW + dx : startW - dx;
const v = Math.min(cfg.max, Math.max(cfg.min, raw));
if (side === 'left') railWidth = v;
else panelWidth = v;
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
document.body.style.cursor = prevCursor;
document.body.style.userSelect = prevSelect;
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
function nudge(side: 'left' | 'right', ev: KeyboardEvent) {
const cfg = side === 'left' ? RAIL : PANEL;
let delta = 0;
if (ev.key === 'ArrowLeft') delta = side === 'left' ? -16 : 16;
else if (ev.key === 'ArrowRight') delta = side === 'left' ? 16 : -16;
else return;
ev.preventDefault();
if (side === 'left') railWidth = Math.min(cfg.max, Math.max(cfg.min, railWidth + delta));
else panelWidth = Math.min(cfg.max, Math.max(cfg.min, panelWidth + delta));
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
}
const handle =
'bg-transparent hover:bg-primary/40 focus-visible:bg-primary/40 hidden w-1 shrink-0 cursor-col-resize p-0 transition-colors outline-none';
async function logout() {
if (auth.refresh) {
await authApi.logout(auth.refresh).catch(() => {});
}
auth.clear();
goto('/login');
}
</script>
<div class="bg-canvas text-text flex h-screen flex-col overflow-hidden">
<header
class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-2 py-0.5 sm:px-3"
>
<div class="flex min-w-0 items-center gap-1">
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1 lg:hidden"
aria-label="Альбомы"
title="Альбомы"
onclick={() => (railOpen = true)}
>
<Icon name="menu" class="size-4" />
</button>
<a href="/" class="inline-flex items-center gap-1.5 truncate text-sm font-semibold">
<Icon name="photo_camera" class="size-4" />
Galo
</a>
</div>
<div class="flex items-center gap-1 sm:gap-1.5">
{#if panel && panelVisible}
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1 xl:hidden"
aria-label="Панель"
title="Панель"
onclick={() => (panelOpen = true)}
>
<Icon name="wallpaper" class="size-4" />
</button>
{/if}
{#if auth.isAuthenticated}
<span class="text-text-muted hidden text-xs sm:inline">{auth.user?.name ?? ''}</span>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
onclick={logout}
>
<Icon name="logout" class="size-4" />
<span class="hidden sm:inline">Выйти</span>
</button>
{:else}
<a
href="/login"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
>
<Icon name="login" class="size-4" />
<span class="hidden sm:inline">Войти</span>
</a>
{/if}
<ThemeSwitcher />
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'}
title={isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'}
aria-pressed={isFullscreen}
onclick={toggleFullscreen}
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
</button>
</div>
</header>
<div class="relative flex min-h-0 flex-1">
{#if railOpen}
<button
type="button"
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
aria-label="Закрыть панель альбомов"
onclick={() => (railOpen = false)}
></button>
{/if}
<aside
class="border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 -translate-x-full overflow-y-auto border-r transition-transform lg:static lg:z-auto lg:w-[var(--rail-w)] lg:translate-x-0 {railOpen
? 'translate-x-0'
: ''}"
style="--rail-w: {railWidth}px"
aria-label="Альбомы"
>
{#if rail}{@render rail()}{/if}
</aside>
<button
type="button"
class="{handle} lg:block"
aria-label="Изменить ширину панели альбомов"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('left', e)}
onkeydown={(e) => nudge('left', e)}
></button>
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
{@render children()}
</main>
{#if panel && panelVisible}
{#if panelOpen}
<button
type="button"
class="fixed inset-0 z-30 bg-black/50 xl:hidden"
aria-label="Закрыть панель"
onclick={() => (panelOpen = false)}
></button>
{/if}
<button
type="button"
class="{handle} xl:block"
aria-label="Изменить ширину боковой панели"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('right', e)}
onkeydown={(e) => nudge('right', e)}
></button>
<aside
class="border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 translate-x-full overflow-y-auto border-l transition-transform xl:static xl:z-auto xl:w-[var(--panel-w)] xl:translate-x-0 {panelOpen
? 'translate-x-0'
: ''}"
style="--panel-w: {panelWidth}px"
aria-label="Панель"
>
{@render panel()}
</aside>
{/if}
</div>
</div>
+6 -4
View File
@@ -75,18 +75,20 @@
{/each} {/each}
{#if auth.isAuthenticated} {#if auth.isAuthenticated}
<form class="mt-2 flex gap-2" onsubmit={submit}> <form class="relative mt-2" onsubmit={submit}>
<input <input
bind:value={text} bind:value={text}
placeholder="Комментарий…" placeholder="Комментарий…"
class="border-border bg-bg text-text flex-1 rounded-lg border px-3 py-1.5 text-sm" class="border-border bg-bg text-text w-full rounded-lg border py-1.5 pr-10 pl-3 text-sm"
/> />
<button <button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm" class="text-primary hover:bg-bg-muted absolute top-1/2 right-1 inline-flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 disabled:opacity-50"
type="submit" type="submit"
aria-label="Отправить"
title="Отправить"
disabled={!text.trim()}
> >
<Icon name="send" class="size-4" /> <Icon name="send" class="size-4" />
Отправить
</button> </button>
</form> </form>
{/if} {/if}
@@ -0,0 +1,64 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
interface Props {
title: string;
message?: string;
confirmLabel?: string;
busy?: boolean;
onConfirm: () => void;
onClose: () => void;
}
let {
title,
message,
confirmLabel = 'Удалить',
busy = false,
onConfirm,
onClose
}: Props = $props();
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={onKey}
>
<div class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5">
<h2 class="text-text text-lg font-semibold">{title}</h2>
{#if message}<p class="text-text-muted break-words text-sm">{message}</p>{/if}
<div class="flex justify-end gap-2">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={onClose}
>
Отмена
</button>
<button
type="button"
disabled={busy}
class="bg-danger text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={onConfirm}
>
<Icon name="delete" class="size-4" />
{busy ? 'Удаление…' : confirmLabel}
</button>
</div>
</div>
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { contextMenu } from '$lib/stores/context-menu.svelte';
const width = 200;
const itemH = 34;
const style = $derived(
(() => {
const pad = 8;
let x = contextMenu.x;
let y = contextMenu.y;
if (typeof window !== 'undefined') {
x = Math.min(x, window.innerWidth - width - pad);
y = Math.min(y, window.innerHeight - contextMenu.items.length * itemH - pad);
}
return `left: ${Math.max(pad, x)}px; top: ${Math.max(pad, y)}px; min-width: ${width}px;`;
})()
);
function run(item: (typeof contextMenu.items)[number]) {
contextMenu.close();
item.run();
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') contextMenu.close();
}
</script>
<svelte:window
onkeydown={onKey}
onresize={() => contextMenu.close()}
onscroll={() => contextMenu.close()}
onclick={() => contextMenu.close()}
oncontextmenu={() => contextMenu.close()}
/>
{#if contextMenu.open}
<div
use:portal
class="border-border bg-bg-elevated fixed z-[70] overflow-hidden rounded-lg border py-1 shadow-lg"
style={style}
role="menu"
tabindex="-1"
oncontextmenu={(e) => e.preventDefault()}
>
{#each contextMenu.items as item (item.label)}
<button
type="button"
role="menuitem"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm {item.danger
? 'text-danger hover:bg-bg-muted'
: 'text-text hover:bg-bg-muted'}"
onclick={() => run(item)}
>
<Icon name={item.icon} class="size-4" />
{item.label}
</button>
{/each}
</div>
{/if}
@@ -0,0 +1,114 @@
<script lang="ts">
import { api } from '$lib/api';
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { albums } from '$lib/stores/albums.svelte';
import type { Album, Photo } from '$lib/types';
interface Props {
albumId: string;
onClose: () => void;
onSelected?: (album: Album) => void;
}
let { albumId, onClose, onSelected }: Props = $props();
let photos = $state<Photo[]>([]);
let loading = $state(true);
let error = $state('');
let busy = $state(false);
$effect(() => {
const id = albumId;
loading = true;
error = '';
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`)
.then((data) => (photos = data.photos))
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
});
async function choose(photo: Photo) {
if (busy) return;
busy = true;
error = '';
try {
const album = await albums.setCover(albumId, photo.id);
onSelected?.(album);
onClose();
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div
use:portal
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Выбор обложки"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={onKey}
>
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border">
<div class="border-border flex items-center justify-between border-b px-4 py-3">
<h2 class="font-semibold">Выбрать обложку</h2>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label="Закрыть"
title="Закрыть"
onclick={onClose}
>
<Icon name="close" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-4">
{#if error}<p class="text-danger mb-3 text-sm">{error}</p>{/if}
{#if loading}
<p class="text-text-muted text-sm">Загрузка…</p>
{:else if photos.length === 0}
<p class="text-text-muted text-sm">В альбоме нет фотографий.</p>
{:else}
<ul class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5">
{#each photos as photo (photo.id)}
<li>
<button
type="button"
disabled={busy}
class="border-border bg-bg-muted block aspect-square w-full overflow-hidden rounded-lg border hover:border-primary disabled:opacity-50"
onclick={() => choose(photo)}
>
{#if photo.thumb_url}
<img
src={photo.thumb_url}
alt={photo.original_filename}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
обработка…
</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { albums } from '$lib/stores/albums.svelte';
import type { Album } from '$lib/types';
interface Props {
onClose: () => void;
onCreated?: (album: Album) => void;
}
let { onClose, onCreated }: Props = $props();
let title = $state('');
let busy = $state(false);
let error = $state('');
async function submit(ev: Event) {
ev.preventDefault();
if (!title.trim() || busy) return;
busy = true;
error = '';
try {
const album = await albums.create(title.trim());
onCreated?.(album);
onClose();
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') onClose();
}
function autofocus(node: HTMLInputElement) {
node.focus();
}
</script>
<svelte:window onkeydown={onKey} />
<div
use:portal
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Новый альбом"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={onKey}
>
<form
class="border-border bg-bg-elevated w-full max-w-sm space-y-4 rounded-xl border p-5"
onsubmit={submit}
>
<h2 class="text-lg font-semibold">Новый альбом</h2>
<label class="block">
<span class="text-text-muted mb-1 block text-sm">Название</span>
<input
bind:value={title}
use:autofocus
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
placeholder="Например, Отпуск"
/>
</label>
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
<div class="flex justify-end gap-2">
<button
type="button"
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
onclick={onClose}
>
Отмена
</button>
<button
type="submit"
disabled={busy || !title.trim()}
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
<Icon name="add" class="size-4" />
{busy ? 'Создание…' : 'Создать'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { density } from '$lib/stores/density.svelte';
</script>
<div class="flex items-center gap-2">
<Icon name="grid_view" class="text-text-subtle size-4" />
<input
type="range"
min="2"
max="8"
step="1"
value={density.cols}
oninput={(e) => density.set(Number(e.currentTarget.value))}
class="accent-primary h-1 w-24 cursor-pointer"
aria-label="Размер миниатюр"
title="Размер миниатюр"
/>
</div>
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
import { api } from '$lib/api';
import type { Photo } from '$lib/types';
import Comments from './Comments.svelte';
import Icon from './Icon.svelte';
interface Props {
photos: Photo[];
index: number;
onClose: () => void;
onIndexChange: (index: number) => void;
}
let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
const current = $derived(photos[index]);
$effect(() => {
const id = current?.id;
fresh = null;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.catch(() => {});
});
function prev() {
if (index > 0) onIndexChange(index - 1);
}
function next() {
if (index < photos.length - 1) onIndexChange(index + 1);
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') onClose();
if (ev.key === 'ArrowLeft') prev();
if (ev.key === 'ArrowRight') next();
}
function onTouchStart(ev: TouchEvent) {
touchX = ev.changedTouches[0].clientX;
}
function onTouchEnd(ev: TouchEvent) {
const dx = ev.changedTouches[0].clientX - touchX;
if (dx > 50) prev();
else if (dx < -50) next();
}
</script>
<svelte:window onkeydown={onKey} />
<div
class="bg-bg/95 fixed inset-0 z-50 flex flex-col"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
onkeydown={onKey}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
<div class="flex items-center justify-between p-3">
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
<div class="flex gap-2">
{#if fresh?.download_url}
<a
href={fresh.download_url}
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
target="_blank"
rel="noreferrer"
>
<Icon name="download" class="size-4" />
Скачать
</a>
{/if}
<button
type="button"
onclick={onClose}
aria-label="Закрыть"
title="Закрыть"
class="border-border text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
>
<Icon name="close" />
</button>
</div>
</div>
<div class="flex flex-1 items-center justify-center overflow-hidden px-2 pb-2">
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center px-2"
aria-label="Предыдущее фото"
title="Предыдущее фото"
onclick={prev}
disabled={index === 0}
>
<Icon name="chevron_left" class="size-8" />
</button>
{#if current}
<img
src={fresh?.preview_url ?? fresh?.original_url ?? current.preview_url ?? current.original_url}
alt={current.original_filename}
class="max-h-[75vh] max-w-full rounded-lg object-contain"
/>
{/if}
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center px-2"
aria-label="Следующее фото"
title="Следующее фото"
onclick={next}
disabled={index === photos.length - 1}
>
<Icon name="chevron_right" class="size-8" />
</button>
</div>
{#if current}
<div class="border-border bg-bg-elevated max-h-56 overflow-y-auto border-t p-3">
<p class="text-text text-sm">
{current.uploader.name} · {current.original_filename}
</p>
<Comments photoId={current.id} />
</div>
{/if}
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import type { Snippet } from 'svelte';
interface Props {
id: string;
title: string;
open?: boolean;
actions?: Snippet;
children: Snippet;
}
let { id, title, open = true, actions, children }: Props = $props();
// svelte-ignore state_referenced_locally
let isOpen = $state(open);
onMount(() => {
const v = localStorage.getItem(`panel-section-${id}`);
if (v === '0') isOpen = false;
else if (v === '1') isOpen = true;
});
function toggle() {
isOpen = !isOpen;
localStorage.setItem(`panel-section-${id}`, isOpen ? '1' : '0');
}
</script>
<section class="border-border border-b">
<div class="flex items-center">
<button
type="button"
class="text-text-muted hover:text-text flex flex-1 items-center justify-between gap-2 px-4 py-2.5 text-left"
aria-expanded={isOpen}
onclick={toggle}
>
<span class="text-xs font-semibold tracking-wide uppercase">{title}</span>
<Icon
name="expand_more"
class="size-4 shrink-0 transition-transform {isOpen ? 'rotate-180' : ''}"
/>
</button>
{#if actions}
<div class="flex items-center pr-3">{@render actions()}</div>
{/if}
</div>
{#if isOpen}
<div class="px-4 pb-4">{@render children()}</div>
{/if}
</section>
+25 -5
View File
@@ -1,25 +1,45 @@
<script lang="ts"> <script lang="ts">
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import { density } from '$lib/stores/density.svelte';
import type { Photo } from '$lib/types'; import type { Photo } from '$lib/types';
interface Props { interface Props {
photos: Photo[]; photos: Photo[];
selectedIds?: string[];
onSelect?: (index: number, event: MouseEvent) => void;
onOpen?: (index: number) => void; onOpen?: (index: number) => void;
onDragStart?: (index: number, event: DragEvent) => void;
onContextMenu?: (index: number, event: MouseEvent) => void;
} }
let { photos, onOpen }: Props = $props(); let { photos, selectedIds = [], onSelect, onOpen, onDragStart, onContextMenu }: Props =
$props();
const isSelected = (id: string) => selectedIds.includes(id);
</script> </script>
{#if photos.length === 0} {#if photos.length === 0}
<p class="text-text-muted p-8 text-center">Пока нет фотографий.</p> <p class="text-text-muted p-8 text-center">Пока нет фотографий.</p>
{:else} {:else}
<ul class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"> <ul
class="grid gap-2"
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
>
{#each photos as photo, i (photo.id)} {#each photos as photo, i (photo.id)}
<li> <li>
<button <button
type="button" type="button"
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border" draggable={isSelected(photo.id)}
onclick={() => onOpen?.(i)} class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border {isSelected(
photo.id
)
? 'ring-primary ring-2'
: ''}"
aria-pressed={isSelected(photo.id)}
onclick={(e) => onSelect?.(i, e)}
ondblclick={() => onOpen?.(i)}
ondragstart={(e) => onDragStart?.(i, e)}
oncontextmenu={(e) => onContextMenu?.(i, e)}
> >
{#if photo.thumb_url} {#if photo.thumb_url}
<img <img
@@ -27,7 +47,7 @@
alt={photo.original_filename} alt={photo.original_filename}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105" class="pointer-events-none h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
/> />
{:else} {:else}
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs"> <span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
@@ -0,0 +1,179 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import type { Preferences, PhotoInfoPreferences } from '$lib/types';
interface Props {
attributes: string[];
defaults: Record<string, string>;
visible: string[];
labels: Record<string, string>;
busy?: boolean;
onSave: (prefs: Preferences) => void;
onReset: () => void;
onClose: () => void;
}
let {
attributes,
defaults,
visible,
labels,
busy = false,
onSave,
onReset,
onClose
}: Props = $props();
// svelte-ignore state_referenced_locally
let checked = $state<Set<string>>(new Set(visible));
// svelte-ignore state_referenced_locally
let labelDraft = $state<Record<string, string>>({ ...labels });
function isChecked(key: string): boolean {
return checked.has(key);
}
function setChecked(key: string, value: boolean) {
const next = new Set(checked);
if (value) next.add(key);
else next.delete(key);
checked = next;
}
function selectAll() {
checked = new Set(attributes);
}
function deselectAll() {
checked = new Set();
}
function save() {
const cleanLabels: Record<string, string> = {};
for (const [key, value] of Object.entries(labelDraft)) {
const trimmed = value.trim();
if (trimmed) cleanLabels[key] = trimmed;
}
const photo_info: PhotoInfoPreferences = {
visible: attributes.filter((key) => checked.has(key))
};
if (Object.keys(cleanLabels).length) photo_info.labels = cleanLabels;
onSave({ photo_info });
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape' && !busy) onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Настройки атрибутов"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onClose();
}}
onkeydown={onKey}
>
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-lg flex-col rounded-xl border">
<div class="border-border flex items-center justify-between border-b px-5 py-3">
<h2 class="text-text text-base font-semibold">Настройки атрибутов</h2>
<div class="flex items-center gap-1">
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
aria-label="Отметить все"
title="Отметить все"
onclick={selectAll}
>
<Icon name="check_all" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
aria-label="Снять все"
title="Снять все"
onclick={deselectAll}
>
<Icon name="uncheck_all" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
aria-label="Закрыть"
onclick={onClose}
>
<Icon name="close" class="size-4" />
</button>
</div>
</div>
<p class="text-text-muted px-5 pt-3 text-xs">
Отметьте, какие атрибуты показывать, и задайте свои подписи. Скрытые атрибуты доступны по
кнопке «…» в разделе «Информация».
</p>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
{#if attributes.length === 0}
<p class="text-text-subtle py-4 text-sm">Нет доступных атрибутов.</p>
{:else}
<ul class="space-y-1.5">
{#each attributes as key (key)}
<li class="flex items-center gap-3">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="accent-primary size-4"
checked={isChecked(key)}
onchange={(e) => setChecked(key, e.currentTarget.checked)}
/>
<span class="text-text w-44 shrink-0 truncate text-sm">{defaults[key] ?? key}</span>
</label>
<input
type="text"
class="border-border bg-bg text-text min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
placeholder={defaults[key] ?? key}
maxlength="128"
value={labelDraft[key] ?? ''}
oninput={(e) => (labelDraft[key] = e.currentTarget.value)}
/>
</li>
{/each}
</ul>
{/if}
</div>
<div class="border-border flex items-center justify-between gap-2 border-t px-5 py-3">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-3 py-1.5 text-xs"
onclick={onReset}
>
Сбросить
</button>
<div class="flex gap-2">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={onClose}
>
Отмена
</button>
<button
type="button"
disabled={busy}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={save}
>
{busy ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,452 @@
<script lang="ts">
import { api, photosApi, tagsApi } from '$lib/api';
import Icon from './Icon.svelte';
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
import type { Photo, Preferences, Tag } from '$lib/types';
interface Props {
photo: Photo;
role: string;
viewer?: boolean;
onOpen?: () => void;
onRenamed?: (photo: Photo) => void;
onDeleted?: () => void;
}
let { photo, role, viewer = false, onOpen, onRenamed, onDeleted }: Props = $props();
const canEdit = $derived(role === 'owner' || role === 'admin');
const canRename = $derived(canEdit || photo.uploader_id === auth.user?.id);
const canDelete = $derived(canEdit || photo.uploader_id === auth.user?.id);
let editingName = $state(false);
let nameDraft = $state('');
let savingName = $state(false);
let nameError = $state('');
let pendingDelete = $state(false);
let deleting = $state(false);
let settingsOpen = $state(false);
let showAll = $state(false);
let savingPrefs = $state(false);
const prefs = $derived(auth.preferences);
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
const visiblePrefs = $derived(prefs.photo_info?.visible);
const effectiveVisible = $derived(
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
);
const exif = $derived((photo.exif ?? {}) as Record<string, unknown>);
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
const attributeKeys = $derived(
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
)
);
$effect(() => {
// сброс временного состояния при смене фото
photo.id;
editingName = false;
nameError = '';
showAll = false;
tagQuery = '';
tagOpen = false;
});
let tagQuery = $state('');
let tagOptions = $state<Tag[]>([]);
let tagOpen = $state(false);
let tagBusy = $state(false);
const attachedTagIds = $derived(new Set((photo.tags ?? []).map((t) => t.id)));
const tagSuggestions = $derived(
tagOptions.filter((t) => !attachedTagIds.has(t.id))
);
$effect(() => {
const q = tagQuery;
if (!tagOpen) return;
const handle = setTimeout(async () => {
try {
tagOptions = (await tagsApi.list(q)).tags;
} catch {
tagOptions = [];
}
}, 180);
return () => clearTimeout(handle);
});
async function addTag(name: string) {
const n = name.trim();
if (!n || tagBusy) return;
tagBusy = true;
try {
const res = await photosApi.addTag(photo.id, n);
onRenamed?.(res.photo);
tagQuery = '';
tagOpen = false;
} catch {
/* ignore */
} finally {
tagBusy = false;
}
}
async function removeTag(tagId: string) {
try {
const res = await photosApi.removeTag(photo.id, tagId);
onRenamed?.(res.photo);
} catch {
/* ignore */
}
}
function startEdit() {
nameDraft = photo.original_filename;
nameError = '';
editingName = true;
}
async function saveName(ev: Event) {
ev.preventDefault();
if (!nameDraft.trim()) return;
savingName = true;
nameError = '';
try {
const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, {
method: 'PATCH',
body: { original_filename: nameDraft.trim() }
});
onRenamed?.(data.photo);
editingName = false;
} catch (e) {
nameError = (e as Error).message;
} finally {
savingName = false;
}
}
function autofocus(node: HTMLInputElement) {
node.focus();
node.select();
}
function formatSize(bytes: number): string {
if (!bytes) return '—';
const units = ['Б', 'КБ', 'МБ', 'ГБ'];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
}
async function savePreferences(next: Preferences) {
savingPrefs = true;
try {
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
method: 'PATCH',
body: next
});
auth.setPreferences(res.preferences ?? {});
showAll = false;
settingsOpen = false;
} catch {
/* ignore */
} finally {
savingPrefs = false;
}
}
function requestDelete() {
if (viewer) {
viewerActions.requestDelete(photo.id);
} else {
pendingDelete = true;
}
}
async function confirmDelete() {
deleting = true;
try {
await photosApi.deleteBatch([photo.id]);
pendingDelete = false;
onDeleted?.();
} catch {
/* ignore */
} finally {
deleting = false;
}
}
const navLink =
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
</script>
<div>
<div class="p-4">
{#if editingName}
<form class="relative" onsubmit={saveName}>
<input
bind:value={nameDraft}
use:autofocus
aria-label="Имя файла"
class="border-border bg-bg text-text w-full rounded-lg border py-1 pr-16 pl-2 text-sm"
/>
<div class="absolute top-1/2 right-1 flex -translate-y-1/2 items-center gap-0.5">
<button
type="submit"
class="text-primary hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1 disabled:opacity-50"
aria-label="Сохранить"
title="Сохранить"
disabled={savingName || !nameDraft.trim()}
>
<Icon name="check" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label="Отмена"
title="Отмена"
onclick={() => (editingName = false)}
>
<Icon name="close" class="size-4" />
</button>
</div>
</form>
{#if nameError}<p class="text-danger mt-1 text-xs">{nameError}</p>{/if}
{:else if canRename}
<button
type="button"
class="text-text hover:text-primary block w-full truncate text-left text-sm font-semibold"
title="Переименовать"
onclick={startEdit}
>
{photo.original_filename}
</button>
{:else}
<h2 class="text-text truncate text-sm font-semibold">{photo.original_filename}</h2>
{/if}
</div>
<PanelSection id="photo-actions" title="Действия">
<nav class="space-y-0.5">
{#if !viewer}
<button type="button" class={navLink} onclick={() => onOpen?.()}>
<Icon name="image" class="size-4" />
Открыть (просмотр)
</button>
{/if}
{#if photo.download_url}
<a class={navLink} href={photo.download_url} target="_blank" rel="noreferrer" download>
<Icon name="download" class="size-4" />
Скачать
</a>
{/if}
<button
type="button"
class={navLink}
onclick={() => transfer.start([photo.id], null, 'move')}
>
<Icon name="arrow_back" class="size-4" />
Переместить
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start([photo.id], null, 'copy')}
>
<Icon name="content_copy" class="size-4" />
Копировать
</button>
{#if canDelete}
<button
type="button"
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
onclick={requestDelete}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
{/if}
</nav>
</PanelSection>
<PanelSection id="photo-tags" title="Метки">
<div class="space-y-2">
{#if (photo.tags ?? []).length}
<div class="flex flex-wrap gap-1.5">
{#each photo.tags ?? [] as tag (tag.id)}
<span
class="border-border bg-bg-muted text-text inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs"
>
{tag.name}
<button
type="button"
class="text-text-muted hover:text-danger inline-flex"
aria-label="Убрать метку {tag.name}"
title="Убрать"
onclick={() => removeTag(tag.id)}
>
<Icon name="close" class="size-3" />
</button>
</span>
{/each}
</div>
{/if}
<div class="relative">
<input
bind:value={tagQuery}
onfocus={() => (tagOpen = true)}
oninput={() => (tagOpen = true)}
onblur={() => setTimeout(() => (tagOpen = false), 150)}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagQuery);
} else if (e.key === 'Escape') {
tagOpen = false;
}
}}
placeholder="Добавить метку…"
maxlength="64"
aria-label="Метки фото"
class="border-border bg-bg text-text w-full rounded-md border px-2 py-1 text-sm"
/>
{#if tagOpen && (tagSuggestions.length > 0 || tagQuery.trim())}
<div
class="border-border bg-bg-elevated absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded-md border py-1 shadow-lg"
>
{#if tagQuery.trim()}
<button
type="button"
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
onmousedown={(e) => {
e.preventDefault();
addTag(tagQuery);
}}
>
<Icon name="add" class="size-4" />
Создать «{tagQuery.trim()}»
</button>
{/if}
{#each tagSuggestions as tag (tag.id)}
<button
type="button"
class="hover:bg-bg-muted text-text flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm"
onmousedown={(e) => {
e.preventDefault();
addTag(tag.name);
}}
>
<Icon name="label" class="size-4 text-text-subtle" />
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
</PanelSection>
<PanelSection id="photo-info" title="Информация">
{#snippet actions()}
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
aria-label="Настройки атрибутов"
title="Настройки атрибутов"
onclick={(e) => {
e.stopPropagation();
settingsOpen = true;
}}
>
<Icon name="settings" class="size-4" />
</button>
{/snippet}
<dl class="space-y-1">
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
<div class={metaRow}><span>Тип</span><span class="text-text">{photo.mime_type}</span></div>
<div class={metaRow}>
<span>Разрешение</span>
<span class="text-text">{photo.width}×{photo.height}</span>
</div>
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
<div class={metaRow}>
<span>{row.label}</span>
<span class="text-text">{row.value}</span>
</div>
{/each}
{#if hiddenCount > 0 && !showAll}
<button
type="button"
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
aria-label="Показать все атрибуты"
title="Показать все атрибуты"
onclick={() => (showAll = true)}
>
…
</button>
{/if}
{#if photo.taken_at}
<div class={metaRow}>
<span>Снято</span>
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
</div>
{/if}
{#if photo.latitude != null && photo.longitude != null}
<div class={metaRow}>
<span>Координаты</span>
<span class="text-text">{photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)}</span>
</div>
{/if}
<div class={metaRow}>
<span>Загружено</span>
<span class="text-text">{new Date(photo.created_at).toLocaleString()}</span>
</div>
</dl>
</PanelSection>
<PanelSection id="photo-comments" title="Комментарии">
<Comments photoId={photo.id} />
</PanelSection>
</div>
{#if settingsOpen}
<PhotoInfoSettingsDialog
attributes={attributeKeys}
defaults={EXIF_LABELS}
visible={[...effectiveVisible]}
labels={prefs.photo_info?.labels ?? {}}
busy={savingPrefs}
onSave={savePreferences}
onReset={() => savePreferences({})}
onClose={() => (settingsOpen = false)}
/>
{/if}
{#if pendingDelete}
<ConfirmDialog
title="Удалить фото?"
message={photo.original_filename}
confirmLabel="Удалить"
busy={deleting}
onConfirm={confirmDelete}
onClose={() => (pendingDelete = false)}
/>
{/if}
@@ -1,29 +1,34 @@
<script lang="ts"> <script lang="ts">
import IconButton from './IconButton.svelte'; import Icon from './Icon.svelte';
import type { IconName } from '$lib/icons/registry'; import type { IconName } from '$lib/icons/registry';
import { theme, type Theme } from '$lib/stores/theme.svelte'; import { theme, type Theme } from '$lib/stores/theme.svelte';
const options: { value: Theme; label: string; icon: IconName }[] = [ const options: { value: Theme; label: string; icon: IconName }[] = [
{ value: 'light', label: 'Light', icon: 'light_mode' },
{ value: 'dark', label: 'Dark', icon: 'dark_mode' }, { value: 'dark', label: 'Dark', icon: 'dark_mode' },
{ value: 'neutral', label: 'Neutral', icon: 'palette' }, { value: 'neutral', label: 'Neutral', icon: 'contrast' },
{ value: 'light', label: 'Light', icon: 'light_mode' },
{ value: 'system', label: 'System', icon: 'brightness_auto' } { value: 'system', label: 'System', icon: 'brightness_auto' }
]; ];
</script> </script>
<div <div
class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1" class="border-border bg-bg-elevated inline-flex items-center gap-0 rounded-md border p-0.5"
role="group" role="group"
aria-label="Theme" aria-label="Theme"
> >
{#each options as option (option.value)} {#each options as option (option.value)}
<IconButton <button
name={option.icon} type="button"
label={option.label} class="inline-flex items-center justify-center rounded p-0.5 transition-colors {theme.current ===
active={theme.current === option.value} option.value
pressed={theme.current === option.value} ? 'bg-primary text-primary-fg'
class="px-2 py-1 text-sm" : 'text-text-muted hover:bg-bg-muted hover:text-text'}"
aria-label={option.label}
title={option.label}
aria-pressed={theme.current === option.value}
onclick={() => theme.set(option.value)} onclick={() => theme.set(option.value)}
/> >
<Icon name={option.icon} class="size-4" />
</button>
{/each} {/each}
</div> </div>
@@ -0,0 +1,157 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { api } from '$lib/api';
import { albums } from '$lib/stores/albums.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer, type TransferAction } from '$lib/stores/transfer.svelte';
let targetId = $state('');
let action = $state<TransferAction>('move');
let busy = $state(false);
let error = $state('');
$effect(() => {
if (transfer.open) {
targetId = transfer.targetId ?? '';
action = transfer.action ?? 'move';
error = '';
}
});
$effect(() => {
if (transfer.open) albums.load();
});
async function confirm() {
if (!targetId || busy) return;
busy = true;
error = '';
try {
const body = { ids: transfer.ids, album_id: targetId };
if (action === 'move') await api('/photo-batch/move', { method: 'POST', body });
else await api('/photo-batch/copy', { method: 'POST', body });
selection.clear();
transfer.close();
albums.load();
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape' && !busy) transfer.close();
}
</script>
<svelte:window onkeydown={onKey} />
{#if transfer.open}
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Перенос или копирование"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) transfer.close();
}}
onkeydown={onKey}
>
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
<div class="border-border flex items-center justify-between border-b px-5 py-3">
<h2 class="text-text text-base font-semibold">
{action === 'move' ? 'Переместить' : 'Копировать'} {transfer.ids.length} фото
</h2>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
aria-label="Закрыть"
onclick={() => transfer.close()}
>
<Icon name="close" class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div class="border-border inline-flex rounded-lg border p-0.5">
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm {action === 'move'
? 'bg-primary text-primary-fg'
: 'text-text-muted hover:text-text'}"
onclick={() => (action = 'move')}
>
Переместить
</button>
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm {action === 'copy'
? 'bg-primary text-primary-fg'
: 'text-text-muted hover:text-text'}"
onclick={() => (action = 'copy')}
>
Копировать
</button>
</div>
<div>
<p class="text-text-muted mb-1.5 text-xs">Альбом</p>
{#if albums.list.length === 0}
<p class="text-text-subtle text-sm">Нет доступных альбомов.</p>
{:else}
<ul class="max-h-56 space-y-1 overflow-y-auto">
{#each albums.list as album (album.id)}
<li>
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
album.id
? 'border-primary bg-bg-muted text-text'
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
aria-pressed={targetId === album.id}
onclick={() => (targetId = album.id)}
>
<span
class="bg-bg-muted border-border flex size-8 shrink-0 items-center justify-center overflow-hidden rounded border"
>
{#if album.cover_thumb_url}
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
{:else}
<Icon name="image" class="size-4 text-text-subtle" />
{/if}
</span>
<span class="truncate">{album.title}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
</div>
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={() => transfer.close()}
>
Отмена
</button>
<button
type="button"
disabled={busy || !targetId}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={confirm}
>
{busy ? 'Обработка…' : action === 'move' ? 'Переместить' : 'Копировать'}
</button>
</div>
</div>
</div>
{/if}
+51 -63
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
import Icon from './Icon.svelte';
import type { Photo } from '$lib/types'; import type { Photo } from '$lib/types';
interface Props { interface Props {
@@ -10,39 +9,35 @@
let { albumId, onUploaded }: Props = $props(); let { albumId, onUploaded }: Props = $props();
let dragging = $state(false); let fileInput: HTMLInputElement | undefined = $state();
let progress = $state(0);
let busy = $state(false); let busy = $state(false);
let progress = $state(0);
let error = $state(''); let error = $state('');
function pick() { const MAX_SIZE = 100 * 1024 * 1024;
export function pick() {
fileInput?.click(); fileInput?.click();
} }
let fileInput: HTMLInputElement | undefined = $state(); export function upload(files: FileList | File[]) {
const list = Array.from(files);
function onDrop(ev: DragEvent) { if (list.length === 0) return;
ev.preventDefault(); const tooBig = list.filter((f) => f.size > MAX_SIZE);
dragging = false; if (tooBig.length > 0) {
if (ev.dataTransfer?.files?.length) { error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Максимум 100 МБ.`;
upload(ev.dataTransfer.files); return;
}
const total = list.reduce((sum, f) => sum + f.size, 0);
if (total > MAX_SIZE) {
error = 'Суммарный размер файлов превышает 100 МБ. Загрузите меньшими порциями.';
return;
} }
}
function onChange(ev: Event) {
const target = ev.target as HTMLInputElement;
if (target.files?.length) upload(target.files);
target.value = '';
}
function upload(files: FileList) {
error = ''; error = '';
busy = true; busy = true;
progress = 0; progress = 0;
const form = new FormData(); const form = new FormData();
for (const file of Array.from(files)) { for (const file of list) form.append('files', file);
form.append('files', file);
}
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/albums/${albumId}/photos`); xhr.open('POST', `/api/albums/${albumId}/photos`);
@@ -53,6 +48,7 @@
xhr.onload = () => { xhr.onload = () => {
busy = false; busy = false;
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
progress = 0;
try { try {
const data = JSON.parse(xhr.responseText); const data = JSON.parse(xhr.responseText);
onUploaded?.(data.photos ?? []); onUploaded?.(data.photos ?? []);
@@ -73,48 +69,40 @@
}; };
xhr.send(form); xhr.send(form);
} }
function onChange(ev: Event) {
const input = ev.target as HTMLInputElement;
if (input.files?.length) upload(input.files);
input.value = '';
}
</script> </script>
<div <input
class="border-border rounded-xl border-2 border-dashed p-6 text-center transition-colors {dragging bind:this={fileInput}
? 'bg-bg-muted border-primary' type="file"
: 'bg-bg-elevated'}" accept="image/*"
ondragover={(e) => { multiple
e.preventDefault(); class="hidden"
dragging = true; onchange={onChange}
}} />
ondragleave={() => (dragging = false)}
ondrop={onDrop}
ondragenter={(e) => e.preventDefault()}
role="region"
aria-label="Загрузка фотографий"
>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
class="hidden"
onchange={onChange}
/>
<Icon name="cloud_upload" class="text-text-subtle mx-auto mb-3 size-10" />
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
<button
type="button"
onclick={pick}
disabled={busy}
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
<Icon name="add_photo_alternate" class="size-4" />
{busy ? 'Загрузка…' : 'Выбрать файлы'}
</button>
{#if busy} {#if busy}
<div class="bg-bg-muted mt-4 h-2 w-full overflow-hidden rounded"> <div
class="bg-bg-elevated border-border fixed right-4 bottom-4 z-50 w-64 rounded-lg border p-3 shadow-lg"
role="status"
aria-live="polite"
>
<p class="text-text mb-2 text-sm">Загрузка… {progress}%</p>
<div class="bg-bg-muted h-1.5 w-full overflow-hidden rounded">
<div class="bg-primary h-full transition-all" style="width: {progress}%"></div> <div class="bg-primary h-full transition-all" style="width: {progress}%"></div>
</div> </div>
{/if} </div>
{#if error} {/if}
<p class="text-danger mt-3 text-sm">{error}</p> {#if error}
{/if} <div
</div> class="bg-bg-elevated border-danger text-danger fixed right-4 bottom-4 z-50 rounded-lg border p-3 text-sm"
role="alert"
>
{error}
</div>
{/if}
+433
View File
@@ -0,0 +1,433 @@
<script lang="ts">
import { tick } from 'svelte';
import { api } from '$lib/api';
import { page } from '$app/stores';
import Icon from './Icon.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import type { Photo } from '$lib/types';
interface Props {
photos: Photo[];
index: number;
onClose: () => void;
onIndexChange: (index: number) => void;
}
let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
const current = $derived(photos[index]);
const MAX_ZOOM = 2;
let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state();
let imgEl: HTMLImageElement | undefined = $state();
let topEl: HTMLDivElement | undefined = $state();
let bottomEl: HTMLDivElement | undefined = $state();
let isFullscreen = $state(false);
let zoomed = $state(false);
let scale = $state(1);
let hiResLoaded = $state(false);
let showTop = $state(false);
let showBottom = $state(false);
let showLeft = $state(false);
let showRight = $state(false);
const EDGE = 8;
let dragging = false;
let startX = 0;
let startY = 0;
let startL = 0;
let startT = 0;
const previewSrc = $derived(
fresh?.preview_url ?? fresh?.original_url ?? current?.preview_url ?? current?.original_url ?? ''
);
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
$effect(() => {
const id = current?.id;
fresh = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.catch(() => {});
});
// In zoom mode, preload the full-resolution original and swap it in once loaded.
$effect(() => {
if (!zoomed || hiResLoaded) return;
const url = fullSrc;
if (!url) return;
const im = new Image();
let alive = true;
im.onload = () => {
if (alive) hiResLoaded = true;
};
im.src = url;
return () => {
alive = false;
im.onload = null;
};
});
$effect(() => {
const el = scroller;
if (!el) return;
const handler = (e: WheelEvent) => onWheel(e);
el.addEventListener('wheel', handler, { passive: false });
return () => el.removeEventListener('wheel', handler);
});
$effect(() => {
const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
resetBars();
};
document.addEventListener('fullscreenchange', onFs);
return () => document.removeEventListener('fullscreenchange', onFs);
});
const topClass = $derived(
`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 bg-transparent px-2 py-0.5 transition-transform duration-200 ${isFullscreen && !showTop ? '-translate-y-full' : 'translate-y-0'}`
);
const bottomClass = $derived(
isFullscreen
? `border-border bg-chrome absolute inset-x-0 bottom-0 z-30 flex gap-2 overflow-x-auto border-t p-2 transition-transform duration-200 ${showBottom ? 'translate-y-0' : 'translate-y-full'}`
: 'border-border bg-chrome flex shrink-0 gap-2 overflow-x-auto border-t p-2'
);
const arrowPos =
'absolute top-1/2 z-20 inline-flex -translate-y-1/2 items-center justify-center rounded-full bg-black/40 p-1.5 text-text backdrop-blur transition-opacity duration-200 hover:text-white';
const arrowVisibility = (shown: boolean, disabled: boolean, isZoomed: boolean) =>
isZoomed || !shown ? 'pointer-events-none opacity-0' : disabled ? 'opacity-40' : 'opacity-100';
const leftDisabled = $derived(index === 0 || zoomed);
const rightDisabled = $derived(index === photos.length - 1 || zoomed);
const leftBtnClass = $derived(
`${arrowPos} left-2 ${arrowVisibility(showLeft, leftDisabled, zoomed)}`
);
const rightBtnClass = $derived(
`${arrowPos} right-2 ${arrowVisibility(showRight, rightDisabled, zoomed)}`
);
function resetBars() {
showTop = false;
showBottom = false;
showLeft = false;
showRight = false;
}
function inRect(el: HTMLElement | undefined, x: number, y: number): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
function onWinMouseMove(e: MouseEvent) {
const r = viewerEl?.getBoundingClientRect();
if (r) {
const zoneX = r.width / 6;
const insideY = e.clientY >= r.top && e.clientY <= r.bottom;
showLeft = !zoomed && insideY && e.clientX >= r.left && e.clientX <= r.left + zoneX;
showRight = !zoomed && insideY && e.clientX <= r.right && e.clientX >= r.right - zoneX;
}
if (!isFullscreen) return;
const nearTop = e.clientY <= EDGE;
const nearBottom = e.clientY >= window.innerHeight - EDGE;
showTop = nearTop || inRect(topEl, e.clientX, e.clientY);
showBottom = nearBottom || inRect(bottomEl, e.clientX, e.clientY);
}
function baseWidth(): number {
return fresh?.width || current?.width || imgEl?.naturalWidth || 0;
}
function baseHeight(): number {
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
}
function prev() {
if (index > 0) onIndexChange(index - 1);
}
function next() {
if (index < photos.length - 1) onIndexChange(index + 1);
}
function toggleActual() {
if (zoomed) {
zoomed = false;
scale = 1;
if (scroller) {
scroller.scrollLeft = 0;
scroller.scrollTop = 0;
}
} else {
zoomed = true;
scale = 1;
tick().then(() => {
if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
});
}
}
function onWheel(ev: WheelEvent) {
if (!current || !scroller || !imgEl) return;
ev.preventDefault();
ev.stopPropagation();
const natW = baseWidth();
const natH = baseHeight();
if (!natW || !natH) return;
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
const fitScale = Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 1);
const curScale = zoomed ? scale : fitScale;
const newScale = Math.min(MAX_ZOOM, Math.max(fitScale, curScale * factor));
if (Math.abs(newScale - curScale) < 1e-4) return;
const r = imgEl.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width;
const fy = (ev.clientY - r.top) / r.height;
zoomed = true;
scale = newScale;
tick().then(() => {
if (!scroller) return;
const sr = scroller.getBoundingClientRect();
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
});
}
function onPointerDown(ev: PointerEvent) {
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
dragging = true;
startX = ev.clientX;
startY = ev.clientY;
startL = scroller.scrollLeft;
startT = scroller.scrollTop;
try {
scroller.setPointerCapture(ev.pointerId);
} catch {
/* ignore */
}
}
function onPointerMove(ev: PointerEvent) {
if (!dragging || !scroller) return;
ev.preventDefault();
scroller.scrollLeft = startL - (ev.clientX - startX);
scroller.scrollTop = startT - (ev.clientY - startY);
}
function onPointerUp(ev: PointerEvent) {
if (!dragging) return;
dragging = false;
try {
scroller?.releasePointerCapture(ev.pointerId);
} catch {
/* ignore */
}
}
async function toggleViewerFullscreen() {
if (!viewerEl) return;
try {
if (document.fullscreenElement === viewerEl) await document.exitFullscreen();
else await viewerEl.requestFullscreen();
} catch {
/* недоступно */
}
}
function isTyping(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
function onKey(ev: KeyboardEvent) {
if (isTyping()) return;
if (ev.key === 'Escape') {
if (!document.fullscreenElement) onClose();
return;
}
if (ev.code === 'Space') {
ev.preventDefault();
toggleActual();
return;
}
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
ev.preventDefault();
toggleViewerFullscreen();
return;
}
if (zoomed) return;
if (ev.key === 'ArrowLeft') prev();
else if (ev.key === 'ArrowRight') next();
}
function onTouchStart(ev: TouchEvent) {
touchX = ev.changedTouches[0].clientX;
}
function onTouchEnd(ev: TouchEvent) {
if (zoomed) return;
const dx = ev.changedTouches[0].clientX - touchX;
if (dx > 50) prev();
else if (dx < -50) next();
}
function onContextMenu(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const p = current;
if (!p) return;
const items: ContextMenuItem[] = [
...(p.download_url
? [
{
label: 'Скачать',
icon: 'download' as const,
run: () => window.open(p.download_url, '_blank')
}
]
: []),
{
label: 'Переместить',
icon: 'arrow_back',
run: () => transfer.start([p.id], null, 'move')
},
{
label: 'Копировать',
icon: 'content_copy',
run: () => transfer.start([p.id], null, 'copy')
},
{
label: 'Удалить',
icon: 'delete',
danger: true,
run: () => viewerActions.requestDelete(p.id)
}
];
contextMenu.show(e.clientX, e.clientY, items);
}
const btn =
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
</script>
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
<div bind:this={topEl} class={topClass}>
<div class="pointer-events-auto flex items-center gap-1">
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
<Icon name="arrow_back" class="size-4" />
</button>
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
</div>
<div class="pointer-events-auto flex items-center gap-1">
<button
type="button"
class={btn}
aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'}
title={isFullscreen
? 'Выйти из полноэкранного просмотра (f)'
: 'Полноэкранный просмотр (f)'}
aria-pressed={isFullscreen}
onclick={toggleViewerFullscreen}
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
</button>
</div>
</div>
<div class="relative flex min-h-0 flex-1 items-stretch">
<button
type="button"
class={leftBtnClass}
aria-label="Предыдущее фото"
title="Предыдущее фото"
onclick={prev}
disabled={leftDisabled}
>
<Icon name="chevron_left" class="size-8" />
</button>
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
{#if current}
<img
bind:this={imgEl}
src={displaySrc}
alt={current.original_filename}
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}"
/>
{/if}
</div>
<button
type="button"
class={rightBtnClass}
aria-label="Следующее фото"
title="Следующее фото"
onclick={next}
disabled={rightDisabled}
>
<Icon name="chevron_right" class="size-8" />
</button>
</div>
<div bind:this={bottomEl} class={bottomClass} aria-label="Лента миниатюр">
{#each photos as photo, i (photo.id)}
<button
type="button"
class="h-16 w-16 shrink-0 overflow-hidden rounded border {i === index
? 'border-primary'
: 'border-border'}"
onclick={() => onIndexChange(i)}
aria-label="Фото {i + 1}"
aria-current={i === index ? 'true' : undefined}
>
{#if photo.thumb_url}
<img
src={photo.thumb_url}
alt=""
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<span class="bg-bg-muted block h-full w-full"></span>
{/if}
</button>
{/each}
</div>
</div>
+224
View File
@@ -0,0 +1,224 @@
export const EXIF_LABELS: Record<string, string> = {
Make: 'Производитель',
Model: 'Модель',
LensMake: 'Производитель объектива',
LensModel: 'Объектив',
ExposureTime: 'Выдержка',
ShutterSpeedValue: 'Выдержка (APEX)',
FNumber: 'Диафрагма',
ApertureValue: 'Диафрагма (APEX)',
MaxApertureValue: 'Макс. диафрагма',
FocalLength: 'Фокусное расстояние',
FocalLengthIn35mmFilm: 'Фокусное (35 мм)',
ISOSpeedRatings: 'ISO',
PhotographicSensitivity: 'ISO',
ExposureProgram: 'Программа съёмки',
ExposureMode: 'Режим экспозиции',
ExposureBiasValue: 'Экспокоррекция',
BrightnessValue: 'Яркость (APEX)',
MeteringMode: 'Режим замера',
LightSource: 'Источник света',
Flash: 'Вспышка',
WhiteBalance: 'Баланс белого',
Orientation: 'Ориентация',
Software: 'ПО',
ColorSpace: 'Цветовое пространство',
SceneCaptureType: 'Тип сцены',
Contrast: 'Контраст',
Saturation: 'Насыщенность',
Sharpness: 'Резкость',
CustomRendered: 'Обработка',
GainControl: 'Усиление',
SensingMethod: 'Метод съёмки',
SubjectDistanceRange: 'Дистанция',
DigitalZoomRatio: 'Цифровой зум',
CompressedBitsPerPixel: 'Сжатие (бит/пиксель)',
ImageDescription: 'Описание'
};
export const EXIF_SKIP = new Set([
'GPSLatitude',
'GPSLongitude',
'GPSLatitudeRef',
'GPSLongitudeRef',
'GPSAltitude',
'GPSAltitudeRef',
'GPSVersionID',
'GPSDateStamp',
'GPSTimeStamp',
'GPSProcessingMethod',
'GPSAreaInformation',
'GPSImgDirection',
'GPSImgDirectionRef',
'GPSDestBearing',
'GPSDestBearingRef',
'GPSSpeed',
'GPSSpeedRef',
'GPSTrack',
'GPSTrackRef',
'GPSMapDatum',
'GPSDifferential',
'DateTime',
'DateTimeDigitized',
'DateTimeOriginal',
'SubSecTime',
'SubSecTimeDigitized',
'SubSecTimeOriginal',
'ExifVersion',
'FlashpixVersion',
'InteroperabilityVersion',
'InteroperabilityIndex',
'ExifTag',
'GPSTag',
'InteroperabilityTag',
'ExifIFDPointer',
'GPSInfoIFDPointer',
'ComponentsConfiguration',
'MakerNote',
'UserComment',
'FileSource',
'SceneType',
'PhotometricInterpretation',
'YCbCrPositioning',
'ResolutionUnit',
'BitsPerSample',
'XResolution',
'YResolution',
'ImageWidth',
'ImageLength',
'PixelXDimension',
'PixelYDimension'
]);
export const EXIF_ENUMS: Record<string, Record<number, string>> = {
Orientation: {
1: 'Горизонтальная',
2: 'Зеркальная (гориз.)',
3: 'Повёрнута на 180°',
4: 'Зеркальная (верт.)',
5: 'Зеркальная + 90° CW',
6: 'Повёрнута на 90° CW',
7: 'Зеркальная + 90° CCW',
8: 'Повёрнута на 90° CCW'
},
ExposureProgram: {
0: 'Не задано',
1: 'Ручная',
2: 'Программная',
3: 'Приоритет диафрагмы',
4: 'Приоритет выдержки',
5: 'Творческая',
6: 'Спортивная',
7: 'Портрет',
8: 'Пейзаж'
},
ExposureMode: { 0: 'Авто', 1: 'Ручной', 2: 'Авто-брекетинг' },
MeteringMode: {
0: 'Неизвестно',
1: 'Средневзвешенный',
2: 'Центрально-взвешенный',
3: 'Точечный',
4: 'Многозонный',
5: 'Матричный',
6: 'Частичный',
255: 'Другое'
},
LightSource: {
0: 'Неизвестно',
1: 'Дневной свет',
2: 'Люминесцентный',
3: 'Лампа накаливания',
4: 'Вспышка',
9: 'Хорошая погода',
10: 'Облачно',
11: 'Тень'
},
Flash: {
0: 'Не сработала',
1: 'Сработала',
9: 'Сработала (принуд.)',
16: 'Отключена',
24: 'Не сработала (авто)',
25: 'Сработала (авто)'
},
WhiteBalance: { 0: 'Авто', 1: 'Ручной' },
SceneCaptureType: { 0: 'Стандартный', 1: 'Пейзаж', 2: 'Портрет', 3: 'Ночной' },
Contrast: { 0: 'Обычный', 1: 'Мягкий', 2: 'Жёсткий' },
Saturation: { 0: 'Обычная', 1: 'Низкая', 2: 'Высокая' },
Sharpness: { 0: 'Обычная', 1: 'Мягкая', 2: 'Жёсткая' },
CustomRendered: { 0: 'Обычная', 1: 'Особая' },
GainControl: {
0: 'Нет',
1: 'Малое усиление',
2: 'Большое усиление',
3: 'Низкое усиление',
4: 'Высокое усиление'
},
SensingMethod: {
1: 'Не задано',
2: 'Однокристальный',
3: 'Двухчиповый',
4: 'Трёхчиповый',
5: 'Цветной последовательный',
7: 'Трёхлинейный',
8: 'Цветной последовательный'
},
SubjectDistanceRange: { 0: 'Неизвестно', 1: 'Макро', 2: 'Близко', 3: 'Далеко' },
ColorSpace: { 1: 'sRGB', 65535: 'Некалиброванное' }
};
export function formatExifValue(key: string, value: unknown): string {
if (value == null) return '';
const n = Number(value);
if (key === 'ExposureTime' && !Number.isNaN(n) && n > 0) {
return n >= 1 ? `${n} с` : `1/${Math.round(1 / n)}`;
}
if (
(key === 'FNumber' || key === 'ApertureValue' || key === 'MaxApertureValue') &&
!Number.isNaN(n)
) {
return `f/${n}`;
}
if ((key === 'FocalLength' || key === 'FocalLengthIn35mmFilm') && !Number.isNaN(n)) {
return `${n} мм`;
}
if (key === 'ExposureBiasValue' && !Number.isNaN(n)) {
return `${n > 0 ? '+' : ''}${n} EV`;
}
const enums = EXIF_ENUMS[key];
if (enums && !Number.isNaN(n) && enums[n] != null) return enums[n];
return String(value);
}
export interface ExifRow {
key: string;
label: string;
value: string;
}
export const EXIF_BASE: string[] = [
'Make',
'Model',
'LensModel',
'ExposureTime',
'FNumber',
'ISOSpeedRatings',
'FocalLength',
'FocalLengthIn35mmFilm'
];
export function buildExifRows(
exif: Record<string, unknown>,
labels: Record<string, string> = {}
): ExifRow[] {
return Object.entries(exif)
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
.map(([k, v]) => ({
key: k,
label: labels[k] ?? EXIF_LABELS[k] ?? k,
value: formatExifValue(k, v)
}))
.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
}
export const EXIF_CATALOG: string[] = Object.keys(EXIF_LABELS);
+11 -1
View File
@@ -40,7 +40,17 @@ export const icons = {
link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' }, link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' },
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' }, delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' }, content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' } check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' },
edit: { path: '<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83l3.75 3.75z"/>' },
label: { path: '<path fill="currentColor" d="M17.63 5.84C17.27 5.33 16.67 5 16 5H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h11c.67 0 1.27-.33 1.63-.84L22 12z"/>' },
menu: { path: '<path fill="currentColor" d="M3 18v-2h18v2zm0-5v-2h18v2zm0-5V6h18v2z"/>' },
grid_view: { path: '<path fill="currentColor" d="M3 3v8h8V3zm10 0v8h8V3zM3 13v8h8v-8zm10 0v8h8v-8z"/>' },
wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' },
fullscreen: { path: '<path fill="currentColor" d="M7 14H5v5h5v-2H7zm-2-4h2V7h3V5H5zm12 7h-3v2h5v-5h-2zM14 5v2h3v3h2V5z"/>' },
fullscreen_exit: { path: '<path fill="currentColor" d="M5 16h3v3h2v-5H5zm3-8H5v2h5V5H8zm6 11h2v-3h3v-2h-5zm2-11v3h-2v2h5V5z"/>' },
contrast: { path: '<path fill="currentColor" fill-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4a8 8 0 0 1 0 16z"/>' },
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' }
} satisfies Record<string, IconDef>; } satisfies Record<string, IconDef>;
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;
@@ -0,0 +1,21 @@
import type { Album } from '$lib/types';
class AlbumActionsStore {
renaming = $state<Album | null>(null);
deleting = $state<Album | null>(null);
openRename(album: Album) {
this.renaming = album;
}
openDelete(album: Album) {
this.deleting = album;
}
close() {
this.renaming = null;
this.deleting = null;
}
}
export const albumActions = new AlbumActionsStore();
+76
View File
@@ -0,0 +1,76 @@
import { api } from '$lib/api';
import type { Album } from '$lib/types';
import { auth } from './auth.svelte';
class AlbumsStore {
list = $state<Album[]>([]);
loading = $state(false);
loaded = $state(false);
error = $state('');
async load() {
if (!auth.isAuthenticated) {
this.list = [];
this.loaded = true;
return;
}
this.loading = true;
this.error = '';
try {
const data = await api<{ albums: Album[] }>('/albums');
this.list = data.albums;
this.loaded = true;
} catch (e) {
this.error = (e as Error).message;
} finally {
this.loading = false;
}
}
async create(title: string): Promise<Album> {
const data = await api<{ album: Album }>('/albums', { method: 'POST', body: { title } });
this.list = [data.album, ...this.list];
return data.album;
}
async setCover(albumId: string, coverPhotoId: string | null): Promise<Album> {
const body = coverPhotoId ? { cover_photo_id: coverPhotoId } : { clear_cover: true };
const data = await api<{ album: Album }>(`/albums/${albumId}`, { method: 'PATCH', body });
this.upsert(data.album);
return data.album;
}
async rename(id: string, title: string): Promise<Album> {
const data = await api<{ album: Album }>(`/albums/${id}`, {
method: 'PATCH',
body: { title }
});
this.upsert(data.album);
return data.album;
}
async destroy(id: string): Promise<void> {
await api(`/albums/${id}`, { method: 'DELETE' });
this.remove(id);
}
upsert(album: Album) {
const i = this.list.findIndex((a) => a.id === album.id);
if (i === -1) {
this.list = [album, ...this.list];
} else {
this.list = this.list.map((a) => (a.id === album.id ? album : a));
}
}
remove(id: string) {
this.list = this.list.filter((a) => a.id !== id);
}
reset() {
this.list = [];
this.loaded = false;
}
}
export const albums = new AlbumsStore();
+15
View File
@@ -1,8 +1,11 @@
import type { Preferences } from '$lib/types';
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
name: string; name: string;
avatar_url?: string; avatar_url?: string;
preferences?: Preferences;
} }
export interface Tokens { export interface Tokens {
@@ -56,6 +59,18 @@ class AuthStore {
} }
} }
get preferences(): Preferences {
return this.user?.preferences ?? {};
}
setPreferences(preferences: Preferences) {
if (!this.user) return;
this.user = { ...this.user, preferences };
if (typeof localStorage !== 'undefined') {
localStorage.setItem(USER_KEY, JSON.stringify(this.user));
}
}
clear() { clear() {
this.access = null; this.access = null;
this.refresh = null; this.refresh = null;
@@ -0,0 +1,30 @@
import type { IconName } from '$lib/icons/registry';
export interface ContextMenuItem {
label: string;
icon: IconName;
danger?: boolean;
run: () => void;
}
class ContextMenuStore {
open = $state(false);
x = $state(0);
y = $state(0);
items = $state<ContextMenuItem[]>([]);
show(x: number, y: number, items: ContextMenuItem[]) {
if (items.length === 0) return;
this.x = x;
this.y = y;
this.items = items;
this.open = true;
}
close() {
this.open = false;
this.items = [];
}
}
export const contextMenu = new ContextMenuStore();
+20
View File
@@ -0,0 +1,20 @@
const KEY = 'grid-cols';
const MIN = 2;
const MAX = 8;
class DensityStore {
cols = $state(5);
init() {
if (typeof window === 'undefined') return;
const v = parseInt(localStorage.getItem(KEY) ?? '', 10);
if (v >= MIN && v <= MAX) this.cols = v;
}
set(value: number) {
this.cols = Math.min(MAX, Math.max(MIN, Math.round(value)));
if (typeof window !== 'undefined') localStorage.setItem(KEY, String(this.cols));
}
}
export const density = new DensityStore();
@@ -0,0 +1,36 @@
import type { Photo } from '$lib/types';
class SelectionStore {
albumId = $state('');
items = $state<Photo[]>([]);
anchorId = $state<string | null>(null);
get ids(): string[] {
return this.items.map((p) => p.id);
}
get count(): number {
return this.items.length;
}
has(id: string): boolean {
return this.items.some((p) => p.id === id);
}
set(albumId: string, items: Photo[]) {
this.albumId = albumId;
this.items = items;
}
replace(photo: Photo) {
this.items = this.items.map((p) => (p.id === photo.id ? photo : p));
}
clear() {
this.albumId = '';
this.items = [];
this.anchorId = null;
}
}
export const selection = new SelectionStore();
+3 -3
View File
@@ -14,13 +14,13 @@ function resolve(theme: Theme): Resolved {
} }
class ThemeStore { class ThemeStore {
current = $state<Theme>('system'); current = $state<Theme>('dark');
resolved = $state<Resolved>('light'); resolved = $state<Resolved>('dark');
private media: MediaQueryList | null = null; private media: MediaQueryList | null = null;
init() { init() {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const stored = (localStorage.getItem(KEY) as Theme | null) ?? 'system'; const stored = (localStorage.getItem(KEY) as Theme | null) ?? 'dark';
this.current = stored; this.current = stored;
this.apply(); this.apply();
this.media = window.matchMedia('(prefers-color-scheme: dark)'); this.media = window.matchMedia('(prefers-color-scheme: dark)');
@@ -0,0 +1,25 @@
export type TransferAction = 'move' | 'copy';
class TransferStore {
open = $state(false);
ids = $state<string[]>([]);
targetId = $state<string | null>(null);
action = $state<TransferAction | null>(null);
start(ids: string[], targetId: string | null = null, action: TransferAction | null = null) {
if (ids.length === 0) return;
this.ids = ids;
this.targetId = targetId;
this.action = action;
this.open = true;
}
close() {
this.open = false;
this.ids = [];
this.targetId = null;
this.action = null;
}
}
export const transfer = new TransferStore();
+24
View File
@@ -0,0 +1,24 @@
import { photosApi } from '$lib/api';
import { auth } from './auth.svelte';
class TrashStore {
count = $state(0);
async load() {
if (!auth.isAuthenticated) {
this.count = 0;
return;
}
try {
this.count = (await photosApi.trashCount()).count;
} catch {
/* ignore */
}
}
reset() {
this.count = 0;
}
}
export const trash = new TrashStore();
@@ -0,0 +1,13 @@
class ViewerActions {
deleteRequested = $state<string | null>(null);
requestDelete(id: string) {
this.deleteRequested = id;
}
clear() {
this.deleteRequested = null;
}
}
export const viewerActions = new ViewerActions();
+22
View File
@@ -1,8 +1,18 @@
export interface PhotoInfoPreferences {
visible?: string[];
labels?: Record<string, string>;
}
export interface Preferences {
photo_info?: PhotoInfoPreferences;
}
export interface User { export interface User {
id: string; id: string;
email?: string; email?: string;
name: string; name: string;
avatar_url?: string; avatar_url?: string;
preferences?: Preferences;
} }
export interface Album { export interface Album {
@@ -11,6 +21,9 @@ export interface Album {
title: string; title: string;
description: string; description: string;
cover_photo_id?: string | null; cover_photo_id?: string | null;
cover_thumb_url?: string;
photos_count?: number;
members_count?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -23,6 +36,11 @@ export interface AlbumMember {
user: User; user: User;
} }
export interface Tag {
id: string;
name: string;
}
export interface Photo { export interface Photo {
id: string; id: string;
album_id: string; album_id: string;
@@ -36,6 +54,10 @@ export interface Photo {
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
caption: string; caption: string;
exif?: Record<string, unknown>;
tags?: Tag[];
deleted_at?: string | null;
album_title?: string;
created_at: string; created_at: string;
uploader: User; uploader: User;
likes_count: number; likes_count: number;
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { page } from '$app/stores';
import AppShell from '$lib/components/AppShell.svelte';
import AlbumRail from '$lib/components/AlbumRail.svelte';
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
import TransferDialog from '$lib/components/TransferDialog.svelte';
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
let { children } = $props();
const showPanel = $derived($page.url.pathname !== '/');
</script>
<AppShell panelVisible={showPanel}>
{#snippet rail()}
<AlbumRail />
{/snippet}
{#snippet panel()}
<AlbumPanel />
{/snippet}
{@render children()}
</AppShell>
<TransferDialog />
<AlbumActionsDialogs />
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import DensityControl from '$lib/components/DensityControl.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { density } from '$lib/stores/density.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albumActions } from '$lib/stores/album-actions.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import type { Album } from '$lib/types';
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
});
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
ev.preventDefault();
ev.stopPropagation();
const items: ContextMenuItem[] = [
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
];
contextMenu.show(ev.clientX, ev.clientY, items);
}
</script>
{#if !auth.isAuthenticated}
<div class="py-16 text-center">
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
<a
href="/login"
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
>
<Icon name="login" class="size-5" />
Войти
</a>
</div>
{:else}
<div class="p-4 sm:p-6">
<div class="mb-4 flex items-center justify-between gap-4">
<h1 class="text-xl font-semibold">Мои альбомы</h1>
<DensityControl />
</div>
{#if albums.loading && albums.list.length === 0}
<p class="text-text-muted">Загрузка…</p>
{:else}
{#if albums.list.length === 0}
<div class="text-text-muted mb-4 flex flex-col items-center gap-3 py-10">
<Icon name="photo_library" class="size-16 text-text-subtle" />
<p>Альбомов пока нет. Создайте первый кнопкой <span class="text-text">+</span> в панели альбомов.</p>
</div>
{/if}
<ul
class="grid gap-3"
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
>
{#each albums.list as album (album.id)}
<li>
<a
href="/albums/{album.id}"
class="group block"
oncontextmenu={(e) => onAlbumContextMenu(e, album)}
>
<div
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
>
{#if album.cover_thumb_url}
<img
src={album.cover_thumb_url}
alt=""
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
{:else}
<span class="flex h-full w-full items-center justify-center">
<Icon name="image" class="size-12 text-text-subtle" />
</span>
{/if}
</div>
<div class="mt-2">
<h2 class="text-text truncate text-sm font-medium">{album.title}</h2>
<p class="text-text-subtle text-xs">{album.photos_count ?? 0} фото</p>
</div>
</a>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
@@ -0,0 +1,384 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, photosApi } from '$lib/api';
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Uploader from '$lib/components/Uploader.svelte';
import DensityControl from '$lib/components/DensityControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
let album = $state<Album | null>(null);
let role = $state('');
let photos = $state<Photo[]>([]);
let nextCursor = $state('');
let loading = $state(true);
let error = $state('');
let dragging = $state(false);
let pendingDelete = $state(false);
let deletingMenu = $state(false);
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
async function loadAlbum() {
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
album = data.album;
role = data.role;
albums.upsert(data.album);
}
async function loadPhotos() {
const data = await api<{ photos: Photo[]; next_cursor: string }>(
`/albums/${albumId}/photos`
);
photos = data.photos;
nextCursor = data.next_cursor;
}
async function loadMore() {
if (!nextCursor) return;
const data = await api<{ photos: Photo[]; next_cursor: string }>(
`/albums/${albumId}/photos?cursor=${encodeURIComponent(nextCursor)}`
);
photos = [...photos, ...data.photos];
nextCursor = data.next_cursor;
}
$effect(() => {
const id = albumId;
selection.clear();
loading = true;
error = '';
Promise.all([loadAlbum(), loadPhotos()])
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
});
let client: SSEClient | null = null;
$effect(() => {
const id = albumId;
if (!auth.access) return;
client = new SSEClient({
path: `/api/sse/albums/${id}`,
onEvent: handleEvent,
onReconnect: () => loadPhotos()
});
client.connect(auth.access);
return () => {
client?.close();
client = null;
};
});
function handleEvent(ev: SSEEvent) {
const data = ev.data as Record<string, unknown>;
switch (ev.type) {
case 'photo.created': {
const photo = data as unknown as Photo;
if (photos.some((p) => p.id === photo.id)) {
photos = photos.map((p) => (p.id === photo.id ? photo : p));
} else {
photos = [photo, ...photos];
}
loadAlbum().catch(() => {});
break;
}
case 'photo.deleted': {
photos = photos.filter((p) => p.id !== data.id);
loadAlbum().catch(() => {});
break;
}
case 'photo.updated': {
const photo = data as unknown as Photo;
photos = photos.map((p) => (p.id === photo.id ? photo : p));
break;
}
case 'like.added': {
photos = photos.map((p) =>
p.id === data.photo_id
? {
...p,
likes_count: p.likes_count + 1,
liked_by_me: data.user_id === auth.user?.id || p.liked_by_me
}
: p
);
break;
}
case 'like.removed': {
photos = photos.map((p) =>
p.id === data.photo_id
? { ...p, likes_count: Math.max(0, p.likes_count - 1), liked_by_me: false }
: p
);
break;
}
case 'comment.created': {
const comment = data as unknown as { photo_id: string };
photos = photos.map((p) =>
p.id === comment.photo_id ? { ...p, comments_count: p.comments_count + 1 } : p
);
break;
}
case 'album.updated': {
loadAlbum().catch(() => {});
break;
}
}
}
function selectPhoto(i: number, e: MouseEvent) {
const photo = photos[i];
if (!photo) return;
const cur = selection.albumId === albumId ? selection.items : [];
if (e.shiftKey && selection.anchorId && selection.albumId === albumId) {
const a = photos.findIndex((p) => p.id === selection.anchorId);
if (a >= 0) {
const lo = Math.min(a, i);
const hi = Math.max(a, i);
selection.set(albumId, photos.slice(lo, hi + 1));
return;
}
}
if (e.ctrlKey || e.metaKey) {
const exists = cur.some((p) => p.id === photo.id);
selection.set(
albumId,
exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo]
);
selection.anchorId = photo.id;
return;
}
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
function dragPhoto(i: number, e: DragEvent) {
const photo = photos[i];
if (!photo) return;
if (!(selection.albumId === albumId && selection.has(photo.id))) {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
const ids = selection.ids;
e.dataTransfer?.setData('application/x-galo-photos', JSON.stringify(ids));
e.dataTransfer?.setData('text/plain', `${ids.length} фото`);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copyMove';
}
function onDrop(ev: DragEvent) {
ev.preventDefault();
dragging = false;
if (canUpload && ev.dataTransfer?.files?.length) {
uploader?.upload(ev.dataTransfer.files);
}
}
function openMenuForPhoto(i: number, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
const photo = photos[i];
if (!photo) return;
const inSel = selection.albumId === albumId && selection.has(photo.id);
const multi = inSel && selection.count > 1;
if (!inSel) {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
const openFirst = () => {
const first = selection.albumId === albumId ? selection.items[0] : photo;
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
};
let items: ContextMenuItem[];
if (multi) {
const ids = selection.ids;
items = [
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
{ label: 'Переместить', icon: 'arrow_back', run: () => transfer.start(ids, null, 'move') },
{ label: 'Копировать', icon: 'content_copy', run: () => transfer.start(ids, null, 'copy') },
{ label: 'Удалить', icon: 'delete', danger: true, run: () => (pendingDelete = true) }
];
} else {
items = [
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
...(photo.download_url
? [
{
label: 'Скачать',
icon: 'download' as const,
run: () => window.open(photo.download_url, '_blank')
}
]
: []),
{
label: 'Переместить',
icon: 'arrow_back',
run: () => transfer.start([photo.id], null, 'move')
},
{
label: 'Копировать',
icon: 'content_copy',
run: () => transfer.start([photo.id], null, 'copy')
},
{
label: 'Удалить',
icon: 'delete',
danger: true,
run: () => {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
pendingDelete = true;
}
}
];
}
contextMenu.show(e.clientX, e.clientY, items);
}
async function confirmMenuDelete() {
deletingMenu = true;
try {
await photosApi.deleteBatch(selection.ids);
selection.clear();
pendingDelete = false;
} catch (e) {
error = (e as Error).message;
} finally {
deletingMenu = false;
}
}
</script>
{#if loading}
<p class="text-text-muted p-4">Загрузка…</p>
{:else if error}
<p class="text-danger p-4">{error}</p>
{:else}
<div
class="relative flex min-h-full flex-col"
role="region"
aria-label="Альбом"
ondragover={(e) => {
if (!canUpload) return;
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
dragging = true;
}}
ondragleave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) dragging = false;
}}
ondrop={onDrop}
>
<div
class="border-border bg-chrome sticky top-0 z-10 flex items-center justify-between gap-3 border-b px-3 py-2"
>
<div class="flex min-w-0 items-center gap-1">
<a
href="/"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex shrink-0 items-center justify-center rounded-md p-1.5"
aria-label="Ко всем альбомам"
title="Ко всем альбомам"
>
<Icon name="arrow_back" class="size-5" />
</a>
<h1 class="text-text truncate text-sm font-medium">{album?.title ?? ''}</h1>
</div>
<div class="flex items-center gap-3">
<DensityControl />
{#if canUpload}
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5"
aria-label="Загрузить фото"
title="Загрузить фото"
onclick={() => uploader?.pick()}
>
<Icon name="cloud_upload" />
</button>
{/if}
</div>
</div>
<div class="flex-1 p-3 sm:p-4">
{#if canUpload && photos.length === 0}
<button
type="button"
class="border-border text-text-muted hover:border-primary flex w-full flex-col items-center gap-3 rounded-xl border-2 border-dashed p-12 text-center"
onclick={() => uploader?.pick()}
>
<Icon name="cloud_upload" class="size-10 text-text-subtle" />
<span>Перетащите фото сюда или выберите файлы</span>
</button>
{:else}
<PhotoGrid
{photos}
selectedIds={selection.albumId === albumId ? selection.ids : []}
onSelect={selectPhoto}
onDragStart={dragPhoto}
onContextMenu={openMenuForPhoto}
onOpen={(i) => {
const p = photos[i];
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
}}
/>
{/if}
{#if nextCursor}
<div class="mt-6 text-center">
<button
type="button"
class="border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}
>
<Icon name="expand_more" class="size-4" />
Показать ещё
</button>
</div>
{/if}
</div>
{#if dragging}
<div
class="bg-bg/80 border-primary pointer-events-none fixed inset-3 z-40 flex items-center justify-center rounded-xl border-2 border-dashed"
>
<span class="text-text inline-flex items-center gap-2 text-lg">
<Icon name="cloud_upload" class="size-6" />
Отпустите, чтобы загрузить
</span>
</div>
{/if}
<Uploader
bind:this={uploader}
{albumId}
onUploaded={(created) => {
for (const p of created) {
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
}
loadAlbum().catch(() => {});
}}
/>
</div>
{/if}
{#if pendingDelete}
<ConfirmDialog
title="Удалить выбранные фото?"
message="{selection.count} фото будут перемещены в корзину."
confirmLabel="Удалить"
busy={deletingMenu}
onConfirm={confirmMenuDelete}
onClose={() => (pendingDelete = false)}
/>
{/if}
@@ -0,0 +1,105 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import Viewer from '$lib/components/Viewer.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? '');
let photos = $state<Photo[]>([]);
let loading = $state(true);
let error = $state('');
let pendingDelete = $state<Photo | null>(null);
let deleting = $state(false);
const index = $derived(photos.findIndex((p) => p.id === photoId));
$effect(() => {
const id = albumId;
loading = true;
error = '';
Promise.all([
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
api<{ album: Album; role: string }>(`/albums/${id}`)
])
.then(([p]) => {
photos = p.photos;
})
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
});
function close() {
goto(`/albums/${albumId}`);
}
function change(i: number) {
const p = photos[i];
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
}
$effect(() => {
const id = viewerActions.deleteRequested;
if (!id) return;
const photo = photos.find((p) => p.id === id);
if (photo) pendingDelete = photo;
viewerActions.clear();
});
async function confirmDelete() {
const photo = pendingDelete;
if (!photo) return;
deleting = true;
error = '';
try {
await api(`/photos/${photo.id}`, { method: 'DELETE' });
pendingDelete = null;
const remaining = photos.filter((p) => p.id !== photo.id);
if (remaining.length > 0) {
const next = remaining[Math.min(Math.max(index, 0), remaining.length - 1)];
await goto(`/albums/${albumId}/photos/${next.id}`, {
replaceState: true,
noScroll: true
});
} else {
await goto(`/albums/${albumId}`);
}
photos = remaining;
} catch (e) {
error = (e as Error).message;
} finally {
deleting = false;
}
}
</script>
{#if loading}
<p class="text-text-muted p-4">Загрузка…</p>
{:else if error}
<p class="text-danger p-4">{error}</p>
{:else if index < 0}
<div class="text-text-muted p-6 text-center">
<p class="mb-3">Фото не найдено.</p>
<a href="/albums/{albumId}" class="text-primary">К альбому</a>
</div>
{:else}
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1">
<Viewer {photos} {index} onClose={close} onIndexChange={change} />
</div>
</div>
{#if pendingDelete}
<ConfirmDialog
title="Удалить фото?"
message={pendingDelete.original_filename}
busy={deleting}
onConfirm={confirmDelete}
onClose={() => (pendingDelete = null)}
/>
{/if}
{/if}
@@ -0,0 +1,267 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { portal } from '$lib/actions/portal';
import { photosApi, ApiError } from '$lib/api';
import { albums } from '$lib/stores/albums.svelte';
import type { Photo } from '$lib/types';
let photos = $state<Photo[]>([]);
let loading = $state(true);
let error = $state('');
let pending = $state<Photo | null>(null);
let busy = $state(false);
let restoring = $state<Photo | null>(null);
let targetId = $state('');
let createNew = $state(false);
let newTitle = $state('');
let restoreBusy = $state(false);
let restoreError = $state('');
async function load() {
loading = true;
error = '';
try {
photos = (await photosApi.trash()).photos;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
$effect(() => {
load();
});
$effect(() => {
if (restoring && !albums.loaded) albums.load();
});
async function restore(p: Photo) {
try {
await photosApi.restore(p.id);
photos = photos.filter((x) => x.id !== p.id);
} catch (e) {
if (e instanceof ApiError && e.code === 'album_required') {
restoring = p;
targetId = '';
createNew = false;
newTitle = '';
restoreError = '';
} else {
error = (e as Error).message;
}
}
}
async function confirmRestore() {
const photo = restoring;
if (!photo) return;
restoreBusy = true;
restoreError = '';
try {
let id = targetId;
if (createNew) {
if (!newTitle.trim()) return;
const album = await albums.create(newTitle.trim());
id = album.id;
}
if (!id) return;
await photosApi.restore(photo.id, id);
photos = photos.filter((x) => x.id !== photo.id);
restoring = null;
} catch (e) {
restoreError = (e as Error).message;
} finally {
restoreBusy = false;
}
}
async function remove() {
if (!pending) return;
busy = true;
try {
await photosApi.permanentDelete(pending.id);
photos = photos.filter((x) => x.id !== pending?.id);
pending = null;
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
<div class="p-4 sm:p-6">
<div class="mb-4 flex items-center gap-2">
<Icon name="delete" class="size-5 text-text-muted" />
<h1 class="text-xl font-semibold">Корзина</h1>
</div>
{#if error}<p class="text-danger mb-3 text-sm">{error}</p>{/if}
{#if loading}
<p class="text-text-muted">Загрузка…</p>
{:else if photos.length === 0}
<div class="text-text-muted flex flex-col items-center gap-3 py-16">
<Icon name="delete" class="size-16 text-text-subtle" />
<p>Корзина пуста.</p>
</div>
{:else}
<ul class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each photos as photo (photo.id)}
<li class="border-border bg-bg-muted overflow-hidden rounded-lg border">
<div class="bg-bg-muted aspect-square overflow-hidden">
{#if photo.thumb_url}
<img src={photo.thumb_url} alt="" class="h-full w-full object-cover" loading="lazy" />
{:else}
<span class="flex h-full w-full items-center justify-center">
<Icon name="image" class="size-8 text-text-subtle" />
</span>
{/if}
</div>
<div class="space-y-1.5 p-2">
<p class="text-text truncate text-xs" title={photo.original_filename}>
{photo.original_filename}
</p>
<p class="text-text-subtle truncate text-xs">
{photo.album_title || 'Без альбома'}
</p>
<div class="flex gap-1">
<button
type="button"
class="border-border text-text hover:bg-bg-muted inline-flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-xs"
onclick={() => restore(photo)}
>
<Icon name="arrow_back" class="size-3.5" />
Вернуть
</button>
<button
type="button"
class="border-border text-danger hover:bg-bg-muted inline-flex items-center justify-center rounded-md border px-2 py-1"
aria-label="Удалить навсегда"
title="Удалить навсегда"
onclick={() => (pending = photo)}
>
<Icon name="delete_forever" class="size-3.5" />
</button>
</div>
</div>
</li>
{/each}
</ul>
{/if}
</div>
{#if pending}
<ConfirmDialog
title="Удалить навсегда?"
message="Фото будет удалено без возможности восстановления. Файлы хранилища удаляются, если на них нет ссылок в других альбомах."
confirmLabel="Удалить навсегда"
busy={busy}
onConfirm={remove}
onClose={() => (pending = null)}
/>
{/if}
{#if restoring}
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Восстановление фото"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !restoreBusy) restoring = null;
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !restoreBusy) restoring = null;
}}
>
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
<div class="border-border flex items-center justify-between border-b px-5 py-3">
<h2 class="text-text text-base font-semibold">Восстановить фото</h2>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
aria-label="Закрыть"
onclick={() => (restoring = null)}
>
<Icon name="close" class="size-4" />
</button>
</div>
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
<p class="text-text-muted text-xs">
Исходный альбом удалён. Выберите альбом или создайте новый.
</p>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" class="accent-primary size-4" bind:checked={createNew} />
Создать новый альбом
</label>
{#if createNew}
<input
bind:value={newTitle}
placeholder="Название альбома"
maxlength="120"
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2 text-sm"
/>
{:else if albums.list.length === 0}
<p class="text-text-subtle text-sm">Нет альбомов — создайте новый.</p>
{:else}
<ul class="max-h-56 space-y-1 overflow-y-auto">
{#each albums.list as album (album.id)}
<li>
<button
type="button"
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
album.id
? 'border-primary bg-bg-muted text-text'
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
aria-pressed={targetId === album.id}
onclick={() => (targetId = album.id)}
>
<span
class="bg-bg-muted border-border flex size-8 shrink-0 items-center justify-center overflow-hidden rounded border"
>
{#if album.cover_thumb_url}
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
{:else}
<Icon name="image" class="size-4 text-text-subtle" />
{/if}
</span>
<span class="truncate">{album.title}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if restoreError}<p class="text-danger text-sm">{restoreError}</p>{/if}
</div>
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={() => (restoring = null)}
>
Отмена
</button>
<button
type="button"
disabled={restoreBusy || (createNew ? !newTitle.trim() : !targetId)}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={confirmRestore}
>
{restoreBusy ? 'Восстановление…' : 'Восстановить'}
</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import Icon from '$lib/components/Icon.svelte';
let { children } = $props();
</script>
<div class="bg-canvas text-text min-h-screen">
<header class="border-border bg-chrome border-b">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
<Icon name="photo_camera" />
Galo
</a>
<ThemeSwitcher />
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-6">
{@render children()}
</main>
</div>
@@ -3,6 +3,7 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import PhotoGrid from '$lib/components/PhotoGrid.svelte'; import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import Viewer from '$lib/components/Viewer.svelte';
import type { Album, Photo } from '$lib/types'; import type { Album, Photo } from '$lib/types';
const token = $derived($page.params.token); const token = $derived($page.params.token);
@@ -65,41 +66,13 @@
{/if} {/if}
{#if active >= 0} {#if active >= 0}
<div <div class="bg-canvas fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label="Просмотр">
class="bg-bg/95 fixed inset-0 z-50 flex items-center justify-center" <Viewer
role="dialog" {photos}
tabindex="-1" index={active}
onkeydown={(e) => { onClose={() => (active = -1)}
if (e.key === 'Escape') active = -1; onIndexChange={(i) => (active = i)}
}}
onclick={() => (active = -1)}
>
<img
src={photos[active].preview_url ?? photos[active].original_url}
alt={photos[active].original_filename}
class="max-h-[90vh] max-w-full object-contain"
/> />
<div class="absolute top-4 right-4 flex gap-2">
<a
href={photos[active].download_url}
target="_blank"
rel="noreferrer"
class="border-border bg-bg-elevated text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
onclick={(e) => e.stopPropagation()}
>
<Icon name="download" class="size-4" />
Скачать
</a>
<button
type="button"
aria-label="Закрыть"
title="Закрыть"
class="border-border bg-bg-elevated text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
onclick={() => (active = -1)}
>
<Icon name="close" />
</button>
</div>
</div> </div>
{/if} {/if}
{:else} {:else}
+6 -47
View File
@@ -1,68 +1,27 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import '../app.css'; import '../app.css';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import Icon from '$lib/components/Icon.svelte';
import IconSprite from '$lib/components/IconSprite.svelte'; import IconSprite from '$lib/components/IconSprite.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme.svelte'; import { theme } from '$lib/stores/theme.svelte';
import { density } from '$lib/stores/density.svelte';
import { authApi } from '$lib/api'; import { authApi } from '$lib/api';
let { children } = $props(); let { children } = $props();
onMount(() => { onMount(() => {
theme.init(); theme.init();
density.init();
if (auth.isAuthenticated && !auth.user) { if (auth.isAuthenticated && !auth.user) {
authApi authApi
.me() .me()
.then((data) => auth.setUser(data.user)) .then((data) => auth.setUser({ ...data.user, preferences: data.preferences ?? {} }))
.catch(() => {}); .catch(() => {});
} }
}); });
async function logout() {
if (auth.refresh) {
await authApi.logout(auth.refresh).catch(() => {});
}
auth.clear();
goto('/login');
}
</script> </script>
<IconSprite /> <IconSprite />
<div class="bg-bg text-text min-h-screen"> {@render children()}
<header class="border-border bg-bg-elevated border-b"> <ContextMenu />
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
<Icon name="photo_camera" />
Galo
</a>
<div class="flex items-center gap-3">
{#if auth.isAuthenticated}
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
onclick={logout}
>
<Icon name="logout" class="size-4" />
Выйти
</button>
{:else}
<a
href="/login"
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
>
<Icon name="login" class="size-4" />
Войти
</a>
{/if}
<ThemeSwitcher />
</div>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-6">
{@render children()}
</main>
</div>
-107
View File
@@ -1,107 +0,0 @@
<script lang="ts">
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album } from '$lib/types';
let albums = $state<Album[]>([]);
let loading = $state(true);
let title = $state('');
let error = $state('');
async function load() {
if (!auth.isAuthenticated) {
loading = false;
return;
}
try {
const data = await api<{ albums: Album[] }>('/albums');
albums = data.albums;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
$effect(() => {
load();
});
async function create(ev: Event) {
ev.preventDefault();
if (!title.trim()) return;
try {
const data = await api<{ album: Album }>('/albums', {
method: 'POST',
body: { title }
});
albums = [data.album, ...albums];
title = '';
} catch (e) {
error = (e as Error).message;
}
}
</script>
{#if !auth.isAuthenticated}
<div class="py-16 text-center">
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
<a
href="/login"
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
>
<Icon name="login" class="size-5" />
Войти
</a>
</div>
{:else}
<div class="mb-6 flex items-center justify-between gap-4">
<h1 class="text-2xl font-bold">Мои альбомы</h1>
<form class="flex gap-2" onsubmit={create}>
<input
bind:value={title}
placeholder="Название альбома"
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
/>
<button
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
type="submit"
>
<Icon name="add" class="size-4" />
Создать
</button>
</form>
</div>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
{#if loading}
<p class="text-text-muted">Загрузка…</p>
{:else if albums.length === 0}
<div class="text-text-muted flex flex-col items-center gap-3 py-12">
<Icon name="photo_library" class="size-16 text-text-subtle" />
<p>Альбомов пока нет. Создайте первый.</p>
</div>
{:else}
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each albums as album (album.id)}
<li>
<a
href="/albums/{album.id}"
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
>
<div class="bg-bg-muted flex aspect-video items-center justify-center">
<Icon name="image" class="size-16 text-text-subtle" />
</div>
<div class="p-4">
<h2 class="text-text font-semibold">{album.title}</h2>
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
</div>
</a>
</li>
{/each}
</ul>
{/if}
{/if}
@@ -1,186 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import Lightbox from '$lib/components/Lightbox.svelte';
import Uploader from '$lib/components/Uploader.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
import type { Album, Photo } from '$lib/types';
const albumId = $derived($page.params.id ?? '');
let album = $state<Album | null>(null);
let role = $state('');
let photos = $state<Photo[]>([]);
let nextCursor = $state('');
let loading = $state(true);
let error = $state('');
let lightboxIndex = $state(-1);
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
async function loadAlbum() {
const data = await api<{ album: Album; role: string }>(`/albums/${albumId}`);
album = data.album;
role = data.role;
}
async function loadPhotos() {
const data = await api<{ photos: Photo[]; next_cursor: string }>(
`/albums/${albumId}/photos`
);
photos = data.photos;
nextCursor = data.next_cursor;
}
async function loadMore() {
if (!nextCursor) return;
const data = await api<{ photos: Photo[]; next_cursor: string }>(
`/albums/${albumId}/photos?cursor=${encodeURIComponent(nextCursor)}`
);
photos = [...photos, ...data.photos];
nextCursor = data.next_cursor;
}
$effect(() => {
const id = albumId;
loading = true;
error = '';
Promise.all([loadAlbum(), loadPhotos()])
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
});
let client: SSEClient | null = null;
$effect(() => {
const id = albumId;
if (!auth.access) return;
client = new SSEClient({
path: `/api/sse/albums/${id}`,
onEvent: handleEvent,
onReconnect: () => loadPhotos()
});
client.connect(auth.access);
return () => {
client?.close();
client = null;
};
});
function handleEvent(ev: SSEEvent) {
const data = ev.data as Record<string, unknown>;
switch (ev.type) {
case 'photo.created': {
const photo = data as unknown as Photo;
if (photos.some((p) => p.id === photo.id)) {
photos = photos.map((p) => (p.id === photo.id ? photo : p));
} else {
photos = [photo, ...photos];
}
break;
}
case 'photo.deleted': {
photos = photos.filter((p) => p.id !== data.id);
break;
}
case 'like.added': {
photos = photos.map((p) =>
p.id === data.photo_id
? { ...p, likes_count: p.likes_count + 1, liked_by_me: data.user_id === auth.user?.id || p.liked_by_me }
: p
);
break;
}
case 'like.removed': {
photos = photos.map((p) =>
p.id === data.photo_id
? { ...p, likes_count: Math.max(0, p.likes_count - 1), liked_by_me: false }
: p
);
break;
}
case 'comment.created': {
const comment = data as unknown as { photo_id: string };
photos = photos.map((p) =>
p.id === comment.photo_id ? { ...p, comments_count: p.comments_count + 1 } : p
);
break;
}
case 'album.updated': {
loadAlbum().catch(() => {});
break;
}
}
}
</script>
{#if loading}
<p class="text-text-muted">Загрузка…</p>
{:else if error}
<p class="text-danger">{error}</p>
{:else if album}
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold">{album.title}</h1>
<p class="text-text-muted">{album.description}</p>
</div>
<div class="flex gap-2">
<a
href="/albums/{albumId}/members"
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
>
<Icon name="group" class="size-4" />
Участники
</a>
{#if role === 'owner' || role === 'admin'}
<a
href="/albums/{albumId}/settings"
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
>
<Icon name="settings" class="size-4" />
Настройки
</a>
{/if}
</div>
</div>
{#if canUpload}
<div class="mb-6">
<Uploader
{albumId}
onUploaded={(created) => {
for (const p of created) {
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
}
}}
/>
</div>
{/if}
<PhotoGrid {photos} onOpen={(i) => (lightboxIndex = i)} />
{#if nextCursor}
<div class="mt-6 text-center">
<button
type="button"
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}
>
<Icon name="expand_more" class="size-4" />
Показать ещё
</button>
</div>
{/if}
{#if lightboxIndex >= 0}
<Lightbox
{photos}
index={lightboxIndex}
onClose={() => (lightboxIndex = -1)}
onIndexChange={(i) => (lightboxIndex = i)}
/>
{/if}
{/if}
+2
View File
@@ -11,6 +11,8 @@ export default {
muted: 'hsl(var(--bg-muted) / <alpha-value>)' muted: 'hsl(var(--bg-muted) / <alpha-value>)'
}, },
border: 'hsl(var(--border) / <alpha-value>)', border: 'hsl(var(--border) / <alpha-value>)',
chrome: 'hsl(var(--chrome) / <alpha-value>)',
canvas: 'hsl(var(--canvas) / <alpha-value>)',
text: { text: {
DEFAULT: 'hsl(var(--text) / <alpha-value>)', DEFAULT: 'hsl(var(--text) / <alpha-value>)',
muted: 'hsl(var(--text-muted) / <alpha-value>)', muted: 'hsl(var(--text-muted) / <alpha-value>)',
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-10
@@ -0,0 +1,313 @@
# Design
## Context
См. proposal.md — Why. Текущее состояние, которое формирует подход:
- `albums.cover_photo_id` уже существует (`migrations/000001_init.up.sql`), `PATCH /albums/:id`
принимает `cover_photo_id`/`clear_cover` (`albums/handlers.go:55`), но список и `GET`
отдают только id — URL миниатюры негде взять, поэтому карточки рисуют иконку-заглушку.
- `photos.Service.attachURLs` уже пресигнит `ThumbKey`/`PreviewKey` (`photos/service.go:132`),
а `albums.Service` держит `storage *storage.Client` — приём с presigned URL доступен и там.
- Фоновая обработка загруженного фото безусловно зовёт `photos.Store.SetCover`
(`photos/service.go:124`), перезаписывая выбор пользователя.
- FK на `cover_photo_id` нет; удаление фото-обложки оставляет висячий id.
- Фронтенд: один плоский `+layout.svelte` (топбар + `max-w-6xl` + центр), лайтбокс —
оверлей, на `share/[token]` свой инлайн-лайтбокс. Темы: light/dark/neutral/system, дефолт
`system` (`stores/theme.svelte.ts`).
## Goals / Non-Goals
**Goals:**
- Постоянный каркас с рейлом и контекстной панелью, центр = текущий маршрут (Q).
- Обложки доезжают до UI; явный выбор + разумный дефолт; корректное удаление.
- Тёмная графитовая тема по умолчанию при сохранении остальных тем.
- Просмотр фото как маршрут в каркасе, общий с публичной share-страницей.
- Адаптивность до мобильного без потери каркаса.
- Пер-пользовательские настройки раздела «Информация» (видимость атрибутов и подписи),
хранимые в профиле.
**Non-Goals:**
- Мультивыбор фото и массовые действия / bulk-удаление.
- Замена роутинга на «чистые панели» без маршрутов (вариант P).
- Новые внешние зависимости (иконки и токены вендорятся/расширяются).
- Изменение доменных контрактов API (только добавление поля ответа и FK).
- Порядок атрибутов и настройки на уровне альбома/фото — настройки глобальны для пользователя.
## Decisions
### 1. Каркас: группы маршрутов, центр = маршрут
`+layout.svelte` разбивается на группы: `(app)` — каркас с топбаром, левым рейлом и правой
панелью, и `(public)` — только токены. Центр `(app)` рендерит текущий маршрут; рейл живёт
между навигациями.
Альтернатива — один layout с условиями по `$page.route.id` (проще, но разрастается) или P
(панели вместо маршрутов) — отклонена: ломает deeplink и историю.
### 2. Обложка: LEFT JOIN + presign (A)
`albums.Store` добавляет `LEFT JOIN photos p ON p.id = a.cover_photo_id` и отдаёт
`cover_thumb_key`; `albums.Service` пресигнит его в `Album.CoverThumbURL`. Один запрос и один
presign на альбом; висячий id безопасно даёт `NULL`. Альтернативы: N+1 с `GET /photos/:id`
(chatty) и выделенный thumb-эндпоинт (расходится с текущим паттерном presigned URL).
### 3. Автообложка: писать только если пусто
`processVariants` вызывает `SetCover` только при `cover_photo_id IS NULL`. Выбор пользователя
не слетает, дефолт появляется с первым фото. Альтернатива — дефолт на чтении (COALESCE) —
отклонена в пользу простоты записи/чтения; осознанно принимаем, что в пачке побеждает
фото, обработанное первым.
### 4. Целостность обложки: FK ON DELETE SET NULL (M)
Миграция `000002_album_cover_fk`: сперва обнуляет уже висячие `cover_photo_id`, затем
`ALTER TABLE albums ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id)
ON DELETE SET NULL`. БД сама чинит любой путь удаления. Циклический FK `albums <-> photos`
безвреден: альбом вставляется с `NULL`, фото ссылается на альбом, cover выставляется позже.
Альтернатива — чистка в `photos.Delete` (легко забыть на новых путях удаления).
### 5. Точка выбора обложки: правая панель (T1)
Правая панель даёт `[Выбрать обложку]` (модальный пикер из миниатюр альбома) и `[Сбросить]`;
всё под `roles.CanEditAlbum`. Действие «Сделать обложкой» в тулбаре просмотра и hover-оверлей
в сетке (T3) не используются — шум и случайные клики.
### 6. Тема: сохранить темы, dark = графит и дефолт (α)
Тёмная тема перенастраивается в графит Lightroom и становится значением по умолчанию
(`theme.current = 'dark'` при отсутствии сохранённого выбора). Добавляются аддитивные токены
`--chrome` (топбар/рейл/панель) и `--canvas` (центр), чтобы «утопить» контент между панелями.
`light`/`neutral`/`system` остаются. Спека `ui-iconography` не затрагивается.
### 7. Адаптив: drawer + sheet + горизонтальная лента (M1)
Порог рабочего стола — `lg`/`md`. Ниже: рейл — off-canvas drawer по кнопке, правая панель —
сворачиваемая секция/bottom-sheet, filmstrip — горизонтальный скролл. Публичные экраны
получают ту же мобильную логику просмотра без рейла/панели.
### 8. Плотность сетки, без выбора (D)
Регулятор размера миниатюр в тулбаре сетки управляет `grid-template-columns` (пресеты или
`minmax`), значение хранится в `localStorage`. Мультивыбор/bulk — вне объёма.
### 9. Просмотр как маршрут (L2)
Новый маршрут `albums/[id]/photos/[photoId]` рендерит общий компонент `Viewer` (крупное фото,
filmstrip, панель метаданных/комментариев, тулбар). `share/[token]` переиспользует `Viewer`
без рейла/панели. Навигация синхронизирует маршрут и filmstrip; клавиши ←/→/Esc.
### 10. Загрузка из тулбара (U1)
Иконочная кнопка загрузки (роль-gated) открывает файловый диалог; `dragover` на весь альбом
показывает полноэкранный оверлей; пустой альбом показывает центральную зону-подсказку.
Прогресс — компактная полоса/тост вместо крупного блока.
### 11. Создание альбома через модалку
Кнопка `+` с хинтом (в рейле и/или топбаре) открывает модалку с полем названия; `POST
/albums`, созданный альбом становится активным. Инлайн-форма удаляется.
### 12. Переименование фото: `PATCH /photos/:id`
Имя файла меняется через новый эндпоинт; права — как у удаления (`CanDeleteAnyPhoto` или
автор своего фото). Пустое имя отклоняется, длина ограничивается 255 символами, изменение
транслируется событием `photo.updated`. UI — инлайн-редактор в правой панели. Альтернатива —
редактировать `caption` вместо имени — отклонена: пользователь просил имя файла.
### 13. Просмотр: полный экран и зум
Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по
`fullscreenchange`. `f`/кнопка переключают именно полноэкранный режим вьюера: если активен
полноэкранный режим приложения, `f` входит в просмотр фото вложенным режимом и не выключает
режим приложения. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото
всплывают у соответствующих кромок без таймеров — показываются и скрываются сразу по позиции
курсора (панели — по касанию края; стрелки — при курсоре в 1/6 левой/правой области, только
внутри границ вьюера, поэтому при уходе в боковые панели они скрыты; в зуме стрелки скрыты
полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото:
колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%;
`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум
догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование —
перетаскиванием и скроллом. Хоткеи игнорируются при фокусе в поле ввода; зум сбрасывается при
смене фото. Альтернатива — только `object-fit` без ручного масштаба — не даёт 1:1 и
панорамирования.
### 14. Панели: ресайз и сворачиваемые разделы
Ширина рейла и панели хранится в `localStorage`, меняется перетаскиванием разделителя
(и стрелками с клавиатуры), ширина подаётся CSS-переменной на десктопных брейкпоинтах.
Разделы панели — компонент `PanelSection` со сворачиванием и сохранением состояния.
Альтернатива — фиксированные ширины и всегда развёрнутые секции — отклонена: не отвечает
запросу.
### 15. Лимит загрузки
Общий лимит тела запроса поднят до 100 МБ; при `http.MaxBytesError` возвращается
`413 file_too_large` с понятным сообщением вместо маскировки под ошибку разбора multipart.
Клиент заранее проверяет размер каждого файла и суммарный.
### 16. Счётчики альбома и событие обновления фото
`photos_count`/`members_count` считаются подзапросами в тех же `ListForUser`/`GetByID`, чтобы
панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома
обновляет запись в списке.
### 17. Настройки информации: явный список видимых в профиле пользователя
Настройки раздела «Информация» хранятся в профиле как явный список видимых атрибутов:
`users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида
`{ "visible": ["Make", "Model", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие
секции `photo_info` означает базовый набор по умолчанию (камера, объектив, выдержка,
диафрагма, ISO, фокусное расстояние) — не «все атрибуты». Список `visible` хранится явно,
поэтому различимо состояние «ничего не выбрано»; новые и незнакомые теги не «теряются» (их
можно включить в диалоге). `GET /auth/me` отдаёт `preferences` вместе с пользователем;
сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной валидацией:
строки и массивы строк, ограничение длины подписи/ключа и числа записей). Пустой объект
(`{}`) удаляет настройки, возвращая базовый набор. Настройки не входят в `PublicUser`
(приватны). UI: `PanelSection` получает слот `actions` под шестерёнку; модалка
`PhotoInfoSettingsDialog` строится из объединения известного каталога подписей и ключей
текущего фото и даёт действия «отметить все»/«снять все» и «Сбросить»; список в панели
фильтруется по сохранённым видимым атрибутам, а остальные сворачиваются под кнопку «…»
(раскрытие — временное, для текущего фото). Альтернатива с оверрайдами `hidden` (пусто = все
видимы) отклонена: не даёт базовый набор по умолчанию.
### 18. Метаданные фото: устойчивый парсер и хранение в jsonb
EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`)
как плоская карта «тег → значение» всех распознаваемых библиотекой тегов. Парсер —
`github.com/dsoprea/go-exif/v3`: он переживает нестандартные и битые блоки EXIF, на которых
падал прежний `rwcarlsen/goexif` (реальные снимки HUAWEI давали zero-length sub-IFD и пустой
результат). Числовые значения нормализуются (дроби → число); дата съёмки и GPS дополнительно
пишутся в колонки `taken_at`, `latitude`/`longitude`. На клиенте теги показываются с русскими
подписями и переводом enum. Для ранее загруженных фото предусмотрена разовая команда
`cmd/backfill-exif`, которая тянет оригиналы из S3 (`storage.Get`) и заполняет
`exif`/`taken_at`/координаты. Альтернатива — фиксированный набор полей — отклонена:
пользователю нужны все атрибуты.
### 19. Выбор фото в сетке через общий стор
Выбор фото в сетке и контекстная панель живут в разных местах дерева (страница маршрута и слот
`AppShell`), поэтому выбранное фото хранится в общем сторе `selection` (`albumId` + `photo`).
Одиночный клик в `PhotoGrid` записывает выбор (рамка `ring` + `aria-pressed`), и панель
показывает сведения о выбранном фото; двойной клик открывает маршрут просмотра. Панель
использует `panelPhoto = photoId ? routePhoto : selectedPhoto`, поэтому в просмотре приоритет у
маршрута, а в альбоме — у выбора; выбор сбрасывается при смене альбома. Альтернатива — хранить
выбор в URL (query) — избыточно для локального выделения.
### 20. Корзина: мягкое удаление и подсчёт ссылок
Удаление фото — мягкое (`photos.deleted_at`, миграция `000005_photo_trash`): списки и просмотр
исключают такие фото, `GET /trash` отдаёт их по альбомам пользователя (владелец или участник).
Восстановление снимает `deleted_at`. Окончательное удаление удаляет запись, и только если
после этого `storage_key` больше нигде не встречается (копии в других альбомах разделяют
объект), удаляются объекты S3 (оригинал, thumb, preview). Альтернатива — удалять S3 сразу —
порвала бы копии.
### 21. Перенос и копирование — только метаданные
Перенос меняет `photos.album_id`; копирование создаёт новую запись с тем же `storage_key`,
thumb/preview и метаданными (клон). Объекты S3 не дублируются, поэтому «ссылки в любых
альбомах» — это строки photos с общим `storage_key`. Права: удаление в источнике (автор или
owner/admin) и загрузка в цель (member+).
### 22. Мультивыбор и трансфер через общие сторы
Выбор — стор `selection` (набор фото + anchor); Ctrl/Cmd — toggle, Shift — диапазон по
индексам текущей сетки. Действия инициируют диалог: стор `transfer` хранит ids, необязательный
целевой альбом и действие; глобальный `TransferDialog` (портал) выбирает альбом и
перенос/копию. Перетаскивание с сетки на альбом рейла передаёт ids через `dataTransfer` типа
`application/x-galo-photos`; дроп-цель — элементы рейла. Обработчик загрузки показывает оверлей
только для типа `Files`, поэтому при переносе фото зона загрузки не появляется.
### 23. Рейл: вход в корзину и скрытие панели
В рейле альбомов последним элементом списка добавлена «Корзина» (иконка корзины), ведущая на
маршрут `/trash` со списком удалённых фото и действиями «Вернуть»/«Удалить навсегда». Пустая
правая панель на обзоре скрыта: `AppShell` получает `panelVisible`, который layout выключает на
корневом маршруте.
### 24. Компактный верхний хром
Верхняя панель приложения (`AppShell`) и верхняя панель режима просмотра (`Viewer`) сведены к
минимуму: узкие вертикальные отступы (`py-0.5`/`py-1`), иконочные органы управления без рамок,
иконки `size-4`, компактный переключатель темы. Верхняя панель просмотра — прозрачный оверлей
(`absolute`, без фона и рамки, `pointer-events-none` с активными кнопками), поэтому не занимает
место в раскладке и увеличивает область фото. Функциональность (меню, панель, темы, полный
экран, навигация, скачивание, удаление) сохраняется.
### 25. Действия в режиме просмотра — в правой панели
Скачивание и удаление убраны из верхней панели просмотра и перенесены в правую панель, в раздел
«Действия», вместе с «Переместить» и «Копировать». Скачивание — ссылка на presigned оригинал;
перенос/копирование — через `transfer`/`TransferDialog`; удаление инициируется стором
`viewerActions`, а обрабатывает его страница просмотра (та же логика перехода к соседнему фото
после удаления).
### 26. Единая панель фото
Содержимое правой панели для одного фото (имя файла, «Действия», «Информация», «Комментарии»)
вынесено в общий компонент `PhotoPanel`, который переиспользуется и в режиме просмотра
(`viewer=true`), и при одиночном выборе фото в сетке — это одна и та же панель, а не копия.
Отличие только в наборе действий: в просмотре нет «Открыть», а удаление идёт через стор
`viewerActions` (обрабатывает страница просмотра с переходом к соседнему фото); при выборе в
сетке удаление выполняет сам компонент и очищает выбор. Мультивыбор (>1) показывает только
раздел «Действия».
### 27. Контекстное меню фото
Правый клик по фото в сетке и по изображению в просмотре открывает контекстное меню с теми же
пунктами, что и раздел «Действия». Меню — общий стор `contextMenu` (`x`, `y`, items) и глобальный
компонент `ContextMenu` (портал), закрывается по клику вне, `Escape`, скроллу и ресайзу.
Пункты формируются на местах: в сетке — «Открыть (просмотр)», «Скачать», «Переместить»,
«Копировать», «Удалить» (для мультивыбора без «Скачать»); в просмотре — без «Открыть».
### 28. Удаление альбома и восстановление фото
Удаление альбома не должно терять файлы: перед удалением альбома его фото мягко удаляются
(`deleted_at`), а FK `photos.album_id` меняется на `ON DELETE SET NULL` (колонка nullable,
миграция `000006`) — после удаления альбома фото остаются строками с `album_id = NULL` в
корзине. Объекты S3 не удаляются (каскадный `DeletePrefix` убран: он ломал бы копии в других
альбомах). `GET /trash` включает такие «без альбома» фото загрузчика. Восстановление с
указанием целевого альбома (`POST /photos/:id/restore {album_id}`) переносит фото туда; без
цели и без альбома возвращается ошибка `album_required`, и клиент предлагает выбрать альбом или
создать новый.
### 29. Контекстное меню альбома и счётчики в рейле
Правый клик по альбому (в рейле и на обзоре) открывает меню «Переименовать»/«Удалить» через
общий стор `contextMenu`; диалоги переименования и подтверждения удаления — глобальный
`AlbumActionsDialogs` (стор `albumActions`). В рейле у каждого альбома показано число фото
(`album.photos_count`), у корзины — число удалённых фото (лёгкий `GET /trash/count`, стор
`trash`).
### 30. Метки фото (область — пользователь)
Метки принадлежат пользователю: таблицы `tags` (`user_id`, уникальность по `lower(name)`) и
`photo_tags` (миграция `000007`). Фото отдаёт метки текущего пользователя (подзапрос
`json_agg` в `photoColumns` по `$2`-viewer). Эндпоинты: `GET /tags?q=` (поиск по меткам
пользователя), `POST /photos/:id/tags {name}` (создаёт метку при необходимости и привязывает,
возвращает обновлённое фото), `DELETE /photos/:id/tags/:tag_id`. UI — раздел «Метки» в
`PhotoPanel`: чипы с удалением и поле с автодополнением (поиск + «Создать «…»»). Права: метку
можно ставить на фото альбома, доступного пользователю.
## Risks / Trade-offs
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
при необходимости кэшировать URL на короткий TTL.
- [Presigned URL обложки истекает при долго открытой странице] → URL обновляется при загрузке
списка; при ошибке загрузки картинки показывать плейсхолдер.
- [Циклический FK albums↔photos] → миграция обнуляет висячие id до создания constraint;
каскадное удаление альбома обрабатывается БД (SET NULL по уже удаляемой строке безвреден).
- [Реструктуризация `+layout` задевает все маршруты] → выносить каркас инкрементально, группы
`(app)`/`(public)` в одном шаге, публичные страницы регрессионно проверяем.
- [Добавление токенов темы задевает все экраны] → токены аддитивны, старые классы продолжают
работать; проверка триад тем.
- [Пачка загрузок: обложкой становится первое обработанное фото] → принято; дальнейшие
загрузки обложку не трогают, пользователь может задать явно.
- [Дополнительное состояние в маршруте просмотра (filmstrip, клавиатура)] → единый компонент
`Viewer` с props и общим источником фото.
- [Зум/панорамирование конфликтует с навигацией и прокруткой] → при увеличении отключаем
стрелки и свайп; колесо над областью фото перехватывается с `preventDefault`.
- [Глобальные хоткеи при вводе текста] → игнорируем `f`/`Space`/стрелки, когда фокус в
`input`/`textarea`/`contenteditable`.
- [Крупные файлы утяжеляют память] → лимит 100 МБ и чтение через `LimitReader`; превышение
даёт понятную 413.
- [Сохранённые ширина/состояние панелей] → значения ограничиваются допустимым диапазоном.
- [Настройки в профиле: рост jsonb и приватность] → `preferences` не входят в `PublicUser`;
серверная валидация ограничивает структуру, длину подписей и число скрытых ключей.
## Migration Plan
1. Миграция `000002_album_cover_fk`: `UPDATE albums SET cover_photo_id = NULL WHERE
cover_photo_id IS NOT NULL AND cover_photo_id NOT IN (SELECT id FROM photos);` затем
`ADD CONSTRAINT ... ON DELETE SET NULL`. Down — `DROP CONSTRAINT`.
2. Миграция `000003_photo_exif`: `ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
3. Миграция `000004_user_preferences`: `ALTER TABLE users ADD COLUMN preferences jsonb NOT
NULL DEFAULT '{}'::jsonb`. Down — `DROP COLUMN`.
4. Бэкенд: `models.Album` (+`CoverThumbURL`, `PhotosCount`, `MembersCount`), `albums/store.go`
(LEFT JOIN + счётчики), `albums/service.go` (enrich + presign), `photos` (автообложка только
если NULL; `PATCH /photos/:id` для переименования; лимит 100 МБ и `413`; извлечение EXIF в
`photos.exif` и команда `cmd/backfill-exif`), `models.User` (+`Preferences`), `auth`
(`GetUserByID` c `preferences`, `Me` отдаёт настройки, `PATCH /auth/me/preferences` с
валидацией).
5. Фронтенд: каркас и маршруты, компоненты (шелл с ресайзом/фуллскрином, рейл, панель с
разделами и EXIF, модалки, `Viewer` с зумом), слот действий и модалка настроек атрибутов,
стор настроек, `types.ts`.
6. Откат: снять constraint и колонки `photos.exif`/`users.preferences` (down-миграции);
фронтенд откатывается независимо, поля ответа игнорируются старым клиентом.
## Open Questions
- Набор пресетов регулятора плотности (сколько ступеней) — решается при реализации, спеки не
меняет.
- Хинты: нативный `title` или кастомный тултип — косметика, спеки не меняет.
@@ -0,0 +1,121 @@
# Proposal
## Why
Сейчас интерфейс — это центрированный «маркетинговый» лейаут: список альбомов с
инлайн-формой создания, серая заглушка вместо обложек, отдельные страницы «Участники» и
«Настройки», лайтбокс-оверлей. Обложки в бэкенде уже есть (`albums.cover_photo_id`,
`PATCH /albums/:id`), но до UI не доходят. Пользователь хочет полноценный «рабочий стол»
в духе Adobe Lightroom: постоянный рейл альбомов, контекстная панель, плотная сетка,
режим просмотра как часть рабочего стола, обложки с явным выбором и единый тёмный
графитовый хром.
## What Changes
- **Рабочий стол (шелл).** Группа `(app)` с постоянным левым рейлом альбомов, топбаром и
контекстной правой панелью. Публичные маршруты (`/login`, `/invites/[token]`,
`/share/[token]`) остаются вне шелла, но в тех же токенах.
- **Создание альбома.** Инлайн-форма заменяется иконочной кнопкой с хинтом, открывающей
модалку ввода названия.
- **Обложки.** `Album` получает `cover_thumb_url`; карточки и рейл показывают реальную
миниатюру. Выбор обложки: пикер в правой панели (действие «сделать обложкой» в
просмотре не используется). Автообложка пишется только когда обложка ещё не задана; FK
`albums.cover_photo_id -> photos(id) ON DELETE SET NULL` чинит висячие ссылки.
- **Просмотр как маршрут.** `Lightbox`-оверлей заменяется маршрутом
`/albums/:id/photos/:photoId` внутри шелла: крупное фото, filmstrip снизу, панель
метаданных/комментариев. `share/[token]` переиспользует тот же компонент.
- **Плотность сетки.** Слайдер размера миниатюр в тулбаре (состояние в localStorage).
Мультивыбор и массовые действия — вне объёма.
- **Загрузка.** Крупный dropzone заменяется кнопкой в тулбаре, полноэкранным
drag-overlay и dropzone-намёком только в пустом альбоме. Лимит — 100 МБ с понятной
ошибкой при превышении.
- **Переименование фото.** `PATCH /photos/:id` меняет `original_filename`; инлайн-редактор
в правой панели, доступный автору фото и owner/admin.
- **Метаданные фото (EXIF).** При загрузке система извлекает все распознаваемые EXIF-атрибуты
(камера, объектив, экспозиция, ISO, дата съёмки, GPS и др.) и хранит их в `photos.exif`;
они возвращаются вместе с фото и показываются в разделе «Информация». Для ранее загруженных
фото есть команда бэкфилла метаданных из оригиналов.
- **Метки фото.** Пользователь присваивает фото свои метки: создаёт новые и выбирает из списка
с поиском; метки показываются и снимаются в разделе «Метки» панели фото.
- **Контекстные меню.** Правый клик по фото (в сетке и в просмотре) открывает меню с пунктами
раздела «Действия»; правый клик по альбому (в рейле и на обзоре) — меню «Переименовать»/
«Удалить».
- **Удаление альбома.** Удаление альбома помещает его фото в корзину, не удаляя объекты
хранилища; при восстановлении фото без альбома предлагается выбрать существующий альбом или
создать новый.
- **Просмотр.** Полноэкранный режим (кнопка и `f`) с автоскрытием верхней/нижней панелей и
стрелок у кромок; зум колесом к указателю (без прокрутки, максимум 200%), `Space`
возвращает из любого зума во вписанный вид, в зуме догружается оригинал изображения;
действия просмотра (скачать, удалить, перенести, копировать) — в правой панели, в разделе
«Действия»; удаление с модальным подтверждением; возврат к обзору.
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую
модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять
все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…»
(клик раскрывает все). Настройки хранятся в профиле пользователя (server-side), применяются
ко всем фото, а при отсутствии настроек показывается базовый набор; «Сбросить» удаляет
настройки из профиля.
- **Мультивыбор и операции.** Ctrl/Shift-выбор нескольких фото в сетке; в правой панели —
раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»;
перетаскивание выделенных фото на альбом в рейле открывает диалог перенос/копирование.
Перенос и копирование меняют только метаданные (объекты S3 не трогаются). Любое удаление
помещает фото в корзину; объекты S3 удаляются лишь когда на них нет ссылок в других
альбомах. Корзина — последний элемент списка альбомов в левом рейле (иконка корзины); на
обзоре убрана пустая правая панель.
- **Счётчики.** В ответе альбома — число фото и участников; в рейле у каждого альбома показано
число фото, у корзины — число удалённых фото.
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
системная; добавляются токены `--chrome`/`--canvas`; скроллбары стилизованы в тон темы.
Верхняя панель приложения и верхняя панель режима просмотра — компактные (минимум высоты).
- **Адаптив.** На узких экранах рейл становится off-canvas drawer, правая панель —
bottom-sheet, filmstrip — горизонтальным скроллом.
## Capabilities
### New Capabilities
- `ui-workspace`: рабочий стол приложения — шелл, рейл альбомов, контекстная панель,
режим просмотра как маршрут, адаптивное поведение и тёмный хром.
### Modified Capabilities
- `albums`: отображение обложки, счётчики фото/участников, явный выбор обложки и удаление в
корзину.
- `photos`: автообложка только при отсутствии выбора; переименование фото; лимит размера
загрузки; извлечение и хранение EXIF-метаданных; перенос/копирование, корзина и метки.
- `auth`: хранение пользовательских настроек отображения информации в профиле.
## Impact
- **Backend:** миграция FK `albums.cover_photo_id -> photos(id) ON DELETE SET NULL`;
`internal/albums/store.go` (LEFT JOIN `photos` → `cover_thumb_key` и счётчики
`photos_count`/`members_count` в `ListForUser`/`GetByID`); `internal/albums/service.go`
(presign → `CoverThumbURL`, обогащение списка); `internal/models` (`Album.CoverThumbURL`,
`PhotosCount`, `MembersCount`); `internal/photos` (автообложка только при
`cover_photo_id IS NULL`; `PATCH /photos/:id` для переименования; лимит загрузки 100 МБ с
`413 file_too_large`; событие `photo.updated`; извлечение EXIF через
`github.com/dsoprea/go-exif/v3` в `photos.exif`, миграция `000003_photo_exif`, команда
`cmd/backfill-exif` и `storage.Get`); миграция `users.preferences`,
`models.User.Preferences`, `internal/auth` (`Me` отдаёт настройки,
`PATCH /auth/me/preferences` с валидацией); миграции `000005_photo_trash` (мягкое удаление),
`000006_photo_album_nullable` (FK `photos.album_id` → `ON DELETE SET NULL`, удаление альбома
в корзину), `000007_photo_tags` (метки); `GET /trash`/`GET /trash/count`,
`POST /photos/:id/restore` (c `album_id`), `DELETE /photos/:id/permanent`,
`POST /photo-batch/{delete,move,copy}`, `GET /tags`, `POST/DELETE /photos/:id/tags`.
- **Frontend:** реструктуризация `+layout.svelte` в группы `(app)`/`(public)`, `AppShell`
с ресайзом панелей и кнопкой полного экрана; компоненты рейла, контекстной панели со
сворачиваемыми разделами и EXIF, модалки создания/пикера обложки/подтверждения,
`Viewer` с полноэкранным режимом и зумом, общий стор альбомов, контрол плотности; новый
маршрут `albums/[id]/photos/[photoId]`; слот действий в `PanelSection`, модалка настроек
атрибутов с пользовательскими подписями, стор настроек, фильтрация и «…» в панели;
новые значения токенов темы и стили скроллбаров; `types.ts` (+`cover_thumb_url`, счётчики,
`photos_count`, `preferences`, `tags`); общие компоненты `PhotoPanel`, `ContextMenu`,
`TransferDialog`, `AlbumActionsDialogs`; маршрут `/trash`; сторы `selection`, `transfer`,
`contextMenu`, `albumActions`, `trash`, `viewerActions`; новые иконки в реестре.
- **Спеки:** `ui-workspace` (новая), `albums`, `photos`, `auth` (дельты). `ui-iconography` не
затрагивается — иконки по-прежнему наследуют цвет темы.
- **Совместимость:** API-контракты сохраняются; добавляется поле ответа и один FK. Ломающих
изменений API нет.
@@ -0,0 +1,49 @@
# Spec Delta
## ADDED Requirements
### Requirement: Отображение обложки альбома
Данные альбома SHALL включать URL миниатюры обложки, когда обложка задана, чтобы клиенты
могли показать её в списке, рейле и карточке.
#### Scenario: Обложка задана
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
- **THEN** ответ содержит URL миниатюры обложки
#### Scenario: Обложка отсутствует
- **WHEN** у альбома нет обложки или фото обложки удалено
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
### Requirement: Выбор обложки
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
обложку; после сброса обложка определяется заново по умолчанию.
#### Scenario: Назначение обложки
- **WHEN** владелец выбирает фото альбома как обложку
- **THEN** система сохраняет выбор и отражает его в списке и карточках
#### Scenario: Сброс обложки
- **WHEN** админ сбрасывает обложку
- **THEN** система удаляет явный выбор, и обложка возвращается к значению по умолчанию
#### Scenario: Нет прав на смену обложки
- **WHEN** участник без права редактирования пытается сменить обложку
- **THEN** система отклоняет запрос
### Requirement: Счётчики альбома
Данные альбома SHALL включать количество фото и участников, чтобы панель и карточки могли
показать их без отдельных запросов.
#### Scenario: Счётчики в ответе
- **WHEN** клиент запрашивает список альбомов или альбом по id
- **THEN** ответ содержит число фото и число участников альбома
## MODIFIED Requirements
### Requirement: Удаление альбома
Владелец SHALL иметь возможность удалить альбом; фото альбома SHALL помещаться в корзину без
удаления объектов хранилища, а участники и share-ссылки SHALL удаляться вместе с альбомом.
#### Scenario: Удаление альбома
- **WHEN** владелец удаляет альбом
- **THEN** фото альбома попадают в корзину без привязки к альбому и файлы сохраняются, участники и share-ссылки удаляются
@@ -0,0 +1,24 @@
# Spec Delta
## ADDED Requirements
### Requirement: Хранение пользовательских настроек интерфейса
Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при
получении текущего пользователя. Настройки SHALL быть приватными для пользователя.
Отсутствие настроек SHALL трактоваться как значения по умолчанию.
#### Scenario: Получение настроек
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию)
#### Scenario: Сохранение настроек
- **WHEN** пользователь сохраняет настройки отображения
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
#### Scenario: Изоляция настроек
- **WHEN** другой пользователь запрашивает свой профиль
- **THEN** он не получает настройки первого пользователя
#### Scenario: Некорректные настройки
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
@@ -0,0 +1,118 @@
# Spec Delta
## ADDED Requirements
### Requirement: Обложка по умолчанию
При обработке загруженного фото система SHALL назначать его обложкой альбома только если
обложка ещё не задана.
#### Scenario: Первое фото
- **WHEN** фото обработано, и у альбома нет обложки
- **THEN** система назначает это фото обложкой
#### Scenario: Обложка уже задана
- **WHEN** фото обработано, а обложка уже задана
- **THEN** система не меняет обложку
### Requirement: Переименование фото
Автор фото, владелец и админ альбома SHALL иметь возможность изменить имя файла фото;
остальные SHALL получать отказ, а пустое имя SHALL отклоняться.
#### Scenario: Переименование автором
- **WHEN** автор меняет имя файла своего фото на непустое
- **THEN** система сохраняет новое имя и транслирует изменение подписчикам альбома
#### Scenario: Пустое имя
- **WHEN** пользователь подтверждает пустое имя файла
- **THEN** система отклоняет запрос
#### Scenario: Нет прав
- **WHEN** участник без прав переименовывает чужое фото
- **THEN** система отклоняет запрос
### Requirement: Ограничение размера загрузки
Система SHALL ограничивать размер загружаемых файлов и SHALL отвечать понятной ошибкой при
превышении лимита, а не ошибкой разбора формы.
#### Scenario: Превышение лимита
- **WHEN** загружаемый файл превышает максимальный размер
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
#### Scenario: Файл в пределах лимита
- **WHEN** файл не превышает лимит
- **THEN** система принимает multipart-загрузку и обрабатывает фото
### Requirement: Метаданные фото (EXIF)
При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их
вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор.
#### Scenario: Фото с метаданными
- **WHEN** загружается фото с EXIF
- **THEN** данные фото содержат извлечённые атрибуты (камера, экспозиция, ISO, дата съёмки и др.)
#### Scenario: Фото без метаданных
- **WHEN** загружается фото без читаемых EXIF
- **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно
### Requirement: Перенос и копирование фото между альбомами
Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь
возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только
метаданные и SHALL не изменять объекты хранилища.
#### Scenario: Перенос
- **WHEN** пользователь переносит фото в другой альбом
- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты S3 не меняются
#### Scenario: Копирование
- **WHEN** пользователь копирует фото в другой альбом
- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты S3
### Requirement: Корзина фото
Удаление фото SHALL помещать его в корзину (мягкое удаление). Восстановление SHALL возвращать
фото в его альбом. Окончательное удаление SHALL удалять запись, а объекты хранилища SHALL
удаляться только когда на них не остаётся ссылок ни в одном альбоме.
#### Scenario: Удаление в корзину
- **WHEN** пользователь удаляет фото
- **THEN** фото пропадает из альбома и появляется в корзине
#### Scenario: Восстановление
- **WHEN** пользователь восстанавливает фото из корзины
- **THEN** фото возвращается в свой альбом
#### Scenario: Окончательное удаление без ссылок
- **WHEN** пользователь удаляет фото из корзины навсегда, и на те же объекты нет ссылок в других альбомах
- **THEN** запись и объекты хранилища удаляются
#### Scenario: Окончательное удаление при наличии ссылок
- **WHEN** на объект хранилища ссылается копия в другом альбоме
- **THEN** запись удаляется, а объект хранилища сохраняется
### Requirement: Удаление альбома и восстановление фото без альбома
Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при
восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий
альбом или создать новый.
#### Scenario: Удаление альбома
- **WHEN** владелец удаляет альбом
- **THEN** его фото оказываются в корзине без привязки к альбому, файлы сохраняются
#### Scenario: Восстановление без альбома
- **WHEN** пользователь восстанавливает фото, у которого нет альбома
- **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него
### Requirement: Метки фото
Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и
выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься.
#### Scenario: Создание метки
- **WHEN** пользователь вводит новую метку и подтверждает
- **THEN** метка создаётся и привязывается к фото
#### Scenario: Выбор из списка с поиском
- **WHEN** пользователь вводит часть названия метки
- **THEN** показываются подходящие метки пользователя для выбора
#### Scenario: Снятие метки
- **WHEN** пользователь убирает метку с фото
- **THEN** метка отвязывается от фото
@@ -0,0 +1,384 @@
# Spec Delta
## Purpose
Рабочий стол веб-приложения: постоянный каркас с рейлом альбомов, контекстной панелью,
режимом просмотра фото и адаптивным поведением, обеспечивающий цельный «lightroom»-вид.
## ADDED Requirements
### Requirement: Рабочий стол приложения
Аутентифицированные экраны SHALL отображаться внутри постоянного каркаса с топбаром, левым
рейлом альбомов и контекстной правой панелью. Публичные экраны (вход, инвайт, share-ссылка)
SHALL отображаться вне каркаса, но в общей теме оформления.
#### Scenario: Внутренний экран
- **WHEN** аутентифицированный пользователь открывает список альбомов или альбом
- **THEN** экран отрисован внутри каркаса с рейлом и панелью, рейл не перезагружается при переходе
#### Scenario: Публичный экран
- **WHEN** пользователь открывает публичную share-ссылку без входа
- **THEN** экран отрисован без рейла и контекстной панели, но в тех же цветовых токенах
### Requirement: Рейл альбомов
Рейл SHALL постоянно показывать список доступных альбомов с их обложками и отмечать
активный альбом.
#### Scenario: Активный альбом
- **WHEN** пользователь находится в альбоме
- **THEN** соответствующий элемент рейла выделен, остальные показывают обложки или плейсхолдер
### Requirement: Создание альбома
Создание альбома SHALL выполняться через иконочную кнопку с подсказкой, открывающую
модальное окно ввода названия, вместо инлайн-формы.
#### Scenario: Создание через модалку
- **WHEN** пользователь нажимает кнопку создания, вводит название и подтверждает
- **THEN** система создаёт альбом и делает его активным
#### Scenario: Пустое название
- **WHEN** пользователь подтверждает пустое название
- **THEN** создание не выполняется
### Requirement: Контекстная панель альбома
Правая панель SHALL показывать превью обложки, название, счётчики фото и участников,
управление обложкой и переходы к участникам, настройкам и ссылкам. Элементы управления
обложкой SHALL быть доступны только owner и admin.
#### Scenario: Владелец альбома
- **WHEN** owner открывает альбом
- **THEN** панель показывает управление обложкой и переходы к участникам и настройкам
#### Scenario: Наблюдатель
- **WHEN** viewer открывает альбом
- **THEN** панель не показывает управление обложкой
### Requirement: Режим просмотра фотографии
Просмотр фото SHALL быть отдельным маршрутом внутри каркаса с крупным изображением,
лентой миниатюр снизу и панелью метаданных и комментариев. Тот же режим SHALL
использоваться на публичных share-страницах.
#### Scenario: Открытие фото
- **WHEN** пользователь выбирает фото в сетке
- **THEN** открывается маршрут фото в каркасе, рейл и панель остаются доступны, адрес отражает фото
#### Scenario: Навигация в просмотре
- **WHEN** пользователь использует стрелки влево/вправо или ленту миниатюр
- **THEN** показывается соседнее фото, адрес обновляется
#### Scenario: Публичный просмотр
- **WHEN** посетитель share-ссылки открывает фото
- **THEN** используется тот же режим просмотра без рейла и панели
### Requirement: Плотность сетки
Сетка SHALL давать пользователю управлять размером миниатюр, и выбранный размер SHALL
сохраняться между сессиями.
#### Scenario: Изменение размера
- **WHEN** пользователь двигает регулятор плотности
- **THEN** размер миниатюр меняется, значение сохраняется и восстанавливается при следующем открытии
### Requirement: Загрузка фотографий из рабочего стола
Загрузка SHALL запускаться действием в тулбаре; перетаскивание файлов над альбомом SHALL
показывать оверлей и загружать их; пустой альбом SHALL показывать подсказку-зону загрузки.
Загрузка SHALL быть доступна только ролям с правом загрузки.
#### Scenario: Перетаскивание
- **WHEN** пользователь с правом загрузки перетаскивает файлы над альбомом
- **THEN** показывается оверлей загрузки, и файлы загружаются
#### Scenario: Пустой альбом
- **WHEN** пользователь открывает альбом без фото и имеет право загрузки
- **THEN** в центре показана зона-подсказка загрузки
### Requirement: Темы и графитовый хром
Тёмная тема SHALL использовать графитовый хром Lightroom и SHALL применяться по умолчанию;
нейтральная тема SHALL быть тёплым графитом; остальные темы SHALL оставаться доступными, а
скроллбары SHALL быть стилизованы в цвет темы.
#### Scenario: Первый вход
- **WHEN** пользователь впервые открывает приложение без сохранённой темы
- **THEN** применяется тёмная графитовая тема
#### Scenario: Смена темы
- **WHEN** пользователь выбирает другую тему
- **THEN** интерфейс, иконки и скроллбары меняются согласованно, и выбор сохраняется
#### Scenario: Нейтральная тема
- **WHEN** пользователь выбирает нейтральную тему
- **THEN** применяется тёплый графитовый хром, отличный от холодной тёмной темы
### Requirement: Адаптивность рабочего стола
На узких экранах рейл SHALL становиться выдвижной панелью, контекстная панель —
сворачиваемой секцией, а лента миниатюр — горизонтально прокручиваемой.
#### Scenario: Узкий экран
- **WHEN** ширина окна меньше порога рабочего стола
- **THEN** рейл скрыт за кнопкой открытия, панель сворачиваема, лента прокручивается горизонтально
### Requirement: Доступность рабочего стола
Иконочные органы управления каркаса SHALL иметь доступные имена, панели SHALL быть
достижимы с клавиатуры с видимым фокусом, анимации SHALL учитывать
предпочтение уменьшенного движения.
#### Scenario: Клавиатурная навигация
- **WHEN** пользователь работает только с клавиатуры
- **THEN** органы управления каркаса достижимы, доступные имена присутствуют, фокус виден
### Requirement: Изменение ширины панелей
Пользователь SHALL иметь возможность менять ширину левой и правой панелей перетаскиванием, а
выбранная ширина SHALL сохраняться между сессиями.
#### Scenario: Перетаскивание разделителя
- **WHEN** пользователь тянет разделитель у панели на рабочем столе
- **THEN** панель меняет ширину в допустимых пределах
#### Scenario: Сохранение ширины
- **WHEN** пользователь перезагружает приложение
- **THEN** восстановленные панели имеют сохранённую ширину
### Requirement: Сворачиваемые разделы панели
Контекстная панель SHALL группировать содержимое в сворачиваемые разделы, а состояние
сворачивания SHALL сохраняться; раздел «Информация» SHALL показывать метаданные фото,
включая извлечённые EXIF-атрибуты.
#### Scenario: Сворачивание раздела
- **WHEN** пользователь нажимает заголовок раздела
- **THEN** содержимое раздела скрывается или показывается, состояние сохраняется
#### Scenario: Метаданные фото
- **WHEN** открыт просмотр фото
- **THEN** раздел «Информация» показывает размер, тип, разрешение, дату съёмки, координаты и извлечённые EXIF-атрибуты
### Requirement: Полноэкранный просмотр фотографии
В режиме просмотра SHALL быть доступен переход в полноэкранный режим кнопкой и хоткеем `f`.
В полноэкранном режиме верхняя и нижняя панели SHALL скрываться и показываться у
соответствующей кромки экрана.
#### Scenario: Вход в полный экран
- **WHEN** пользователь нажимает кнопку полного экрана или клавишу `f`
- **THEN** просмотр разворачивается на весь экран, панели скрыты
#### Scenario: Панель по кромке
- **WHEN** курсор касается верхней или нижней кромки экрана
- **THEN** соответствующая панель показывается, а при уходе курсора скрывается сразу
#### Scenario: Выход из полного экрана
- **WHEN** пользователь повторно нажимает кнопку или клавишу `f`
- **THEN** полноэкранный режим закрывается
#### Scenario: Полноэкранный режим приложения
- **WHEN** включён полноэкранный режим приложения, и пользователь нажимает `f`
- **THEN** открывается полноэкранный просмотр фото (вложенно), а полноэкранный режим приложения не выключается
### Requirement: Зум в просмотре
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
вписанный вид, а из вписанного — в 100%. При зуме SHALL догружаться оригинал изображения.
Стрелки соседних фото SHALL появляться у левой/правой кромки и прятаться в зуме. Зум SHALL
работать и в полноэкранном режиме.
#### Scenario: Зум колесом к указателю
- **WHEN** пользователь вращает колесо мыши над фото
- **THEN** масштаб меняется относительно курсора, страница не прокручивается, предел — 200%
#### Scenario: Пробел
- **WHEN** пользователь нажимает `Space`
- **THEN** из вписанного вида изображение переходит в 100%, а из любого зума возвращается во вписанный
#### Scenario: Оригинал в зуме
- **WHEN** пользователь входит в зум
- **THEN** догружается оригинал изображения и подменяется по факту загрузки
#### Scenario: Стрелки соседних фото
- **WHEN** курсор находится у левой или правой кромки области просмотра
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
#### Scenario: Панорамирование
- **WHEN** фото увеличено
- **THEN** пользователь может перемещать изображение перетаскиванием и прокруткой
### Requirement: Удаление фото из просмотра
Автор фото, владелец и админ альбома SHALL иметь возможность удалить текущее фото из режима
просмотра; удаление SHALL подтверждаться диалогом.
#### Scenario: Подтверждение удаления
- **WHEN** пользователь с правом удаления нажимает «Удалить»
- **THEN** открывается диалог подтверждения, и после подтверждения фото удаляется
#### Scenario: Лента после удаления
- **WHEN** фото удалено
- **THEN** просмотр переходит к соседнему фото, а лента миниатюр обновляется без удалённого
### Requirement: Возврат к обзору
Из альбома и из режима просмотра SHALL быть доступен возврат к обзору альбомов.
#### Scenario: Возврат из альбома
- **WHEN** пользователь нажимает кнопку «назад» слева от названия альбома
- **THEN** открывается обзор альбомов
#### Scenario: Возврат из просмотра
- **WHEN** пользователь нажимает кнопку «назад» в режиме просмотра
- **THEN** открывается альбом с сеткой фото
### Requirement: Настройки списка атрибутов информации
Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим
модальное окно со списком EXIF-атрибутов, где пользователь SHALL отмечать отображаемые
атрибуты и задавать их подписи. В окне SHALL быть доступны действия «отметить все» и
«снять все». Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле
пользователя. При отсутствии сохранённых настроек SHALL отображаться базовый набор атрибутов
(камера, объектив, выдержка, диафрагма, ISO, фокусное расстояние).
#### Scenario: Открытие настроек
- **WHEN** пользователь нажимает иконку настроек в разделе «Информация»
- **THEN** открывается модальное окно со списком EXIF-атрибутов: галочки видимости и поля подписей, а также действия «отметить все»/«снять все»
#### Scenario: Значения по умолчанию
- **WHEN** у пользователя нет сохранённых настроек
- **THEN** в разделе «Информация» показан базовый набор атрибутов, а остальные доступны по элементу «…»
#### Scenario: Сохранение настроек
- **WHEN** пользователь отмечает атрибуты, задаёт подпись и сохраняет
- **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле
#### Scenario: Сброс настроек
- **WHEN** пользователь сбрасывает настройки
- **THEN** сохранённые настройки удаляются из профиля, и отображается базовый набор с исходными подписями
### Requirement: Свёртка скрытых атрибутов
Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент
«…»; нажатие на него SHALL раскрывать все атрибуты текущего фото, включая скрытые. Если
скрытых атрибутов нет, элемент «…» SHALL не отображаться.
#### Scenario: Скрытые атрибуты
- **WHEN** в разделе «Информация» есть скрытые настройками атрибуты
- **THEN** после видимых атрибутов показан элемент «…»
#### Scenario: Раскрытие всех атрибутов
- **WHEN** пользователь нажимает «…»
- **THEN** отображаются все атрибуты фото, включая скрытые
#### Scenario: Нет скрытых атрибутов
- **WHEN** ни один атрибут не скрыт
- **THEN** элемент «…» не отображается
### Requirement: Выбор фото в сетке
В режиме альбома одиночный выбор фото SHALL отмечать его рамкой и показывать его сведения в
контекстной панели, не открывая просмотр; двойной выбор SHALL открывать режим просмотра фото.
#### Scenario: Одиночный выбор
- **WHEN** пользователь кликает по фото в сетке
- **THEN** фото отмечено рамкой, правая панель показывает сведения об этом фото, просмотр не открывается
#### Scenario: Двойной выбор
- **WHEN** пользователь дважды кликает по фото в сетке
- **THEN** открывается режим просмотра этого фото
#### Scenario: Смена альбома
- **WHEN** пользователь переходит в другой альбом
- **THEN** отметка выбора сбрасывается
### Requirement: Мультивыбор фото в сетке
В режиме альбома пользователь SHALL выбирать несколько фото: обычный клик — одно фото,
Ctrl/Cmd-клик — добавить или убрать, Shift-клик — диапазон. Выбранные фото SHALL быть отмечены.
#### Scenario: Мультивыбор
- **WHEN** пользователь кликает по фото с зажатым Ctrl/Cmd или Shift
- **THEN** соответствующие фото отмечены, а панель показывает количество выбранных
### Requirement: Действия с выбранными фото
Когда выбрано одно или несколько фото, правая панель SHALL показывать раздел «Действия» со
строками-пунктами (иконка + подпись) «Открыть (просмотр)», «Переместить», «Копировать» и
«Удалить»; удаление SHALL подтверждаться диалогом.
#### Scenario: Действия
- **WHEN** выбрано одно или несколько фото
- **THEN** панель показывает раздел «Действия» с пунктами «Открыть (просмотр)», «Переместить», «Копировать» и «Удалить»
### Requirement: Перетаскивание выбранных фото на альбом
Выбранные фото SHALL перетаскиваться на альбом в рейле; при отпускании SHALL открываться
диалог выбора переноса или копирования с указанием целевого альбома. Во время такого
перетаскивания зона загрузки файлов SHALL не показываться.
#### Scenario: Перетаскивание на альбом
- **WHEN** пользователь перетаскивает выделенные фото на альбом в рейле и отпускает
- **THEN** открывается диалог с выбором переноса/копирования и целевым альбомом
### Requirement: Корзина в списке альбомов
Рейл альбомов SHALL содержать вход в корзину последним элементом списка (с иконкой корзины).
Экран обзора альбомов SHALL не показывать пустую правую панель.
#### Scenario: Вход в корзину
- **WHEN** пользователь открывает приложение
- **THEN** в левом рейле последним элементом списка показана «Корзина» с иконкой корзины
#### Scenario: Обзор без панели
- **WHEN** пользователь открывает обзор альбомов
- **THEN** правая панель не отображается
### Requirement: Действия в режиме просмотра
В режиме просмотра правая панель SHALL содержать раздел «Действия» со строками-пунктами
(иконка + подпись) «Скачать», «Переместить», «Копировать» и «Удалить» (удаление — по правам).
Верхняя панель просмотра SHALL не дублировать скачивание и удаление.
#### Scenario: Действия в просмотре
- **WHEN** открыт режим просмотра фото
- **THEN** раздел «Действия» содержит «Скачать», «Переместить», «Копировать» и «Удалить», а в верхней панели этих кнопок нет
#### Scenario: Удаление из панели
- **WHEN** пользователь удаляет фото из раздела «Действия»
- **THEN** открывается подтверждение, и после удаления просмотр переходит к соседнему фото
### Requirement: Контекстное меню фото
Правый клик по фото в сетке и по изображению в режиме просмотра SHALL открывать контекстное
меню с пунктами раздела «Действия» («Скачать», «Переместить», «Копировать», «Удалить»; в сетке
также «Открыть (просмотр)»), выполняющими те же действия.
#### Scenario: Меню в сетке
- **WHEN** пользователь кликает правой кнопкой по фото в сетке
- **THEN** открывается меню с пунктами «Открыть (просмотр)», «Скачать», «Переместить», «Копировать», «Удалить»
#### Scenario: Меню в просмотре
- **WHEN** пользователь кликает правой кнопкой по изображению в режиме просмотра
- **THEN** открывается меню с пунктами «Скачать», «Переместить», «Копировать», «Удалить»
### Requirement: Контекстное меню альбома
Правый клик по альбому в рейле и по карточке на обзоре SHALL открывать меню с пунктами
«Переименовать» и «Удалить»; переименование SHALL открывать диалог, удаление — подтверждение.
#### Scenario: Меню альбома
- **WHEN** пользователь кликает правой кнопкой по альбому
- **THEN** открывается меню с «Переименовать» и «Удалить»
#### Scenario: Удаление альбома
- **WHEN** пользователь подтверждает удаление альбома
- **THEN** альбом удаляется, а его фото попадают в корзину
### Requirement: Счётчики в рейле
Рейл SHALL показывать число фото у каждого альбома и число элементов в корзине.
#### Scenario: Счётчики
- **WHEN** пользователь смотрит на рейл альбомов
- **THEN** у каждого альбома показано число фото, а у корзины — число удалённых фото
### Requirement: Метки в панели фото
Панель фото SHALL содержать раздел «Метки» с чипами меток фото и полем добавления, дающим
поиск по меткам пользователя и создание новых.
#### Scenario: Раздел «Метки»
- **WHEN** открыт просмотр или выбран один фото в сетке
- **THEN** в панели есть раздел «Метки» с текущими метками и полем добавления с поиском
### Requirement: Компактный верхний хром
Верхняя панель приложения и верхняя панель режима просмотра SHALL иметь минимальную высоту
(узкие отступы, иконочные органы управления), сохраняя все действия доступными. Верхняя панель
режима просмотра SHALL быть прозрачным оверлеем и SHALL не занимать место в раскладке,
увеличивая область фото.
#### Scenario: Компактные верхние панели
- **WHEN** открыт список альбомов или режим просмотра фото
- **THEN** верхняя панель занимает минимум высоты, а её действия (навигация, полный экран, скачивание, удаление) доступны
#### Scenario: Прозрачная панель просмотра
- **WHEN** открыт режим просмотра фото
- **THEN** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения
@@ -0,0 +1,85 @@
# Tasks
## 1. Backend: обложка и целостность
- [x] 1.1 Добавить миграцию `000002_album_cover_fk` (up/down): обнулить висячие `cover_photo_id`, затем `ADD CONSTRAINT ... FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL`. Проверка: `make migrate` применяется, `\d albums` показывает constraint, down-миграция снимает его.
- [x] 1.2 Добавить `CoverThumbURL` в `models.Album` (json `cover_thumb_url,omitempty`). Проверка: `cd backend && go build ./...` проходит.
- [x] 1.3 В `albums/store.go` добавить `LEFT JOIN photos p ON p.id = a.cover_photo_id` и выбирать `p.thumb_key AS cover_thumb_key` в `ListForUser` и `GetByID`. Проверка: запрос возвращает `cover_thumb_key` для альбома с обложкой и `NULL` без неё.
- [x] 1.4 В `albums/service.go` пресигнить `cover_thumb_key` в `CoverThumbURL` в `List` и `Get`. Проверка: `GET /api/albums` в ответе содержит `cover_thumb_url` у альбома с обложкой.
- [x] 1.5 В `photos/service.go` вызывать `SetCover` только при `cover_photo_id IS NULL`. Проверка: загрузка двух фото в новый альбом оставляет обложкой первое; после удаления фото-обложки cover становится `NULL`, следующая загрузка ставит новый.
- [x] 1.6 Покрыть тестами store-join обложки и автообложку; проверить `cd backend && go test ./...`.
## 2. Frontend: темы и каркас
- [x] 2.1 Добавить токены `--chrome`/`--canvas`, перенастроить `dark` в графит и сделать его дефолтом (при отсутствии сохранённого выбора). Проверка: `cd frontend && npm run check` проходит, все три темы визуально согласованы.
- [x] 2.2 Добавить `cover_thumb_url` в `lib/types.ts` и недостающие иконки (обложка, плотность/меню) в `lib/icons/registry.ts`. Проверка: `npm run check` без ошибок типов.
- [x] 2.3 Разбить `+layout.svelte` на группы `(app)`/`(public)` и ввести `AppShell` (топбар, слот рейла, слот панели, центр = маршрут). Проверка: навигация между альбомами не перезагружает рейл; `/login`, `/invites/[token]`, `/share/[token]` рендерятся без каркаса.
- [x] 2.4 Реализовать адаптивный каркас: рейл → drawer, панель → сворачиваемая секция, лента → горизонтальный скролл на узкой ширине. Проверка: при ширине меньше порога каркас управляем без горизонтального overflow.
## 3. Рейл и создание альбома
- [x] 3.1 Компонент `AlbumRail`: список альбомов с обложками, плейсхолдер, подсветка активного. Проверка: активный альбом выделен, остальные показывают обложку/плейсхолдер.
- [x] 3.2 Кнопка создания `+` с хинтом и модалка ввода названия (замена инлайн-формы). Проверка: создание альбома через модалку, новый альбом становится активным; пустое название не создаёт альбом.
## 4. Контекстная панель и обложки
- [x] 4.1 Компонент `AlbumPanel`: превью обложки, название, счётчики, переходы к участникам/настройкам/ссылкам; управление обложкой только для owner/admin. Проверка: viewer не видит управление обложкой.
- [x] 4.2 Модальный `CoverPicker` (сетка миниатюр) + `PATCH /albums/:id` с `cover_photo_id`/`clear_cover`. Проверка: назначение и сброс обложки отражаются в рейле, карточке и панели.
- [x] 4.3 Выбор обложки выполняется только из правой панели (модальный пикер); действие «Сделать обложкой» в тулбаре просмотра не используется. Проверка: обложка задаётся и сбрасывается из панели, в тулбаре просмотра кнопки нет.
## 5. Центр: сетка, плотность, загрузка
- [x] 5.1 Сетка альбомов edge-to-edge с реальными обложками вместо карточек-заглушек. Проверка: карточки показывают `cover_thumb_url`, при отсутствии — плейсхолдер.
- [x] 5.2 Регулятор плотности миниатюр в тулбаре сетки с сохранением в `localStorage`. Проверка: размер меняется и восстанавливается после перезагрузки.
- [x] 5.3 Загрузка: иконочная кнопка в тулбаре, полноэкранный drag-overlay, зона-подсказка в пустом альбоме; прогресс компактной полосой. Проверка: перетаскивание и выбор файлов загружают фото, пустой альбом показывает подсказку.
## 6. Просмотр как маршрут
- [x] 6.1 Маршрут `albums/[id]/photos/[photoId]` и компонент `Viewer` (крупное фото, filmstrip, панель метаданных/комментариев, тулбар, клавиши ←/→/Esc). Проверка: переход из сетки открывает маршрут, адрес отражает фото, навигация синхронна с filmstrip.
- [x] 6.2 Переиспользовать `Viewer` на `share/[token]` вместо инлайн-лайтбокса. Проверка: публичный просмотр использует тот же компонент без рейла/панели.
## 7. Интеграция
- [x] 7.1 Пройти сценарий: создать альбом → загрузить фото → задать обложку → открыть просмотр → открыть share-ссылку; проверить поведение на десктопе и узком экране.
- [x] 7.2 Проверить `make build` (сборка фронтенда в `backend/static`, статический Go-бинарник) и `openspec validate "lightroom-workspace-ui"`.
## 8. Дополнительно реализовано (после планирования)
- [x] 8.1 Переименование фото: `PATCH /photos/:id` (права автора/owner/admin, пустое имя 400) и инлайн-редактор в правой панели; событие `photo.updated`. Проверка: переименование сохраняется, пустое имя отклоняется, чужое фото — отказ, событие приходит.
- [x] 8.2 Удаление фото из просмотра с модальным подтверждением; переход к соседнему и обновление ленты. Проверка: после удаления лента не содержит удалённое фото.
- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) с автоскрытием верхней/нижней панелей у кромок и стрелок (1/6 края, в зуме скрыты); поведение без таймеров — панели и стрелки показываются/скрываются сразу, стрелки только в границах вьюера; зум колесом к указателю без прокрутки (макс 200%), `Space` возвращает из любого зума во вписанный вид, в зуме подгружается оригинал. Проверка: поведение подтверждено в браузере (автоскрытие, зум к указателю, предел 200%, подмена превью→оригинал).
- [x] 8.4 Сворачиваемые разделы правой панели с сохранением состояния и раздел «Информация» (EXIF). Проверка: состояние сохраняется между сессиями, метаданные отображаются.
- [x] 8.5 Ресайз левой и правой панелей перетаскиванием с сохранением ширины. Проверка: ширина восстанавливается после перезагрузки.
- [x] 8.6 Лимит загрузки 100 МБ и понятные ошибки (клиентская проверка и `413 file_too_large`). Проверка: 60 МБ проходит разбор, превышение лимита даёт явную ошибку размера.
- [x] 8.7 Счётчики `photos_count`/`members_count` в ответе альбома. Проверка: `GET /api/albums` и `GET /api/albums/:id` содержат счётчики.
- [x] 8.8 Тема и хром: нейтральная тема — тёплый графит, порядок и значок переключателя, стилизация скроллбаров. Проверка: темы переключаются согласованно, скроллбары в тон темы.
- [x] 8.9 Извлечение EXIF: парсер заменён на `github.com/dsoprea/go-exif/v3` (все распознаваемые теги, нормализация чисел), хранение в `photos.exif` (миграция `000003_photo_exif`, jsonb `'{}'` по умолчанию), отдача в API фото; дата съёмки и GPS дублируются в `taken_at`/координаты. Проверка: реальное фото HUAWEI nova 15 Pro даёт 67 атрибутов; `go test ./...`.
- [x] 8.10 Бэкфилл метаданных существующих фото: команда `cmd/backfill-exif` + `storage.Get` + `photos.Store.BackfillEXIF` (тянет оригиналы из S3, заполняет `exif`/`taken_at`/координаты). Проверка: прогон обновил 25 фото, `taken_at` проставлен.
- [x] 8.11 Раздел «Информация» показывает все EXIF-атрибуты с русскими подписями, переводом enum и единицами (выдержка `1/N`, `f/N`, `N мм`, EV); служебные/дублирующие теги скрыты. Проверка: Playwright на реальном фото — 9/9.
## 9. Настройки информации пользователя
- [x] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает.
- [x] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется.
- [x] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы.
- [x] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`.
- [x] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле.
- [x] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы.
- [x] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
- [x] 9.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12).
- [x] 9.9 Выбор фото в сетке: одиночный клик выделяет фото рамкой и показывает его сведения в правой панели, двойной — открывает просмотр; стор `selection`, сброс при смене альбома. Проверка: Playwright — выделение без навигации, панель показывает инфо, двойной клик открывает просмотр (5/5).
- [x] 9.10 Вьюер и модалки: убраны таймеры автоскрытия (панели/стрелки мгновенно), стрелки появляются только в границах вьюера, `f`/кнопка переключают вложенный fullscreen вьюера (не выключают режим приложения); модалки рендерятся порталом в `document.body` (центрирование во вьюпорте). Проверка: Playwright — стрелки 4/4, fullscreen+f 6/6, центрирование 2/2.
- [x] 9.11 Корзина: миграция `000005_photo_trash` (`deleted_at`), мягкое удаление (`DELETE /photos/:id`), `GET /trash`, `POST /photos/:id/restore`, `DELETE /photos/:id/permanent` с удалением объектов S3 только при отсутствии ссылок на `storage_key`. Проверка: удаление → в корзине, восстановление возвращает, копия сохраняет объект.
- [x] 9.12 Перенос/копирование: `POST /photo-batch/{delete,move,copy}`, клон записи с общими объектами S3 (метаданные), события SSE для источника и цели. Проверка: копия даёт N фото в цели, перенос убирает из источника.
- [x] 9.13 Фронтенд: стор `selection` (Ctrl/Shift-мультивыбор), действия в панели «Удалить/Переместить/Копировать», drag выделенных на альбом рейла, глобальный `TransferDialog`. Проверка: Playwright — мультивыбор и copy 9/9, drag+move 4/4.
- [x] 9.14 Рейл: вход «Корзина» последним элементом списка с иконкой, скрытие пустой правой панели (`panelVisible`), маршрут `/trash` с восстановлением и окончательным удалением. Проверка: в рейле есть вход в корзину, на обзоре нет правой панели.
- [x] 9.15 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
- [x] 9.16 Раздел «Действия» в панели — пункты-надписи с иконками (не кнопки): «Открыть (просмотр)», «Переместить», «Копировать», «Удалить»; надпись «Выбрано:» убрана. Проверка: Playwright — «Открыть (просмотр)» открывает просмотр, раздел присутствует (4/4).
- [x] 9.17 Компактный верхний хром: верхняя панель приложения и верхняя панель режима просмотра сведены к минимуму (узкие отступы, иконочные кнопки `size-4`, компактный переключатель темы). Проверка: высота `<header>` ~31px, верхней панели просмотра ~29px; `npm run check`.
- [x] 9.18 Действия просмотра перенесены в правую панель: в разделе «Действия» — «Скачать», «Переместить», «Копировать», «Удалить»; из верхней панели просмотра скачивание и удаление убраны; удаление через стор `viewerActions` и страницу просмотра. Проверка: Playwright — верх без скачать/удалить, панель с действиями, удаление из панели переходит к соседнему фото (9/9).
- [x] 9.19 Единая панель фото: содержимое для одного фото вынесено в общий компонент `PhotoPanel`, переиспользуемый в просмотре и при одиночном выборе в сетке (не копия); мультивыбор — только «Действия». Проверка: Playwright — просмотр 9/9, выбор 5/5, мультивыбор 9/9; `npm run check`.
- [x] 9.20 Контекстное меню фото (правая кнопка) в сетке и просмотре с пунктами из «Действий»; общий стор `contextMenu` и глобальный `ContextMenu`. Проверка: Playwright — меню в сетке (5 пунктов), меню в просмотре (4 пункта), «Открыть» из меню и «Переместить» → диалог (12/12).
- [x] 9.21 Контекстное меню альбома («Переименовать»/«Удалить») в рейле и на обзоре; диалоги в `AlbumActionsDialogs`. Проверка: Playwright — меню, переименование, удаление (4/4).
- [x] 9.22 Удаление альбома помещает фото в корзину без потери файлов (FK `photos.album_id` → `ON DELETE SET NULL`, миграция `000006`); восстановление без альбома предлагает выбрать/создать альбом (`album_required` + `POST /photos/:id/restore {album_id}`). Проверка: Playwright — удалить альбом → фото в корзине, диалог восстановления, фото в новом альбоме (7/7).
- [x] 9.23 Счётчики в рейле: число фото у альбома и число элементов корзины (`GET /trash/count`, стор `trash`); обновление счётчика альбома после загрузки/удаления. Проверка: Playwright — альбом 2, корзина 0→1 (3/3).
- [x] 9.24 Метки фото (область — пользователь): таблицы `tags`/`photo_tags` (миграция `000007`), `GET /tags?q=`, `POST /photos/:id/tags`, `DELETE /photos/:id/tags/:tag_id`, метки в ответе фото; раздел «Метки» в панели с чипами, поиском и созданием. Проверка: API — создание/поиск/снятие; Playwright — создание, снятие, выбор из поиска (4/4).
+41 -2
View File
@@ -35,8 +35,47 @@
- **THEN** система сохраняет изменения - **THEN** система сохраняет изменения
### Requirement: Удаление альбома ### Requirement: Удаление альбома
Владелец SHALL иметь возможность удалить альбом вместе с содержимым. Владелец SHALL иметь возможность удалить альбом; фото альбома SHALL помещаться в корзину без
удаления объектов хранилища, а участники и share-ссылки SHALL удаляться вместе с альбомом.
#### Scenario: Удаление альбома #### Scenario: Удаление альбома
- **WHEN** владелец удаляет альбом - **WHEN** владелец удаляет альбом
- **THEN** система удаляет альбом, его фото, участников и share-ссылки - **THEN** фото альбома попадают в корзину без привязки к альбому и файлы сохраняются, участники и share-ссылки удаляются
### Requirement: Отображение обложки альбома
Данные альбома SHALL включать URL миниатюры обложки, когда обложка задана, чтобы клиенты
могли показать её в списке, рейле и карточке.
#### Scenario: Обложка задана
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
- **THEN** ответ содержит URL миниатюры обложки
#### Scenario: Обложка отсутствует
- **WHEN** у альбома нет обложки или фото обложки удалено
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
### Requirement: Выбор обложки
Владелец и админ SHALL иметь возможность назначить обложкой любое фото альбома или сбросить
обложку; после сброса обложка определяется заново по умолчанию.
#### Scenario: Назначение обложки
- **WHEN** владелец выбирает фото альбома как обложку
- **THEN** система сохраняет выбор и отражает его в списке и карточках
#### Scenario: Сброс обложки
- **WHEN** админ сбрасывает обложку
- **THEN** система удаляет явный выбор, и обложка возвращается к значению по умолчанию
#### Scenario: Нет прав на смену обложки
- **WHEN** участник без права редактирования пытается сменить обложку
- **THEN** система отклоняет запрос
### Requirement: Счётчики альбома
Данные альбома SHALL включать количество фото и участников, чтобы панель и карточки могли
показать их без отдельных запросов.
#### Scenario: Счётчики в ответе
- **WHEN** клиент запрашивает список альбомов или альбом по id
- **THEN** ответ содержит число фото и число участников альбома
+21
View File
@@ -45,3 +45,24 @@
#### Scenario: Выход #### Scenario: Выход
- **WHEN** пользователь вызывает logout с действующим refresh-токеном - **WHEN** пользователь вызывает logout с действующим refresh-токеном
- **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется - **THEN** система отзывает refresh-токен, и его дальнейшее использование отклоняется
### Requirement: Хранение пользовательских настроек интерфейса
Система SHALL хранить настройки отображения в профиле пользователя и SHALL возвращать их при
получении текущего пользователя. Настройки SHALL быть приватными для пользователя.
Отсутствие настроек SHALL трактоваться как значения по умолчанию.
#### Scenario: Получение настроек
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
- **THEN** ответ содержит его настройки отображения (или пустой объект по умолчанию)
#### Scenario: Сохранение настроек
- **WHEN** пользователь сохраняет настройки отображения
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
#### Scenario: Изоляция настроек
- **WHEN** другой пользователь запрашивает свой профиль
- **THEN** он не получает настройки первого пользователя
#### Scenario: Некорректные настройки
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
+122
View File
@@ -55,3 +55,125 @@
#### Scenario: Удаление чужого фото участником #### Scenario: Удаление чужого фото участником
- **WHEN** участник с ролью member пытается удалить чужое фото - **WHEN** участник с ролью member пытается удалить чужое фото
- **THEN** система отклоняет запрос - **THEN** система отклоняет запрос
### Requirement: Обложка по умолчанию
При обработке загруженного фото система SHALL назначать его обложкой альбома только если
обложка ещё не задана.
#### Scenario: Первое фото
- **WHEN** фото обработано, и у альбома нет обложки
- **THEN** система назначает это фото обложкой
#### Scenario: Обложка уже задана
- **WHEN** фото обработано, а обложка уже задана
- **THEN** система не меняет обложку
### Requirement: Переименование фото
Автор фото, владелец и админ альбома SHALL иметь возможность изменить имя файла фото;
остальные SHALL получать отказ, а пустое имя SHALL отклоняться.
#### Scenario: Переименование автором
- **WHEN** автор меняет имя файла своего фото на непустое
- **THEN** система сохраняет новое имя и транслирует изменение подписчикам альбома
#### Scenario: Пустое имя
- **WHEN** пользователь подтверждает пустое имя файла
- **THEN** система отклоняет запрос
#### Scenario: Нет прав
- **WHEN** участник без прав переименовывает чужое фото
- **THEN** система отклоняет запрос
### Requirement: Ограничение размера загрузки
Система SHALL ограничивать размер загружаемых файлов и SHALL отвечать понятной ошибкой при
превышении лимита, а не ошибкой разбора формы.
#### Scenario: Превышение лимита
- **WHEN** загружаемый файл превышает максимальный размер
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
#### Scenario: Файл в пределах лимита
- **WHEN** файл не превышает лимит
- **THEN** система принимает multipart-загрузку и обрабатывает фото
### Requirement: Метаданные фото (EXIF)
При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их
вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор.
#### Scenario: Фото с метаданными
- **WHEN** загружается фото с EXIF
- **THEN** данные фото содержат извлечённые атрибуты (камера, экспозиция, ISO, дата съёмки и др.)
#### Scenario: Фото без метаданных
- **WHEN** загружается фото без читаемых EXIF
- **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно
### Requirement: Перенос и копирование фото между альбомами
Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь
возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только
метаданные и SHALL не изменять объекты хранилища.
#### Scenario: Перенос
- **WHEN** пользователь переносит фото в другой альбом
- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты S3 не меняются
#### Scenario: Копирование
- **WHEN** пользователь копирует фото в другой альбом
- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты S3
### Requirement: Корзина фото
Удаление фото SHALL помещать его в корзину (мягкое удаление). Восстановление SHALL возвращать
фото в его альбом. Окончательное удаление SHALL удалять запись, а объекты хранилища SHALL
удаляться только когда на них не остаётся ссылок ни в одном альбоме.
#### Scenario: Удаление в корзину
- **WHEN** пользователь удаляет фото
- **THEN** фото пропадает из альбома и появляется в корзине
#### Scenario: Восстановление
- **WHEN** пользователь восстанавливает фото из корзины
- **THEN** фото возвращается в свой альбом
#### Scenario: Окончательное удаление без ссылок
- **WHEN** пользователь удаляет фото из корзины навсегда, и на те же объекты нет ссылок в других альбомах
- **THEN** запись и объекты хранилища удаляются
#### Scenario: Окончательное удаление при наличии ссылок
- **WHEN** на объект хранилища ссылается копия в другом альбоме
- **THEN** запись удаляется, а объект хранилища сохраняется
### Requirement: Удаление альбома и восстановление фото без альбома
Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при
восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий
альбом или создать новый.
#### Scenario: Удаление альбома
- **WHEN** владелец удаляет альбом
- **THEN** его фото оказываются в корзине без привязки к альбому, файлы сохраняются
#### Scenario: Восстановление без альбома
- **WHEN** пользователь восстанавливает фото, у которого нет альбома
- **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него
### Requirement: Метки фото
Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и
выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься.
#### Scenario: Создание метки
- **WHEN** пользователь вводит новую метку и подтверждает
- **THEN** метка создаётся и привязывается к фото
#### Scenario: Выбор из списка с поиском
- **WHEN** пользователь вводит часть названия метки
- **THEN** показываются подходящие метки пользователя для выбора
#### Scenario: Снятие метки
- **WHEN** пользователь убирает метку с фото
- **THEN** метка отвязывается от фото
+384
View File
@@ -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** верхняя панель нарисована поверх фото без фона и не уменьшает область изображения