Compare commits
15 Commits
main
...
fix/edit-f
Author | SHA1 | Date | |
---|---|---|---|
|
8fd54e3994 | ||
|
ad9aafd484 | ||
|
8eca25007e | ||
|
86b200e870 | ||
|
5dcc4ddfc6 | ||
|
6b10994bd2 | ||
|
12ede06411 | ||
|
121e9f1f1c | ||
|
c263607515 | ||
|
4cb2e16549 | ||
|
0f8fb8d38b | ||
|
107157b856 | ||
|
87cbcc02c3 | ||
|
62531a75eb | ||
|
29c7663caa |
@ -6,14 +6,13 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Please use this form to request new feature for Immich
|
Please use this form to request new feature for Immich
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: I have searched the existing feature requests to make sure this is not a duplicate request.
|
|
||||||
options:
|
options:
|
||||||
- label: "Yes"
|
- label: I have searched the existing feature requests to make sure this is not a duplicate request.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature
|
id: feature
|
||||||
attributes:
|
attributes:
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
/.github/ @bo0tzz
|
|
||||||
/docker/ @bo0tzz
|
|
||||||
/server/ @danieldietzler
|
|
||||||
/e2e/ @danieldietzler
|
|
@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine3.19@sha256:7e227295e96f5b00aa79555ae166f50610940d888fc2e321cf36304cbd17d7d6 as core
|
FROM node:20-alpine3.19@sha256:ef3f47741e161900ddd07addcaca7e76534a9205e4cd73b2ed091ba339004a75 as core
|
||||||
|
|
||||||
WORKDIR /usr/src/open-api/typescript-sdk
|
WORKDIR /usr/src/open-api/typescript-sdk
|
||||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||||
|
@ -69,8 +69,9 @@ services:
|
|||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
pgdata:
|
||||||
model-cache:
|
model-cache:
|
||||||
|
@ -14,6 +14,5 @@ DB_PASSWORD=postgres
|
|||||||
DB_HOSTNAME=immich_postgres
|
DB_HOSTNAME=immich_postgres
|
||||||
DB_USERNAME=postgres
|
DB_USERNAME=postgres
|
||||||
DB_DATABASE_NAME=immich
|
DB_DATABASE_NAME=immich
|
||||||
DB_DATA_LOCATION=./postgres
|
|
||||||
|
|
||||||
REDIS_HOSTNAME=immich_redis
|
REDIS_HOSTNAME=immich_redis
|
||||||
|
@ -20,6 +20,10 @@ In any other situation, there are 3 different options that can appear:
|
|||||||
|
|
||||||
- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file).
|
- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file).
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
To get rid of Offline paths you can follow this [guide](/docs/guides/remove-offline-files.md)
|
||||||
|
:::
|
||||||
|
|
||||||
- UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug.
|
- UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug.
|
||||||
|
|
||||||
In addition, you can download the information from a page, mark everything (in order to check hashing) and correct the problem if a match is found in the hashing.
|
In addition, you can download the information from a page, mark everything (in order to check hashing) and correct the problem if a match is found in the hashing.
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
# Community Projects
|
|
||||||
|
|
||||||
This page lists community projects that are built around Immich, but not officially supported by the development team.
|
|
||||||
|
|
||||||
:::warning
|
|
||||||
This list comes with no guarantees about security, performance, reliability, or accuracy. Use at your own risk.
|
|
||||||
:::
|
|
||||||
|
|
||||||
import CommunityProjects from '../src/components/community-projects.tsx';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
<CommunityProjects />
|
|
Before Width: | Height: | Size: 1.9 MiB |
Before Width: | Height: | Size: 4.9 MiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 183 KiB |
Before Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 2.2 MiB |
BIN
docs/docs/features/img/search-ex-2.webp
Normal file
After Width: | Height: | Size: 162 KiB |
BIN
docs/docs/features/img/search-ex-3.webp
Normal file
After Width: | Height: | Size: 59 KiB |
@ -1,57 +1,17 @@
|
|||||||
# Partner Sharing
|
# Partner Sharing
|
||||||
|
|
||||||
|
Immich allows you to share your library with other users. They can then view your library and download the assets.
|
||||||
|
|
||||||
|
You can manage one or multiple users to have access to your library from the [User Settings](docs/features/user-settings.md) page.
|
||||||
|
|
||||||
|
<img src={require('./img/partner-sharing-1.png').default} title='Partner Sharing 1' />
|
||||||
|
|
||||||
|
<img src={require('./img/partner-sharing-2.png').default} title='Partner Sharing 2' />
|
||||||
|
|
||||||
|
Accessing the shared library can be done from the Sharing page.
|
||||||
|
|
||||||
|
<img src={require('./img/partner-sharing-3.png').default} title='Partner Sharing 3' />
|
||||||
|
|
||||||
:::tip Sharing specific assets
|
:::tip Sharing specific assets
|
||||||
For sharing a specific set of assets, you can use the shared album feature of Immich.
|
For sharing a specific set of assets, you can use the shared album feature of Immich.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Immich allows you to share your library with other users. They can then view your library and download the assets. You can manage Partner Sharing from the [User Settings](docs/features/user-settings.md) page on the web.
|
|
||||||
|
|
||||||
Partner Sharing includes:
|
|
||||||
|
|
||||||
- Access to all non-archived and trashed photos and videos.
|
|
||||||
- Access to all metadata, including GPS information.
|
|
||||||
- Access to share assets via shared links, albums, etc.
|
|
||||||
|
|
||||||
:::info
|
|
||||||
Partner sharing is one-way. To view your partner's assets, they must also share them with you.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Sharing with a Partner
|
|
||||||
|
|
||||||
:::note Duplicates
|
|
||||||
Partner sharing may result in displaying duplicate assets on the main timeline.
|
|
||||||
:::
|
|
||||||
|
|
||||||
<img src={require('./img/partner-sharing-1.png').default} width="70%" title='Add Partner 1' />
|
|
||||||
|
|
||||||
<img src={require('./img/partner-sharing-2.png').default} width="70%" title='Add Partner 2' />
|
|
||||||
|
|
||||||
<img src={require('./img/partner-sharing-4.png').default} width="70%" title='Add Partner 4' />
|
|
||||||
|
|
||||||
## Viewing Partner Assets
|
|
||||||
|
|
||||||
Access partner assets via the Sharing page.
|
|
||||||
|
|
||||||
<img src={require('./img/partner-sharing-3.png').default} width="70%" title='Access to the Shared Library' />
|
|
||||||
|
|
||||||
## Timeline Integration
|
|
||||||
|
|
||||||
Partner shared photos can be displayed in the main timeline. This feature can be enabled on a per-partner basis and can be viewed and updated on both the web and mobile app.
|
|
||||||
|
|
||||||
### Web:
|
|
||||||
|
|
||||||
Account Settings -> Sharing -> Show in timeline
|
|
||||||
|
|
||||||
<img src={require('./img/partner-sharing-5.png').default} width="70%" title='Partner Sharing for the web interface' />
|
|
||||||
|
|
||||||
### Mobile App:
|
|
||||||
|
|
||||||
From the partner’s view, on the top right corner of the app bar
|
|
||||||
|
|
||||||
<img src={require('./img/partner-sharing-6.png').default} width="30%" title='Partner Sharing for the mobile app' />
|
|
||||||
|
|
||||||
## Removing Access
|
|
||||||
|
|
||||||
In order to remove a partner, you can go to User -> Account Settings -> Sharing and click on the X button.
|
|
||||||
|
|
||||||
<img src={require('./img/partner-sharing-7.png').default} width="70%" title='Remove Partner' />
|
|
||||||
|
@ -8,7 +8,7 @@ During Exif Extraction, assets with latitudes and longitudes are reverse geocode
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Data from a reverse geocode is displayed in the image details, and used in [Smart Search](/docs/features/smart-search.md).
|
Data from a reverse geocode is displayed in the image details, and used in [Search](/docs/features/search.md).
|
||||||
|
|
||||||
<img src={require('./img/reverse-geocoding-mobile1.png').default} width='33%' title='Reverse Geocoding' />
|
<img src={require('./img/reverse-geocoding-mobile1.png').default} width='33%' title='Reverse Geocoding' />
|
||||||
<img src={require('./img/reverse-geocoding-mobile2.png').default} width='33%' title='Reverse Geocoding' />
|
<img src={require('./img/reverse-geocoding-mobile2.png').default} width='33%' title='Reverse Geocoding' />
|
||||||
|
14
docs/docs/features/search.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Search
|
||||||
|
|
||||||
|
Immich uses Postgres as its search database for both metadata and smart search.
|
||||||
|
|
||||||
|
Smart search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvecto.rs) extension, utilizing machine learning models like CLIP to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata.
|
||||||
|
|
||||||
|
Metadata search (prefixed with `m:`) can search specifically by text without the use of a model.
|
||||||
|
|
||||||
|
Archived photos are not included in search results by default. To include them, add the query parameter `withArchived=true` to the url.
|
||||||
|
|
||||||
|
Some search examples:
|
||||||
|
<img src={require('./img/search-ex-2.webp').default} title='Search Example 1' />
|
||||||
|
|
||||||
|
<img src={require('./img/search-ex-3.webp').default} title='Search Example 2' />
|
@ -1,49 +0,0 @@
|
|||||||
import Tabs from '@theme/Tabs';
|
|
||||||
import TabItem from '@theme/TabItem';
|
|
||||||
|
|
||||||
# Smart Search
|
|
||||||
|
|
||||||
Immich uses Postgres as its search database for both metadata and smart search.
|
|
||||||
|
|
||||||
Smart search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvecto.rs) extension, utilizing machine learning models like [CLIP](https://openai.com/research/clip) to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata.
|
|
||||||
|
|
||||||
Archived photos are not included in search results by default. To include them, mark the checkbox in [advanced search filters](/docs/features/smart-search#advanced-search-filters).
|
|
||||||
|
|
||||||
:::tip Alternative CLIP Models
|
|
||||||
More powerful models can be used for more accurate search results. For more information, see the related [FAQ](/docs/FAQ#can-i-use-a-custom-clip-model).
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::info
|
|
||||||
Smart Search is currently limited to 5,000 results for a single search on the web.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## Advanced Search Filters
|
|
||||||
|
|
||||||
In addition, Immich offers advanced search functionality, allowing you to find specific content using customizable search filters. These filters include location, one or more faces, specific albums, and more. You can try out the search filters on the [Demo site](https://demo.immich.app).
|
|
||||||
|
|
||||||
Smart search features include:
|
|
||||||
|
|
||||||
- Search for one or more faces (with or without context search).
|
|
||||||
- Search by Country or State or City or by all three.
|
|
||||||
- Search by camera make and model.
|
|
||||||
- Search by date range.
|
|
||||||
- Search by file name.
|
|
||||||
- Search by media types: image, video or all (**Note:** Image includes live images).
|
|
||||||
- Search by condition: not in any album or archive or Favorite or all conditions.
|
|
||||||
|
|
||||||
<Tabs>
|
|
||||||
<TabItem value="Computer" label="Computer" default>
|
|
||||||
|
|
||||||
Some search examples:
|
|
||||||
|
|
||||||
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />
|
|
||||||
|
|
||||||
<img src={require('./img/search-ex-1.png').default} width="70%" title='Search Example 1' />
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
<TabItem value="Mobile" label="Mobile">
|
|
||||||
|
|
||||||
<img src={require('./img/moblie-smart-serach.webp').default} width="30%" title='Smart search on mobile' />
|
|
||||||
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
130
docs/docs/guides/api-album-sync.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# API Album Sync (Python Script)
|
||||||
|
|
||||||
|
This is an example of a python script for syncing an album to a local folder. This was used for a digital photoframe so the displayed photos could be managed from the immich web or app UI.
|
||||||
|
|
||||||
|
The script is copied below in it's current form. A repository is hosted [here](https://git.orenit.solutions/open/immichalbumpull).
|
||||||
|
|
||||||
|
:::danger
|
||||||
|
This guide uses a generated API key. This key gives the same access to your immich instance as the user it is attached to, so be careful how the config file is stored and transferred.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- [requests library](https://pypi.org/project/requests/)
|
||||||
|
|
||||||
|
### Installing
|
||||||
|
|
||||||
|
Copy the contents of 'pull.py' (shown below) to your chosen location or clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.orenit.solutions/open/immichalbumpull
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit or create the 'config.ini' file in the same directory as the script with the necessary details:
|
||||||
|
|
||||||
|
```ini title='config.ini'
|
||||||
|
[immich]
|
||||||
|
# URL of target immich instance
|
||||||
|
url = https://photo.example.com
|
||||||
|
# API key from Account Settings -> API Keys
|
||||||
|
apikey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
# Full local path to target directory
|
||||||
|
destination = /home/photo/photos
|
||||||
|
# immich album name
|
||||||
|
album = Photoframe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Run the script directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./pull.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from cron (every 5 minutes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
*/5 * * * * /usr/bin/python /home/user/immichalbumpull/pull.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python Script
|
||||||
|
|
||||||
|
```python title='pull.py'
|
||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import configparser
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# Read config file
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read('config.ini')
|
||||||
|
|
||||||
|
url = config['immich']['url']
|
||||||
|
apikey = config['immich']['apikey']
|
||||||
|
photodir = config['immich']['destination']
|
||||||
|
albumname = config['immich']['album']
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'x-api-key': apikey
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set up the directory for the downloaded images
|
||||||
|
os.makedirs(photodir, exist_ok=True)
|
||||||
|
|
||||||
|
# Get the list of albums from the API
|
||||||
|
response = requests.get(url + "/api/album", headers=headers)
|
||||||
|
|
||||||
|
# Parse the JSON response
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Find the chosen album id
|
||||||
|
for item in data:
|
||||||
|
if item['albumName'] == albumname:
|
||||||
|
albumid = item['id']
|
||||||
|
|
||||||
|
# Get the list of photos from the API using the albumid
|
||||||
|
response = requests.get(url + "/api/album/" + albumid, headers=headers)
|
||||||
|
|
||||||
|
# Parse the JSON response and extract the URLs of the images
|
||||||
|
data = response.json()
|
||||||
|
image_urls = data['assets']
|
||||||
|
|
||||||
|
# Download each image from the URL and save it to the directory
|
||||||
|
headers = {
|
||||||
|
'Accept': 'application/octet-stream',
|
||||||
|
'x-api-key': apikey
|
||||||
|
}
|
||||||
|
|
||||||
|
photolist = []
|
||||||
|
|
||||||
|
for id in image_urls:
|
||||||
|
# Query asset info endpoint for correct extension
|
||||||
|
assetinfourl = url + "/api/asset/" + str(id['id'])
|
||||||
|
response = requests.get(assetinfourl, headers=headers)
|
||||||
|
assetinfo = response.json()
|
||||||
|
ext = os.path.splitext(assetinfo['originalFileName'])
|
||||||
|
|
||||||
|
asseturl = url + "/api/download/asset/" + str(id['id'])
|
||||||
|
response = requests.post(asseturl, headers=headers, stream=True)
|
||||||
|
|
||||||
|
# Build current photo list for deletions below
|
||||||
|
photo = os.path.basename(asseturl) + ext[1]
|
||||||
|
photolist.append(photo)
|
||||||
|
|
||||||
|
photofullpath = photodir + '/' + os.path.basename(asseturl) + ext[1]
|
||||||
|
# Only download file if it doesn't already exist
|
||||||
|
if not os.path.exists(photofullpath):
|
||||||
|
with open(photofullpath, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(1024):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# Delete old photos removed from album
|
||||||
|
for filename in os.listdir(photodir):
|
||||||
|
if filename not in photolist:
|
||||||
|
os.unlink(os.path.join(photodir, filename))
|
||||||
|
```
|
@ -4,7 +4,7 @@ To alleviate [performance issues on low-memory systems](/docs/FAQ.mdx#why-is-imm
|
|||||||
|
|
||||||
- Set the URL in Machine Learning Settings on the Admin Settings page to point to the designated ML system, e.g. `http://workstation:3003`.
|
- Set the URL in Machine Learning Settings on the Admin Settings page to point to the designated ML system, e.g. `http://workstation:3003`.
|
||||||
- Copy the following `docker-compose.yml` to your ML system.
|
- Copy the following `docker-compose.yml` to your ML system.
|
||||||
- Start the container by running `docker compose up -d`.
|
- Start the container by running `docker-compose up -d` or `docker compose up -d` (depending on your Docker version).
|
||||||
|
|
||||||
:::note Info
|
:::note Info
|
||||||
Starting with version v1.93.0 face detection work and face recognize were split. From now on face detection is done in the immich_machine_learning service, but facial recognition is done in the immich_microservices service.
|
Starting with version v1.93.0 face detection work and face recognize were split. From now on face detection is done in the immich_machine_learning service, but facial recognition is done in the immich_microservices service.
|
||||||
|
176
docs/docs/guides/remove-offline-files.md
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
# Remove Offline Files [Community]
|
||||||
|
|
||||||
|
import Tabs from '@theme/Tabs';
|
||||||
|
import TabItem from '@theme/TabItem';
|
||||||
|
|
||||||
|
:::note
|
||||||
|
**Before running the script**, please make sure you have a [backup](/docs/administration/backup-and-restore) of your assets and database.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::info
|
||||||
|
**None** of the scripts can delete orphaned files from the external library.
|
||||||
|
:::
|
||||||
|
|
||||||
|
This page is a guide to get rid of offline files from the repair page.
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
|
||||||
|
<TabItem value="Python script (Best way)" label="Python script (Best way)">
|
||||||
|
|
||||||
|
This way works by retrieving a file that contains a list of all the files that are defined as offline files, running a script that uses the [Immich API](/docs/api/delete-assets) in order to remove the offline files.
|
||||||
|
|
||||||
|
1. Create an API key under Admin User -> Account Settings -> API Keys -> New API Key -> Copy to clipboard.
|
||||||
|
2. Copy and save the code to file -> `Immich Remove Offline Files.py`.
|
||||||
|
3. Run the script and follow the instructions.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
You might need to run `pip install halo tabulate tqdm` if these dependencies are missing on your machine.
|
||||||
|
:::
|
||||||
|
|
||||||
|
```bash title='Python'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Note: you might need to run "pip install halo tabulate tqdm" if these dependencies are missing on your machine
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from halo import Halo
|
||||||
|
from tabulate import tabulate
|
||||||
|
from tqdm import tqdm
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
def parse_arguments():
|
||||||
|
parser = argparse.ArgumentParser(description='Fetch file report and delete orphaned media assets from Immich.')
|
||||||
|
parser.add_argument('--apikey', help='Immich API key for authentication')
|
||||||
|
parser.add_argument('--immichaddress', help='Full address for Immich, including protocol and port')
|
||||||
|
parser.add_argument('--no_prompt', action='store_true', help='Delete orphaned media assets without confirmation')
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args
|
||||||
|
|
||||||
|
def filter_entities(response_json, entity_type):
|
||||||
|
return [
|
||||||
|
{'pathValue': entity['pathValue'], 'entityId': entity['entityId'], 'entityType': entity['entityType']}
|
||||||
|
for entity in response_json.get('orphans', []) if entity.get('entityType') == entity_type
|
||||||
|
]
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_arguments()
|
||||||
|
try:
|
||||||
|
if args.apikey:
|
||||||
|
api_key = args.apikey
|
||||||
|
else:
|
||||||
|
api_key = input('Enter the Immich API key: ')
|
||||||
|
|
||||||
|
if args.immichaddress:
|
||||||
|
immich_server = args.immichaddress
|
||||||
|
else:
|
||||||
|
immich_server = input('Enter the full web address for Immich, including protocol and port: ')
|
||||||
|
immich_parsed_url = urlparse(immich_server)
|
||||||
|
base_url = f'{immich_parsed_url.scheme}://{immich_parsed_url.netloc}'
|
||||||
|
api_url = f'{base_url}/api'
|
||||||
|
file_report_url = api_url + '/audit/file-report'
|
||||||
|
headers = {'x-api-key': api_key}
|
||||||
|
|
||||||
|
print()
|
||||||
|
spinner = Halo(text='Retrieving list of orphaned media assets...', spinner='dots')
|
||||||
|
spinner.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(file_report_url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
spinner.succeed('Success!')
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
spinner.fail(f'Failed to fetch assets: {str(e)}')
|
||||||
|
|
||||||
|
person_assets = filter_entities(response.json(), 'person')
|
||||||
|
orphan_media_assets = filter_entities(response.json(), 'asset')
|
||||||
|
|
||||||
|
num_entries = len(orphan_media_assets)
|
||||||
|
|
||||||
|
if num_entries == 0:
|
||||||
|
print('No orphaned media assets found; exiting.')
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
if not args.no_prompt:
|
||||||
|
table_data = []
|
||||||
|
for asset in orphan_media_assets:
|
||||||
|
table_data.append([asset['pathValue'], asset['entityId']])
|
||||||
|
print(tabulate(table_data, headers=['Path Value', 'Entity ID'], tablefmt='pretty'))
|
||||||
|
print()
|
||||||
|
|
||||||
|
if person_assets:
|
||||||
|
print('Found orphaned person assets! Please run the "RECOGNIZE FACES > ALL" job in Immich after running this tool to correct this.')
|
||||||
|
print()
|
||||||
|
|
||||||
|
if num_entries > 0:
|
||||||
|
summary = f'There {"is" if num_entries == 1 else "are"} {num_entries} orphaned media asset{"s" if num_entries != 1 else ""}. Would you like to delete {"them" if num_entries != 1 else "it"} from Immich? (yes/no): '
|
||||||
|
user_input = input(summary).lower()
|
||||||
|
print()
|
||||||
|
|
||||||
|
if user_input not in ('y', 'yes'):
|
||||||
|
print('Exiting without making any changes.')
|
||||||
|
return
|
||||||
|
|
||||||
|
with tqdm(total=num_entries, desc="Deleting orphaned media assets", unit="asset") as progress_bar:
|
||||||
|
for asset in orphan_media_assets:
|
||||||
|
entity_id = asset['entityId']
|
||||||
|
asset_url = f'{api_url}/asset'
|
||||||
|
delete_payload = json.dumps({'force': True, 'ids': [entity_id]})
|
||||||
|
headers = {'Content-Type': 'application/json', 'x-api-key': api_key}
|
||||||
|
response = requests.delete(asset_url, headers=headers, data=delete_payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
progress_bar.set_postfix_str(entity_id)
|
||||||
|
progress_bar.update(1)
|
||||||
|
print()
|
||||||
|
print('Orphaned media assets deleted successfully!')
|
||||||
|
except Exception as e:
|
||||||
|
print()
|
||||||
|
print(f"An error occurred: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
Thanks to [DooMRunneR](https://discord.com/channels/979116623879368755/1179655214870040596/1194308198413373482) and [Sircharlo](https://discord.com/channels/979116623879368755/1179655214870040596/1195038609812758639) for writing this script.
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem value="Bash and PowerShell script" label="Bash and PowerShell script" default>
|
||||||
|
|
||||||
|
This way works by downloading a JSON file that contains a list of all the files that are defined as offline files, running a script that uses the [Immich API](/docs/api/delete-assets) in order to remove the offline files.
|
||||||
|
|
||||||
|
1. Create an API key under Admin User -> Account Settings -> API Keys -> New API Key -> Copy to clipboard.
|
||||||
|
2. Download the JSON file under Administration -> repair -> Export.
|
||||||
|
3. Replace `YOUR_IP_HERE` and `YOUR_API_KEY_HERE` with your actual IP address and API key in the script.
|
||||||
|
4. Run the script in the same folder where the JSON file is located.
|
||||||
|
|
||||||
|
## Script for Linux based systems:
|
||||||
|
|
||||||
|
```bash title='Bash'
|
||||||
|
awk -F\" '/entityId/ {print $4}' orphans.json | while read line; do curl --location --request DELETE 'http://YOUR_IP_HERE:2283/api/asset' --header 'Content- Type: application/json' --header 'x-api-key: YOUR_API_KEY_HERE' --data '{ "force": true, "ids": ["'"$line"'"]}';done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script for the Windows system (run through PowerShell):
|
||||||
|
|
||||||
|
```powershell title='PowerShell'
|
||||||
|
Get-Content orphans.json | Select-String -Pattern 'entityId' | ForEach-Object {
|
||||||
|
$line = $_ -split '"' | Select-Object -Index 3
|
||||||
|
$body = [pscustomobject]@{
|
||||||
|
'ids' = @($line)
|
||||||
|
'force' = (' true ' | ConvertFrom-Json)
|
||||||
|
} | ConvertTo-Json -Depth 3
|
||||||
|
Invoke-RestMethod -Uri 'http://YOUR_IP_HERE:2283/api/asset' -Method Delete -Headers @{
|
||||||
|
'Content-Type' = 'application/json'
|
||||||
|
'x-api-key' = 'YOUR_API_KEY_HERE'
|
||||||
|
} -Body $body
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Thanks to [DooMRunneR](https://discord.com/channels/979116623879368755/1179655214870040596/1194308198413373482) for writing this script.
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
@ -1,134 +0,0 @@
|
|||||||
---
|
|
||||||
sidebar_position: 90
|
|
||||||
---
|
|
||||||
|
|
||||||
# Podman deploy with quadlets
|
|
||||||
|
|
||||||
You can deploy Immich on Podman using quadlets.
|
|
||||||
|
|
||||||
Here are some sample rootless quadlet container files that can be placed in /etc/containers/systemd/users/${ID} where ID is the uid of whatever your rootless user is.
|
|
||||||
|
|
||||||
Please note you'll need :z or :Z for selinux enabled hosts.
|
|
||||||
|
|
||||||
immich-database.container
|
|
||||||
```bash
|
|
||||||
[Unit]
|
|
||||||
Description=Immich Database
|
|
||||||
Requires=immich-redis.service
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
AutoUpdate=registry
|
|
||||||
EnvironmentFile=${location_of_env_file}
|
|
||||||
Image=registry.hub.docker.com/tensorchord/pgvecto-rs:pg16-v0.2.1
|
|
||||||
Label=registry
|
|
||||||
Network=slirp4netns:port_handler=slirp4netns
|
|
||||||
PublishPort=5432:5432
|
|
||||||
Volume=${host_database_directory}:/var/lib/postgresql/data:z
|
|
||||||
Volume=/etc/localtime:/etc/localtime:ro
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
immich-microservices.container
|
|
||||||
```bash
|
|
||||||
[Unit]
|
|
||||||
Description=Immich Microservices
|
|
||||||
Requires=immich-redis.service immich-database.service
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
#AddDevice=/dev/dri:/dev/dri #Needed for HWA
|
|
||||||
#AddDevice=nvidia.com/gpu=0 #Needed for nvidia HWA, after setting up container tools
|
|
||||||
AutoUpdate=registry
|
|
||||||
EnvironmentFile=${location_of_env_file}
|
|
||||||
Image=ghcr.io/immich-app/immich-server:release
|
|
||||||
Label=registry
|
|
||||||
Network=slirp4netns:port_handler=slirp4netns
|
|
||||||
PublishPort=3002:3002
|
|
||||||
Volume=${host_upload_directory}:/usr/src/app/upload:z
|
|
||||||
Volume=/etc/localtime:/etc/localtime:ro
|
|
||||||
Exec=start.sh microservices
|
|
||||||
#Unmask=/dev/dri:/dev/dri #May be needed if doing HWA
|
|
||||||
#UserNS=keep-id #May be needed if doing HWA
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
immich-ml.container
|
|
||||||
```bash
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=Immich Machine Learning
|
|
||||||
Requires=immich-redis.service immich-database.service
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
#AddDevice=/dev/dri:/dev/dri #Needed for HWA
|
|
||||||
#AddDevice=nvidia.com/gpu=0 #Needed for nvidia HWA, after setting up container tools
|
|
||||||
AutoUpdate=registry
|
|
||||||
EnvironmentFile=${location_of_env_file}
|
|
||||||
Image=ghcr.io/immich-app/immich-machine-learning:release
|
|
||||||
Label=registry
|
|
||||||
Network=slirp4netns:port_handler=slirp4netns
|
|
||||||
PublishPort=3003:3003
|
|
||||||
Volume=${cache_directory}:/cache:z
|
|
||||||
Volume=/etc/localtime:/etc/localtime:ro
|
|
||||||
#Unmask=/dev/dri:/dev/dri #May be needed for HWA
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
immich-redis.container
|
|
||||||
```bash
|
|
||||||
[Unit]
|
|
||||||
Description=Immich Redis
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
AutoUpdate=registry
|
|
||||||
Image=registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
|
|
||||||
Label=registry
|
|
||||||
Network=slirp4netns:port_handler=slirp4netns
|
|
||||||
PublishPort=6379:6379
|
|
||||||
Timezone=America/Montreal
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
immich-server.container
|
|
||||||
```bash
|
|
||||||
[Unit]
|
|
||||||
Description=Immich Server
|
|
||||||
Requires=immich-redis.service immich-database.service
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
AutoUpdate=registry
|
|
||||||
EnvironmentFile=${location_of_env_file}
|
|
||||||
Image=ghcr.io/immich-app/immich-server:release
|
|
||||||
Label=registry
|
|
||||||
Network=slirp4netns:port_handler=slirp4netns
|
|
||||||
Exec=start.sh immich
|
|
||||||
PublishPort=3000:3000
|
|
||||||
PublishPort=3001:3001
|
|
||||||
Volume=${host_upload_directory}:/usr/src/app/upload
|
|
||||||
Volume=/etc/localtime:/etc/localtime:ro
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target default.target
|
|
||||||
```
|
|
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
sidebar_position: 100
|
sidebar_position: 90
|
||||||
---
|
---
|
||||||
|
|
||||||
import RegisterAdminUser from '/docs/partials/_register-admin.md';
|
import RegisterAdminUser from '/docs/partials/_register-admin.md';
|
||||||
|
@ -11,10 +11,6 @@ Hardware and software requirements for Immich
|
|||||||
- [Docker](https://docs.docker.com/get-docker/)
|
- [Docker](https://docs.docker.com/get-docker/)
|
||||||
- [Docker Compose](https://docs.docker.com/compose/install/)
|
- [Docker Compose](https://docs.docker.com/compose/install/)
|
||||||
|
|
||||||
:::note
|
|
||||||
Immich requires the command `docker compose` - the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer compatible with Immich.
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::info Podman
|
:::info Podman
|
||||||
You can also use Podman to run the application. However, additional configuration might be required.
|
You can also use Podman to run the application. However, additional configuration might be required.
|
||||||
:::
|
:::
|
||||||
|
@ -17,11 +17,12 @@ curl -o- https://raw.githubusercontent.com/immich-app/immich/main/install.sh | b
|
|||||||
The script will perform the following actions:
|
The script will perform the following actions:
|
||||||
|
|
||||||
1. Download [docker-compose.yml](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml), and the [.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file from the main branch of the [repository](https://github.com/immich-app/immich).
|
1. Download [docker-compose.yml](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml), and the [.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file from the main branch of the [repository](https://github.com/immich-app/immich).
|
||||||
2. Start the containers.
|
2. Populate the `.env` file with necessary information based on the current directory path.
|
||||||
|
3. Start the containers.
|
||||||
|
|
||||||
The web application will be available at `http://<machine-ip-address>:2283`, and the server URL for the mobile app will be `http://<machine-ip-address>:2283/api`
|
The web application will be available at `http://<machine-ip-address>:2283`, and the server URL for the mobile app will be `http://<machine-ip-address>:2283/api`
|
||||||
|
|
||||||
The directory which is used to store the library files is `./immich-app` relative to the current directory.
|
The directory which is used to store the library files is `./immich-data` relative to the current directory.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
For common next steps, see [Post Install Steps](/docs/install/post-install.mdx).
|
For common next steps, see [Post Install Steps](/docs/install/post-install.mdx).
|
||||||
|
@ -98,7 +98,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
|
|||||||
|
|
||||||
> Note: This can take several minutes depending on your Internet speed and Unraid hardware
|
> Note: This can take several minutes depending on your Internet speed and Unraid hardware
|
||||||
|
|
||||||
9. Once on the Docker page you will see several Immich containers, one of them will be labelled `immich_server` and will have a port mapping. Visit the `IP:PORT` displayed in your web browser and you should see the Immich admin setup page.
|
9. Once on the Docker page you will see several Immich containers, one of them will be labelled `immich_web` and will have a port mapping. Visit the `IP:PORT` displayed in your web browser and you should see the Immich admin setup page.
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={require('./img/unraid06.webp').default}
|
src={require('./img/unraid06.webp').default}
|
||||||
|
@ -1,66 +0,0 @@
|
|||||||
import Link from '@docusaurus/Link';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface CommunityProjectProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects: CommunityProjectProps[] = [
|
|
||||||
{
|
|
||||||
title: 'immich-go',
|
|
||||||
description: `An alternative to the immich-CLI command that doesn't depend on nodejs installation. It tries its best for importing google photos takeout archives.`,
|
|
||||||
url: 'https://github.com/simulot/immich-go',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'ImmichFrame',
|
|
||||||
description: 'Run an Immich slideshow in a photo frame.',
|
|
||||||
url: 'https://github.com/3rob3/ImmichFrame',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'API Album Sync',
|
|
||||||
description: 'A python script to sync folders as albums.',
|
|
||||||
url: 'https://git.orenit.solutions/open/immichalbumpull',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Remove offline files',
|
|
||||||
description: 'A python script to remove offline files.',
|
|
||||||
url: 'https://gist.github.com/Thoroslives/ca5d8e1efd15111febc1e7b34ac72668',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
|
|
||||||
return (
|
|
||||||
<section className="flex flex-col gap-4 justify-between dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl p-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="m-0 items-start flex gap-2">
|
|
||||||
<span>{title}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="m-0 text-sm text-gray-600 dark:text-gray-300">{description}</p>
|
|
||||||
<p className="m-0 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
<a href={url}>{url}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex">
|
|
||||||
<Link
|
|
||||||
className="px-4 py-2 bg-immich-primary/10 dark:bg-gray-300 rounded-full hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
|
|
||||||
to={url}
|
|
||||||
>
|
|
||||||
View Project
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CommunityProjects(): JSX.Element {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<CommunityProject {...project} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
3
docs/static/_redirects
vendored
@ -25,6 +25,3 @@
|
|||||||
/docs/developer/contributing /docs/developer/pr-checklist 301
|
/docs/developer/contributing /docs/developer/pr-checklist 301
|
||||||
/docs/guides/machine-learning /docs/guides/remote-machine-learning 301
|
/docs/guides/machine-learning /docs/guides/remote-machine-learning 301
|
||||||
/docs/administration/password-login /docs/administration/system-settings 301
|
/docs/administration/password-login /docs/administration/system-settings 301
|
||||||
/docs/features/search /docs/features/smart-search 301
|
|
||||||
/docs/guides/api-album-sync /docs/community-projects 301
|
|
||||||
/docs/guides/remove-offline-files /docs/community-projects 301
|
|
||||||
|
80
install.sh
@ -1,78 +1,62 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -o nounset
|
|
||||||
set -o pipefail
|
|
||||||
|
|
||||||
create_immich_directory() { local -r Tgt='./immich-app'
|
echo "Starting Immich installation..."
|
||||||
|
|
||||||
|
ip_address=$(hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
create_immich_directory() {
|
||||||
echo "Creating Immich directory..."
|
echo "Creating Immich directory..."
|
||||||
if [[ -e $Tgt ]]; then
|
mkdir -p ./immich-app
|
||||||
echo "Found existing directory $Tgt, will overwrite YAML files"
|
cd ./immich-app || exit
|
||||||
else
|
|
||||||
mkdir "$Tgt" || return
|
|
||||||
fi
|
|
||||||
cd "$Tgt" || return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
download_docker_compose_file() {
|
download_docker_compose_file() {
|
||||||
echo "Downloading docker-compose.yml..."
|
echo "Downloading docker-compose.yml..."
|
||||||
"${Curl[@]}" "$RepoUrl"/docker-compose.yml -o ./docker-compose.yml
|
curl -L https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml -o ./docker-compose.yml >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
download_dot_env_file() {
|
download_dot_env_file() {
|
||||||
echo "Downloading .env file..."
|
echo "Downloading .env file..."
|
||||||
"${Curl[@]}" "$RepoUrl"/example.env -o ./.env
|
curl -L https://github.com/immich-app/immich/releases/latest/download/example.env -o ./.env >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
start_docker_compose() {
|
start_docker_compose() {
|
||||||
echo "Starting Immich's docker containers"
|
echo "Starting Immich's docker containers"
|
||||||
|
|
||||||
if ! docker compose >/dev/null 2>&1; then
|
if docker compose >/dev/null 2>&1; then
|
||||||
echo "failed to find 'docker compose'"
|
docker_bin="docker compose"
|
||||||
return 1
|
elif docker-compose >/dev/null 2>&1; then
|
||||||
|
docker_bin="docker-compose"
|
||||||
|
else
|
||||||
|
echo "Cannot find \`docker compose\` or \`docker-compose\`."
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! docker compose up --remove-orphans -d; then
|
if $docker_bin up --remove-orphans -d; then
|
||||||
|
show_friendly_message
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
echo "Could not start. Check for errors above."
|
echo "Could not start. Check for errors above."
|
||||||
return 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
show_friendly_message
|
|
||||||
}
|
}
|
||||||
|
|
||||||
show_friendly_message() {
|
show_friendly_message() {
|
||||||
local ip_address
|
echo "Successfully deployed Immich!"
|
||||||
ip_address=$(hostname -I | awk '{print $1}')
|
echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api"
|
||||||
cat << EOF
|
echo "---------------------------------------------------"
|
||||||
Successfully deployed Immich!
|
echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
||||||
You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api
|
|
||||||
---------------------------------------------------
|
|
||||||
If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc.
|
|
||||||
|
|
||||||
1. First bring down the containers with the command 'docker compose down' in the immich-app directory,
|
1. First bring down the containers with the command 'docker-compose down' in the immich-app directory,
|
||||||
|
|
||||||
2. Then change the information that fits your needs in the '.env' file,
|
2. Then change the information that fits your needs in the '.env' file,
|
||||||
|
|
||||||
3. Finally, bring the containers back up with the command 'docker compose up --remove-orphans -d' in the immich-app directory
|
3. Finally, bring the containers back up with the command 'docker-compose up --remove-orphans -d' in the immich-app directory"
|
||||||
EOF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# MAIN
|
# MAIN
|
||||||
main() {
|
create_immich_directory
|
||||||
echo "Starting Immich installation..."
|
download_docker_compose_file
|
||||||
local -r RepoUrl='https://github.com/immich-app/immich/releases/latest/download'
|
download_dot_env_file
|
||||||
local -a Curl
|
start_docker_compose
|
||||||
if command -v curl >/dev/null; then
|
|
||||||
Curl=(curl -fsSL)
|
|
||||||
else
|
|
||||||
echo 'no curl binary found; please install curl and try again'
|
|
||||||
return 14
|
|
||||||
fi
|
|
||||||
|
|
||||||
create_immich_directory || { echo 'error creating Immich directory'; return 10; }
|
|
||||||
download_docker_compose_file || { echo 'error downloading Docker Compose file'; return 11; }
|
|
||||||
download_dot_env_file || { echo 'error downloading .env'; return 12; }
|
|
||||||
start_docker_compose || { echo 'error starting Docker'; return 13; }
|
|
||||||
return 0; }
|
|
||||||
|
|
||||||
main
|
|
||||||
Exit=$?
|
|
||||||
[[ $Exit == 0 ]] || echo "There was an error installing Immich. Exit code: $Exit. Please provide these logs when asking for assistance."
|
|
||||||
exit "$Exit"
|
|
||||||
|
1
mobile/openapi/doc/CreateLibraryDto.md
generated
@ -11,6 +11,7 @@ Name | Type | Description | Notes
|
|||||||
**exclusionPatterns** | **List<String>** | | [optional] [default to const []]
|
**exclusionPatterns** | **List<String>** | | [optional] [default to const []]
|
||||||
**importPaths** | **List<String>** | | [optional] [default to const []]
|
**importPaths** | **List<String>** | | [optional] [default to const []]
|
||||||
**isVisible** | **bool** | | [optional]
|
**isVisible** | **bool** | | [optional]
|
||||||
|
**isWatched** | **bool** | | [optional]
|
||||||
**name** | **String** | | [optional]
|
**name** | **String** | | [optional]
|
||||||
**ownerId** | **String** | |
|
**ownerId** | **String** | |
|
||||||
**type** | [**LibraryType**](LibraryType.md) | |
|
**type** | [**LibraryType**](LibraryType.md) | |
|
||||||
|
19
mobile/openapi/lib/model/create_library_dto.dart
generated
@ -16,6 +16,7 @@ class CreateLibraryDto {
|
|||||||
this.exclusionPatterns = const [],
|
this.exclusionPatterns = const [],
|
||||||
this.importPaths = const [],
|
this.importPaths = const [],
|
||||||
this.isVisible,
|
this.isVisible,
|
||||||
|
this.isWatched,
|
||||||
this.name,
|
this.name,
|
||||||
required this.ownerId,
|
required this.ownerId,
|
||||||
required this.type,
|
required this.type,
|
||||||
@ -33,6 +34,14 @@ class CreateLibraryDto {
|
|||||||
///
|
///
|
||||||
bool? isVisible;
|
bool? isVisible;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
bool? isWatched;
|
||||||
|
|
||||||
///
|
///
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
@ -50,6 +59,7 @@ class CreateLibraryDto {
|
|||||||
_deepEquality.equals(other.exclusionPatterns, exclusionPatterns) &&
|
_deepEquality.equals(other.exclusionPatterns, exclusionPatterns) &&
|
||||||
_deepEquality.equals(other.importPaths, importPaths) &&
|
_deepEquality.equals(other.importPaths, importPaths) &&
|
||||||
other.isVisible == isVisible &&
|
other.isVisible == isVisible &&
|
||||||
|
other.isWatched == isWatched &&
|
||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.ownerId == ownerId &&
|
other.ownerId == ownerId &&
|
||||||
other.type == type;
|
other.type == type;
|
||||||
@ -60,12 +70,13 @@ class CreateLibraryDto {
|
|||||||
(exclusionPatterns.hashCode) +
|
(exclusionPatterns.hashCode) +
|
||||||
(importPaths.hashCode) +
|
(importPaths.hashCode) +
|
||||||
(isVisible == null ? 0 : isVisible!.hashCode) +
|
(isVisible == null ? 0 : isVisible!.hashCode) +
|
||||||
|
(isWatched == null ? 0 : isWatched!.hashCode) +
|
||||||
(name == null ? 0 : name!.hashCode) +
|
(name == null ? 0 : name!.hashCode) +
|
||||||
(ownerId.hashCode) +
|
(ownerId.hashCode) +
|
||||||
(type.hashCode);
|
(type.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, name=$name, ownerId=$ownerId, type=$type]';
|
String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, isWatched=$isWatched, name=$name, ownerId=$ownerId, type=$type]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -76,6 +87,11 @@ class CreateLibraryDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'isVisible'] = null;
|
// json[r'isVisible'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.isWatched != null) {
|
||||||
|
json[r'isWatched'] = this.isWatched;
|
||||||
|
} else {
|
||||||
|
// json[r'isWatched'] = null;
|
||||||
|
}
|
||||||
if (this.name != null) {
|
if (this.name != null) {
|
||||||
json[r'name'] = this.name;
|
json[r'name'] = this.name;
|
||||||
} else {
|
} else {
|
||||||
@ -101,6 +117,7 @@ class CreateLibraryDto {
|
|||||||
? (json[r'importPaths'] as Iterable).cast<String>().toList(growable: false)
|
? (json[r'importPaths'] as Iterable).cast<String>().toList(growable: false)
|
||||||
: const [],
|
: const [],
|
||||||
isVisible: mapValueOfType<bool>(json, r'isVisible'),
|
isVisible: mapValueOfType<bool>(json, r'isVisible'),
|
||||||
|
isWatched: mapValueOfType<bool>(json, r'isWatched'),
|
||||||
name: mapValueOfType<String>(json, r'name'),
|
name: mapValueOfType<String>(json, r'name'),
|
||||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||||
type: LibraryType.fromJson(json[r'type'])!,
|
type: LibraryType.fromJson(json[r'type'])!,
|
||||||
|
5
mobile/openapi/test/create_library_dto_test.dart
generated
@ -31,6 +31,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// bool isWatched
|
||||||
|
test('to test the property `isWatched`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
// String name
|
// String name
|
||||||
test('to test the property `name`', () async {
|
test('to test the property `name`', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
@ -8000,6 +8000,9 @@
|
|||||||
"isVisible": {
|
"isVisible": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"isWatched": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -461,6 +461,7 @@ export type CreateLibraryDto = {
|
|||||||
exclusionPatterns?: string[];
|
exclusionPatterns?: string[];
|
||||||
importPaths?: string[];
|
importPaths?: string[];
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
|
isWatched?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
"type": LibraryType;
|
"type": LibraryType;
|
||||||
|
@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
|
|||||||
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
|
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
|
||||||
|
|
||||||
# web build
|
# web build
|
||||||
FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e as web
|
FROM node:iron-alpine3.18@sha256:fa5d3cf51725bd42d32e67917623038539dbe720dab082f590785c001eb4dfef as web
|
||||||
|
|
||||||
WORKDIR /usr/src/open-api/typescript-sdk
|
WORKDIR /usr/src/open-api/typescript-sdk
|
||||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||||
|
@ -32,6 +32,9 @@ export class CreateLibraryDto {
|
|||||||
@ArrayUnique()
|
@ArrayUnique()
|
||||||
@ArrayMaxSize(128)
|
@ArrayMaxSize(128)
|
||||||
exclusionPatterns?: string[];
|
exclusionPatterns?: string[];
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isWatched?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateLibraryDto {
|
export class UpdateLibraryDto {
|
||||||
|
@ -1058,6 +1058,14 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
expect(libraryMock.create).not.toHaveBeenCalled();
|
expect(libraryMock.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not create watched', async () => {
|
||||||
|
await expect(
|
||||||
|
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(storageMock.watch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -266,6 +266,9 @@ export class LibraryService extends EventEmitter {
|
|||||||
if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) {
|
if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) {
|
||||||
throw new BadRequestException('Upload libraries cannot have exclusion patterns');
|
throw new BadRequestException('Upload libraries cannot have exclusion patterns');
|
||||||
}
|
}
|
||||||
|
if (dto.isWatched) {
|
||||||
|
throw new BadRequestException('Upload libraries cannot be watched');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -346,6 +346,19 @@ describe(SystemConfigService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('refreshConfig', () => {
|
||||||
|
it('should notify the subscribers', async () => {
|
||||||
|
const changeMock = jest.fn();
|
||||||
|
const subscription = sut.config$.subscribe(changeMock);
|
||||||
|
|
||||||
|
await sut.refreshConfig();
|
||||||
|
|
||||||
|
expect(changeMock).toHaveBeenCalledWith(defaults);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getCustomCss', () => {
|
describe('getCustomCss', () => {
|
||||||
it('should return the default theme', async () => {
|
it('should return the default theme', async () => {
|
||||||
await expect(sut.getCustomCss()).resolves.toEqual(defaults.theme.customCss);
|
await expect(sut.getCustomCss()).resolves.toEqual(defaults.theme.customCss);
|
||||||
|
@ -90,6 +90,13 @@ export class SystemConfigService {
|
|||||||
return mapConfig(newConfig);
|
return mapConfig(newConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// this is only used by the cli on config change, and it's not actually needed anymore
|
||||||
|
async refreshConfig() {
|
||||||
|
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
|
||||||
|
await this.core.refreshConfig();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
|
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
|
||||||
const options = new SystemConfigTemplateStorageOptionDto();
|
const options = new SystemConfigTemplateStorageOptionDto();
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e
|
FROM node:iron-alpine3.18@sha256:fa5d3cf51725bd42d32e67917623038539dbe720dab082f590785c001eb4dfef
|
||||||
|
|
||||||
RUN apk add --no-cache tini
|
RUN apk add --no-cache tini
|
||||||
USER node
|
USER node
|
||||||
|
@ -42,8 +42,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="delete-user-confirmation-modal"
|
title="Delete User"
|
||||||
title="Delete user"
|
|
||||||
confirmText={forceDelete ? 'Permanently Delete' : 'Delete'}
|
confirmText={forceDelete ? 'Permanently Delete' : 'Delete'}
|
||||||
onConfirm={handleDeleteUser}
|
onConfirm={handleDeleteUser}
|
||||||
onClose={() => dispatch('cancel')}
|
onClose={() => dispatch('cancel')}
|
@ -147,7 +147,6 @@
|
|||||||
|
|
||||||
{#if confirmJob}
|
{#if confirmJob}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="reprocess-faces-modal"
|
|
||||||
prompt="Are you sure you want to reprocess all faces? This will also clear named people."
|
prompt="Are you sure you want to reprocess all faces? This will also clear named people."
|
||||||
{onConfirm}
|
{onConfirm}
|
||||||
onClose={() => (confirmJob = null)}
|
onClose={() => (confirmJob = null)}
|
||||||
|
@ -28,8 +28,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="restore-user-modal"
|
title="Restore User"
|
||||||
title="Restore user"
|
|
||||||
confirmText="Continue"
|
confirmText="Continue"
|
||||||
confirmColor="green"
|
confirmColor="green"
|
||||||
onConfirm={handleRestoreUser}
|
onConfirm={handleRestoreUser}
|
@ -5,7 +5,7 @@
|
|||||||
export let onConfirm: () => void;
|
export let onConfirm: () => void;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ConfirmDialogue id="disable-login-modal" title="Disable login" onClose={onCancel} {onConfirm}>
|
<ConfirmDialogue title="Disable Login" onClose={onCancel} {onConfirm}>
|
||||||
<svelte:fragment slot="prompt">
|
<svelte:fragment slot="prompt">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
|
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
|
||||||
|
@ -179,7 +179,7 @@
|
|||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="TRANSCODE POLICY"
|
label="TRANSCODE POLICY"
|
||||||
{disabled}
|
{disabled}
|
||||||
desc="Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled)."
|
desc="Policy for when a video should be transcoded."
|
||||||
bind:value={config.ffmpeg.transcode}
|
bind:value={config.ffmpeg.transcode}
|
||||||
name="transcode"
|
name="transcode"
|
||||||
options={[
|
options={[
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { updateAlbumInfo, type AlbumResponseDto, type UserResponseDto, AssetOrder } from '@immich/sdk';
|
import { updateAlbumInfo, type AlbumResponseDto, type UserResponseDto, AssetOrder } from '@immich/sdk';
|
||||||
import { mdiArrowDownThin, mdiArrowUpThin, mdiPlus } from '@mdi/js';
|
import { mdiArrowDownThin, mdiArrowUpThin, mdiClose, mdiPlus } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
@ -50,52 +52,67 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="album-options-modal" title="Options" onClose={() => dispatch('close')}>
|
<FullScreenModal onClose={() => dispatch('close')}>
|
||||||
<div class="items-center justify-center">
|
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden p-2 md:p-0">
|
||||||
<div class="py-2">
|
<div
|
||||||
<h2 class="text-gray text-sm mb-2">SETTINGS</h2>
|
class="w-[550px] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<div class="grid p-2 gap-y-2">
|
>
|
||||||
{#if order}
|
<div class="px-2 pt-2">
|
||||||
<SettingDropdown
|
<div class="flex items-center">
|
||||||
title="Display order"
|
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary">Options</h1>
|
||||||
options={Object.values(options)}
|
|
||||||
selectedOption={options[order]}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<SettingSwitch
|
|
||||||
id="comments-likes"
|
|
||||||
title="Comments & likes"
|
|
||||||
subtitle="Let others respond"
|
|
||||||
checked={album.isActivityEnabled}
|
|
||||||
on:toggle={() => dispatch('toggleEnableActivity')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="py-2">
|
|
||||||
<div class="text-gray text-sm mb-3">PEOPLE</div>
|
|
||||||
<div class="p-2">
|
|
||||||
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
|
|
||||||
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
|
|
||||||
<div><Icon path={mdiPlus} size="25" /></div>
|
|
||||||
</div>
|
|
||||||
<div>Invite People</div>
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-2 py-2 mt-2">
|
|
||||||
<div>
|
<div>
|
||||||
<UserAvatar {user} size="md" />
|
<CircleIconButton icon={mdiClose} title="Close" on:click={() => dispatch('close')} />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">{user.name}</div>
|
|
||||||
<div>Owner</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#each album.sharedUsers as user (user.id)}
|
|
||||||
<div class="flex items-center gap-2 py-2">
|
<div class=" items-center justify-center p-4">
|
||||||
<div>
|
<div class="py-2">
|
||||||
<UserAvatar {user} size="md" />
|
<h2 class="text-gray text-sm mb-2">SETTINGS</h2>
|
||||||
|
<div class="grid p-2 gap-y-2">
|
||||||
|
{#if order}
|
||||||
|
<SettingDropdown
|
||||||
|
title="Display order"
|
||||||
|
options={Object.values(options)}
|
||||||
|
selectedOption={options[order]}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<SettingSwitch
|
||||||
|
id="comments-likes"
|
||||||
|
title="Comments & likes"
|
||||||
|
subtitle="Let others respond"
|
||||||
|
checked={album.isActivityEnabled}
|
||||||
|
on:toggle={() => dispatch('toggleEnableActivity')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">{user.name}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<div class="py-2">
|
||||||
|
<div class="text-gray text-sm mb-3">PEOPLE</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
|
||||||
|
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
|
||||||
|
<div><Icon path={mdiPlus} size="25" /></div>
|
||||||
|
</div>
|
||||||
|
<div>Invite People</div>
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2 py-2 mt-2">
|
||||||
|
<div>
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">{user.name}</div>
|
||||||
|
<div>Owner</div>
|
||||||
|
</div>
|
||||||
|
{#each album.sharedUsers as user (user.id)}
|
||||||
|
<div class="flex items-center gap-2 py-2">
|
||||||
|
<div>
|
||||||
|
<UserAvatar {user} size="md" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">{user.name}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -432,7 +432,7 @@
|
|||||||
{#if allowEdit}
|
{#if allowEdit}
|
||||||
<!-- Edit Modal -->
|
<!-- Edit Modal -->
|
||||||
{#if albumToEdit}
|
{#if albumToEdit}
|
||||||
<FullScreenModal id="edit-album-modal" title="Edit album" width="wide" onClose={() => (albumToEdit = null)}>
|
<FullScreenModal onClose={() => (albumToEdit = null)}>
|
||||||
<EditAlbumForm album={albumToEdit} onEditSuccess={successEditAlbumInfo} onCancel={() => (albumToEdit = null)} />
|
<EditAlbumForm album={albumToEdit} onEditSuccess={successEditAlbumInfo} onCancel={() => (albumToEdit = null)} />
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
{/if}
|
{/if}
|
||||||
@ -458,8 +458,7 @@
|
|||||||
<!-- Delete Modal -->
|
<!-- Delete Modal -->
|
||||||
{#if albumToDelete}
|
{#if albumToDelete}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="delete-album-dialogue-modal"
|
title="Delete Album"
|
||||||
title="Delete album"
|
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
onConfirm={deleteSelectedAlbum}
|
onConfirm={deleteSelectedAlbum}
|
||||||
onClose={() => (albumToDelete = null)}
|
onClose={() => (albumToDelete = null)}
|
||||||
|
@ -121,8 +121,7 @@
|
|||||||
|
|
||||||
{#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id}
|
{#if selectedRemoveUser && selectedRemoveUser?.id === currentUser?.id}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="leave-album-modal"
|
title="Leave Album?"
|
||||||
title="Leave album?"
|
|
||||||
prompt="Are you sure you want to leave {album.albumName}?"
|
prompt="Are you sure you want to leave {album.albumName}?"
|
||||||
confirmText="Leave"
|
confirmText="Leave"
|
||||||
onConfirm={handleRemoveUser}
|
onConfirm={handleRemoveUser}
|
||||||
@ -132,8 +131,7 @@
|
|||||||
|
|
||||||
{#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id}
|
{#if selectedRemoveUser && selectedRemoveUser?.id !== currentUser?.id}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="remove-user-modal"
|
title="Remove User?"
|
||||||
title="Remove user?"
|
|
||||||
prompt="Are you sure you want to remove {selectedRemoveUser.name}"
|
prompt="Are you sure you want to remove {selectedRemoveUser.name}"
|
||||||
confirmText="Remove"
|
confirmText="Remove"
|
||||||
onConfirm={handleRemoveUser}
|
onConfirm={handleRemoveUser}
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
export let peopleWithFaces: AssetFaceResponseDto[];
|
export let peopleWithFaces: AssetFaceResponseDto[];
|
||||||
export let allPeople: PersonResponseDto[];
|
export let allPeople: PersonResponseDto[];
|
||||||
export let editedPersonIndex: number;
|
export let editedPerson: PersonResponseDto;
|
||||||
export let assetType: AssetTypeEnum;
|
export let assetType: AssetTypeEnum;
|
||||||
export let assetId: string;
|
export let assetId: string;
|
||||||
|
|
||||||
@ -106,7 +106,7 @@
|
|||||||
|
|
||||||
const handleCreatePerson = async () => {
|
const handleCreatePerson = async () => {
|
||||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||||
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||||
|
|
||||||
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
|
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
|
||||||
|
|
||||||
@ -229,7 +229,7 @@
|
|||||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||||
{#if searchName == ''}
|
{#if searchName == ''}
|
||||||
{#each allPeople as person (person.id)}
|
{#each allPeople as person (person.id)}
|
||||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
{#if person.id !== editedPerson.id}
|
||||||
<div class="w-fit">
|
<div class="w-fit">
|
||||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@ -255,7 +255,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each searchedPeople as person (person.id)}
|
{#each searchedPeople as person (person.id)}
|
||||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
{#if person.id !== editedPerson.id}
|
||||||
<div class="w-fit">
|
<div class="w-fit">
|
||||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
@ -161,7 +161,6 @@
|
|||||||
|
|
||||||
{#if isShowConfirmation}
|
{#if isShowConfirmation}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="merge-people-modal"
|
|
||||||
title="Merge people"
|
title="Merge people"
|
||||||
confirmText="Merge"
|
confirmText="Merge"
|
||||||
onConfirm={handleMerge}
|
onConfirm={handleMerge}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { type PersonResponseDto } from '@immich/sdk';
|
import { type PersonResponseDto } from '@immich/sdk';
|
||||||
import { mdiArrowLeft, mdiMerge } from '@mdi/js';
|
import { mdiArrowLeft, mdiClose, mdiMerge } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
@ -30,80 +30,95 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="merge-people-modal" title="Merge people - {title}" onClose={() => dispatch('close')}>
|
<FullScreenModal onClose={() => dispatch('close')}>
|
||||||
<div class="flex items-center justify-center py-4 md:h-36 md:py-4">
|
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
|
||||||
{#if !choosePersonToMerge}
|
<div
|
||||||
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
|
class="w-[250px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[375px]"
|
||||||
<ImageThumbnail
|
>
|
||||||
circle
|
<div class="relative flex items-center justify-between">
|
||||||
shadow
|
<h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||||
url={getPeopleThumbnailUrl(personMerge1.id)}
|
Merge People - {title}
|
||||||
altText={personMerge1.name}
|
</h1>
|
||||||
widthStyle="100%"
|
<div class="p-2">
|
||||||
/>
|
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-0.5 flex md:mx-2">
|
|
||||||
<CircleIconButton
|
|
||||||
title="Swap merge direction"
|
|
||||||
icon={mdiMerge}
|
|
||||||
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="flex items-center justify-center px-2 py-4 md:h-36 md:px-4 md:py-4">
|
||||||
disabled={potentialMergePeople.length === 0}
|
{#if !choosePersonToMerge}
|
||||||
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
|
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
|
||||||
on:click={() => {
|
<ImageThumbnail
|
||||||
if (potentialMergePeople.length > 0) {
|
circle
|
||||||
choosePersonToMerge = !choosePersonToMerge;
|
shadow
|
||||||
}
|
url={getPeopleThumbnailUrl(personMerge1.id)}
|
||||||
}}
|
altText={personMerge1.name}
|
||||||
>
|
widthStyle="100%"
|
||||||
<ImageThumbnail
|
/>
|
||||||
border={potentialMergePeople.length > 0}
|
</div>
|
||||||
circle
|
<div class="mx-0.5 flex md:mx-2">
|
||||||
shadow
|
<CircleIconButton
|
||||||
url={getPeopleThumbnailUrl(personMerge2.id)}
|
title="Swap merge direction"
|
||||||
altText={personMerge2.name}
|
icon={mdiMerge}
|
||||||
widthStyle="100%"
|
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<div class="grid w-full grid-cols-1 gap-2">
|
|
||||||
<div class="px-2">
|
|
||||||
<button on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
|
|
||||||
{#each potentialMergePeople as person (person.id)}
|
|
||||||
<div class="h-24 w-24 md:h-28 md:w-28">
|
|
||||||
<button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
|
|
||||||
<ImageThumbnail
|
|
||||||
border={true}
|
|
||||||
circle
|
|
||||||
shadow
|
|
||||||
url={getPeopleThumbnailUrl(person.id)}
|
|
||||||
altText={person.name}
|
|
||||||
widthStyle="100%"
|
|
||||||
on:click={() => changePersonToMerge(person)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex px-4 md:pt-4">
|
<button
|
||||||
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same person?</h1>
|
disabled={potentialMergePeople.length === 0}
|
||||||
</div>
|
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
|
||||||
<div class="flex px-4 pt-2">
|
on:click={() => {
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
|
if (potentialMergePeople.length > 0) {
|
||||||
</div>
|
choosePersonToMerge = !choosePersonToMerge;
|
||||||
<div class="mt-8 flex w-full gap-4 pb-4">
|
}
|
||||||
<Button fullwidth color="gray" on:click={() => dispatch('reject')}>No</Button>
|
}}
|
||||||
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
border={potentialMergePeople.length > 0}
|
||||||
|
circle
|
||||||
|
shadow
|
||||||
|
url={getPeopleThumbnailUrl(personMerge2.id)}
|
||||||
|
altText={personMerge2.name}
|
||||||
|
widthStyle="100%"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="grid w-full grid-cols-1 gap-2">
|
||||||
|
<div class="px-2">
|
||||||
|
<button on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
|
||||||
|
{#each potentialMergePeople as person (person.id)}
|
||||||
|
<div class="h-24 w-24 md:h-28 md:w-28">
|
||||||
|
<button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
|
||||||
|
<ImageThumbnail
|
||||||
|
border={true}
|
||||||
|
circle
|
||||||
|
shadow
|
||||||
|
url={getPeopleThumbnailUrl(person.id)}
|
||||||
|
altText={person.name}
|
||||||
|
widthStyle="100%"
|
||||||
|
on:click={() => changePersonToMerge(person)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex px-4 md:px-8 md:pt-4">
|
||||||
|
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same person?</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex px-4 pt-2 md:px-8">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4 pb-4">
|
||||||
|
<Button fullwidth color="gray" on:click={() => dispatch('reject')}>No</Button>
|
||||||
|
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -28,14 +28,14 @@
|
|||||||
export let assetType: AssetTypeEnum;
|
export let assetType: AssetTypeEnum;
|
||||||
|
|
||||||
// keep track of the changes
|
// keep track of the changes
|
||||||
let numberOfPersonToCreate: string[] = [];
|
let peopleToCreate: string[] = [];
|
||||||
let numberOfAssetFaceGenerated: string[] = [];
|
let assetFaceGenerated: string[] = [];
|
||||||
|
|
||||||
// faces
|
// faces
|
||||||
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
||||||
let selectedPersonToReassign: (PersonResponseDto | null)[];
|
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
|
||||||
let selectedPersonToCreate: (string | null)[];
|
let selectedPersonToCreate: Record<string, string> = {};
|
||||||
let editedPersonIndex: number;
|
let editedPerson: PersonResponseDto;
|
||||||
|
|
||||||
// loading spinners
|
// loading spinners
|
||||||
let isShowLoadingDone = false;
|
let isShowLoadingDone = false;
|
||||||
@ -49,6 +49,8 @@
|
|||||||
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
||||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const thumbnailWidth = '90px';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
close: void;
|
close: void;
|
||||||
refresh: void;
|
refresh: void;
|
||||||
@ -60,8 +62,6 @@
|
|||||||
const { people } = await getAllPeople({ withHidden: true });
|
const { people } = await getAllPeople({ withHidden: true });
|
||||||
allPeople = people;
|
allPeople = people;
|
||||||
peopleWithFaces = await getFaces({ id: assetId });
|
peopleWithFaces = await getFaces({ id: assetId });
|
||||||
selectedPersonToCreate = Array.from({ length: peopleWithFaces.length });
|
|
||||||
selectedPersonToReassign = Array.from({ length: peopleWithFaces.length });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, "Can't get faces");
|
handleError(error, "Can't get faces");
|
||||||
} finally {
|
} finally {
|
||||||
@ -71,12 +71,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPersonThumbnail = (personId: string) => {
|
const onPersonThumbnail = (personId: string) => {
|
||||||
numberOfAssetFaceGenerated.push(personId);
|
assetFaceGenerated.push(personId);
|
||||||
if (
|
if (
|
||||||
isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
|
isEqual(assetFaceGenerated, peopleToCreate) &&
|
||||||
loaderLoadingDoneTimeout &&
|
loaderLoadingDoneTimeout &&
|
||||||
automaticRefreshTimeout &&
|
automaticRefreshTimeout &&
|
||||||
selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
|
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
|
||||||
) {
|
) {
|
||||||
clearTimeout(loaderLoadingDoneTimeout);
|
clearTimeout(loaderLoadingDoneTimeout);
|
||||||
clearTimeout(automaticRefreshTimeout);
|
clearTimeout(automaticRefreshTimeout);
|
||||||
@ -97,36 +97,41 @@
|
|||||||
dispatch('close');
|
dispatch('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = (index: number) => {
|
const handleReset = (id: string) => {
|
||||||
if (selectedPersonToReassign[index]) {
|
if (selectedPersonToReassign[id]) {
|
||||||
selectedPersonToReassign[index] = null;
|
delete selectedPersonToReassign[id];
|
||||||
|
|
||||||
|
// trigger reactivity
|
||||||
|
selectedPersonToReassign = selectedPersonToReassign;
|
||||||
}
|
}
|
||||||
if (selectedPersonToCreate[index]) {
|
if (selectedPersonToCreate[id]) {
|
||||||
selectedPersonToCreate[index] = null;
|
delete selectedPersonToCreate[id];
|
||||||
|
|
||||||
|
// trigger reactivity
|
||||||
|
selectedPersonToCreate = selectedPersonToCreate;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditFaces = async () => {
|
const handleEditFaces = async () => {
|
||||||
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
||||||
const numberOfChanges =
|
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
|
||||||
selectedPersonToCreate.filter((person) => person !== null).length +
|
|
||||||
selectedPersonToReassign.filter((person) => person !== null).length;
|
|
||||||
if (numberOfChanges > 0) {
|
if (numberOfChanges > 0) {
|
||||||
try {
|
try {
|
||||||
for (const [index, peopleWithFace] of peopleWithFaces.entries()) {
|
for (const personWithFace of peopleWithFaces) {
|
||||||
const personId = selectedPersonToReassign[index]?.id;
|
const personId = selectedPersonToReassign[personWithFace.id]?.id;
|
||||||
|
|
||||||
if (personId) {
|
if (personId) {
|
||||||
await reassignFacesById({
|
await reassignFacesById({
|
||||||
id: personId,
|
id: personId,
|
||||||
faceDto: { id: peopleWithFace.id },
|
faceDto: { id: personWithFace.id },
|
||||||
});
|
});
|
||||||
} else if (selectedPersonToCreate[index]) {
|
} else if (selectedPersonToCreate[personWithFace.id]) {
|
||||||
const data = await createPerson({ personCreateDto: {} });
|
const data = await createPerson({ personCreateDto: {} });
|
||||||
numberOfPersonToCreate.push(data.id);
|
peopleToCreate.push(data.id);
|
||||||
await reassignFacesById({
|
await reassignFacesById({
|
||||||
id: data.id,
|
id: data.id,
|
||||||
faceDto: { id: peopleWithFace.id },
|
faceDto: { id: personWithFace.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,7 +146,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
isShowLoadingDone = false;
|
isShowLoadingDone = false;
|
||||||
if (numberOfPersonToCreate.length === 0) {
|
if (peopleToCreate.length === 0) {
|
||||||
clearTimeout(loaderLoadingDoneTimeout);
|
clearTimeout(loaderLoadingDoneTimeout);
|
||||||
dispatch('refresh');
|
dispatch('refresh');
|
||||||
} else {
|
} else {
|
||||||
@ -150,23 +155,26 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
||||||
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||||
if (newFeaturePhoto && personToUpdate) {
|
if (newFeaturePhoto && personToUpdate) {
|
||||||
selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto;
|
selectedPersonToCreate[personToUpdate.id] = newFeaturePhoto;
|
||||||
}
|
}
|
||||||
showSeletecFaces = false;
|
showSeletecFaces = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReassignFace = (person: PersonResponseDto | null) => {
|
const handleReassignFace = (person: PersonResponseDto | null) => {
|
||||||
if (person) {
|
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||||
selectedPersonToReassign[editedPersonIndex] = person;
|
if (person && personToUpdate) {
|
||||||
|
selectedPersonToReassign[personToUpdate.id] = person;
|
||||||
showSeletecFaces = false;
|
showSeletecFaces = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePersonPicker = (index: number) => {
|
const handlePersonPicker = (person: PersonResponseDto | null) => {
|
||||||
editedPersonIndex = index;
|
if (person) {
|
||||||
showSeletecFaces = true;
|
editedPerson = person;
|
||||||
|
showSeletecFaces = true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -217,35 +225,48 @@
|
|||||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<ImageThumbnail
|
{#if selectedPersonToCreate[face.id]}
|
||||||
curve
|
<ImageThumbnail
|
||||||
shadow
|
curve
|
||||||
url={selectedPersonToCreate[index] ||
|
shadow
|
||||||
getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)}
|
url={selectedPersonToCreate[face.id]}
|
||||||
altText={selectedPersonToReassign[index]
|
altText={selectedPersonToCreate[face.id]}
|
||||||
? selectedPersonToReassign[index]?.name
|
title={'New person'}
|
||||||
: selectedPersonToCreate[index]
|
widthStyle={thumbnailWidth}
|
||||||
? 'New person'
|
heightStyle={thumbnailWidth}
|
||||||
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
/>
|
||||||
title={selectedPersonToReassign[index]
|
{:else if selectedPersonToReassign[face.id]}
|
||||||
? selectedPersonToReassign[index]?.name
|
<ImageThumbnail
|
||||||
: selectedPersonToCreate[index]
|
curve
|
||||||
? 'New person'
|
shadow
|
||||||
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
|
||||||
widthStyle="90px"
|
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
|
||||||
heightStyle="90px"
|
title={getPersonNameWithHiddenValue(
|
||||||
thumbhash={null}
|
selectedPersonToReassign[face.id].name,
|
||||||
hidden={selectedPersonToReassign[index]
|
face.person?.isHidden,
|
||||||
? selectedPersonToReassign[index]?.isHidden
|
)}
|
||||||
: selectedPersonToCreate[index]
|
widthStyle={thumbnailWidth}
|
||||||
? false
|
heightStyle={thumbnailWidth}
|
||||||
: face.person?.isHidden}
|
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={getPeopleThumbnailUrl(face.person.id)}
|
||||||
|
altText={face.person.name || face.person.id}
|
||||||
|
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
||||||
|
widthStyle={thumbnailWidth}
|
||||||
|
heightStyle={thumbnailWidth}
|
||||||
|
hidden={face.person.isHidden}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if !selectedPersonToCreate[index]}
|
|
||||||
|
{#if !selectedPersonToCreate[face.id]}
|
||||||
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||||
{#if selectedPersonToReassign[index]?.id}
|
{#if selectedPersonToReassign[face.id]?.id}
|
||||||
{selectedPersonToReassign[index]?.name}
|
{selectedPersonToReassign[face.id]?.name}
|
||||||
{:else}
|
{:else}
|
||||||
{face.person?.name}
|
{face.person?.name}
|
||||||
{/if}
|
{/if}
|
||||||
@ -253,8 +274,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
|
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
|
||||||
{#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
|
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
||||||
<button on:click={() => handleReset(index)} class="flex h-full w-full">
|
<button on:click={() => handleReset(face.id)} class="flex h-full w-full">
|
||||||
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||||
<div>
|
<div>
|
||||||
<Icon path={mdiRestart} size={18} />
|
<Icon path={mdiRestart} size={18} />
|
||||||
@ -262,7 +283,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
|
<button on:click={() => handlePersonPicker(face.person)} class="flex h-full w-full">
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
|
class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
|
||||||
/>
|
/>
|
||||||
@ -282,7 +303,7 @@
|
|||||||
<AssignFaceSidePanel
|
<AssignFaceSidePanel
|
||||||
{peopleWithFaces}
|
{peopleWithFaces}
|
||||||
{allPeople}
|
{allPeople}
|
||||||
{editedPersonIndex}
|
{editedPerson}
|
||||||
{assetType}
|
{assetType}
|
||||||
{assetId}
|
{assetId}
|
||||||
on:close={() => (showSeletecFaces = false)}
|
on:close={() => (showSeletecFaces = false)}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
||||||
import { mdiCake } from '@mdi/js';
|
import { mdiCake } from '@mdi/js';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import DateInput from '../elements/date-input.svelte';
|
import DateInput from '../elements/date-input.svelte';
|
||||||
|
|
||||||
export let birthDate: string;
|
export let birthDate: string;
|
||||||
@ -20,27 +21,36 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="set-birthday-modal" title="Set date of birth" icon={mdiCake} onClose={handleCancel}>
|
<FullScreenModal onClose={handleCancel}>
|
||||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
<div
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
Date of birth is used to calculate the age of this person at the time of a photo.
|
>
|
||||||
</p>
|
<div
|
||||||
</div>
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<Icon path={mdiCake} size="4em" />
|
||||||
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Set date of birth</h1>
|
||||||
|
|
||||||
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<div class="my-4 flex flex-col gap-2">
|
Date of birth is used to calculate the age of this person at the time of a photo.
|
||||||
<DateInput
|
</p>
|
||||||
class="immich-form-input"
|
|
||||||
id="birthDate"
|
|
||||||
name="birthDate"
|
|
||||||
type="date"
|
|
||||||
bind:value={birthDate}
|
|
||||||
max={todayFormatted}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 flex w-full gap-4">
|
|
||||||
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
|
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
||||||
<Button type="submit" fullwidth>Set</Button>
|
<div class="m-4 flex flex-col gap-2">
|
||||||
</div>
|
<DateInput
|
||||||
</form>
|
class="immich-form-input"
|
||||||
|
id="birthDate"
|
||||||
|
name="birthDate"
|
||||||
|
type="date"
|
||||||
|
bind:value={birthDate}
|
||||||
|
max={todayFormatted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
|
||||||
|
<Button type="submit" fullwidth>Set</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import type { ApiKeyResponseDto } from '@immich/sdk';
|
import type { ApiKeyResponseDto } from '@immich/sdk';
|
||||||
import { mdiKeyVariant } from '@mdi/js';
|
import { mdiKeyVariant } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
@ -7,7 +8,7 @@
|
|||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||||
|
|
||||||
export let apiKey: Partial<ApiKeyResponseDto>;
|
export let apiKey: Partial<ApiKeyResponseDto>;
|
||||||
export let title: string;
|
export let title = 'API Key';
|
||||||
export let cancelText = 'Cancel';
|
export let cancelText = 'Cancel';
|
||||||
export let submitText = 'Save';
|
export let submitText = 'Save';
|
||||||
|
|
||||||
@ -28,16 +29,29 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="api-key-modal" {title} icon={mdiKeyVariant} onClose={handleCancel}>
|
<FullScreenModal onClose={handleCancel}>
|
||||||
<form on:submit|preventDefault={handleSubmit} autocomplete="off">
|
<div
|
||||||
<div class="mb-4 flex flex-col gap-2">
|
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<label class="immich-form-label" for="name">Name</label>
|
>
|
||||||
<input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
|
<div
|
||||||
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<Icon path={mdiKeyVariant} size="4em" />
|
||||||
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex w-full gap-4">
|
<form on:submit|preventDefault={handleSubmit} autocomplete="off">
|
||||||
<Button color="gray" fullwidth on:click={handleCancel}>{cancelText}</Button>
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<Button type="submit" fullwidth>{submitText}</Button>
|
<label class="immich-form-label" for="name">Name</label>
|
||||||
</div>
|
<input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
<Button color="gray" fullwidth on:click={handleCancel}>{cancelText}</Button>
|
||||||
|
<Button type="submit" fullwidth>{submitText}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
import { mdiKeyVariant } from '@mdi/js';
|
import { mdiKeyVariant } from '@mdi/js';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
@ -19,22 +20,31 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="api-key-secret-modal" title="API key" icon={mdiKeyVariant} onClose={() => handleDone()}>
|
<FullScreenModal>
|
||||||
<div class="text-immich-primary dark:text-immich-dark-primary">
|
<div
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
This value will only be shown once. Please be sure to copy it before closing the window.
|
>
|
||||||
</p>
|
<div
|
||||||
</div>
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<Icon path={mdiKeyVariant} size="4em" />
|
||||||
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">API Key</h1>
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<!-- <label class="immich-form-label" for="secret">API Key</label> -->
|
This value will only be shown once. Please be sure to copy it before closing the window.
|
||||||
<textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex w-full gap-4">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
{#if canCopyImagesToClipboard}
|
<!-- <label class="immich-form-label" for="secret">API Key</label> -->
|
||||||
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
|
<textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
|
||||||
{/if}
|
</div>
|
||||||
<Button on:click={() => handleDone()} fullwidth>Done</Button>
|
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
{#if canCopyImagesToClipboard}
|
||||||
|
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
|
||||||
|
{/if}
|
||||||
|
<Button on:click={() => handleDone()} fullwidth>Done</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
import { createUser } from '@immich/sdk';
|
import { createUser } from '@immich/sdk';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
|
import ImmichLogo from '../shared-components/immich-logo.svelte';
|
||||||
import PasswordField from '../shared-components/password-field.svelte';
|
import PasswordField from '../shared-components/password-field.svelte';
|
||||||
import Slider from '../elements/slider.svelte';
|
import Slider from '../elements/slider.svelte';
|
||||||
|
|
||||||
@ -68,53 +69,62 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
<div
|
||||||
<div class="my-4 flex flex-col gap-2">
|
class="max-h-screen w-[500px] max-w-[95vw] overflow-y-auto immich-scrollbar rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<label class="immich-form-label" for="email">Email</label>
|
>
|
||||||
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
|
<div class="flex flex-col place-content-center place-items-center gap-4 px-4">
|
||||||
|
<ImmichLogo noText class="text-center" height="75" width="75" />
|
||||||
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Create new user</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<form on:submit|preventDefault={registerUser} autocomplete="off">
|
||||||
<label class="immich-form-label" for="password">Password</label>
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<PasswordField id="password" bind:password autocomplete="new-password" />
|
<label class="immich-form-label" for="email">Email</label>
|
||||||
</div>
|
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
<label class="immich-form-label" for="password">Password</label>
|
||||||
<PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
|
<PasswordField id="password" bind:password autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex place-items-center justify-between gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="text-sm dark:text-immich-dark-fg" for="require-password-change">
|
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||||
Require user to change password on first login
|
<PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
|
||||||
</label>
|
</div>
|
||||||
<Slider id="require-password-change" bind:checked={shouldChangePassword} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div class="m-4 flex place-items-center justify-between gap-2">
|
||||||
<label class="immich-form-label" for="name">Name</label>
|
<label class="text-sm dark:text-immich-dark-fg" for="require-password-change">
|
||||||
<input class="immich-form-input" id="name" bind:value={name} type="text" required />
|
Require user to change password on first login
|
||||||
</div>
|
</label>
|
||||||
|
<Slider id="require-password-change" bind:checked={shouldChangePassword} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
|
<label class="immich-form-label" for="name">Name</label>
|
||||||
Quota Size (GiB)
|
<input class="immich-form-input" id="name" bind:value={name} type="text" required />
|
||||||
{#if quotaSizeWarning}
|
</div>
|
||||||
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
<input class="immich-form-input" id="quotaSize" type="number" min="0" bind:value={quotaSize} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
<div class="m-4 flex flex-col gap-2">
|
||||||
<p class="text-sm text-red-400">{error}</p>
|
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
|
||||||
{/if}
|
Quota Size (GiB)
|
||||||
|
{#if quotaSizeWarning}
|
||||||
|
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<input class="immich-form-input" id="quotaSize" type="number" min="0" bind:value={quotaSize} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if success}
|
{#if error}
|
||||||
<p class="text-sm text-immich-primary">{success}</p>
|
<p class="ml-4 text-sm text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex w-full gap-4 pt-4">
|
|
||||||
<Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
|
{#if success}
|
||||||
<Button type="submit" disabled={isCreatingUser} fullwidth>Create</Button>
|
<p class="ml-4 text-sm text-immich-primary">{success}</p>
|
||||||
</div>
|
{/if}
|
||||||
</form>
|
<div class="flex w-full gap-4 p-4">
|
||||||
|
<Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={isCreatingUser} fullwidth>Create</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@ -34,29 +34,39 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form on:submit|preventDefault={handleUpdateAlbumInfo} autocomplete="off">
|
<div
|
||||||
<div class="flex items-center">
|
class="max-h-screen w-[700px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<div class="hidden sm:flex">
|
>
|
||||||
<AlbumCover {album} css="h-[200px] w-[200px] m-4 shadow-lg" />
|
<div
|
||||||
</div>
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 mb-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
<div class="flex-grow">
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit Album</h1>
|
||||||
<div class="m-4 flex flex-col gap-2">
|
|
||||||
<label class="immich-form-label" for="name">Name</label>
|
|
||||||
<input class="immich-form-input" id="name" type="text" bind:value={albumName} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="m-4 flex flex-col gap-2">
|
|
||||||
<label class="immich-form-label" for="description">Description</label>
|
|
||||||
<textarea class="immich-form-input" id="description" bind:value={description} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<form on:submit|preventDefault={handleUpdateAlbumInfo} autocomplete="off">
|
||||||
<div class="mt-8 flex w-full sm:w-2/3 gap-4">
|
<div class="flex items-center">
|
||||||
<Button color="gray" fullwidth on:click={() => onCancel?.()}>Cancel</Button>
|
<div class="hidden sm:flex">
|
||||||
<Button type="submit" fullwidth disabled={isSubmitting}>OK</Button>
|
<AlbumCover {album} css="h-[200px] w-[200px] m-4 shadow-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow">
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="immich-form-label" for="name">Name</label>
|
||||||
|
<input class="immich-form-input" id="name" type="text" bind:value={albumName} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="immich-form-label" for="description">Description</label>
|
||||||
|
<textarea class="immich-form-input" id="description" bind:value={description} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
<div class="flex justify-center">
|
||||||
|
<div class="mt-8 flex w-full sm:w-2/3 gap-4 px-4">
|
||||||
|
<Button color="gray" fullwidth on:click={() => onCancel?.()}>Cancel</Button>
|
||||||
|
<Button type="submit" fullwidth disabled={isSubmitting}>OK</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { serverInfo } from '$lib/stores/server-info.store';
|
import { serverInfo } from '$lib/stores/server-info.store';
|
||||||
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
|
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateUser, type UserResponseDto } from '@immich/sdk';
|
import { updateUser, type UserResponseDto } from '@immich/sdk';
|
||||||
|
import { mdiAccountEditOutline, mdiClose } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
|
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
export let canResetPassword = true;
|
export let canResetPassword = true;
|
||||||
@ -87,64 +91,82 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form on:submit|preventDefault={editUser} autocomplete="off">
|
<FocusTrap>
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div
|
||||||
<label class="immich-form-label" for="email">Email</label>
|
class="relative max-h-screen w-[500px] max-w-[95vw] overflow-y-auto rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
|
>
|
||||||
</div>
|
<div class="absolute top-0 right-0 px-2 py-2 h-fit">
|
||||||
|
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div
|
||||||
<label class="immich-form-label" for="name">Name</label>
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
|
||||||
<label class="flex items-center gap-2 immich-form-label" for="quotaSize"
|
|
||||||
>Quota Size (GiB) {#if quotaSizeWarning}
|
|
||||||
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
|
|
||||||
{/if}</label
|
|
||||||
>
|
>
|
||||||
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
|
<Icon path={mdiAccountEditOutline} size="4em" />
|
||||||
<p>Note: Enter 0 for unlimited quota</p>
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Edit user</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form on:submit|preventDefault={editUser} autocomplete="off">
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="immich-form-label" for="email">Email</label>
|
||||||
|
<input class="immich-form-input" id="email" name="email" type="email" bind:value={user.email} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="immich-form-label" for="name">Name</label>
|
||||||
|
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="flex items-center gap-2 immich-form-label" for="quotaSize"
|
||||||
|
>Quota Size (GiB) {#if quotaSizeWarning}
|
||||||
|
<p class="text-red-400 text-sm">You set a quota higher than the disk size</p>
|
||||||
|
{/if}</label
|
||||||
|
>
|
||||||
|
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
|
||||||
|
<p>Note: Enter 0 for unlimited quota</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="immich-form-label" for="storage-label">Storage Label</label>
|
||||||
|
<input
|
||||||
|
class="immich-form-input"
|
||||||
|
id="storage-label"
|
||||||
|
name="storage-label"
|
||||||
|
type="text"
|
||||||
|
bind:value={user.storageLabel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Note: To apply the Storage Label to previously uploaded assets, run the
|
||||||
|
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
|
||||||
|
Storage Migration Job</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="ml-4 text-sm text-red-400">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<p class="ml-4 text-sm text-immich-primary">{success}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
{#if canResetPassword}
|
||||||
|
<Button color="light-red" fullwidth on:click={() => (isShowResetPasswordConfirmation = true)}
|
||||||
|
>Reset password</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<Button type="submit" fullwidth>Confirm</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
<div class="my-4 flex flex-col gap-2">
|
|
||||||
<label class="immich-form-label" for="storage-label">Storage Label</label>
|
|
||||||
<input
|
|
||||||
class="immich-form-input"
|
|
||||||
id="storage-label"
|
|
||||||
name="storage-label"
|
|
||||||
type="text"
|
|
||||||
bind:value={user.storageLabel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Note: To apply the Storage Label to previously uploaded assets, run the
|
|
||||||
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"> Storage Migration Job</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="ml-4 text-sm text-red-400">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if success}
|
|
||||||
<p class="ml-4 text-sm text-immich-primary">{success}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="mt-8 flex w-full gap-4">
|
|
||||||
{#if canResetPassword}
|
|
||||||
<Button color="light-red" fullwidth on:click={() => (isShowResetPasswordConfirmation = true)}
|
|
||||||
>Reset password</Button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<Button type="submit" fullwidth>Confirm</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{#if isShowResetPasswordConfirmation}
|
{#if isShowResetPasswordConfirmation}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="reset-password-modal"
|
title="Reset Password"
|
||||||
title="Reset password"
|
|
||||||
confirmText="Reset"
|
confirmText="Reset"
|
||||||
onConfirm={resetPassword}
|
onConfirm={resetPassword}
|
||||||
onClose={() => (isShowResetPasswordConfirmation = false)}
|
onClose={() => (isShowResetPasswordConfirmation = false)}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { mdiFolderRemove } from '@mdi/js';
|
import { mdiFolderRemove } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@ -28,42 +29,48 @@
|
|||||||
const handleSubmit = () => dispatch('submit', { excludePattern: exclusionPattern });
|
const handleSubmit = () => dispatch('submit', { excludePattern: exclusionPattern });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal
|
<FullScreenModal onClose={handleCancel}>
|
||||||
id="add-exclusion-pattern-modal"
|
<div
|
||||||
title="Add exclusion pattern"
|
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
icon={mdiFolderRemove}
|
>
|
||||||
onClose={handleCancel}
|
<div
|
||||||
>
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
>
|
||||||
<p class="py-5 text-sm">
|
<Icon path={mdiFolderRemove} size="4em" />
|
||||||
Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Add Exclusion pattern</h1>
|
||||||
folders that contain files you don't want to import, such as RAW files.
|
|
||||||
<br /><br />
|
|
||||||
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
|
|
||||||
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore".
|
|
||||||
</p>
|
|
||||||
<div class="my-4 flex flex-col gap-2">
|
|
||||||
<label class="immich-form-label" for="exclusionPattern">Pattern</label>
|
|
||||||
<input
|
|
||||||
class="immich-form-input"
|
|
||||||
id="exclusionPattern"
|
|
||||||
name="exclusionPattern"
|
|
||||||
type="text"
|
|
||||||
bind:value={exclusionPattern}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 flex w-full gap-4">
|
|
||||||
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
|
|
||||||
{#if isEditing}
|
|
||||||
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
|
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
||||||
</div>
|
<p class="p-5 text-sm">
|
||||||
<div class="mt-8 flex w-full gap-4">
|
Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have
|
||||||
{#if isDuplicate}
|
folders that contain files you don't want to import, such as RAW files.
|
||||||
<p class="text-red-500 text-sm">This exclusion pattern already exists.</p>
|
<br /><br />
|
||||||
{/if}
|
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
|
||||||
</div>
|
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore".
|
||||||
</form>
|
</p>
|
||||||
|
<div class="m-4 flex flex-col gap-2">
|
||||||
|
<label class="immich-form-label" for="exclusionPattern">Pattern</label>
|
||||||
|
<input
|
||||||
|
class="immich-form-input"
|
||||||
|
id="exclusionPattern"
|
||||||
|
name="exclusionPattern"
|
||||||
|
type="text"
|
||||||
|
bind:value={exclusionPattern}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
|
||||||
|
{#if isEditing}
|
||||||
|
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
{#if isDuplicate}
|
||||||
|
<p class="text-red-500 text-sm">This exclusion pattern already exists.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
||||||
import { mdiFolderSync } from '@mdi/js';
|
import { mdiFolderSync } from '@mdi/js';
|
||||||
@ -30,30 +31,45 @@
|
|||||||
const handleSubmit = () => dispatch('submit', { importPath });
|
const handleSubmit = () => dispatch('submit', { importPath });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="library-import-path-modal" {title} icon={mdiFolderSync} onClose={handleCancel}>
|
<FullScreenModal onClose={handleCancel}>
|
||||||
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
<div
|
||||||
<p class="py-5 text-sm">
|
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.
|
>
|
||||||
</p>
|
<div
|
||||||
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
<div class="my-4 flex flex-col gap-2">
|
>
|
||||||
<label class="immich-form-label" for="path">Path</label>
|
<Icon path={mdiFolderSync} size="4em" />
|
||||||
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex w-full gap-4">
|
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
||||||
<Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
|
<p class="p-5 text-sm">
|
||||||
{#if isEditing}
|
Specify a folder to import. This folder, including subfolders, will be scanned for images and videos. Note that
|
||||||
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
|
you are only allowed to import paths inside of your account's external path, configured in the administrative
|
||||||
{/if}
|
settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
|
<div class="m-4 flex flex-col gap-2">
|
||||||
</div>
|
<label class="immich-form-label" for="path">Path</label>
|
||||||
|
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex w-full gap-4">
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
{#if isDuplicate}
|
<Button color="gray" fullwidth on:click={() => handleCancel()}>{cancelText}</Button>
|
||||||
<p class="text-red-500 text-sm">This import path already exists.</p>
|
{#if isEditing}
|
||||||
{/if}
|
<Button color="red" fullwidth on:click={() => dispatch('delete')}>Delete</Button>
|
||||||
</div>
|
{/if}
|
||||||
</form>
|
|
||||||
|
<Button type="submit" disabled={!canSubmit} fullwidth>{submitText}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
{#if isDuplicate}
|
||||||
|
<p class="text-red-500 text-sm">This import path already exists.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -152,7 +152,7 @@
|
|||||||
|
|
||||||
{#if addImportPath}
|
{#if addImportPath}
|
||||||
<LibraryImportPathForm
|
<LibraryImportPathForm
|
||||||
title="Add import path"
|
title="Add Import Path"
|
||||||
submitText="Add"
|
submitText="Add"
|
||||||
bind:importPath={importPathToAdd}
|
bind:importPath={importPathToAdd}
|
||||||
{importPaths}
|
{importPaths}
|
||||||
@ -166,7 +166,7 @@
|
|||||||
|
|
||||||
{#if editImportPath != undefined}
|
{#if editImportPath != undefined}
|
||||||
<LibraryImportPathForm
|
<LibraryImportPathForm
|
||||||
title="Edit import path"
|
title="Edit Import Path"
|
||||||
submitText="Save"
|
submitText="Save"
|
||||||
isEditing={true}
|
isEditing={true}
|
||||||
bind:importPath={editedImportPath}
|
bind:importPath={editedImportPath}
|
||||||
|
@ -169,7 +169,7 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
addExclusionPattern = true;
|
addExclusionPattern = true;
|
||||||
}}>Add exclusion pattern</Button
|
}}>Add Exclusion Pattern</Button
|
||||||
></td
|
></td
|
||||||
></tr
|
></tr
|
||||||
>
|
>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
||||||
import { mdiFolderSync } from '@mdi/js';
|
import { mdiFolderSync } from '@mdi/js';
|
||||||
@ -27,21 +28,27 @@
|
|||||||
const handleSubmit = () => dispatch('submit', { ownerId });
|
const handleSubmit = () => dispatch('submit', { ownerId });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal
|
<FullScreenModal onClose={handleCancel}>
|
||||||
id="select-library-owner-modal"
|
<div
|
||||||
title="Select library owner"
|
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
icon={mdiFolderSync}
|
>
|
||||||
onClose={handleCancel}
|
<div
|
||||||
>
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
>
|
||||||
<p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
|
<Icon path={mdiFolderSync} size="4em" />
|
||||||
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Select library owner</h1>
|
||||||
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
|
|
||||||
|
|
||||||
<div class="mt-8 flex w-full gap-4">
|
|
||||||
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
|
|
||||||
|
|
||||||
<Button type="submit" fullwidth>Create</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
|
||||||
|
<p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
|
||||||
|
|
||||||
|
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
|
||||||
|
|
||||||
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
|
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
|
||||||
|
|
||||||
|
<Button type="submit" fullwidth>Create</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -21,92 +21,98 @@
|
|||||||
const handleClose = () => dispatch('close');
|
const handleClose = () => dispatch('close');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="map-settings-modal" title="Map settings" onClose={handleClose}>
|
<FullScreenModal onClose={handleClose}>
|
||||||
<form
|
<div
|
||||||
on:submit|preventDefault={() => dispatch('save', settings)}
|
class="flex w-96 max-w-lg flex-col gap-8 rounded-3xl border bg-white p-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||||
class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
|
|
||||||
>
|
>
|
||||||
<SettingSwitch id="allow-dark-mode" title="Allow dark mode" bind:checked={settings.allowDarkMode} />
|
<h1 class="self-center text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Map Settings</h1>
|
||||||
<SettingSwitch id="only-favorites" title="Only favorites" bind:checked={settings.onlyFavorites} />
|
|
||||||
<SettingSwitch id="include-archived" title="Include archived" bind:checked={settings.includeArchived} />
|
|
||||||
<SettingSwitch id="include-shared-with-me" title="Include shared with me" bind:checked={settings.withPartners} />
|
|
||||||
{#if customDateRange}
|
|
||||||
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
|
|
||||||
<div class="flex items-center justify-between gap-8">
|
|
||||||
<label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label>
|
|
||||||
<DateInput
|
|
||||||
class="immich-form-input w-40"
|
|
||||||
type="date"
|
|
||||||
id="date-after"
|
|
||||||
max={settings.dateBefore}
|
|
||||||
bind:value={settings.dateAfter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-8">
|
|
||||||
<label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label>
|
|
||||||
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-center text-xs">
|
|
||||||
<LinkButton
|
|
||||||
on:click={() => {
|
|
||||||
customDateRange = false;
|
|
||||||
settings.dateAfter = '';
|
|
||||||
settings.dateBefore = '';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove custom date range
|
|
||||||
</LinkButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
|
|
||||||
<SettingSelect
|
|
||||||
label="Date range"
|
|
||||||
name="date-range"
|
|
||||||
bind:value={settings.relativeDate}
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
value: '',
|
|
||||||
text: 'All',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: Duration.fromObject({ hours: 24 }).toISO() || '',
|
|
||||||
text: 'Past 24 hours',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: Duration.fromObject({ days: 7 }).toISO() || '',
|
|
||||||
text: 'Past 7 days',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: Duration.fromObject({ days: 30 }).toISO() || '',
|
|
||||||
text: 'Past 30 days',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: Duration.fromObject({ years: 1 }).toISO() || '',
|
|
||||||
text: 'Past year',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: Duration.fromObject({ years: 3 }).toISO() || '',
|
|
||||||
text: 'Past 3 years',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div class="text-xs">
|
|
||||||
<LinkButton
|
|
||||||
on:click={() => {
|
|
||||||
customDateRange = true;
|
|
||||||
settings.relativeDate = '';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Use custom date range instead
|
|
||||||
</LinkButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-4 flex w-full gap-4">
|
<form
|
||||||
<Button color="gray" size="sm" fullwidth on:click={handleClose}>Cancel</Button>
|
on:submit|preventDefault={() => dispatch('save', settings)}
|
||||||
<Button type="submit" size="sm" fullwidth>Save</Button>
|
class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
</div>
|
>
|
||||||
</form>
|
<SettingSwitch id="allow-dark-mode" title="Allow dark mode" bind:checked={settings.allowDarkMode} />
|
||||||
|
<SettingSwitch id="only-favorites" title="Only favorites" bind:checked={settings.onlyFavorites} />
|
||||||
|
<SettingSwitch id="include-archived" title="Include archived" bind:checked={settings.includeArchived} />
|
||||||
|
<SettingSwitch id="include-shared-with-me" title="Include shared with me" bind:checked={settings.withPartners} />
|
||||||
|
{#if customDateRange}
|
||||||
|
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-8">
|
||||||
|
<label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label>
|
||||||
|
<DateInput
|
||||||
|
class="immich-form-input w-40"
|
||||||
|
type="date"
|
||||||
|
id="date-after"
|
||||||
|
max={settings.dateBefore}
|
||||||
|
bind:value={settings.dateAfter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-8">
|
||||||
|
<label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label>
|
||||||
|
<DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center text-xs">
|
||||||
|
<LinkButton
|
||||||
|
on:click={() => {
|
||||||
|
customDateRange = false;
|
||||||
|
settings.dateAfter = '';
|
||||||
|
settings.dateBefore = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove custom date range
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1">
|
||||||
|
<SettingSelect
|
||||||
|
label="Date range"
|
||||||
|
name="date-range"
|
||||||
|
bind:value={settings.relativeDate}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
text: 'All',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: Duration.fromObject({ hours: 24 }).toISO() || '',
|
||||||
|
text: 'Past 24 hours',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: Duration.fromObject({ days: 7 }).toISO() || '',
|
||||||
|
text: 'Past 7 days',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: Duration.fromObject({ days: 30 }).toISO() || '',
|
||||||
|
text: 'Past 30 days',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: Duration.fromObject({ years: 1 }).toISO() || '',
|
||||||
|
text: 'Past year',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: Duration.fromObject({ years: 3 }).toISO() || '',
|
||||||
|
text: 'Past 3 years',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div class="text-xs">
|
||||||
|
<LinkButton
|
||||||
|
on:click={() => {
|
||||||
|
customDateRange = true;
|
||||||
|
settings.relativeDate = '';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use custom date range instead
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4 flex w-full gap-4">
|
||||||
|
<Button color="gray" size="sm" fullwidth on:click={handleClose}>Cancel</Button>
|
||||||
|
<Button type="submit" size="sm" fullwidth>Save</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -57,7 +57,6 @@
|
|||||||
|
|
||||||
{#if isShowConfirmation}
|
{#if isShowConfirmation}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="remove-from-album-modal"
|
|
||||||
title="Remove from {album.albumName}"
|
title="Remove from {album.albumName}"
|
||||||
confirmText="Remove"
|
confirmText="Remove"
|
||||||
onConfirm={removeFromAlbum}
|
onConfirm={removeFromAlbum}
|
||||||
|
@ -50,8 +50,7 @@
|
|||||||
|
|
||||||
{#if removing}
|
{#if removing}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="remove-assets-modal"
|
title="Remove Assets?"
|
||||||
title="Remove assets?"
|
|
||||||
prompt="Are you sure you want to remove {getAssets().size} asset(s) from this shared link?"
|
prompt="Are you sure you want to remove {getAssets().size} asset(s) from this shared link?"
|
||||||
confirmText="Remove"
|
confirmText="Remove"
|
||||||
onConfirm={() => handleRemove()}
|
onConfirm={() => handleRemove()}
|
||||||
|
@ -25,8 +25,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="permanently-delete-asset-modal"
|
title="Permanently Delete Asset{size > 1 ? 's' : ''}"
|
||||||
title="Permanently delete asset{size > 1 ? 's' : ''}"
|
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onClose={() => dispatch('cancel')}
|
onClose={() => dispatch('cancel')}
|
||||||
|
@ -3,9 +3,12 @@
|
|||||||
import { quintOut } from 'svelte/easing';
|
import { quintOut } from 'svelte/easing';
|
||||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import { clickOutside } from '$lib/utils/click-outside';
|
import { clickOutside } from '$lib/utils/click-outside';
|
||||||
|
import { mdiClose } from '@mdi/js';
|
||||||
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
|
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
|
||||||
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
|
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
close: void;
|
close: void;
|
||||||
@ -25,8 +28,6 @@
|
|||||||
*/
|
*/
|
||||||
export let icon: string | undefined = undefined;
|
export let icon: string | undefined = undefined;
|
||||||
|
|
||||||
$: titleId = `${id}-title`;
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const scrollTop = document.documentElement.scrollTop;
|
const scrollTop = document.documentElement.scrollTop;
|
||||||
@ -50,7 +51,7 @@
|
|||||||
<FocusTrap>
|
<FocusTrap>
|
||||||
<div
|
<div
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={`${id}-title`}
|
||||||
style:z-index={zIndex}
|
style:z-index={zIndex}
|
||||||
transition:fade={{ duration: 100, easing: quintOut }}
|
transition:fade={{ duration: 100, easing: quintOut }}
|
||||||
class="fixed left-0 top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50"
|
class="fixed left-0 top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50"
|
||||||
@ -60,11 +61,23 @@
|
|||||||
onOutclick: () => dispatch('close'),
|
onOutclick: () => dispatch('close'),
|
||||||
onEscape: () => dispatch('close'),
|
onEscape: () => dispatch('close'),
|
||||||
}}
|
}}
|
||||||
class="min-h-[200px] w-[450px] overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar scroll-pb-20"
|
class="max-h-[800px] min-h-[200px] w-[450px] overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar"
|
||||||
style="max-height: min(95vh, 800px);"
|
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<ModalHeader id={titleId} {title} {showLogo} {icon} on:close />
|
<div class="flex place-items-center justify-between px-5 py-3">
|
||||||
|
<div class="flex gap-2 place-items-center">
|
||||||
|
{#if showLogo}
|
||||||
|
<ImmichLogo noText={true} width={32} />
|
||||||
|
{:else if icon}
|
||||||
|
<Icon path={icon} size={32} ariaHidden={true} class="text-immich-primary dark:text-immich-dark-primary" />
|
||||||
|
{/if}
|
||||||
|
<h1 id={`${id}-title`}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} size={'20'} title="Close" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -63,16 +63,16 @@
|
|||||||
|
|
||||||
<div role="presentation" on:keydown={handleKeydown}>
|
<div role="presentation" on:keydown={handleKeydown}>
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="edit-date-time-modal"
|
|
||||||
confirmColor="primary"
|
confirmColor="primary"
|
||||||
cancelColor="secondary"
|
cancelColor="secondary"
|
||||||
title="Edit date and time"
|
title="Edit date & time"
|
||||||
prompt="Please select a new date:"
|
prompt="Please select a new date:"
|
||||||
disabled={!date.isValid}
|
disabled={!date.isValid}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onClose={handleCancel}
|
onClose={handleCancel}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col text-md px-4 text-center gap-2" slot="prompt">
|
<div class="flex flex-col text-md px-4 text-center gap-2" slot="prompt">
|
||||||
|
<div class="mt-2" />
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="datetime">Date and Time</label>
|
<label for="datetime">Date and Time</label>
|
||||||
<DateInput
|
<DateInput
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
import SearchBar from '../elements/search-bar.svelte';
|
import SearchBar from '../elements/search-bar.svelte';
|
||||||
import { listNavigation } from '$lib/utils/list-navigation';
|
import { listNavigation } from '$lib/utils/list-navigation';
|
||||||
|
|
||||||
|
export const title = 'Change Location';
|
||||||
export let asset: AssetResponseDto | undefined = undefined;
|
export let asset: AssetResponseDto | undefined = undefined;
|
||||||
|
|
||||||
interface Point {
|
interface Point {
|
||||||
@ -94,11 +95,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="change-location-modal"
|
|
||||||
confirmColor="primary"
|
confirmColor="primary"
|
||||||
cancelColor="secondary"
|
cancelColor="secondary"
|
||||||
title="Change location"
|
title="Change Location"
|
||||||
width="wide"
|
width={800}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onClose={handleCancel}
|
onClose={handleCancel}
|
||||||
>
|
>
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
import Button from '../elements/buttons/button.svelte';
|
import Button from '../elements/buttons/button.svelte';
|
||||||
import type { Color } from '$lib/components/elements/buttons/button.svelte';
|
import type { Color } from '$lib/components/elements/buttons/button.svelte';
|
||||||
|
|
||||||
export let id: string;
|
|
||||||
export let title = 'Confirm';
|
export let title = 'Confirm';
|
||||||
export let prompt = 'Are you sure you want to do this?';
|
export let prompt = 'Are you sure you want to do this?';
|
||||||
export let confirmText = 'Confirm';
|
export let confirmText = 'Confirm';
|
||||||
@ -12,7 +11,7 @@
|
|||||||
export let cancelColor: Color = 'primary';
|
export let cancelColor: Color = 'primary';
|
||||||
export let hideCancelButton = false;
|
export let hideCancelButton = false;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
export let width: 'wide' | 'narrow' = 'narrow';
|
export let width = 500;
|
||||||
export let onClose: () => void;
|
export let onClose: () => void;
|
||||||
export let onConfirm: () => void;
|
export let onConfirm: () => void;
|
||||||
|
|
||||||
@ -24,21 +23,35 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal {title} {id} {onClose} {width}>
|
<FullScreenModal {onClose}>
|
||||||
<div class="text-md py-5 text-center">
|
<div
|
||||||
<slot name="prompt">
|
class="max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<p>{prompt}</p>
|
style="width: {width}px"
|
||||||
</slot>
|
>
|
||||||
</div>
|
<div
|
||||||
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<h1 class="pb-2 text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-md px-4 py-5 text-center">
|
||||||
|
<slot name="prompt">
|
||||||
|
<p>{prompt}</p>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col sm:flex-row w-full gap-4">
|
<div class="mt-4 flex w-full gap-4 px-4">
|
||||||
{#if !hideCancelButton}
|
{#if !hideCancelButton}
|
||||||
<Button color={cancelColor} fullwidth on:click={onClose}>
|
<Button color={cancelColor} fullwidth on:click={onClose}>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={disabled || isConfirmButtonDisabled}>
|
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={disabled || isConfirmButtonDisabled}>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -2,44 +2,8 @@
|
|||||||
import { clickOutside } from '../../utils/click-outside';
|
import { clickOutside } from '../../utils/click-outside';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
|
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
|
||||||
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
|
|
||||||
|
|
||||||
export let onClose: () => void;
|
export let onClose: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique identifier for the modal.
|
|
||||||
*/
|
|
||||||
export let id: string;
|
|
||||||
export let title: string;
|
|
||||||
/**
|
|
||||||
* If true, the logo will be displayed next to the modal title.
|
|
||||||
*/
|
|
||||||
export let showLogo = false;
|
|
||||||
/**
|
|
||||||
* Optional icon to display next to the modal title, if `showLogo` is false.
|
|
||||||
*/
|
|
||||||
export let icon: string | undefined = undefined;
|
|
||||||
/**
|
|
||||||
* Sets the width of the modal.
|
|
||||||
*
|
|
||||||
* - `wide`: 750px
|
|
||||||
* - `narrow`: 450px
|
|
||||||
* - `auto`: fits the width of the modal content, up to a maximum of 550px
|
|
||||||
*/
|
|
||||||
export let width: 'wide' | 'narrow' | 'auto' = 'narrow';
|
|
||||||
|
|
||||||
$: titleId = `${id}-title`;
|
|
||||||
|
|
||||||
let modalWidth: string;
|
|
||||||
$: {
|
|
||||||
if (width === 'wide') {
|
|
||||||
modalWidth = 'w-[750px]';
|
|
||||||
} else if (width === 'narrow') {
|
|
||||||
modalWidth = 'w-[450px]';
|
|
||||||
} else {
|
|
||||||
modalWidth = 'sm:max-w-[550px]';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FocusTrap>
|
<FocusTrap>
|
||||||
@ -48,17 +12,8 @@
|
|||||||
out:fade={{ duration: 100 }}
|
out:fade={{ duration: 100 }}
|
||||||
class="fixed left-0 top-0 z-[9990] flex h-screen w-screen place-content-center place-items-center bg-black/40"
|
class="fixed left-0 top-0 z-[9990] flex h-screen w-screen place-content-center place-items-center bg-black/40"
|
||||||
>
|
>
|
||||||
<div
|
<div class="z-[9999]" use:clickOutside={{ onOutclick: onClose, onEscape: onClose }} tabindex="-1">
|
||||||
class="z-[9999] max-w-[95vw] max-h-[95vh] {modalWidth} overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar"
|
<slot />
|
||||||
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
|
|
||||||
tabindex="-1"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby={titleId}
|
|
||||||
>
|
|
||||||
<ModalHeader id={titleId} {title} {showLogo} {icon} on:close={() => onClose?.()} />
|
|
||||||
<div class="p-5 pt-0">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import { mdiClose } from '@mdi/js';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
|
||||||
close: void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unique identifier for the header text.
|
|
||||||
*/
|
|
||||||
export let id: string;
|
|
||||||
export let title: string;
|
|
||||||
/**
|
|
||||||
* If true, the logo will be displayed next to the modal title.
|
|
||||||
*/
|
|
||||||
export let showLogo = false;
|
|
||||||
/**
|
|
||||||
* Optional icon to display next to the modal title, if `showLogo` is false.
|
|
||||||
*/
|
|
||||||
export let icon: string | undefined = undefined;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex place-items-center justify-between px-5 py-3">
|
|
||||||
<div class="flex gap-2 place-items-center">
|
|
||||||
{#if showLogo}
|
|
||||||
<ImmichLogo noText={true} width={32} />
|
|
||||||
{:else if icon}
|
|
||||||
<Icon path={icon} size={32} ariaHidden={true} class="text-immich-primary dark:text-immich-dark-primary" />
|
|
||||||
{/if}
|
|
||||||
<h1 {id}>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CircleIconButton on:click={() => dispatch('close')} icon={mdiClose} size={'20'} title="Close" />
|
|
||||||
</div>
|
|
@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
|
import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
|
||||||
|
import { mdiClose } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import FullScreenModal from '../full-screen-modal.svelte';
|
import FullScreenModal from '../full-screen-modal.svelte';
|
||||||
import UserAvatar from '../user-avatar.svelte';
|
import UserAvatar from '../user-avatar.svelte';
|
||||||
@ -13,14 +15,28 @@
|
|||||||
const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
|
const colors: UserAvatarColor[] = Object.values(UserAvatarColor);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="avatar-selector-modal" title="Select avatar color" width="auto" onClose={() => dispatch('close')}>
|
<FullScreenModal onClose={() => dispatch('close')}>
|
||||||
<div class="flex items-center justify-center mt-4">
|
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div
|
||||||
{#each colors as color}
|
class=" rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg p-4"
|
||||||
<button on:click={() => dispatch('choose', color)}>
|
>
|
||||||
<UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
|
<div class="flex items-center">
|
||||||
</button>
|
<h1 class="px-4 w-full self-center font-medium text-immich-primary dark:text-immich-dark-primary text-sm">
|
||||||
{/each}
|
SELECT AVATAR COLOR
|
||||||
|
</h1>
|
||||||
|
<div>
|
||||||
|
<CircleIconButton icon={mdiClose} title="Close" on:click={() => dispatch('close')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center p-4 mt-4">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{#each colors as color}
|
||||||
|
<button on:click={() => dispatch('choose', color)}>
|
||||||
|
<UserAvatar label={color} {user} {color} size="xl" showProfileImage={false} />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import FullScreenModal from './full-screen-modal.svelte';
|
import FullScreenModal from './full-screen-modal.svelte';
|
||||||
import { mdiInformationOutline } from '@mdi/js';
|
import { mdiClose, mdiInformationOutline } from '@mdi/js';
|
||||||
import Icon from '../elements/icon.svelte';
|
import Icon from '../elements/icon.svelte';
|
||||||
|
|
||||||
interface Shortcuts {
|
interface Shortcuts {
|
||||||
@ -37,51 +38,63 @@
|
|||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal
|
<FullScreenModal onClose={() => dispatch('close')}>
|
||||||
id="keyboard-shortcuts-modal"
|
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
|
||||||
title="Keyboard shortcuts"
|
<div
|
||||||
width="auto"
|
class="rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
onClose={() => dispatch('close')}
|
>
|
||||||
>
|
<div class="relative px-4 pt-4">
|
||||||
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
|
<h1 class="px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">Keyboard Shortcuts</h1>
|
||||||
<div class="p-4">
|
<div class="absolute inset-y-0 right-0 px-4 py-4">
|
||||||
<h2>General</h2>
|
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
|
||||||
<div class="text-sm">
|
</div>
|
||||||
{#each shortcuts.general as shortcut}
|
|
||||||
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
|
||||||
<div class="flex justify-self-end">
|
|
||||||
{#each shortcut.key as key}
|
|
||||||
<p class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
|
|
||||||
{key}
|
|
||||||
</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
|
||||||
<h2>Actions</h2>
|
<div class="px-4 py-4">
|
||||||
<div class="text-sm">
|
<h2>General</h2>
|
||||||
{#each shortcuts.actions as shortcut}
|
<div class="text-sm">
|
||||||
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
{#each shortcuts.general as shortcut}
|
||||||
<div class="flex justify-self-end">
|
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
||||||
{#each shortcut.key as key}
|
<div class="flex justify-self-end">
|
||||||
<p class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2">
|
{#each shortcut.key as key}
|
||||||
{key}
|
<p
|
||||||
</p>
|
class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
|
||||||
{/each}
|
>
|
||||||
</div>
|
{key}
|
||||||
<div class="flex items-center gap-2">
|
</p>
|
||||||
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
|
{/each}
|
||||||
{#if shortcut.info}
|
</div>
|
||||||
<Icon path={mdiInformationOutline} title={shortcut.info} />
|
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<h2>Actions</h2>
|
||||||
|
<div class="text-sm">
|
||||||
|
{#each shortcuts.actions as shortcut}
|
||||||
|
<div class="grid grid-cols-[30%_70%] items-center gap-4 pt-4 text-sm">
|
||||||
|
<div class="flex justify-self-end">
|
||||||
|
{#each shortcut.key as key}
|
||||||
|
<p
|
||||||
|
class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
|
||||||
|
{#if shortcut.info}
|
||||||
|
<Icon path={mdiInformationOutline} title={shortcut.info} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,28 +33,34 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showModal}
|
{#if showModal}
|
||||||
<FullScreenModal id="new-version-modal" title="🎉 NEW VERSION AVAILABLE" onClose={() => (showModal = false)}>
|
<FullScreenModal onClose={() => (showModal = false)}>
|
||||||
<div>
|
<div
|
||||||
Hi friend, there is a new version of the application please take your time to visit the
|
class="max-w-lg rounded-3xl border bg-immich-bg px-8 py-10 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<span class="font-medium underline"
|
>
|
||||||
><a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"
|
<p class="mb-4 text-2xl">🎉 NEW VERSION AVAILABLE 🎉</p>
|
||||||
>release notes</a
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
and ensure your <code>docker-compose</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations,
|
|
||||||
especially if you use WatchTower or any mechanism that handles updating your application automatically.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 font-medium">Your friend, Alex</div>
|
<div>
|
||||||
|
Hi friend, there is a new version of the application please take your time to visit the
|
||||||
|
<span class="font-medium underline"
|
||||||
|
><a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"
|
||||||
|
>release notes</a
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
and ensure your <code>docker-compose</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations,
|
||||||
|
especially if you use WatchTower or any mechanism that handles updating your application automatically.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="font-sm mt-8">
|
<div class="mt-4 font-medium">Your friend, Alex</div>
|
||||||
<code>Server Version: {serverVersion}</code>
|
|
||||||
<br />
|
|
||||||
<code>Latest Version: {releaseVersion}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 text-right">
|
<div class="font-sm mt-8">
|
||||||
<Button fullwidth on:click={onAcknowledge}>Acknowledge</Button>
|
<code>Server Version: {serverVersion}</code>
|
||||||
|
<br />
|
||||||
|
<code>Latest Version: {releaseVersion}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-right">
|
||||||
|
<Button fullwidth on:click={onAcknowledge}>Acknowledge</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -30,22 +30,31 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal id="slideshow-settings-modal" title="Slideshow settings" {onClose}>
|
<FullScreenModal {onClose}>
|
||||||
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
|
<div
|
||||||
<SettingDropdown
|
class="flex w-full md:w-96 max-w-lg flex-col gap-8 rounded-3xl border bg-white p-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||||
title="Direction"
|
>
|
||||||
options={Object.values(options)}
|
<h1 class="self-center text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||||
selectedOption={options[$slideshowNavigation]}
|
Slideshow Settings
|
||||||
onToggle={(option) => handleToggle(option)}
|
</h1>
|
||||||
/>
|
|
||||||
<SettingSwitch id="show-progress-bar" title="Show Progress Bar" bind:checked={$showProgressBar} />
|
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
|
||||||
<SettingInputField
|
<SettingDropdown
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
title="Direction"
|
||||||
label="Duration"
|
options={Object.values(options)}
|
||||||
desc="Number of seconds to display each image"
|
selectedOption={options[$slideshowNavigation]}
|
||||||
min={1}
|
onToggle={(option) => handleToggle(option)}
|
||||||
bind:value={$slideshowDelay}
|
/>
|
||||||
/>
|
<SettingSwitch id="show-progress-bar" title="Show Progress Bar" bind:checked={$showProgressBar} />
|
||||||
<Button class="w-full" color="gray" on:click={onClose}>Done</Button>
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
|
label="Duration"
|
||||||
|
desc="Number of seconds to display each image"
|
||||||
|
min={1}
|
||||||
|
bind:value={$slideshowDelay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button class="w-full" color="gray" on:click={onClose}>Done</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
|
@ -49,7 +49,6 @@
|
|||||||
|
|
||||||
{#if deleteDevice}
|
{#if deleteDevice}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="log-out-device-modal"
|
|
||||||
prompt="Are you sure you want to log out this device?"
|
prompt="Are you sure you want to log out this device?"
|
||||||
onConfirm={() => handleDelete()}
|
onConfirm={() => handleDelete()}
|
||||||
onClose={() => (deleteDevice = null)}
|
onClose={() => (deleteDevice = null)}
|
||||||
@ -58,7 +57,6 @@
|
|||||||
|
|
||||||
{#if deleteAll}
|
{#if deleteAll}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="log-out-all-modal"
|
|
||||||
prompt="Are you sure you want to log out all devices?"
|
prompt="Are you sure you want to log out all devices?"
|
||||||
onConfirm={() => handleDeleteAll()}
|
onConfirm={() => handleDeleteAll()}
|
||||||
onClose={() => (deleteAll = false)}
|
onClose={() => (deleteAll = false)}
|
||||||
|
@ -189,7 +189,6 @@
|
|||||||
|
|
||||||
{#if removePartnerDto}
|
{#if removePartnerDto}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="stop-sharing-photos-modal"
|
|
||||||
title="Stop sharing your photos?"
|
title="Stop sharing your photos?"
|
||||||
prompt="{removePartnerDto.name} will no longer be able to access your photos."
|
prompt="{removePartnerDto.name} will no longer be able to access your photos."
|
||||||
onClose={() => (removePartnerDto = null)}
|
onClose={() => (removePartnerDto = null)}
|
||||||
|
@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
{#if newKey}
|
{#if newKey}
|
||||||
<APIKeyForm
|
<APIKeyForm
|
||||||
title="New API key"
|
title="New API Key"
|
||||||
submitText="Create"
|
submitText="Create"
|
||||||
apiKey={newKey}
|
apiKey={newKey}
|
||||||
on:submit={({ detail }) => handleCreate(detail)}
|
on:submit={({ detail }) => handleCreate(detail)}
|
||||||
@ -95,7 +95,6 @@
|
|||||||
|
|
||||||
{#if editKey}
|
{#if editKey}
|
||||||
<APIKeyForm
|
<APIKeyForm
|
||||||
title="API key"
|
|
||||||
submitText="Save"
|
submitText="Save"
|
||||||
apiKey={editKey}
|
apiKey={editKey}
|
||||||
on:submit={({ detail }) => handleUpdate(detail)}
|
on:submit={({ detail }) => handleUpdate(detail)}
|
||||||
@ -105,8 +104,7 @@
|
|||||||
|
|
||||||
{#if deleteKey}
|
{#if deleteKey}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="confirm-api-key-delete-modal"
|
prompt="Are you sure you want to delete this API Key?"
|
||||||
prompt="Are you sure you want to delete this API key?"
|
|
||||||
onConfirm={() => handleDelete()}
|
onConfirm={() => handleDelete()}
|
||||||
onClose={() => (deleteKey = null)}
|
onClose={() => (deleteKey = null)}
|
||||||
/>
|
/>
|
||||||
|
@ -683,7 +683,6 @@
|
|||||||
|
|
||||||
{#if viewMode === ViewMode.CONFIRM_DELETE}
|
{#if viewMode === ViewMode.CONFIRM_DELETE}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="delete-album-modal"
|
|
||||||
title="Delete album"
|
title="Delete album"
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
onConfirm={handleRemoveAlbum}
|
onConfirm={handleRemoveAlbum}
|
||||||
|
@ -463,25 +463,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showChangeNameModal}
|
{#if showChangeNameModal}
|
||||||
<FullScreenModal id="change-name-modal" title="Change name" onClose={() => (showChangeNameModal = false)}>
|
<FullScreenModal onClose={() => (showChangeNameModal = false)}>
|
||||||
<form on:submit|preventDefault={submitNameChange} autocomplete="off">
|
<div
|
||||||
<div class="flex flex-col gap-2">
|
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||||
<label class="immich-form-label" for="name">Name</label>
|
>
|
||||||
<!-- svelte-ignore a11y-autofocus -->
|
<div
|
||||||
<input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} autofocus />
|
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Change name</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex w-full gap-4">
|
<form on:submit|preventDefault={submitNameChange} autocomplete="off">
|
||||||
<Button
|
<div class="m-4 flex flex-col gap-2">
|
||||||
color="gray"
|
<label class="immich-form-label" for="name">Name</label>
|
||||||
fullwidth
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
on:click={() => {
|
<input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} autofocus />
|
||||||
showChangeNameModal = false;
|
</div>
|
||||||
}}>Cancel</Button
|
|
||||||
>
|
<div class="mt-8 flex w-full gap-4 px-4">
|
||||||
<Button type="submit" fullwidth>Ok</Button>
|
<Button
|
||||||
</div>
|
color="gray"
|
||||||
</form>
|
fullwidth
|
||||||
|
on:click={() => {
|
||||||
|
showChangeNameModal = false;
|
||||||
|
}}>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" fullwidth>Ok</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
@ -88,8 +88,7 @@
|
|||||||
|
|
||||||
{#if deleteLinkId}
|
{#if deleteLinkId}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="delete-shared-link-modal"
|
title="Delete Shared Link"
|
||||||
title="Delete shared link"
|
|
||||||
prompt="Are you sure you want to delete this shared link?"
|
prompt="Are you sure you want to delete this shared link?"
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
onConfirm={() => handleDeleteLink()}
|
onConfirm={() => handleDeleteLink()}
|
||||||
|
@ -98,8 +98,7 @@
|
|||||||
|
|
||||||
{#if isShowEmptyConfirmation}
|
{#if isShowEmptyConfirmation}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="empty-trash-modal"
|
title="Empty Trash"
|
||||||
title="Empty trash"
|
|
||||||
confirmText="Empty"
|
confirmText="Empty"
|
||||||
onConfirm={handleEmptyTrash}
|
onConfirm={handleEmptyTrash}
|
||||||
onClose={() => (isShowEmptyConfirmation = false)}
|
onClose={() => (isShowEmptyConfirmation = false)}
|
||||||
|
@ -118,9 +118,7 @@
|
|||||||
|
|
||||||
const handleCreate = async (ownerId: string) => {
|
const handleCreate = async (ownerId: string) => {
|
||||||
try {
|
try {
|
||||||
const createdLibrary = await createLibrary({
|
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } });
|
||||||
createLibraryDto: { ownerId, type: LibraryType.External },
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: `Created library: ${createdLibrary.name}`,
|
message: `Created library: ${createdLibrary.name}`,
|
||||||
@ -302,7 +300,6 @@
|
|||||||
|
|
||||||
{#if confirmDeleteLibrary}
|
{#if confirmDeleteLibrary}
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
id="warning-modal"
|
|
||||||
title="Warning!"
|
title="Warning!"
|
||||||
prompt="Are you sure you want to delete this library? This will delete all {deleteAssetCount} contained assets from Immich and cannot be undone. Files will remain on disk."
|
prompt="Are you sure you want to delete this library? This will delete all {deleteAssetCount} contained assets from Immich and cannot be undone. Files will remain on disk."
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||||
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialogue.svelte';
|
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialoge.svelte';
|
||||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||||
import RestoreDialogue from '$lib/components/admin-page/restore-dialogue.svelte';
|
import RestoreDialogue from '$lib/components/admin-page/restore-dialoge.svelte';
|
||||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
|
||||||
@ -21,14 +21,7 @@
|
|||||||
import { asByteUnitString } from '$lib/utils/byte-units';
|
import { asByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
import { UserStatus, getAllUsers, type UserResponseDto } from '@immich/sdk';
|
import { UserStatus, getAllUsers, type UserResponseDto } from '@immich/sdk';
|
||||||
import {
|
import { mdiClose, mdiContentCopy, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||||
mdiAccountEditOutline,
|
|
||||||
mdiClose,
|
|
||||||
mdiContentCopy,
|
|
||||||
mdiDeleteRestore,
|
|
||||||
mdiPencilOutline,
|
|
||||||
mdiTrashCanOutline,
|
|
||||||
} from '@mdi/js';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@ -123,23 +116,13 @@
|
|||||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||||
<section class="w-full pb-28 lg:w-[850px]">
|
<section class="w-full pb-28 lg:w-[850px]">
|
||||||
{#if shouldShowCreateUserForm}
|
{#if shouldShowCreateUserForm}
|
||||||
<FullScreenModal
|
<FullScreenModal onClose={() => (shouldShowCreateUserForm = false)}>
|
||||||
id="create-new-user-modal"
|
|
||||||
title="Create new user"
|
|
||||||
showLogo
|
|
||||||
onClose={() => (shouldShowCreateUserForm = false)}
|
|
||||||
>
|
|
||||||
<CreateUserForm on:submit={onUserCreated} on:cancel={() => (shouldShowCreateUserForm = false)} />
|
<CreateUserForm on:submit={onUserCreated} on:cancel={() => (shouldShowCreateUserForm = false)} />
|
||||||
</FullScreenModal>
|
</FullScreenModal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if shouldShowEditUserForm}
|
{#if shouldShowEditUserForm}
|
||||||
<FullScreenModal
|
<FullScreenModal onClose={() => (shouldShowEditUserForm = false)}>
|
||||||
id="edit-user-modal"
|
|
||||||
title="Edit user"
|
|
||||||
icon={mdiAccountEditOutline}
|
|
||||||
onClose={() => (shouldShowEditUserForm = false)}
|
|
||||||
>
|
|
||||||
<EditUserForm
|
<EditUserForm
|
||||||
user={selectedUser}
|
user={selectedUser}
|
||||||
bind:newPassword
|
bind:newPassword
|
||||||
@ -170,39 +153,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if shouldShowPasswordResetSuccess}
|
{#if shouldShowPasswordResetSuccess}
|
||||||
<ConfirmDialogue
|
<FullScreenModal onClose={() => (shouldShowPasswordResetSuccess = false)}>
|
||||||
id="password-reset-success-modal"
|
<ConfirmDialogue
|
||||||
title="Password reset success"
|
title="Password Reset Success"
|
||||||
confirmText="Done"
|
confirmText="Done"
|
||||||
onConfirm={() => (shouldShowPasswordResetSuccess = false)}
|
onConfirm={() => (shouldShowPasswordResetSuccess = false)}
|
||||||
onClose={() => (shouldShowPasswordResetSuccess = false)}
|
onClose={() => (shouldShowPasswordResetSuccess = false)}
|
||||||
hideCancelButton={true}
|
hideCancelButton={true}
|
||||||
confirmColor="green"
|
confirmColor="green"
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
<svelte:fragment slot="prompt">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<p>The user's password has been reset:</p>
|
<p>The user's password has been reset:</p>
|
||||||
|
|
||||||
<div class="flex justify-center gap-2">
|
<div class="flex justify-center gap-2">
|
||||||
<code
|
<code
|
||||||
class="rounded-md bg-gray-200 px-2 py-1 font-bold text-immich-primary dark:text-immich-dark-primary dark:bg-gray-700"
|
class="rounded-md bg-gray-200 px-2 py-1 font-bold text-immich-primary dark:text-immich-dark-primary dark:bg-gray-700"
|
||||||
>
|
>
|
||||||
{newPassword}
|
{newPassword}
|
||||||
</code>
|
</code>
|
||||||
<LinkButton on:click={() => copyToClipboard(newPassword)} title="Copy password">
|
<LinkButton on:click={() => copyToClipboard(newPassword)} title="Copy password">
|
||||||
<div class="flex place-items-center gap-2 text-sm">
|
<div class="flex place-items-center gap-2 text-sm">
|
||||||
<Icon path={mdiContentCopy} size="18" />
|
<Icon path={mdiContentCopy} size="18" />
|
||||||
</div>
|
</div>
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please provide the temporary password to the user and inform them they will need to change the
|
||||||
|
password at their next login.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</svelte:fragment>
|
||||||
<p>
|
</ConfirmDialogue>
|
||||||
Please provide the temporary password to the user and inform them they will need to change the password
|
</FullScreenModal>
|
||||||
at their next login.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</svelte:fragment>
|
|
||||||
</ConfirmDialogue>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<table class="my-5 w-full text-left">
|
<table class="my-5 w-full text-left">
|
||||||
|