Merge branch 'develop' into feature/custom-tf-model-127

This commit is contained in:
raystlin 2025-07-25 21:22:28 +00:00
commit e704ccfc47
475 changed files with 45088 additions and 27486 deletions

View file

@ -7,6 +7,10 @@
/assets/facenet
/assets/nasnet
/assets/nsfw
/assets/efficientnet
/assets/imagenet
/assets/resnet
/assets/vision
/storage
/build
/photoprism
@ -31,6 +35,10 @@ compose.*.yaml
*.override.yaml
*.tmp.yml
*.tmp.yaml
*.tmp
*.img
*.img.xz
*.img.gz
# Automatically generated files, e.g. by editors and operating systems
.DS_Store

5
.gitignore vendored
View file

@ -18,6 +18,10 @@
*.dll
*.so
*.dylib
*.tmp
*.img
*.img.xz
*.img.gz
/*.zip
/coverage.*
__pycache__
@ -40,6 +44,7 @@ venv
/frontend/src/locales/*.mo
/frontend/tests_output
frontend/coverage/
**/__screenshots__/
/photoprism
/photoprism-*
/photos/originals/*

View file

@ -1,5 +1,5 @@
# Ubuntu 25.04 (Plucky Puffin)
FROM photoprism/develop:250507-plucky
FROM photoprism/develop:250709-plucky
## Alternative Environments:
# FROM photoprism/develop:armv7 # ARMv7 (32bit)
@ -20,3 +20,5 @@ WORKDIR "/go/src/github.com/photoprism/photoprism"
# Copy source to image.
COPY . .
COPY --chown=root:root /scripts/dist/ /scripts/
RUN sudo /scripts/install-yt-dlp.sh

View file

@ -74,6 +74,7 @@ test: test-js test-go
test-go: reset-sqlite run-test-go
test-pkg: reset-sqlite run-test-pkg
test-api: reset-sqlite run-test-api
test-video: reset-sqlite run-test-video
test-entity: reset-sqlite run-test-entity
test-commands: reset-sqlite run-test-commands
test-photoprism: reset-sqlite run-test-photoprism
@ -239,12 +240,15 @@ dep-npm:
sudo npm install -g npm
dep-js:
(cd frontend && npm ci --no-update-notifier --no-audit)
# TODO: If in the future we want to test in a real browser environment, add this (Playwright)
# (cd frontend && npx playwright install chromium)
dep-go:
go build -v ./...
dep-upgrade:
go get -u -t ./...
dep-upgrade-js:
(cd frontend && npm update --legacy-peer-deps)
frontend-update:
make -C frontend update
dep-upgrade-js: frontend-update
dep-tensorflow:
scripts/download-facenet.sh
scripts/download-nasnet.sh
@ -320,6 +324,9 @@ docker-tensorflow-arm64:
terminal-tensorflow-arm64:
mkdir -p ./build
docker run --rm --pull missing -ti --platform=arm64 -v "./build:/build" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/tensorflow:arm64 bash
build-setup: build-setup-nas-raspberry-pi
build-setup-nas-raspberry-pi:
./scripts/setup/nas/raspberry-pi/build.sh
watch-js:
(cd frontend && env BUILD_ENV=development NODE_ENV=production npm run watch)
test-js:
@ -343,6 +350,21 @@ acceptance-auth-short:
acceptance-auth-firefox:
$(info Running JS acceptance-auth tests in Firefox...)
(cd frontend && npm run testcafe -- firefox:headless --test-grep "^(Common|Core)\:*" --test-meta mode=auth --config-file ./testcaferc.json --disable-native-automation "tests/acceptance")
vitest:
$(info Running Vitest unit tests...)
(cd frontend && npm run vitest)
vitest-watch:
$(info Running Vitest unit tests in watch mode...)
(cd frontend && npm run vitest-watch)
vitest-coverage:
$(info Running Vitest unit tests with coverage...)
(cd frontend && npm run vitest-coverage)
vitest-component:
$(info Running Vitest component tests...)
(cd frontend && npm run vitest-component)
vitest-ui:
$(info Opening Vitest UI...)
(cd frontend && npm run vitest-ui)
reset-mariadb:
$(info Resetting photoprism database...)
mysql < scripts/sql/reset-photoprism.sql
@ -376,6 +398,9 @@ run-test-pkg:
run-test-api:
$(info Running all API tests...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./internal/api/...
run-test-video:
$(info Running all video tests...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./internal/ffmpeg/... ./internal/photoprism/dl/... ./pkg/media/...
run-test-entity:
$(info Running all Entity tests...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./internal/entity/...

222
NOTICE
View file

@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with
PhotoPrism. Any information relevant to third-party vendors listed below are
collected using common, reasonable means.
Date generated: 2025-05-06
Date generated: 2025-07-16
================================================================================
@ -408,8 +408,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/cpuguy83/go-md2man/v2/md2man
Version: v2.0.6
License: MIT (https://github.com/cpuguy83/go-md2man/blob/v2.0.6/LICENSE.md)
Version: v2.0.7
License: MIT (https://github.com/cpuguy83/go-md2man/blob/v2.0.7/LICENSE.md)
The MIT License (MIT)
@ -541,7 +541,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
Package: github.com/dsoprea/go-heic-exif-extractor/v2
Version: v2.0.0-20210512044107-62067e44c235
License: MIT (https://github.com/dsoprea/go-heic-exif-extractor/blob/62067e44c235/LICENSE)
License: MIT (https://github.com/dsoprea/go-heic-exif-extractor/blob/62067e44c235/v2/LICENSE)
MIT LICENSE
@ -585,7 +585,7 @@ SOFTWARE.
Package: github.com/dsoprea/go-jpeg-image-structure/v2
Version: v2.0.0-20221012074422-4f3f7e934102
License: MIT (https://github.com/dsoprea/go-jpeg-image-structure/blob/4f3f7e934102/LICENSE)
License: MIT (https://github.com/dsoprea/go-jpeg-image-structure/blob/4f3f7e934102/v2/LICENSE)
MIT LICENSE
@ -661,7 +661,7 @@ SOFTWARE.
Package: github.com/dsoprea/go-png-image-structure/v2
Version: v2.0.0-20210512210324-29b889a6093d
License: MIT (https://github.com/dsoprea/go-png-image-structure/blob/29b889a6093d/LICENSE)
License: MIT (https://github.com/dsoprea/go-png-image-structure/blob/29b889a6093d/v2/LICENSE)
MIT LICENSE
@ -677,7 +677,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
Package: github.com/dsoprea/go-tiff-image-structure/v2
Version: v2.0.0-20221003165014-8ecc4f52edca
License: MIT (https://github.com/dsoprea/go-tiff-image-structure/blob/8ecc4f52edca/LICENSE)
License: MIT (https://github.com/dsoprea/go-tiff-image-structure/blob/8ecc4f52edca/v2/LICENSE)
MIT License
@ -705,7 +705,7 @@ SOFTWARE.
Package: github.com/dsoprea/go-utility/v2
Version: v2.0.0-20221003172846-a3e1774ef349
License: MIT (https://github.com/dsoprea/go-utility/blob/a3e1774ef349/LICENSE)
License: MIT (https://github.com/dsoprea/go-utility/blob/a3e1774ef349/v2/LICENSE)
Copyright 2019 Random Ingenuity InformationWorks
@ -1010,6 +1010,33 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/fatih/color
Version: v1.18.0
License: MIT (https://github.com/fatih/color/blob/v1.18.0/LICENSE.md)
The MIT License (MIT)
Copyright (c) 2013 Fatih Arslan
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/gabriel-vasile/mimetype
Version: v1.4.9
License: MIT (https://github.com/gabriel-vasile/mimetype/blob/v1.4.9/LICENSE)
@ -1095,8 +1122,8 @@ THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/gin-gonic/gin
Version: v1.10.0
License: MIT (https://github.com/gin-gonic/gin/blob/v1.10.0/LICENSE)
Version: v1.10.1
License: MIT (https://github.com/gin-gonic/gin/blob/v1.10.1/LICENSE)
The MIT License (MIT)
@ -1123,8 +1150,8 @@ THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/go-co-op/gocron/v2
Version: v2.16.1
License: MIT (https://github.com/go-co-op/gocron/blob/v2.16.1/LICENSE)
Version: v2.16.2
License: MIT (https://github.com/go-co-op/gocron/blob/v2.16.2/LICENSE)
MIT License
@ -2316,8 +2343,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/golang/geo
Version: v0.0.0-20250505201543-5b58c72585db
License: Apache-2.0 (https://github.com/golang/geo/blob/5b58c72585db/LICENSE)
Version: v0.0.0-20250707181242-c5087ca84cf4
License: Apache-2.0 (https://github.com/golang/geo/blob/c5087ca84cf4/LICENSE)
Apache License
@ -2525,8 +2552,8 @@ License: Apache-2.0 (https://github.com/golang/geo/blob/5b58c72585db/LICENSE)
--------------------------------------------------------------------------------
Package: github.com/google/open-location-code/go
Version: v0.0.0-20250415120251-fa6d7f9d4765
License: Apache-2.0 (https://github.com/google/open-location-code/blob/fa6d7f9d4765/go/LICENSE)
Version: v0.0.0-20250620134813-83986da0156b
License: Apache-2.0 (https://github.com/google/open-location-code/blob/83986da0156b/go/LICENSE)
Apache License
@ -3717,8 +3744,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: github.com/klauspost/cpuid/v2
Version: v2.2.10
License: MIT (https://github.com/klauspost/cpuid/blob/v2.2.10/LICENSE)
Version: v2.3.0
License: MIT (https://github.com/klauspost/cpuid/blob/v2.3.0/LICENSE)
The MIT License (MIT)
@ -3982,8 +4009,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/leonelquinteros/gotext
Version: v1.7.1
License: MIT (https://github.com/leonelquinteros/gotext/blob/v1.7.1/LICENSE)
Version: v1.7.2
License: MIT (https://github.com/leonelquinteros/gotext/blob/v1.7.2/LICENSE)
The MIT License (MIT)
@ -4149,6 +4176,34 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: github.com/mattn/go-colorable
Version: v0.1.14
License: MIT (https://github.com/mattn/go-colorable/blob/v0.1.14/LICENSE)
The MIT License (MIT)
Copyright (c) 2016 Yasuhiro Matsumoto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/mattn/go-isatty
Version: v0.0.20
License: MIT (https://github.com/mattn/go-isatty/blob/v0.0.20/LICENSE)
@ -4318,9 +4373,65 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: github.com/olekukonko/errors
Version: v1.1.0
License: MIT (https://github.com/olekukonko/errors/blob/v1.1.0/LICENSE)
MIT License
Copyright (c) 2025 Oleku Konko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/olekukonko/ll
Version: v0.0.9
License: MIT (https://github.com/olekukonko/ll/blob/v0.0.9/LICENSE)
MIT License
Copyright (c) 2025 Oleku Konko
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/olekukonko/tablewriter
Version: v0.0.5
License: MIT (https://github.com/olekukonko/tablewriter/blob/v0.0.5/LICENSE.md)
Version: v1.0.8
License: MIT (https://github.com/olekukonko/tablewriter/blob/v1.0.8/LICENSE.md)
Copyright (C) 2014 by Oleku Konko
@ -4461,8 +4572,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/pquerna/otp
Version: v1.4.0
License: Apache-2.0 (https://github.com/pquerna/otp/blob/v1.4.0/LICENSE)
Version: v1.5.0
License: Apache-2.0 (https://github.com/pquerna/otp/blob/v1.5.0/LICENSE)
Apache License
@ -4878,8 +4989,8 @@ License: Apache-2.0 (https://github.com/prometheus/client_golang/blob/v1.22.0/LI
--------------------------------------------------------------------------------
Package: github.com/prometheus/client_model/go
Version: v0.6.1
License: Apache-2.0 (https://github.com/prometheus/client_model/blob/v0.6.1/LICENSE)
Version: v0.6.2
License: Apache-2.0 (https://github.com/prometheus/client_model/blob/v0.6.2/LICENSE)
Apache License
Version 2.0, January 2004
@ -5086,8 +5197,8 @@ License: Apache-2.0 (https://github.com/prometheus/client_model/blob/v0.6.1/LICE
--------------------------------------------------------------------------------
Package: github.com/prometheus/common
Version: v0.63.0
License: Apache-2.0 (https://github.com/prometheus/common/blob/v0.63.0/LICENSE)
Version: v0.65.0
License: Apache-2.0 (https://github.com/prometheus/common/blob/v0.65.0/LICENSE)
Apache License
Version 2.0, January 2004
@ -5773,8 +5884,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/ugorji/go/codec
Version: v1.2.12
License: MIT (https://github.com/ugorji/go/blob/codec/v1.2.12/codec/LICENSE)
Version: v1.2.14
License: MIT (https://github.com/ugorji/go/blob/codec/v1.2.14/codec/LICENSE)
The MIT License (MIT)
@ -5831,8 +5942,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/urfave/cli/v2
Version: v2.27.6
License: MIT (https://github.com/urfave/cli/blob/v2.27.6/LICENSE)
Version: v2.27.7
License: MIT (https://github.com/urfave/cli/blob/v2.27.7/LICENSE)
MIT License
@ -6302,9 +6413,9 @@ License: Apache-2.0 (https://github.com/zitadel/logging/blob/v0.6.2/LICENSE)
--------------------------------------------------------------------------------
Package: github.com/zitadel/oidc/v3/pkg
Version: v3.38.1
License: Apache-2.0 (https://github.com/zitadel/oidc/blob/v3.38.1/LICENSE)
Package: github.com/zitadel/oidc/v3
Version: v3.41.0
License: Apache-2.0 (https://github.com/zitadel/oidc/blob/v3.41.0/LICENSE)
Apache License
Version 2.0, January 2004
@ -7586,8 +7697,8 @@ License: Apache-2.0 (https://github.com/go4org/go4/blob/214862532bf5/LICENSE)
--------------------------------------------------------------------------------
Package: golang.org/x/crypto
Version: v0.38.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.38.0:LICENSE)
Version: v0.40.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.40.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7620,8 +7731,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/image
Version: v0.27.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.27.0:LICENSE)
Version: v0.29.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.29.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7654,8 +7765,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/mod/semver
Version: v0.24.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/mod/+/v0.24.0:LICENSE)
Version: v0.26.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/mod/+/v0.26.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7688,8 +7799,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/net
Version: v0.40.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.40.0:LICENSE)
Version: v0.42.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.42.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7722,8 +7833,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/oauth2
Version: v0.29.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/oauth2/+/v0.29.0:LICENSE)
Version: v0.30.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/oauth2/+/v0.30.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7756,8 +7867,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sync/errgroup
Version: v0.14.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.14.0:LICENSE)
Version: v0.16.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.16.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7790,8 +7901,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sys
Version: v0.33.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.33.0:LICENSE)
Version: v0.34.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.34.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7824,8 +7935,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/text
Version: v0.25.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.25.0:LICENSE)
Version: v0.27.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.27.0:LICENSE)
Copyright 2009 The Go Authors.
@ -7858,8 +7969,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/time/rate
Version: v0.11.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/time/+/v0.11.0:LICENSE)
Version: v0.12.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/time/+/v0.12.0:LICENSE)
Copyright 2009 The Go Authors.
@ -8241,10 +8352,13 @@ Package License Copyright
@testing-library/jest-dom MIT Ernesto Garcia <gnapse@gmail.com> (http://gnapse.github.io)
@testing-library/react MIT Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)
@vitejs/plugin-react MIT Evan You
@vitejs/plugin-vue MIT Evan You
@vitest/browser MIT n/a
@vitest/coverage-v8 MIT Anthony Fu <anthonyfu117@hotmail.com>
@vitest/ui MIT n/a
@vue/compiler-sfc MIT Evan You
@vue/language-server MIT n/a
@vue/test-utils MIT Lachlan Miller lachlan.miller.1990@outlook.com
@vvo/tzdb MIT Vincent Voyer <vincent@codeagain.com>
axios MIT Matt Zabriskie
axios-mock-adapter MIT Colin Timmermans <colintimmermans@gmail.com>
@ -8267,7 +8381,6 @@ eslint-plugin-html ISC n/a
eslint-plugin-import MIT Ben Mosher <me@benmosher.com>
eslint-plugin-node MIT Toru Nagashima
eslint-plugin-prettier MIT Teddy Katz
eslint-plugin-promise ISC jden <jason@denizac.org>
eslint-plugin-vue MIT Toru Nagashima (https://github.com/mysticatea)
eslint-plugin-vuetify MIT Kael Watts-Deuchar <kaelwd@gmail.com>
eslint-webpack-plugin MIT Ricardo Gobbo de Souza <ricardogobbosouza@yahoo.com.br>
@ -8295,6 +8408,7 @@ mocha MIT TJ Holowaychuk <tj@vision-media.
node-storage-shim ISC Michael Nahkies
passive-events-support MIT Ignas Damunskis <ignas3run@gmail.com>
photoswipe MIT Dmytro Semenov (https://dimsemenov.com)
playwright Apache-2.0 Microsoft Corporation
postcss MIT Andrey Sitnik <andrey@sitnik.ru>
postcss-import MIT Maxime Thirouin
postcss-loader MIT Andrey Sitnik <andrey@sitnik.ru>

View file

@ -30,6 +30,8 @@ You are [welcome to contact us](https://www.photoprism.app/contact) for change r
**[Andreas Krizek](https://github.com/Cosmic314)** (January 2025)
**[Jason Grim](https://github.com/jgrim)** (June 2025)
## Gold Sponsors ##
[**Simen Eriksen**](https://github.com/dennorske) (GitHub Sponsors, December 2019)

View file

@ -1,4 +1,8 @@
examples
efficientnet
imagenet
resnet
vision
README.md
docs
.*

BIN
assets/examples/bear.m2ts Normal file

Binary file not shown.

BIN
assets/examples/m2ts.mp4 Normal file

Binary file not shown.

View file

@ -294,7 +294,7 @@ msgstr "Importación cancelada"
#: messages.go:172
#, c-format
msgid "Indexing completed in %d s"
msgstr "Indexación completada em %d"
msgstr "Indexación completada en %d"
#: messages.go:173
msgid "Indexing originals..."

View file

@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: ci@photoprism.app\n"
"POT-Creation-Date: 2025-03-11 19:22+0000\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-15 12:54+0000\n"
"PO-Revision-Date: 2025-05-12 23:50+0000\n"
"Last-Translator: Admin <hello@photoprism.app>\n"
"Language-Team: Japanese <https://translate.photoprism.app/projects/"

View file

@ -49,6 +49,9 @@ services:
PHOTOPRISM_PASSWORD_RESET_URI: "https://keycloak.localssl.dev/realms/master/login-actions/reset-credentials"
PHOTOPRISM_USAGE_INFO: "true"
PHOTOPRISM_FILES_QUOTA: "100"
## Customization:
PHOTOPRISM_DEFAULT_LOCALE: "en" # default user interface language, e.g. "en" or "de"
PHOTOPRISM_PLACES_LOCALE: "local" # location details language, e.g. "local", "en", or
## OpenID Connect (pre-configured for local tests):
## see https://keycloak.localssl.dev/realms/master/.well-known/openid-configuration
PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/realms/master"
@ -115,8 +118,8 @@ services:
PHOTOPRISM_FFMPEG_ENCODER: "intel" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "60" # video bitrate limit in Mbps (default: 60)
## Run/install on first startup (options: update tensorflow https intel gpu davfs):
PHOTOPRISM_INIT: "https intel tensorflow"
## Run/install on first startup (options: update tensorflow https intel gpu davfs yt-dlp):
PHOTOPRISM_INIT: "https intel tensorflow yt-dlp"
## Share hardware devices with FFmpeg for hardware video transcoding:
devices:
- "/dev/dri:/dev/dri"

View file

@ -52,6 +52,9 @@ services:
PHOTOPRISM_PASSWORD_RESET_URI: "https://keycloak.localssl.dev/realms/master/login-actions/reset-credentials"
PHOTOPRISM_USAGE_INFO: "true"
PHOTOPRISM_FILES_QUOTA: "100"
## Customization:
PHOTOPRISM_DEFAULT_LOCALE: "en" # default user interface language, e.g. "en" or "de"
PHOTOPRISM_PLACES_LOCALE: "local" # location details language, e.g. "local", "en", or
## OpenID Connect (pre-configured for local tests):
## see https://keycloak.localssl.dev/realms/master/.well-known/openid-configuration
PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/realms/master"
@ -114,14 +117,14 @@ services:
PHOTOPRISM_THUMB_FILTER: "auto" # downscaling filter (imaging best to worst: blackman, lanczos, cubic, linear, nearest)
PHOTOPRISM_THUMB_UNCACHED: "true" # enables on-demand thumbnail rendering (high memory and cpu usage)
TF_CPP_MIN_LOG_LEVEL: 1 # show TensorFlow log messages for development
## Run/install on first startup, see https://github.com/photoprism/photoprism/blob/develop/scripts/dist/Makefile:
PHOTOPRISM_INIT: "https tensorflow-gpu" # common options: update https tensorflow tensorflow-gpu intel gpu davfs
## Nvidia Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/#nvidia-container-toolkit):
NVIDIA_VISIBLE_DEVICES: "all"
NVIDIA_DRIVER_CAPABILITIES: "all"
PHOTOPRISM_FFMPEG_ENCODER: "nvidia" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "60" # video bitrate limit in Mbps (default: 60)
## Run/install on first startup (options: update tensorflow https intel gpu davfs yt-dlp):
PHOTOPRISM_INIT: "https tensorflow-gpu yt-dlp"
## Share hardware devices with FFmpeg and TensorFlow (optional):
# devices:
# - "/dev/dri:/dev/dri" # Intel QSV (Broadwell and later) or VAAPI (Haswell and earlier)

View file

@ -24,7 +24,6 @@ services:
- "go-mod:/go/pkg/mod"
shm_size: "2gb"
environment:
PHOTOPRISM_INIT: "https"
PHOTOPRISM_ADMIN_USER: "admin" # admin login username
PHOTOPRISM_ADMIN_PASSWORD: "photoprism" # initial admin password (8-72 characters)
PHOTOPRISM_AUTH_MODE: "password" # authentication mode (public, password)
@ -66,7 +65,8 @@ services:
PHOTOPRISM_THUMB_SIZE_UNCACHED: 7680 # on-demand rendering size limit (default 7680, min 720, max 7680)
PHOTOPRISM_JPEG_SIZE: 7680 # size limit for converted image files in pixels (720-30000)
TF_CPP_MIN_LOG_LEVEL: 1 # show TensorFlow log messages for development
## Run/install on first startup (options: update tensorflow https intel gpu davfs yt-dlp):
PHOTOPRISM_INIT: "https yt-dlp"
## PostgreSQL Database Server
## Docs: https://www.postgresql.org/docs/
postgres:

View file

@ -57,6 +57,9 @@ services:
PHOTOPRISM_PASSWORD_RESET_URI: "https://keycloak.localssl.dev/realms/master/login-actions/reset-credentials"
PHOTOPRISM_USAGE_INFO: "true"
PHOTOPRISM_FILES_QUOTA: "100"
## Customization:
PHOTOPRISM_DEFAULT_LOCALE: "en" # default user interface language, e.g. "en" or "de"
PHOTOPRISM_PLACES_LOCALE: "local" # location details language, e.g. "local", "en", or "de"
## OpenID Connect (pre-configured for local tests):
## see https://keycloak.localssl.dev/realms/master/.well-known/openid-configuration
PHOTOPRISM_OIDC_URI: "https://keycloak.localssl.dev/realms/master"
@ -124,8 +127,8 @@ services:
# LIBVA_DRIVER_NAME: "i965" # For Intel architectures Haswell and older which do not support QSV yet but use VAAPI instead
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
# PHOTOPRISM_FFMPEG_BITRATE: "60" # video bitrate limit in Mbps (default: 60)
## Run/install on first startup (options: update tensorflow https intel gpu davfs):
PHOTOPRISM_INIT: "https"
## Run/install on first startup (options: update tensorflow https intel gpu davfs yt-dlp):
PHOTOPRISM_INIT: "https yt-dlp"
## Share hardware devices with FFmpeg and TensorFlow (optional):
# devices:
# - "/dev/dri:/dev/dri" # Intel QSV (Broadwell and later) or VAAPI (Haswell and earlier)

View file

@ -91,6 +91,7 @@ COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/
# Update pre-installed packages.
RUN apt-get update && \
apt-get -qq dist-upgrade && \
yt-dlp -U && \
/scripts/cleanup.sh
# Set default working directory.

View file

@ -91,6 +91,7 @@ COPY --chown=root:root --chmod=755 /scripts/dist/ /scripts/
# Update pre-installed packages.
RUN apt-get update && \
apt-get -qq dist-upgrade && \
yt-dlp -U && \
/scripts/cleanup.sh
# Set default working directory.

View file

@ -17,10 +17,15 @@ install-testcafe:
npm install -g testcafe@latest
install-eslint:
npm install -g eslint globals @eslint/eslintrc @eslint/js eslint-config-prettier eslint-formatter-pretty eslint-plugin-html eslint-plugin-import eslint-plugin-node eslint-plugin-prettier eslint-plugin-promise eslint-plugin-vue eslint-webpack-plugin vue-eslint-parser prettier
install:
npm-install:
$(info Installing dependencies...)
npm install --no-update-notifier --no-audit
update:
npm update
install: npm-install
npm-update:
$(info Updating dependencies...)
npm update --save --package-lock
update: npm-update npm-install
upgrade: update
watch:
npm run watch
build:
@ -31,8 +36,6 @@ fmt:
npm run fmt
test:
npm run test
upgrade:
npm run upgrade
testcafe:
npm run testcafe
acceptance-local:

View file

@ -13,10 +13,13 @@ Package License Copyright
@testing-library/jest-dom MIT Ernesto Garcia <gnapse@gmail.com> (http://gnapse.github.io)
@testing-library/react MIT Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)
@vitejs/plugin-react MIT Evan You
@vitejs/plugin-vue MIT Evan You
@vitest/browser MIT n/a
@vitest/coverage-v8 MIT Anthony Fu <anthonyfu117@hotmail.com>
@vitest/ui MIT n/a
@vue/compiler-sfc MIT Evan You
@vue/language-server MIT n/a
@vue/test-utils MIT Lachlan Miller lachlan.miller.1990@outlook.com
@vvo/tzdb MIT Vincent Voyer <vincent@codeagain.com>
axios MIT Matt Zabriskie
axios-mock-adapter MIT Colin Timmermans <colintimmermans@gmail.com>
@ -39,7 +42,6 @@ eslint-plugin-html ISC n/a
eslint-plugin-import MIT Ben Mosher <me@benmosher.com>
eslint-plugin-node MIT Toru Nagashima
eslint-plugin-prettier MIT Teddy Katz
eslint-plugin-promise ISC jden <jason@denizac.org>
eslint-plugin-vue MIT Toru Nagashima (https://github.com/mysticatea)
eslint-plugin-vuetify MIT Kael Watts-Deuchar <kaelwd@gmail.com>
eslint-webpack-plugin MIT Ricardo Gobbo de Souza <ricardogobbosouza@yahoo.com.br>
@ -67,6 +69,7 @@ mocha MIT TJ Holowaychuk <tj@vision-media.
node-storage-shim ISC Michael Nahkies
passive-events-support MIT Ignas Damunskis <ignas3run@gmail.com>
photoswipe MIT Dmytro Semenov (https://dimsemenov.com)
playwright Apache-2.0 Microsoft Corporation
postcss MIT Andrey Sitnik <andrey@sitnik.ru>
postcss-import MIT Maxime Thirouin
postcss-loader MIT Andrey Sitnik <andrey@sitnik.ru>

View file

@ -170,5 +170,5 @@ module.exports = (config) => {
});
// Set default timezone.
process.env.TZ = 'UTC';
process.env.TZ = "UTC";
};

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,10 @@
{
"name": "photoprism",
"description": "AI-Powered Photos App",
"author": "PhotoPrism UG",
"license": "AGPL-3.0",
"version": "1",
"private": true,
"description": "AI-Powered Photos App",
"license": "AGPL-3.0",
"author": "PhotoPrism UG",
"scripts": {
"acceptance-local": "testcafe chromium --selector-timeout 5000 -S -s tests/acceptance/screenshots tests/acceptance",
"build": "webpack --node-env=production",
@ -18,69 +18,71 @@
"gettext-extract": "gettext-extract --output src/locales/translations.pot $(find ${SRC:-src} -type f \\( -iname \\*.vue -o -iname \\*.js \\) -not -path src/common/gettext.js)",
"lint": "eslint --cache src/ *.js",
"test": "karma start",
"test-vitest": "vitest run",
"test-vitest-watch": "vitest",
"test-vitest-coverage": "vitest run --coverage",
"test-vitest-component": "vitest run tests/vitest/component",
"test-vitest-ui": "vitest --ui",
"vitest": "env TZ=UTC vitest run",
"vitest-watch": "env TZ=UTC vitest --watch",
"vitest-coverage": "env TZ=UTC vitest run --coverage",
"vitest-component": "env TZ=UTC vitest run tests/vitest/component",
"vitest-ui": "env TZ=UTC vitest --ui --watch",
"testcafe": "testcafe",
"trace": "webpack --stats-children",
"upgrade": "npm update && npm audit fix",
"update": "npm update --save --package-lock && npm install --no-update-notifier --no-audit",
"watch": "webpack --watch"
},
"browserslist": [
">0.25% and last 2 years"
],
"dependencies": {
"@babel/cli": "^7.27.1",
"@babel/core": "^7.27.1",
"@babel/plugin-transform-runtime": "^7.27.1",
"@babel/preset-env": "^7.27.1",
"@babel/cli": "^7.28.0",
"@babel/core": "^7.28.0",
"@babel/plugin-transform-runtime": "^7.28.0",
"@babel/preset-env": "^7.28.0",
"@babel/register": "^7.27.1",
"@babel/runtime": "^7.27.1",
"@babel/runtime": "^7.27.6",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@lcdp/offline-plugin": "^5.1.1",
"@eslint/js": "9.31.0",
"@lcdp/offline-plugin": "^5.1.3",
"@mdi/font": "^7.4.47",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/coverage-v8": "^3.1.3",
"@vitest/ui": "^3.1.3",
"@vue/compiler-sfc": "^3.5.13",
"@vue/language-server": "^2.2.10",
"@vvo/tzdb": "^6.161.0",
"axios": "^1.9.0",
"@vitejs/plugin-react": "^4.6.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@vue/compiler-sfc": "^3.5.17",
"@vue/language-server": "^3.0.1",
"@vue/test-utils": "^2.4.6",
"@vvo/tzdb": "^6.178.0",
"axios": "^1.10.0",
"axios-mock-adapter": "^2.1.0",
"babel-loader": "^10.0.0",
"babel-plugin-istanbul": "^7.0.0",
"babel-plugin-polyfill-corejs3": "^0.12.0",
"browserslist": "^4.24.5",
"chai": "^5.2.0",
"babel-plugin-polyfill-corejs3": "^0.13.0",
"browserslist": "^4.25.1",
"chai": "^5.2.1",
"cheerio": "1.0.0-rc.12",
"chrome-finder": "^1.0.7",
"core-js": "^3.42.0",
"core-js": "^3.44.0",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"cssnano": "^7.0.7",
"cssnano": "^7.1.0",
"easygettext": "^2.17.0",
"eslint": "^9.26.0",
"eslint-config-prettier": "^10.1.3",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5",
"eslint-formatter-pretty": "^6.0.1",
"eslint-plugin-html": "^8.1.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-html": "^8.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^10.1.0",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-vue": "^10.3.0",
"eslint-plugin-vuetify": "^2.5.2",
"eslint-webpack-plugin": "^5.0.1",
"eslint-webpack-plugin": "^5.0.2",
"eventsource-polyfill": "^0.9.6",
"file-loader": "^6.2.0",
"file-saver": "^2.0.5",
"floating-vue": "^5.2.2",
"globals": "^16.0.1",
"hls.js": "^1.6.2",
"globals": "^16.3.0",
"hls.js": "^1.6.7",
"i": "^0.3.7",
"jsdom": "^26.1.0",
"karma": "^6.4.4",
@ -90,29 +92,30 @@
"karma-mocha": "^2.0.1",
"karma-verbose-reporter": "^0.0.8",
"karma-webpack": "^5.0.1",
"luxon": "^3.6.1",
"maplibre-gl": "^5.5.0",
"luxon": "^3.7.1",
"maplibre-gl": "^5.6.1",
"memoize-one": "^6.0.0",
"mini-css-extract-plugin": "^2.9.2",
"minimist": ">=1.2.8",
"mocha": "^11.2.2",
"minimist": "^1.2.8",
"mocha": "^11.7.1",
"node-storage-shim": "^2.0.1",
"passive-events-support": "^1.1.0",
"photoswipe": "^5.4.4",
"postcss": "^8.5.3",
"postcss-import": "^16.1.0",
"playwright": "^1.54.1",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-loader": "^8.1.1",
"postcss-preset-env": "^10.1.6",
"postcss-preset-env": "^10.2.4",
"postcss-reporter": "^7.1.0",
"postcss-url": "^10.1.3",
"prettier": "^3.5.3",
"prettier": "^3.6.2",
"pubsub-js": "^1.9.5",
"regenerator-runtime": "^0.14.1",
"resolve-url-loader": "^5.0.0",
"sanitize-html": "^2.16.0",
"sass": "^1.87.0",
"sanitize-html": "^2.17.0",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
"server": "^1.0.41",
"server": "^1.0.42",
"sockette": "^2.0.6",
"style-loader": "^4.0.0",
"svg-url-loader": "^8.0.0",
@ -120,8 +123,8 @@
"url-loader": "^4.1.1",
"util": "^0.12.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.3",
"vue": "^3.5.13",
"vitest": "^3.2.4",
"vue": "^3.5.17",
"vue-3-sanitize": "^0.1.4",
"vue-loader": "^17.4.2",
"vue-loader-plugin": "^1.3.0",
@ -130,21 +133,19 @@
"vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0",
"vuetify": "^3.8.4",
"webpack": "^5.99.8",
"vuetify": "^3.9.0",
"webpack": "^5.100.2",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-hot-middleware": "^2.26.1",
"webpack-manifest-plugin": "^5.0.1",
"webpack-md5-hash": "^0.0.6",
"webpack-merge": "^6.0.1",
"webpack-plugin-vuetify": "^3.1.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/test-utils": "^2.4.6"
"webpack-plugin-vuetify": "^3.1.1"
},
"engines": {
"node": ">= 18.0.0",
"npm": ">= 9.0.0",
"yarn": "please use npm"
}
}
}

View file

@ -100,10 +100,18 @@ export default [
meta: {
title: $gettext("Settings"),
requiresAuth: true,
admin: true,
settings: true,
background: "background",
},
beforeEnter: (to, from, next) => {
if ($session.loginRequired()) {
next({ name: loginRoute });
} else if ($config.deny("users", "access_all")) {
next({ name: $session.getDefaultRoute() });
} else {
next();
}
},
},
{
name: "upgrade",

View file

@ -38,6 +38,7 @@ export const FormatWebmAv1 = "webm_av1";
export const FormatMkvAv1 = "mkv_av1";
export const FormatTheora = "ogg";
export const FormatWebp = "webp";
export const FormatM2TS = "m2t";
// Image file formats:
export const FormatJpeg = "jpg";
@ -51,6 +52,7 @@ export const FormatSVG = "svg";
// Content type strings for common media formats, see https://tools.woolyss.com/html5-canplaytype-tester/:
export const ContentTypeMp4 = "video/mp4";
export const ContentTypeMp4AvcMain = ContentTypeMp4 + '; codecs="avc1.4d0028"'; // AVC High Profile Level 4
export const ContentTypeMp4AvcHigh = ContentTypeMp4 + '; codecs="avc1.640028"'; // MPEG-4 AVC (H.264), High Level 4.0
export const ContentTypeMp4HvcMain = ContentTypeMp4 + '; codecs="hvc1.1.6.L93.B0"';
export const ContentTypeMp4HvcMain10 = ContentTypeMp4 + '; codecs="hvc1.2.4.L153.B0"';
export const ContentTypeMp4HevMain = ContentTypeMp4 + '; codecs="hev1.1.6.L93.B0"';

View file

@ -2,6 +2,7 @@ export const Auto = "";
export const Default = "default";
export const Manual = "manual";
export const Estimate = "estimate";
export const File = "file";
export const Name = "name";
export const Meta = "meta";
export const Xmp = "xmp";

View file

@ -202,7 +202,7 @@ export default class $util {
}
static formatNs(d) {
if (!d || typeof d !== "number") {
if (!d || Number.isNaN(d)) {
return "";
}
@ -423,8 +423,9 @@ export default class $util {
return "Matroska Multimedia Container";
case "mts":
return "Advanced Video Coding High Definition (AVCHD)";
case "m2t":
case "m2ts":
return "Blu-ray MPEG-2 Transport Stream";
return "MPEG-2 Transport Stream (M2TS)";
case "webp":
return "Google WebP";
case media.FormatWebm:
@ -524,6 +525,8 @@ export default class $util {
case media.CodecVp09:
case media.FormatVp9:
return "VP9";
case media.FormatM2TS:
return "M2TS";
case "extended webp":
case media.FormatWebp:
return "WebP";
@ -578,6 +581,9 @@ export default class $util {
return "Extended WebP";
case "webm":
return "Google WebM";
case "m2t":
case "m2ts":
return "MPEG-2 Transport Stream (M2TS)";
case "mpeg":
return "Moving Picture Experts Group (MPEG)";
case "mjpg":
@ -825,6 +831,10 @@ export default class $util {
static copyText(text) {
if (!text) {
if (debug) {
console.warn("clipboard: missing text");
}
return false;
}
@ -847,15 +857,18 @@ export default class $util {
})
.catch((err) => {
if (debug && err) {
console.log("copy:", err);
console.error("clipboard:", err);
}
$notify.error($gettext("Not allowed"));
$notify.error($gettext("Cannot copy to clipboard"));
});
return true;
} else if (debug) {
console.warn("clipboard: window.navigator.clipboard is not an instance of EventTarget");
}
$notify.warn($gettext("Not supported"));
$notify.warn($gettext("Cannot copy to clipboard"));
return false;
}
}

View file

@ -18,26 +18,28 @@
</template>
<v-list slim nav density="compact" bg-color="navigation" class="action-menu__list">
<v-list-item
v-for="action in actions"
:key="action.name"
:value="action.name"
:prepend-icon="action.icon"
:title="action.text"
:class="action.class ? action.class : 'action-' + action.name"
:to="action.to ? action.to : undefined"
:href="action.href ? action.href : undefined"
:link="true"
:target="action.target ? '_blank' : '_self'"
:disabled="action.disabled"
:nav="true"
class="action-menu__item"
@click="action.click"
>
<template v-if="action.shortcut && !$isMobile" #append>
<div class="action-menu__shortcut">{{ action.shortcut }}</div>
</template>
</v-list-item>
<template v-for="action in actions" :key="action.name">
<v-divider v-if="action?.color === 'danger'"></v-divider>
<v-list-item
:value="action.name"
:prepend-icon="action.icon"
:title="action.text"
:base-color="action.color"
:class="action.class ? action.class : 'action-' + action.name"
:to="action.to ? action.to : undefined"
:href="action.href ? action.href : undefined"
:link="true"
:target="action.target ? '_blank' : '_self'"
:disabled="action.disabled"
:nav="true"
class="action-menu__item"
@click="action.click"
>
<template v-if="action.shortcut && !$isMobile" #append>
<div class="action-menu__shortcut">{{ action.shortcut }}</div>
</template>
</v-list-item>
</template>
</v-list>
</v-menu>
</div>

View file

@ -31,6 +31,7 @@ export default {
default: false,
},
},
emits: ["close", "confirm"],
data() {
return {};
},

View file

@ -67,19 +67,30 @@
@confirm="dialog.upload = false"
></p-service-upload>
<p-album-edit-dialog :visible="dialog.edit" :album="album" @close="dialog.edit = false"></p-album-edit-dialog>
<p-confirm-dialog
:visible="dialog.delete"
:text="$gettext('Are you sure you want to delete this album?')"
:action="$gettext('Delete')"
icon="mdi-delete-outline"
@close="dialog.delete = false"
@confirm="onDeleteConfirm"
></p-confirm-dialog>
</v-form>
</template>
<script>
import $notify from "common/notify";
import download from "common/download";
import { T } from "common/gettext";
import $api from "common/api";
import PActionMenu from "component/action/menu.vue";
import PConfirmDialog from "component/confirm/dialog.vue";
export default {
name: "PAlbumToolbar",
components: {
PActionMenu,
PConfirmDialog,
},
props: {
album: {
@ -117,6 +128,7 @@ export default {
this.$config.allow("albums", "download") && features.download && !settings?.albums?.download?.disabled,
canShare: this.$config.allow("albums", "share") && features.share,
canManage: this.$config.allow("albums", "manage"),
canDelete: this.$config.allow("albums", "delete"),
experimental: this.$config.get("experimental"),
isFullScreen: !!document.fullscreenElement,
categories: this.$config.albumCategories(),
@ -128,6 +140,7 @@ export default {
share: false,
upload: false,
edit: false,
delete: false,
},
titleRule: (v) => v.length <= this.$config.get("clip") || this.$gettext("Name too long"),
};
@ -193,6 +206,16 @@ export default {
this.download();
},
},
{
name: "delete",
color: "danger",
icon: "mdi-delete-outline",
text: this.$gettext("Delete Album"),
visible: this.canDelete && ["album", "moment", "state"].includes(this.album.Type),
click: () => {
this.dialog.delete = true;
},
},
];
},
T() {
@ -230,6 +253,12 @@ export default {
download(path, "album.zip");
},
onDeleteConfirm() {
$api.delete(`albums/${this.album.UID}`).catch(() => {
$notify.error(this.$gettext("Unable to delete"));
});
this.dialog.delete = false;
},
},
};
</script>

View file

@ -1878,7 +1878,7 @@ export default {
},
// Jumps to the specified time index when a video is loaded and seekable.
seekVideo(seekTo) {
if (typeof seekTo !== "number") {
if (Number.isNaN(seekTo)) {
return false;
}
@ -1891,19 +1891,39 @@ export default {
return;
}
if (seekTo > video.duration) {
video.currentTime = video.duration;
} else if (seekTo <= 0) {
video.currentTime = 0;
// If possible, use the fastSeek() method to quickly jump to the new time index:
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/fastSeek
if (typeof video.fastSeek === "function") {
if (seekTo >= video.duration - 0.01) {
video.loop = false;
video.fastSeek(video.duration);
this.pauseVideo(video);
} else if (seekTo <= 0) {
video.loop = false;
video.fastSeek(0);
this.pauseVideo(video);
} else {
video.fastSeek(seekTo);
}
} else {
video.currentTime = seekTo;
if (seekTo >= video.duration - 0.01) {
video.loop = false;
video.currentTime = video.duration;
this.pauseVideo(video);
} else if (seekTo <= 0) {
video.loop = false;
video.currentTime = 0;
this.pauseVideo(video);
} else {
video.currentTime = seekTo;
}
}
return true;
},
// Skips the specified number of seconds when a video is loaded and seekable.
seekVideoSeconds(seconds) {
if (!seconds || typeof seconds !== "number") {
if (!seconds || Number.isNaN(seconds)) {
return false;
} else if (!this.video.playing) {
return false;

View file

@ -243,7 +243,7 @@ export default {
increase(amount) {
let o = this.progress;
if (o < 100 && typeof amount !== "number") {
if (o < 100 && Number.isNaN(amount)) {
if (o >= 0 && o < 25) {
amount = Math.random() * 3 + 3;
} else if (o >= 25 && o < 50) {

View file

@ -0,0 +1,341 @@
<template>
<v-dialog
:model-value="visible"
:max-width="900"
:fullscreen="$vuetify.display.xs"
persistent
scrim
scrollable
class="p-location-dialog"
@keydown.esc="close"
@after-enter="afterEnter"
@after-leave="afterLeave"
>
<v-card :tile="$vuetify.display.xs">
<v-toolbar v-if="$vuetify.display.xs" flat color="navigation" class="mb-4" density="compact">
<v-btn icon @click.stop="close">
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>
{{ $gettext("Adjust Location") }}
</v-toolbar-title>
</v-toolbar>
<v-card-title v-else class="d-flex justify-start align-center ga-3">
<v-icon size="28" color="primary">mdi-map-marker</v-icon>
<h6 class="text-h6">{{ $gettext("Adjust Location") }}</h6>
</v-card-title>
<v-card-text class="pb-3">
<div class="d-flex flex-column flex-md-row ga-5">
<div class="flex-grow-1 position-relative mb-4 mb-md-0">
<p-map
ref="map"
:latlng="[currentLat, currentLng]"
:zoom="12"
:style="style"
:interactive="true"
:draggable="true"
:show-controls="true"
:clickable="true"
@marker-moved="onMarkerMoved"
@map-clicked="onMapClicked"
/>
</div>
<div
class="map-sidebar d-flex flex-column"
:class="$vuetify.display.xs ? `ga-3` : 'ga-5'"
:style="{
width: $vuetify.display.smAndDown ? '100%' : '300px',
maxWidth: $vuetify.display.smAndDown ? '100%' : '300px',
minWidth: 0,
}"
>
<div>
<v-autocomplete
ref="search"
v-model="selectedPlace"
:items="searchResults"
:loading="searchLoading"
:search="searchQuery"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
:placeholder="$gettext(`Search`)"
item-title="name"
item-value="id"
return-object
auto-select-first
clearable
autocomplete="off"
no-filter
menu-icon=""
:menu-props="{ maxHeight: 300 }"
@update:search="onSearchQueryChange"
@update:model-value="onPlaceSelected"
@click:clear="clearSearch"
>
<template #item="{ props }">
<v-list-item v-bind="props" density="compact">
<template #prepend>
<v-icon>mdi-map-marker</v-icon>
</template>
</v-list-item>
</template>
<template #no-data>
<v-list-item
v-if="searchQuery && searchQuery.length >= 2 && !searchLoading && searchResults.length === 0"
>
<v-list-item-title>{{ $gettext("No results") }}</v-list-item-title>
</v-list-item>
</template>
</v-autocomplete>
</div>
<!-- div v-if="locationInfo">
<div class="text-subtitle-2 mb-2">{{ $gettext("Location Details") }}</div>
<div class="text-body-2">
{{ simplifiedLocationDisplay }}
</div>
</div -->
<div class="text-body-2 mt-3">
{{ $gettext("You can search for a location or move the marker on the map to change the position:") }}
</div>
<div class="flex-grow-1">
<p-location-input
:latlng="[currentLat, currentLng]"
density="comfortable"
:enable-undo="true"
:auto-apply="true"
:label="locationLabel"
@update:latlng="onLatLngUpdate"
@changed="onLocationChanged"
@cleared="onLocationCleared"
></p-location-input>
</div>
<div class="action-buttons">
<v-btn variant="flat" color="button" class="action-cancel" min-width="120" @click.stop="close">
{{ $gettext("Cancel") }}
</v-btn>
<v-btn
color="highlight"
min-width="120"
:disabled="!(currentLat !== null && currentLng !== null) || locationLoading"
:loading="locationLoading"
@click="confirm"
>
{{ $gettext("Confirm") }}
</v-btn>
</div>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
import PLocationInput from "component/location/input.vue";
import PMap from "component/map.vue";
export default {
name: "PLocationDialog",
components: {
PLocationInput,
PMap,
},
props: {
visible: {
type: Boolean,
default: false,
},
latlng: {
type: Array,
default: () => [0, 0],
},
style: {
type: String,
default: "embedded",
},
},
emits: ["update:latlng", "close", "confirm"],
data() {
return {
currentLat: this.latlng[0],
currentLng: this.latlng[1],
location: null,
locationLoading: false,
searchQuery: "",
searchResults: [],
searchLoading: false,
searchTimeout: null,
selectedPlace: null,
};
},
computed: {
locationLabel() {
if (!this.location || !this.location?.place?.label) {
return "";
}
return this.location.place.label;
},
},
watch: {
visible(show) {
if (show) {
this.currentLat = this.latlng[0];
this.currentLng = this.latlng[1];
}
},
},
methods: {
close() {
this.$emit("close");
},
confirm() {
if (this.currentLat !== null && this.currentLng !== null) {
this.$emit("update:latlng", [this.currentLat, this.currentLng]);
this.$emit("confirm", {
lat: this.currentLat,
lng: this.currentLng,
location: this.location,
});
}
},
afterEnter() {
if (this.currentLat && this.currentLng && !(this.currentLat === 0 && this.currentLng === 0)) {
this.fetchLocationInfo(this.currentLat, this.currentLng);
}
},
afterLeave() {
this.location = null;
this.locationLoading = false;
this.resetSearchState();
},
onMarkerMoved(event) {
this.setPositionAndFetchInfo(event.lat, event.lng);
},
onMapClicked(event) {
this.setPositionAndFetchInfo(event.lat, event.lng);
},
onLocationChanged(data) {
if (data.lat && data.lng && !(data.lat === 0 && data.lng === 0)) {
this.fetchLocationInfo(data.lat, data.lng);
}
},
onLatLngUpdate(latlng) {
this.currentLat = latlng[0];
this.currentLng = latlng[1];
},
onLocationCleared() {
this.location = null;
this.locationLoading = false;
// Use the map component's removeMarker method
if (this.$refs.map) {
this.$refs.map.removeMarker();
this.$refs.map.flyTo(20, 0, 2); // lat, lng, zoom
}
},
clearSearchTimeout() {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
},
resetSearchState() {
this.searchQuery = "";
this.searchResults = [];
this.selectedPlace = null;
this.searchLoading = false;
this.clearSearchTimeout();
},
setPositionAndFetchInfo(lat, lng) {
this.currentLat = lat;
this.currentLng = lng;
this.fetchLocationInfo(lat, lng);
},
fetchLocationInfo(lat, lng) {
this.locationLoading = true;
this.$api
.get(`places/reverse?lat=${lat}&lng=${lng}`)
.then((response) => {
if (response.data && response.data?.place?.label) {
this.location = response.data;
} else {
this.location = null;
}
})
.catch((error) => {
console.error("Reverse geocoding error:", error);
this.location = null;
})
.finally(() => {
this.locationLoading = false;
});
},
onSearchQueryChange(query) {
this.searchQuery = query;
this.clearSearchTimeout();
if (!query || query.length < 2) {
this.searchResults = [];
this.searchLoading = false;
return;
}
this.searchLoading = true;
this.searchTimeout = setTimeout(() => {
this.performPlaceSearch(query);
}, 300); // 300ms delay after user stops typing
},
async performPlaceSearch(query) {
if (!query || query.length < 2) {
this.searchLoading = false;
return;
}
try {
const response = await this.$api.get("places/search", {
params: {
q: query,
count: 10,
locale: this.$config.getLanguageLocale() || "en",
},
});
if (this.searchQuery === query) {
if (response.data && Array.isArray(response.data)) {
this.searchResults = response.data;
} else {
this.searchResults = [];
}
}
} catch (error) {
console.error("Place search error:", error);
if (this.searchQuery === query) {
this.searchResults = [];
}
} finally {
if (this.searchQuery === query) {
this.searchLoading = false;
}
}
},
onPlaceSelected(place) {
if (place && place.lat && place.lng) {
this.setPositionAndFetchInfo(place.lat, place.lng);
this.$nextTick(() => {
this.resetSearchState();
});
}
},
clearSearch() {
this.resetSearchState();
},
},
};
</script>

View file

@ -0,0 +1,252 @@
<template>
<v-text-field
v-model="coordinateInput"
:disabled="disabled"
:hide-details="hideDetails"
:label="label"
:placeholder="placeholder"
:density="density"
:validate-on="validateOn"
:rules="[() => !coordinateInput || isValidCoordinateInput]"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
class="input-coordinates"
@keydown.enter="applyCoordinates"
@update:model-value="onCoordinateInputChange"
@paste="pastePosition"
>
<template v-if="icon" #prepend-inner>
<v-icon
v-if="showMapButton"
variant="plain"
:icon="icon"
:title="mapButtonTitle"
:disabled="mapButtonDisabled"
class="action-map"
@click.stop="$emit('open-map')"
>
</v-icon>
<v-icon v-else variant="plain" :icon="icon" class="text-disabled"> </v-icon>
</template>
<template #append-inner>
<v-icon
v-if="showUndoButton"
variant="plain"
icon="mdi-undo"
class="action-undo"
@click.stop="undoClear"
></v-icon>
<v-icon
v-else-if="coordinateInput"
variant="plain"
icon="mdi-close-circle"
class="action-clear"
@click.stop="clearCoordinates"
></v-icon>
</template>
</v-text-field>
</template>
<script>
export default {
name: "PLocationInput",
props: {
latlng: {
type: Array,
default: () => [null, null],
validator: (value) => Array.isArray(value) && value.length === 2,
},
disabled: {
type: Boolean,
default: false,
},
hideDetails: {
type: Boolean,
default: true,
},
label: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "37.75267, -122.543",
},
density: {
type: String,
default: "comfortable",
},
validateOn: {
type: String,
default: "input",
},
showMapButton: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: "mdi-map-marker",
},
mapButtonTitle: {
type: String,
default: "",
},
mapButtonDisabled: {
type: Boolean,
default: false,
},
enableUndo: {
type: Boolean,
default: false,
},
autoApply: {
type: Boolean,
default: true,
},
debounceDelay: {
type: Number,
default: 1000,
},
},
emits: ["update:latlng", "changed", "cleared", "open-map"],
data() {
return {
coordinateInput: "",
inputTimeout: null,
wasCleared: false,
lastValidLat: null,
lastValidLng: null,
};
},
computed: {
isValidCoordinateInput() {
if (!this.coordinateInput) return false;
const parts = this.coordinateInput.split(",").map((part) => part.trim());
if (parts.length !== 2) return false;
const lat = parseFloat(parts[0]);
const lng = parseFloat(parts[1]);
return !isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
},
showUndoButton() {
return this.enableUndo && this.wasCleared && this.lastValidLat !== null && this.lastValidLng !== null;
},
},
watch: {
latlng() {
this.updateCoordinateInput();
},
},
mounted() {
this.updateCoordinateInput();
},
beforeUnmount() {
if (this.inputTimeout) {
clearTimeout(this.inputTimeout);
}
},
methods: {
updateCoordinateInput() {
const lat = this.latlng[0];
const lng = this.latlng[1];
if (lat !== null && lng !== null && !(lat === 0 && lng === 0) && !isNaN(lat) && !isNaN(lng)) {
this.coordinateInput = `${parseFloat(lat)}, ${parseFloat(lng)}`;
this.wasCleared = false;
} else {
this.coordinateInput = "";
}
},
onCoordinateInputChange(value) {
this.coordinateInput = value;
this.wasCleared = false;
if (this.inputTimeout) {
clearTimeout(this.inputTimeout);
}
if (this.autoApply) {
this.inputTimeout = setTimeout(() => {
if (this.isValidCoordinateInput) {
this.applyCoordinates();
}
}, this.debounceDelay);
}
},
applyCoordinates() {
if (!this.isValidCoordinateInput) return;
const parts = this.coordinateInput.split(",").map((part) => part.trim());
const lat = parseFloat(parts[0]);
const lng = parseFloat(parts[1]);
this.$emit("update:latlng", [lat, lng]);
this.$emit("changed", { lat: lat, lng: lng });
},
clearCoordinates() {
if (this.enableUndo) {
this.lastValidLat = this.latlng[0];
this.lastValidLng = this.latlng[1];
}
this.coordinateInput = "";
this.wasCleared = true;
this.$emit("update:latlng", [0, 0]);
this.$emit("changed", { lat: 0, lng: 0 });
this.$emit("cleared", {
lat: 0,
lng: 0,
previousLatitude: this.lastValidLat,
previousLongitude: this.lastValidLng,
});
},
undoClear() {
if (this.lastValidLat !== null && this.lastValidLng !== null) {
this.$emit("update:latlng", [this.lastValidLat, this.lastValidLng]);
this.$emit("changed", {
lat: this.lastValidLat,
lng: this.lastValidLng,
});
this.wasCleared = false;
this.lastValidLat = null;
this.lastValidLng = null;
}
},
pastePosition(event) {
// Autofill the lat and lng fields if the text in the clipboard contains two float values.
const clipboard = event.clipboardData ? event.clipboardData : window.clipboardData;
if (!clipboard) {
return;
}
// Get values from browser clipboard.
const text = clipboard.getData("text");
// Trim spaces before splitting by whitespace and/or commas.
const val = text.trim().split(/[ ,]+/);
if (val.length >= 2) {
const lat = parseFloat(val[0]);
const lng = parseFloat(val[1]);
if (!isNaN(lat) && lat >= -90 && lat <= 90 && !isNaN(lng) && lng >= -180 && lng <= 180) {
// Update coordinates
this.$emit("update:latlng", [lat, lng]);
this.$emit("changed", { lat: lat, lng: lng });
// Prevent default action.
event.preventDefault();
}
}
},
},
};
</script>

View file

@ -10,13 +10,10 @@ let maplibregl = null;
export default {
name: "PMap",
props: {
lat: {
type: Number,
default: 0.0,
},
lng: {
type: Number,
default: 0.0,
latlng: {
type: Array,
default: () => [0.0, 0.0],
validator: (value) => Array.isArray(value) && value.length === 2,
},
zoom: {
type: Number,
@ -26,12 +23,33 @@ export default {
type: String,
default: "embedded",
},
// Interactive mode props
interactive: {
type: Boolean,
default: false,
},
draggable: {
type: Boolean,
default: false,
},
showControls: {
type: Boolean,
default: false,
},
clickable: {
type: Boolean,
default: false,
},
},
emits: ["update:latlng", "marker-moved", "map-clicked"],
data() {
const settings = this.$config.getSettings();
return {
map: null,
marker: null,
position: [0.0, 0.0],
animate: settings.maps.animate,
options: {
container: null,
// Styles can be edited/created with https://maplibre.org/maputnik/.
@ -41,17 +59,14 @@ export default {
style: `https://cdn.photoprism.app/maps/${this.style}.json`,
glyphs: `https://cdn.photoprism.app/maps/font/{fontstack}/{range}.pbf`,
zoom: this.zoom,
interactive: true,
interactive: this.interactive,
attributionControl: false,
},
loaded: false,
};
},
watch: {
lat() {
this.updatePosition();
},
lng() {
latlng() {
this.updatePosition();
},
},
@ -74,52 +89,154 @@ export default {
try {
this.options.container = this.$refs.map;
// Set center based on coordinates or default
if (!(this.latlng[0] && this.latlng[1] && !(this.latlng[0] === 0 && this.latlng[1] === 0))) {
this.options.zoom = 2;
this.options.center = [0, 20];
} else {
this.options.center = [this.latlng[1], this.latlng[0]]; // Convert [lat, lng] to [lng, lat] for MapLibre
}
this.map = new maplibregl.Map(this.options);
// Add controls.
/* this.map.addControl(
new maplibregl.NavigationControl({
showCompass: false,
showZoom: true,
visualizePitch: false,
}),
"top-right"
);
// Add controls if requested
if (this.showControls) {
this.map.addControl(
new maplibregl.NavigationControl({
showCompass: true,
showZoom: true,
visualizePitch: false,
}),
"top-right"
);
this.map.addControl(new maplibregl.ScaleControl({ maxWidth: 80, unit: "metric" }), "bottom-left");
*/
this.map.addControl(new maplibregl.ScaleControl({ maxWidth: 80, unit: "metric" }), "bottom-left");
this.map.addControl(
new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true,
},
trackUserLocation: true,
}),
"top-right"
);
}
this.map.on("error", (e) => {
console.error("map:", e);
});
// Handle missing style images
this.map.on("styleimagemissing", (e) => {
const emptyImage = new ImageData(1, 1);
if (e && e.id) {
this.map.addImage(e.id, emptyImage);
}
});
this.map.on("load", () => {
this.loaded = true;
this.updatePosition();
this.map.resize();
});
// Add click handler for interactive mode
if (this.clickable) {
this.map.on("click", (e) => {
const lat = e.lngLat.lat;
const lng = e.lngLat.lng;
this.$emit("map-clicked", { lat, lng });
this.$emit("update:latlng", [lat, lng]);
});
}
} catch (error) {
console.error("map: initialization failed", error);
this.loaded = false;
}
},
updatePosition() {
if (this.map && this.loaded) {
if (this.position[0] === this.lng && this.position[1] === this.lat) {
return;
}
if (!this.map || !this.loaded) {
return;
}
this.position = [this.lng, this.lat];
this.map.setCenter(this.position);
if (this.position[0] === this.latlng[1] && this.position[1] === this.latlng[0] && this.marker) {
return;
}
// Skip invalid or empty coordinates
if (!(this.latlng[0] && this.latlng[1] && !(this.latlng[0] === 0 && this.latlng[1] === 0))) {
if (this.marker) {
this.marker.setLngLat(this.position);
this.marker.remove();
this.marker = null;
}
return;
}
this.position = [this.latlng[1], this.latlng[0]]; // Convert [lat, lng] to [lng, lat] for MapLibre
if (this.animate > 0) {
this.map.flyTo({
center: this.position,
zoom: this.interactive ? this.zoom : undefined, // Only set zoom in interactive mode
duration: this.animate,
essential: true, // Respects prefers-reduced-motion
});
} else {
// Use setCenter for instant positioning (no animation)
if (this.interactive) {
this.map.setCenter(this.position, {
zoom: this.zoom,
animate: false,
});
} else {
this.marker = new maplibregl.Marker({
color: "#3fb4df",
draggable: false,
})
.setLngLat(this.position)
.addTo(this.map);
this.map.setCenter(this.position);
}
}
if (this.marker) {
this.marker.setLngLat(this.position);
} else {
this.marker = new maplibregl.Marker({
color: "#3fb4df",
draggable: this.draggable,
})
.setLngLat(this.position)
.addTo(this.map);
// Add drag event listener for draggable markers
if (this.draggable) {
this.marker.on("dragend", () => {
const lngLat = this.marker.getLngLat();
this.$emit("marker-moved", { lat: lngLat.lat, lng: lngLat.lng });
this.$emit("update:latlng", [lngLat.lat, lngLat.lng]);
});
}
}
},
// Public method to remove marker
removeMarker() {
if (this.marker) {
this.marker.remove();
this.marker = null;
}
},
// Public method to fly to coordinates
flyTo(lat, lng, zoom = this.zoom) {
if (this.map) {
if (this.animate > 0) {
this.map.flyTo({
center: [lng, lat],
zoom: zoom,
duration: this.animate,
essential: true,
});
} else {
this.map.jumpTo({
center: [lng, lat],
zoom: zoom,
});
}
}
},

View file

@ -351,11 +351,11 @@
v-show="config.count.animated > 0"
:to="{ name: 'animated' }"
variant="text"
class="nav-animated"
class="nav-animated nav-animations"
@click.stop=""
>
<v-list-item-title :class="`nav-menu-item menu-item`">
{{ $gettext(`Animated`) }}
{{ $gettext(`Animations`) }}
</v-list-item-title>
<span v-show="config.count.animated > 0" class="nav-count-item">{{ config.count.animated }}</span>
</v-list-item>
@ -1007,7 +1007,7 @@ export default {
canAccessPrivate: !isRestricted && this.$config.allow("photos", "access_private"),
canManagePhotos: canManagePhotos,
canManagePeople: this.$config.allow("people", "manage"),
canManageUsers: (!isPublic || isDemo) && this.$config.allow("users", "manage"),
canManageUsers: (!isPublic || isDemo) && this.$config.allow("users", "access_all"),
appNameSuffix: appNameSuffix,
appName: this.$config.getName(),
appAbout: this.$config.getAbout(),

View file

@ -146,20 +146,36 @@
@update:model-value="syncTime"
></v-autocomplete>
</v-col>
<v-col cols="12" sm="8" md="4">
<v-col cols="12" sm="6" md="6">
<p-location-input
:latlng="[view.model.Lat, view.model.Lng]"
:disabled="disabled"
hide-details
:label="locationLabel"
density="comfortable"
validate-on="input"
:show-map-button="!placesDisabled"
:map-button-title="$gettext('Adjust Location')"
:map-button-disabled="placesDisabled"
class="input-coordinates"
@update:latlng="updateLatLng"
@changed="onLocationChanged"
@open-map="adjustLocation"
></p-location-input>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-autocomplete
v-model="view.model.Country"
:append-inner-icon="view.model.PlaceSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
:readonly="!!(view.model.Lat || view.model.Lng)"
:placeholder="$gettext('Country')"
:label="$gettext('Country')"
hide-details
hide-no-data
autocomplete="off"
item-value="Code"
item-title="Name"
:items="countries"
prepend-inner-icon="mdi-map-marker"
density="comfortable"
validate-on="input"
:rules="rules.country(true)"
@ -167,7 +183,7 @@
>
</v-autocomplete>
</v-col>
<v-col cols="4" md="2">
<v-col cols="2" class="hidden-sm-and-down">
<v-text-field
v-model="view.model.Altitude"
:disabled="disabled"
@ -183,42 +199,7 @@
validate-on="input"
:rules="rules.number(false, -10000, 1000000)"
class="input-altitude"
></v-text-field>
</v-col>
<v-col cols="4" sm="6" md="3">
<v-text-field
v-model="view.model.Lat"
:append-inner-icon="view.model.PlaceSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
hide-details
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Latitude')"
placeholder=""
density="comfortable"
validate-on="input"
:rules="rules.lat(false)"
class="input-latitude"
@paste="pastePosition"
></v-text-field>
</v-col>
<v-col cols="4" sm="6" md="3">
<v-text-field
v-model="view.model.Lng"
:append-inner-icon="view.model.PlaceSrc === 'manual' ? 'mdi-check' : ''"
:disabled="disabled"
hide-details
autocomplete="off"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Longitude')"
placeholder=""
density="comfortable"
validate-on="input"
:rules="rules.lng(false)"
class="input-longitude"
@paste="pastePosition"
style="flex: 0 0 120px"
></v-text-field>
</v-col>
<v-col cols="12" md="6" class="p-camera-select">
@ -432,6 +413,12 @@
</div>
</div>
</v-form>
<p-location-dialog
:visible="locationDialog"
:latlng="[view.model.Lat ? Number(view.model.Lat) : 0, view.model.Lng ? Number(view.model.Lng) : 0]"
@close="locationDialog = false"
@confirm="confirmLocation"
></p-location-dialog>
</div>
</template>
@ -440,9 +427,15 @@ import countries from "options/countries.json";
import Thumb from "model/thumb";
import * as options from "options/options";
import { rules } from "common/form";
import PLocationDialog from "component/location/dialog.vue";
import PLocationInput from "component/location/input.vue";
export default {
name: "PTabPhotoDetails",
components: {
PLocationDialog,
PLocationInput,
},
props: {
uid: {
type: String,
@ -467,8 +460,11 @@ export default {
showTimePicker: false,
invalidDate: false,
time: "",
locationLabel: this.$gettext("Location"),
locationDialog: false,
textRule: (v) => v.length <= this.$config.get("clip") || this.$gettext("Text too long"),
rtl: this.$isRtl,
placesDisabled: !this.$config.feature("places"),
};
},
computed: {
@ -484,11 +480,11 @@ export default {
},
watch: {
uid() {
this.syncTime();
this.syncData();
},
},
created() {
this.syncTime();
this.syncData();
},
methods: {
setDay(v) {
@ -529,6 +525,22 @@ export default {
this.updateModel();
}
},
syncData() {
this.syncLocation();
this.syncTime();
},
syncLocation() {
if (
this.view?.model?.hasId() &&
this.view?.model?.Place?.PlaceID &&
this.view?.model?.Place?.PlaceID !== "zz" &&
this.view?.model?.Place?.Label
) {
this.locationLabel = this.view.model.Place.Label;
} else {
this.locationLabel = this.$gettext("Location");
}
},
syncTime() {
if (!this.view?.model.hasId()) {
return;
@ -537,36 +549,6 @@ export default {
const taken = this.view.model.getDateTime();
this.time = taken.toFormat("HH:mm:ss");
},
pastePosition(event) {
// Autofill the lat and lng fields if the text in the clipboard contains two float values.
const clipboard = event.clipboardData ? event.clipboardData : window.clipboardData;
if (!clipboard) {
return;
}
// Get values from browser clipboard.
const text = clipboard.getData("text");
// Trim spaces before splitting by whitespace and/or commas.
const val = text.trim().split(/[ ,]+/);
// Two values found?
if (val.length >= 2) {
// Parse values.
const lat = parseFloat(val[0]);
const lng = parseFloat(val[1]);
// Lat and long must be valid floating point numbers.
if (!isNaN(lat) && lat >= -90 && lat <= 90 && !isNaN(lng) && lng >= -180 && lng <= 180) {
// Update view.model values.
this.view.model.Lat = lat;
this.view.model.Lng = lng;
// Prevent default action.
event.preventDefault();
}
}
},
updateModel() {
if (!this.view?.model.hasId()) {
return;
@ -620,12 +602,39 @@ export default {
this.$emit("close");
}
this.syncTime();
this.syncData();
});
},
close() {
this.$emit("close");
},
adjustLocation() {
this.locationDialog = true;
},
confirmLocation(data) {
if (data && data.lat !== undefined && data.lng !== undefined) {
this.updateLatLng([data.lat, data.lng]);
this.onLocationChanged(data);
}
this.locationDialog = false;
},
updateLatLng(latlng) {
this.view.model.Lat = latlng[0];
this.view.model.Lng = latlng[1];
this.view.model.PlaceSrc = "manual";
},
onLocationChanged(data) {
if (data?.location?.country) {
this.view.model.Country = data.location.country;
}
if (data?.location?.place?.label) {
this.locationLabel = data.location.place.label;
} else {
this.locationLabel = this.$gettext("Location");
}
},
},
};
</script>

View file

@ -378,6 +378,8 @@ export default {
return "XMP";
case "estimate":
return this.$gettext("Estimate");
case "file":
return this.$gettext("File");
case "name":
return this.$gettext("Name");
case "title":

View file

@ -357,7 +357,11 @@ export default {
this.$view.leave(this);
},
onCopyAppPassword() {
// Use the browser API to copy the app password to the clipboard.
this.$util.copyText(this.appPassword);
// Flag the password as copied to the clipboard even
// if the copyText() function returns an error.
this.appPasswordCopied = true;
},
formatDate(d) {

View file

@ -273,6 +273,7 @@ export default {
default: () => this.$session.getUser(),
},
},
emits: ["updateUser", "close"],
data() {
return {
busy: false,
@ -378,9 +379,12 @@ export default {
});
},
onCopyRecoveryCode() {
if (this.$util.copyText(this.key.RecoveryCode)) {
this.recoveryCodeCopied = true;
}
// Use the browser API to copy the recovery code to the clipboard.
this.$util.copyText(this.key.RecoveryCode);
// Flag the code as copied to the clipboard even
// if the copyText() function returns an error.
this.recoveryCodeCopied = true;
},
onActivate() {
if (this.busy) {

View file

@ -66,7 +66,7 @@
>
</v-list-item>
<v-list-item v-if="featPlaces" class="mx-0 px-0">
<p-map :lat="model.Lat" :lng="model.Lng"></p-map>
<p-map :latlng="[model.Lat, model.Lng]"></p-map>
</v-list-item>
</template>
</v-list>
@ -120,10 +120,14 @@ export default {
return this.$gettext("Unknown");
}
if (model.TimeZone && model.TimeZone !== "Local") {
return DateTime.fromISO(model.TakenAtLocal, { zone: model.TimeZone }).toLocaleString(formats.DATETIME_MED_TZ);
// Always parse as UTC to avoid time shifts
const dateTime = DateTime.fromISO(model.TakenAtLocal, { zone: "UTC" });
if (model.TimeZone && model.TimeZone !== "Local" && model.TimeZone !== "UTC") {
// We use the real timezone just for display, but don't shift the time (prevents double timezone offset as backend already applied it)
return dateTime.setZone(model.TimeZone, { keepLocalTime: true }).toLocaleString(formats.DATETIME_MED_TZ);
} else {
return DateTime.fromISO(model.TakenAtLocal, { zone: "UTC" }).toLocaleString(formats.DATETIME_MED);
return dateTime.toLocaleString(formats.DATETIME_MED);
}
},
},

View file

@ -45,6 +45,13 @@
}
}
/* Overflow */
.overflow-hidden {
overflow: hidden;
text-overflow: ellipsis;
}
/* Layout Widths */
.width-sm, .width-md, .width-lg {

View file

@ -126,16 +126,6 @@
hyphens: auto;
}
.p-lightbox__container > .p-lightbox__sidebar .p-map {
display: block;
min-height: 259px;
aspect-ratio: 1;
width: auto;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Media Content */
.pswp__content {

View file

@ -224,6 +224,28 @@
}
}
/* Embedded Map and Control Styles */
.p-map {
display: block;
margin: 0;
padding: 0;
overflow: hidden;
}
.p-location-dialog .p-map {
height: 45vh;
min-height: 250px;
width: 100%;
border-radius: 4px;
}
.p-lightbox__container > .p-lightbox__sidebar .p-map {
min-height: 259px;
aspect-ratio: 1;
width: auto;
}
/* Right-to-Left Language Support */
.is-rtl .map-control {
@ -262,3 +284,4 @@
float: left;
margin: 0 0 10px 10px;
}

View file

@ -233,13 +233,21 @@ div.v-dialog.v-dialog--fullscreen > div.v-card {
justify-content: flex-end;
}
/* Lists */
/* Lists and Menus */
.v-menu > .v-overlay__content > .v-list:not(.v-list--nav) {
padding: 0;
opacity: 0.97;
}
.v-menu > .v-overlay__content > .v-list > .v-list-item.v-list-item--density-compact.v-list-item--one-line {
line-height: 36px;
min-height: 36px;
padding-top: 6px;
padding-bottom: 6px;
padding-inline: 10px;
}
/* Progress */
.v-progress-linear,
@ -417,12 +425,8 @@ div.v-dialog.v-dialog--fullscreen > div.v-card {
font-size: 0.75rem;
}
.v-menu > .v-overlay__content > .v-list > .v-list-item.v-list-item--density-compact.v-list-item--one-line {
line-height: 36px;
min-height: 36px;
padding-top: 6px;
padding-bottom: 6px;
padding-inline: 10px;
.v-input--readonly .v-autocomplete__menu-icon {
display: none;
}
.p-clipboard > .v-speed-dial__content > .v-btn.v-btn--icon {
@ -557,6 +561,16 @@ form > .v-card > .v-card-text.dense {
margin-inline-start: 12px;
}
/* Divider */
hr.v-divider {
opacity: 0.1;
}
.v-table hr {
opacity: 0.05;
}
.v-divider.v-divider--vertical {
margin-left: 4px;
margin-right: 4px;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more