Compare commits

...

23 Commits

Author SHA1 Message Date
Alex
fdd9f37abd Added error handling for layout.server.ts to avoid unaccessible to previous deploy instance due to changes in SvelteKit project 2022-08-26 11:30:45 -07:00
Alex
a09bba454c Pump version for release 2022-08-26 10:57:12 -07:00
Alex
4be9aa091b Added error handling notification (#536) 2022-08-26 10:36:41 -07:00
Alex
33b810de74 Removed upload button on sharing and album page 2022-08-26 10:05:15 -07:00
Alex
44ccb1eec1 Added timeout option for notification component 2022-08-26 10:01:47 -07:00
Alex
bef38c670c Reference CLI in limit upload message 2022-08-26 09:42:48 -07:00
Alex
025d7bf192 Merge branch 'main' of github.com:immich-app/immich 2022-08-26 09:42:17 -07:00
Alex
5ad2d62039 Added limit on total of file upload on web (#535) 2022-08-26 09:39:28 -07:00
Alex
a128833e68 Added limit on total of file upload on web 2022-08-26 09:36:54 -07:00
Alex
87f7b0849a Added migration down for change exif file type 2022-08-26 09:13:11 -07:00
Alex
4596a8ee01 Change fileSizeInByte to bigint from int to handle large size (#534) 2022-08-26 09:07:59 -07:00
Alex
f9b1b12b10 Implement notification box for web (#533)
* Added test button

* styling notification box

* Added auto dismission and animation to each notificaiont list

* Remove test button
2022-08-25 23:04:23 -07:00
Alex
68b1655e7f Show the first two letter of user first and last name when profile image not existed (#532)
* Added user first name and last name abbreviation to Circle Avatar:

* Remove unsued code
2022-08-25 15:52:11 -07:00
Alex
658b64df74 Added page navigation progress indicator 2022-08-25 13:02:36 -07:00
Alex
e344503834 Fixed navigating with keyboard skip assets (#531)
* Cleaned up event listner
2022-08-24 22:18:28 -07:00
Alex
bf2760ffef Fixed mobile timeline crash when date group cannot be parsed (#530)
* Handle error when datetime is incorrect

* Added better debug message
2022-08-24 21:31:20 -07:00
Alex
db2ed2d881 Migrate SvelteKit to the latest version 431 (#526) 2022-08-24 21:10:48 -07:00
Thanh Pham
fb0fa742f5 fix(web): buffering for video player (#520)
* fix(web): buffering for video player

* chore(): missing file -_-

* refactor(web): using URL builder

* chore(): add semicolon

* fix(web): video player

* remove deadcode

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-08-23 20:21:41 -07:00
Thanh Pham
3b55cdc0be refactor(server): move constant into common package (#522)
* refactor(server): move constant into common package

* refactor(server): re-arrange import statement in microservice module

* refactor(server): move app.config into common package

* fix(server): e2e testing
2022-08-23 07:34:21 -07:00
Alex
0efcc99f3e Added Dutch locale 2022-08-22 12:52:24 -07:00
Nick Pieper
7a85164a1e Added dutch translation for Immich (#519)
* Create nl-NL.json with dutch translation

* Add nl-NL to localizely.yml
2022-08-22 12:50:56 -07:00
Thanh Pham
ba2cda8955 feat(server): support tiff uploading (#513)
* feat(server): suport tiff uploading

* remove unused variable

Co-authored-by: Alex <alex.tran1502@gmail.com>
2022-08-22 12:49:17 -07:00
Alex
9048be4c8e Added Code of conduct 2022-08-21 12:43:56 -07:00
100 changed files with 2597 additions and 1684 deletions

134
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,134 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for
our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our
mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or
political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in
a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our
standards of acceptable behavior and will take appropriate and fair
corrective action in response to any behavior that they deem
inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct, and will
communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also
applies when an individual is officially representing the community in
public spaces. Examples of representing our community include using an
official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline
event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the community leaders responsible for enforcement
at our Discord channel. All complaints
will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and
security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in
determining the consequences for any action they deem in violation of
this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior
deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders,
providing clarity around the nature of the violation and an
explanation of why the behavior was inappropriate. A public apology
may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued
behavior. No interaction with the people involved, including
unsolicited interaction with those enforcing the Code of Conduct, for
a specified period of time. This includes avoiding interactions in
community spaces as well as external channels like social
media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards,
including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or
public communication with the community for a specified period of
time. No public or private interaction with the people involved,
including unsolicited interaction with those enforcing the Code of
Conduct, is allowed during this period. Violating these terms may lead
to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of
community standards, including sustained inappropriate behavior,
harassment of an individual, or aggression toward or disparagement of
classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor
Covenant][homepage], version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of
conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the
FAQ at https://www.contributor-covenant.org/faq. Translations are
available at https://www.contributor-covenant.org/translations.

View File

@@ -6,6 +6,7 @@ services:
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:dev immich
volumes:
- ../server:/usr/src/app
@@ -24,6 +25,7 @@ services:
build:
context: ../machine-learning
dockerfile: Dockerfile
target: builder
command: npm run start:dev
volumes:
- ../machine-learning:/usr/src/app
@@ -41,6 +43,7 @@ services:
build:
context: ../server
dockerfile: Dockerfile
target: builder
command: npm run start:dev microservices
volumes:
- ../server:/usr/src/app

View File

@@ -9,6 +9,8 @@ upload:
locale_code: de-DE
- file: mobile/assets/i18n/fr-FR.json
locale_code: fr-FR
- file: mobile/assets/i18n/nl-NL.json
locale_code: nl-NL
download:
files:
- file: mobile/assets/i18n/en-US.json
@@ -17,3 +19,5 @@ download:
locale_code: de-DE
- file: mobile/assets/i18n/fr-FR.json
locale_code: fr-FR
- file: mobile/assets/i18n/nl-NL.json
locale_code: nl-NL

View File

@@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
# Fastlane
/fastlane/report.xml

View File

@@ -30,8 +30,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 35,
"android.injected.version.name" => "1.25.0",
"android.injected.version.code" => 36,
"android.injected.version.name" => "1.26.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -5,12 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000212">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000224">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="3.608039">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="65.786484">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="36.344276">
</testcase>

View File

@@ -0,0 +1,153 @@
{
"album_info_card_backup_album_excluded": "UITGESLOTEN",
"album_info_card_backup_album_included": "INGESLOTEN",
"album_viewer_appbar_share_delete": "Verwijder album",
"album_viewer_appbar_share_err_delete": "Fout bij verwijderen album",
"album_viewer_appbar_share_err_leave": "Fout bij verlaten album",
"album_viewer_appbar_share_err_remove": "Er gaat iets mis bij het verwijderen van items uit het album",
"album_viewer_appbar_share_err_title": "Fout bij wijzigen album titel",
"album_viewer_appbar_share_leave": "Verlaat album",
"album_viewer_appbar_share_remove": "Verwijder uit album",
"album_viewer_page_share_add_users": "Voeg gebruiker toe",
"backup_album_selection_page_albums_device": "Albums op apparaat ({})",
"backup_album_selection_page_albums_tap": "Tik om in te voegen, dubbel tik om uit te sluiten",
"backup_album_selection_page_assets_scatter": "Items kunnen over verschillende albums verdeeld zijn, dus albums kunnen ingesloten of uitgesloten zijn van het backup proces.",
"backup_album_selection_page_select_albums": "Selecteer albums",
"backup_album_selection_page_selection_info": "Selectie info",
"backup_album_selection_page_total_assets": "Totaal unieke items",
"backup_all": "Alle",
"backup_background_service_default_notification": "Controleren op nieuw items…",
"backup_background_service_disable_battery_optimizations": "Schakel batterij optimalisatie uit voor Immich om achtergrond backup in te schakelen",
"backup_background_service_upload_failure_notification": "Fout bij upload {}",
"backup_background_service_in_progress_notification": "Backuppen van items…",
"backup_background_service_current_upload_notification": "Uploaden {}",
"backup_background_service_error_title": "Backup fout",
"backup_background_service_connection_failed_message": "Fout bij verbinden server. Opnieuw proberen…",
"backup_background_service_backup_failed_message": "Fout bij backuppen items. Opnieuw proberen…",
"backup_controller_page_albums": "Backup Albums",
"backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Geselecteerd: ",
"backup_controller_page_backup_sub": "Foto's en video's gebackupped",
"backup_controller_page_background_description": "Gebruik achtergrondservice om automatisch nieuwe items te uploaden naar server zonder de app te openen",
"backup_controller_page_background_wifi": "Alleen op WiFi",
"backup_controller_page_background_charging": "Alleen tijdens opladen",
"backup_controller_page_background_is_on": "Automatische achtergrond backup staat aan",
"backup_controller_page_background_is_off": "Automatische achtergrond backup staat uit",
"backup_controller_page_background_turn_on": "Zet achtergrondservice aan",
"backup_controller_page_background_turn_off": "Zet achtergrondservice uit",
"backup_controller_page_background_configure_error": "Achtergrondservice configuratie mislukt",
"backup_controller_page_cancel": "Annuleren",
"backup_controller_page_created": "Gemaakt op: {}",
"backup_controller_page_desc_backup": "Configureer backup om automatisch nieuwe items te uploaden naar server.",
"backup_controller_page_excluded": "Uitgezonderd: ",
"backup_controller_page_failed": "Mislukt ({})",
"backup_controller_page_filename": "Bestandsnaam: {} [{}]",
"backup_controller_page_id": "ID: {}",
"backup_controller_page_info": "Backup informatie",
"backup_controller_page_none_selected": "Geen geselecteerd",
"backup_controller_page_remainder": "Rest",
"backup_controller_page_remainder_sub": "Overgebleven foto's en video's om te backuppen uit selectie",
"backup_controller_page_select": "Selecteer",
"backup_controller_page_server_storage": "Server Opslag",
"backup_controller_page_start_backup": "Start Backup",
"backup_controller_page_status_off": "Backup staat uit",
"backup_controller_page_status_on": "Backup staat aan",
"backup_controller_page_storage_format": "{} van {} gebruikt",
"backup_controller_page_to_backup": "Albums om te backuppen",
"backup_controller_page_total": "Totaal",
"backup_controller_page_total_sub": "Alle unieke foto's en video's uit geselecteerde albums",
"backup_controller_page_turn_off": "Backup uitzetten",
"backup_controller_page_turn_on": "Backup aanzetten",
"backup_controller_page_uploading_file_info": "Bestandsgegevens uploaden",
"backup_err_only_album": "Kan niet alleen het album verwijderen",
"backup_info_card_assets": "items",
"control_bottom_app_bar_delete": "Verwijderen",
"create_shared_album_page_share": "Delen",
"create_shared_album_page_create": "Aanmaken",
"create_shared_album_page_share_add_assets": "VOEG FOTO'S TOE",
"create_shared_album_page_share_select_photos": "Selecteer Foto's",
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a",
"delete_dialog_alert": "Deze items zullen permanent verwijderd worden van Immich en je apparaat",
"delete_dialog_cancel": "Annuleren",
"delete_dialog_ok": "Verwijderen",
"delete_dialog_title": "Verwijder permanent",
"exif_bottom_sheet_description": "Voeg beschrijving toe...",
"exif_bottom_sheet_details": "DETAILS",
"exif_bottom_sheet_location": "LOCATIE",
"login_form_button_text": "Login",
"login_form_email_hint": "jouwemail@email.com",
"login_form_endpoint_hint": "http://jouw-server-ip:port/api",
"login_form_endpoint_url": "Server URL",
"login_form_err_http": "Voer http:// of https:// in",
"login_form_err_invalid_email": "Ongeldige Email",
"login_form_err_leading_whitespace": "Spatie aan het begin",
"login_form_err_trailing_whitespace": "Spatie aan het eind",
"login_form_failed_login": "Fout bij inloggen, controleer server url, email en wachtwoord",
"login_form_label_email": "Email",
"login_form_label_password": "Wachtwoord",
"login_form_password_hint": "wachtwoord",
"login_form_save_login": "Ingelogd blijven",
"monthly_title_text_date_format": "MMMM y",
"profile_drawer_client_server_up_to_date": "Client en Server zijn up-to-date",
"profile_drawer_sign_out": "Uitloggen",
"profile_drawer_settings": "Instellingen",
"search_bar_hint": "Zoek je foto's",
"search_page_no_objects": "Geen object gegevens beschikbaar",
"search_page_no_places": "Geen locatie gegevens beschikbaar",
"search_page_places": "Plaatsen",
"search_page_things": "Dingen",
"search_result_page_new_search_hint": "Nieuw resultaat",
"select_additional_user_for_sharing_page_suggestions": "Suggesties",
"select_user_for_sharing_page_err_album": "Album aanmaken mislukt",
"select_user_for_sharing_page_share_suggestions": "Suggesties",
"share_add": "Toevoegen",
"share_add_photos": "Foto's toevoegen",
"share_add_title": "Titel toevoegen",
"share_create_album": "Album aanmaken",
"share_invite": "Uitnodigen voor album",
"sharing_page_album": "Gedeelde albums",
"sharing_page_description": "Maak gedeelde albums om foto's en video's te delen met mensen in je netwerk.",
"sharing_page_empty_list": "LEGE LIJST",
"sharing_silver_appbar_create_shared_album": "Maak gedeeld album",
"sharing_silver_appbar_share_partner": "Delen met partner",
"tab_controller_nav_photos": "Foto's",
"tab_controller_nav_search": "Zoeken",
"tab_controller_nav_sharing": "Delen",
"tab_controller_nav_library": "Bibliotheek",
"version_announcement_overlay_ack": "Bevestig",
"version_announcement_overlay_release_notes": "release opmerkingen",
"version_announcement_overlay_text_1": "Er is een nieuwe versie beschikbaar van",
"version_announcement_overlay_text_2": "neem je tijd en bezoek de ",
"version_announcement_overlay_text_3": " controleer of je docker-compose en .env up-to-date zijn om te voorkomen dat er misconfiguraties zijn, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je server automatisch configureert.",
"version_announcement_overlay_title": "Nieuwe server versie beschikbaar \uD83C\uDF89",
"album_thumbnail_card_item": "1 item",
"album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Gedeeld",
"library_page_albums": "Albums",
"library_page_new_album": "Nieuw album",
"create_album_page_untitled": "Naamloos",
"share_dialog_preparing": "Voorbereiden...",
"control_bottom_app_bar_share": "Delen",
"setting_pages_app_bar_settings": "Instellingen",
"theme_setting_theme_title": "Thema",
"theme_setting_theme_subtitle": "Kies de thema instelling van de app",
"theme_setting_system_theme_switch": "Automatisch (volg systeeminstelling)",
"theme_setting_dark_mode_switch": "Donkere modus",
"theme_setting_image_viewer_quality_title": "Foto weergave kwaliteit",
"theme_setting_image_viewer_quality_subtitle": "Pas de kwaliteit aan van de gedetailleerde foto weergave",
"theme_setting_three_stage_loading_title": "Drie-laags laden inschakelen",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"asset_list_settings_title": "Foto Grid",
"asset_list_settings_subtitle": "Foto grid layout instellingen",
"theme_setting_asset_list_storage_indicator_title": "Laat ruimte indicator zien bij item tegels",
"theme_setting_asset_list_tiles_per_row_title": "Aantal items per rij ({})",
"setting_notifications_title": "Notificaties",
"setting_notifications_subtitle": "Werk je notificatievoorkeuren bij",
"setting_notifications_notify_failures_grace_period": "Melding achtergrond backup fouten: {}",
"setting_notifications_notify_immediately": "meteen",
"setting_notifications_notify_minutes": "{} minuten",
"setting_notifications_notify_hours": "{} uur",
"setting_notifications_notify_never": "nooit"
}

View File

@@ -360,7 +360,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -495,7 +495,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -522,7 +522,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 40;
CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -17,11 +17,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.21.0</string>
<string>1.26.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>40</string>
<string>51</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
@@ -92,7 +92,9 @@
<string>it</string>
<string>fi</string>
<string>ja</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
</array>
</dict>
</plist>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.25.0"
version_number: "1.26.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000205">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000349">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.360401">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.650297">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.012696">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="7.757602">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.378836">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.421008">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="80.023705">
<testcase classname="fastlane.lanes" name="4: build_app" time="126.240949">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="98.18403">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.206021">
</testcase>

View File

@@ -11,6 +11,7 @@ const List<Locale> locales = [
Locale('fr', 'FR'),
Locale('it', 'IT'),
Locale('ja', 'JP'),
Locale('nl', 'NL'),
Locale('pl', 'PL'),
Locale('pt', 'PR')
];

View File

@@ -65,37 +65,45 @@ class HomePage extends HookConsumerWidget {
int? lastMonth;
assetGroupByDateTime.forEach((dateGroup, immichAssetList) {
DateTime parseDateGroup = DateTime.parse(dateGroup);
int currentMonth = parseDateGroup.month;
try {
DateTime parseDateGroup = DateTime.parse(dateGroup);
int currentMonth = parseDateGroup.month;
if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) {
imageGridGroup.add(
MonthlyTitleText(
isoDate: dateGroup,
),
);
if (lastMonth != null) {
if (currentMonth - lastMonth! != 0) {
imageGridGroup.add(
MonthlyTitleText(
isoDate: dateGroup,
),
);
}
}
imageGridGroup.add(
DailyTitleText(
key: Key('${dateGroup.toString()}title'),
isoDate: dateGroup,
assetGroup: immichAssetList,
),
);
imageGridGroup.add(
ImageGrid(
assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList,
tilesPerRow:
appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator),
),
);
lastMonth = currentMonth;
} catch (e) {
debugPrint(
"[ERROR] Cannot parse $dateGroup - Wrong create date format : ${immichAssetList.map((asset) => asset.createdAt).toList()}",
);
}
imageGridGroup.add(
DailyTitleText(
key: Key('${dateGroup.toString()}title'),
isoDate: dateGroup,
assetGroup: immichAssetList,
),
);
imageGridGroup.add(
ImageGrid(
assetGroup: immichAssetList,
sortedAssetGroup: sortedAssetList,
tilesPerRow: appSettingService.getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService.getSetting(AppSettingsEnum.storageIndicator),
),
);
lastMonth = currentMonth;
});
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.25.0+35
version: 1.26.0+35
environment:
sdk: ">=2.17.0 <3.0.0"

View File

@@ -5,7 +5,7 @@ WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN apk add --update-cache build-base python3 libheif vips-dev
RUN apk add --update-cache build-base python3 libheif vips-dev ffmpeg
RUN npm ci
COPY . .
@@ -22,7 +22,7 @@ COPY package.json package-lock.json ./
COPY start-server.sh start-microservices.sh ./
RUN mkdir -p /usr/src/app/dist \
&& apk add --no-cache libheif vips ffmpeg
&& apk add --no-cache libheif vips ffmpeg
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/dist ./dist

View File

@@ -1,7 +1,7 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { Injectable } from '@nestjs/common';
import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
import diskusage from 'diskusage';
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
@Injectable()
export class ServerInfoService {

View File

@@ -1,3 +1,4 @@
import { immichAppConfig } from '@app/common/config';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { UserModule } from './api-v1/user/user.module';
import { AssetModule } from './api-v1/asset/asset.module';
@@ -5,7 +6,6 @@ import { AuthModule } from './api-v1/auth/auth.module';
import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module';
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
import { ConfigModule } from '@nestjs/config';
import { immichAppConfig } from './config/app.config';
import { BullModule } from '@nestjs/bull';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
import { BackgroundTaskModule } from './modules/background-task/background-task.module';

View File

@@ -1,15 +1,15 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { HttpException, HttpStatus } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
import { randomUUID } from 'crypto';
export const assetUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp)$/)) {
if (file.mimetype.match(/\/(jpg|jpeg|png|gif|mp4|x-msvideo|quicktime|heic|heif|dng|x-adobe-dng|webp|tiff)$/)) {
cb(null, true);
} else {
cb(new HttpException(`Unsupported file type ${extname(file.originalname)}`, HttpStatus.BAD_REQUEST), false);

View File

@@ -1,10 +1,10 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { HttpException, HttpStatus } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { existsSync, mkdirSync } from 'fs';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { Request } from 'express';
import { APP_UPLOAD_LOCATION } from '../constants/upload_location.constant';
export const profileImageUploadOption: MulterOptions = {
fileFilter: (req: Request, file: any, cb: any) => {

View File

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = {
major: 1,
minor: 25,
minor: 26,
patch: 0,
build: 0,
};

View File

@@ -7,6 +7,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"@app/common/(.*)": "<rootDir>../../../libs/common/src/$1",
"@app/database/config/(.*)": "<rootDir>../../../libs/database/src/config/$1",
"@app/database/entities/(.*)": "<rootDir>../../../libs/database/src/entities/$1"
}

View File

@@ -1,26 +1,29 @@
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { immichAppConfig } from '@app/common/config';
import { DatabaseModule } from '@app/database';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { MicroservicesService } from './microservices.service';
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
import {
assetUploadedQueueName,
metadataExtractionQueueName,
thumbnailGeneratorQueueName,
videoConversionQueueName,
} from '@app/job/constants/queue-name.constant';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
import { MicroservicesService } from './microservices.service';
import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
@Module({
imports: [
ConfigModule.forRoot(immichAppConfig),
DatabaseModule,
TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
BullModule.forRootAsync({

View File

@@ -1,7 +1,4 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull';
import { AssetType } from '@app/database/entities/asset.entity';
import { randomUUID } from 'crypto';
import {
IAssetUploadedJob,
IMetadataExtractionJob,
@@ -17,6 +14,9 @@ import {
mp4ConversionProcessorName,
videoMetadataExtractionProcessorName,
} from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull';
import { randomUUID } from 'crypto';
@Processor(assetUploadedQueueName)
export class AssetUploadedProcessor {

View File

@@ -1,18 +1,6 @@
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { Repository } from 'typeorm/repository/Repository';
import { InjectRepository } from '@nestjs/typeorm';
import { ExifEntity } from '@app/database/entities/exif.entity';
import exifr from 'exifr';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import { readFile } from 'fs/promises';
import { Logger } from '@nestjs/common';
import axios from 'axios';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import ffmpeg from 'fluent-ffmpeg';
import path from 'path';
import {
IExifExtractionProcessor,
IVideoLengthExtractionProcessor,
@@ -24,6 +12,18 @@ import {
reverseGeocodingProcessorName,
IReverseGeocodingProcessor,
} from '@app/job';
import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response';
import mapboxGeocoding, { GeocodeService } from '@mapbox/mapbox-sdk/services/geocoding';
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import axios from 'axios';
import { Job } from 'bull';
import exifr from 'exifr';
import ffmpeg from 'fluent-ffmpeg';
import { readFile } from 'fs/promises';
import path from 'path';
import { Repository } from 'typeorm/repository/Repository';
@Processor(metadataExtractionQueueName)
export class MetadataExtractionProcessor {
@@ -60,8 +60,8 @@ export class MetadataExtractionProcessor {
newExif.make = exifData['Make'] || null;
newExif.model = exifData['Model'] || null;
newExif.imageName = path.parse(fileName).name || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || null;
newExif.exifImageHeight = exifData['ExifImageHeight'] || exifData['ImageHeight'] || null;
newExif.exifImageWidth = exifData['ExifImageWidth'] || exifData['ImageWidth'] || null;
newExif.fileSizeInByte = fileSize || null;
newExif.orientation = exifData['Orientation'] || null;
newExif.dateTimeOriginal = exifData['DateTimeOriginal'] || null;

View File

@@ -1,14 +1,4 @@
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Job, Queue } from 'bull';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { Repository } from 'typeorm/repository/Repository';
import { InjectRepository } from '@nestjs/typeorm';
import sharp from 'sharp';
import { existsSync, mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
import ffmpeg from 'fluent-ffmpeg';
import { Logger } from '@nestjs/common';
import {
WebpGeneratorProcessor,
generateJPEGThumbnailProcessorName,
@@ -19,7 +9,17 @@ import {
thumbnailGeneratorQueueName,
JpegGeneratorProcessor,
} from '@app/job';
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
import { Job, Queue } from 'bull';
import ffmpeg from 'fluent-ffmpeg';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync } from 'node:fs';
import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository';
import { CommunicationGateway } from '../../../immich/src/api-v1/communication/communication.gateway';
@Processor(thumbnailGeneratorQueueName)
export class ThumbnailGeneratorProcessor {
@@ -51,62 +51,47 @@ export class ThumbnailGeneratorProcessor {
const jpegThumbnailPath = resizePath + originalFilename + '.jpeg';
if (asset.type == AssetType.IMAGE) {
sharp(asset.originalPath)
.resize(1440, 2560, { fit: 'inside' })
.jpeg()
.rotate()
.toFile(jpegThumbnailPath, async (err) => {
if (!err) {
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
await sharp(asset.originalPath).resize(1440, 2560, { fit: 'inside' }).jpeg().rotate().toFile(jpegThumbnailPath);
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
// Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath;
// Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath;
await this.thumbnailGeneratorQueue.add(
generateWEBPThumbnailProcessorName,
{ asset },
{ jobId: randomUUID() },
);
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
this.wsCommunicationGateway.server
.to(asset.userId)
.emit('on_upload_success', JSON.stringify(mapAsset(asset)));
}
});
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
}
if (asset.type == AssetType.VIDEO) {
ffmpeg(asset.originalPath)
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
.output(jpegThumbnailPath)
.on('start', () => {
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
})
.on('error', (error) => {
Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail');
// reject();
})
.on('end', async () => {
Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail');
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
await new Promise((resolve, reject) => {
ffmpeg(asset.originalPath)
.outputOptions(['-ss 00:00:00.000', '-frames:v 1'])
.output(jpegThumbnailPath)
.on('start', () => {
Logger.log('Start Generating Video Thumbnail', 'generateJPEGThumbnail');
})
.on('error', (error) => {
Logger.error(`Cannot Generate Video Thumbnail ${error}`, 'generateJPEGThumbnail');
reject(error);
})
.on('end', async () => {
Logger.log(`Generating Video Thumbnail Success ${asset.id}`, 'generateJPEGThumbnail');
resolve(asset);
})
.run();
});
// Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath;
await this.assetRepository.update({ id: asset.id }, { resizePath: jpegThumbnailPath });
await this.thumbnailGeneratorQueue.add(
generateWEBPThumbnailProcessorName,
{ asset },
{ jobId: randomUUID() },
);
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
// Update resize path to send to generate webp queue
asset.resizePath = jpegThumbnailPath;
this.wsCommunicationGateway.server
.to(asset.userId)
.emit('on_upload_success', JSON.stringify(mapAsset(asset)));
})
.run();
await this.thumbnailGeneratorQueue.add(generateWEBPThumbnailProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(imageTaggingProcessorName, { asset }, { jobId: randomUUID() });
await this.metadataExtractionQueue.add(objectDetectionProcessorName, { asset }, { jobId: randomUUID() });
this.wsCommunicationGateway.server.to(asset.userId).emit('on_upload_success', JSON.stringify(mapAsset(asset)));
}
}
@@ -117,16 +102,10 @@ export class ThumbnailGeneratorProcessor {
if (!asset.resizePath) {
return;
}
const webpPath = asset.resizePath.replace('jpeg', 'webp');
sharp(asset.resizePath)
.resize(250)
.webp()
.rotate()
.toFile(webpPath, (err) => {
if (!err) {
this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
}
});
await sharp(asset.resizePath).resize(250).webp().rotate().toFile(webpPath);
await this.assetRepository.update({ id: asset.id }, { webpPath: webpPath });
}
}

View File

@@ -1,3 +1,5 @@
import { APP_UPLOAD_LOCATION } from '@app/common/constants';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { mp4ConversionProcessorName } from '@app/job/constants/job-name.constant';
import { videoConversionQueueName } from '@app/job/constants/queue-name.constant';
import { IMp4ConversionProcessor } from '@app/job/interfaces/video-transcode.interface';
@@ -8,8 +10,6 @@ import { Job } from 'bull';
import ffmpeg from 'fluent-ffmpeg';
import { existsSync, mkdirSync } from 'fs';
import { Repository } from 'typeorm';
import { AssetEntity } from '../../../../libs/database/src/entities/asset.entity';
import { APP_UPLOAD_LOCATION } from '../../../immich/src/constants/upload_location.constant';
@Processor(videoConversionQueueName)
export class VideoTranscodeProcessor {

View File

@@ -0,0 +1 @@
export * from './app.config';

View File

@@ -0,0 +1 @@
export * from './upload_location.constant';

View File

@@ -0,0 +1,2 @@
export * from './config';
export * from './constants';

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "../../dist/libs/common"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@@ -23,7 +23,7 @@ export class ExifEntity {
@Column({ type: 'integer', nullable: true })
exifImageHeight!: number | null;
@Column({ type: 'integer', nullable: true })
@Column({ type: 'bigint', nullable: true })
fileSizeInByte!: number | null;
@Column({ type: 'varchar', nullable: true })

View File

@@ -1,18 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCaption1661011331242 implements MigrationInterface {
name = 'AddCaption1661011331242'
name = 'AddCaption1661011331242';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "description" text DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "exif" ADD "fps" double precision`);
// await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" SET NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// await queryRunner.query(`ALTER TABLE "exif" ALTER COLUMN "exifTextSearchableColumn" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "fps"`);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "description"`);
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "description" text DEFAULT ''`);
await queryRunner.query(`ALTER TABLE "exif" ADD "fps" double precision`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "fps"`);
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "description"`);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ChangeExifFileSizeInByteToBigInt1661528919411 implements MigrationInterface {
name = 'ChangeExifFileSizeInByteToBigInt1661528919411';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ALTER COLUMN "fileSizeInByte" type bigint using "fileSizeInByte"::bigint;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE exif
ALTER COLUMN "fileSizeInByte" type integer using "fileSizeInByte"::integer;
`);
}
}

View File

@@ -33,6 +33,15 @@
"tsConfigPath": "apps/microservices/tsconfig.app.json"
}
},
"common": {
"type": "library",
"root": "libs/common",
"entryFile": "index",
"sourceRoot": "libs/common/src",
"compilerOptions": {
"tsConfigPath": "libs/common/tsconfig.lib.json"
}
},
"database": {
"type": "library",
"root": "libs/database",

View File

@@ -16,6 +16,12 @@
"esModuleInterop": true,
"baseUrl": "./",
"paths": {
"@app/common": [
"libs/common/src"
],
"@app/common/*": [
"libs/common/src/*"
],
"@app/database": [
"libs/database/src"
],

1550
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@
"build": "vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"prepare": "svelte-kit sync",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint .",

View File

@@ -1,2 +1,3 @@
export * from './open-api';
export * from './api';
export * from './utils';

12
web/src/api/utils.ts Normal file
View File

@@ -0,0 +1,12 @@
let _basePath = '/api';
export function getFileUrl(aid: string, did: string, isThumb?: boolean, isWeb?: boolean) {
const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file`);
urlObj.searchParams.append('aid', aid);
urlObj.searchParams.append('did', did);
if (isThumb !== undefined && isThumb !== null) urlObj.searchParams.append('isThumb', `${isThumb}`);
if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`);
return urlObj.href;
}

6
web/src/app.d.ts vendored
View File

@@ -8,10 +8,4 @@ declare namespace App {
}
// interface Platform {}
interface Session {
user?: import('@api').UserResponseDto;
}
// interface Stuff {}
}

View File

@@ -1,31 +1,5 @@
import type { ExternalFetch, GetSession, Handle } from '@sveltejs/kit';
import * as cookie from 'cookie';
import { serverApi } from '@api';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookies = cookie.parse(event.request.headers.get('cookie') || '');
if (!cookies['immich_is_authenticated']) {
return await resolve(event);
}
const accessToken = cookies['immich_access_token'];
try {
serverApi.setAccessToken(accessToken);
const { data } = await serverApi.userApi.getMyUserInfo();
event.locals.user = data;
return await resolve(event);
} catch (error) {
event.locals.user = undefined;
return await resolve(event);
}
};
export const getSession: GetSession = async ({ locals }) => {
if (!locals.user) return {};
return {
user: locals.user
};
return await resolve(event);
};

View File

@@ -2,7 +2,7 @@
import { AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import { fade } from 'svelte/transition';
import { fly } from 'svelte/transition';
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
export let album: AlbumResponseDto;

View File

@@ -22,6 +22,10 @@
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import ThumbnailSelection from './thumbnail-selection.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
export let album: AlbumResponseDto;
@@ -129,7 +133,11 @@
album = data;
multiSelectAsset = new Set();
} catch (e) {
console.log('Error [album-viewer] [removeAssetFromAlbum]', e);
console.error('Error [album-viewer] [removeAssetFromAlbum]', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error removing assets from album, check console for more details'
});
}
}
};
@@ -179,7 +187,11 @@
currentAlbumName = album.albumName;
})
.catch((e) => {
console.log('Error [updateAlbumInfo] ', e);
console.error('Error [updateAlbumInfo] ', e);
notificationController.show({
type: NotificationType.Error,
message: "Error updating album's name, check console for more details"
});
});
}
}
@@ -193,7 +205,11 @@
isShowAssetSelection = false;
} catch (e) {
console.log('Error [createAlbumHandler] ', e);
console.error('Error [createAlbumHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error creating album, check console for more details'
});
}
};
@@ -209,7 +225,11 @@
isShowShareUserSelection = false;
} catch (e) {
console.log('Error [createAlbumHandler] ', e);
console.error('Error [addUserHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error adding users to album, check console for more details'
});
}
};
@@ -227,7 +247,11 @@
album = data;
isShowShareInfoModal = false;
} catch (e) {
console.log('Error [sharedUserDeletedHandler] ', e);
console.error('Error [sharedUserDeletedHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting share users, check console for more details'
});
}
};
@@ -241,7 +265,11 @@
await api.albumApi.deleteAlbum(album.id);
goto(backUrl);
} catch (e) {
console.log('Error [userDeleteMenu] ', e);
console.error('Error [userDeleteMenu] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting album, check console for more details'
});
}
}
};
@@ -262,7 +290,11 @@
albumThumbnailAssetId: asset.id
});
} catch (e) {
console.log('Error [setAlbumThumbnailHandler] ', e);
console.error('Error [setAlbumThumbnailHandler] ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error setting album thumbnail, check console for more details'
});
}
isShowThumbnailSelection = false;

View File

@@ -7,6 +7,10 @@
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
export let album: AlbumResponseDto;
@@ -24,6 +28,10 @@
currentUser = data;
} catch (e) {
console.error('Error [share-info-modal] [getAllUsers]', e);
notificationController.show({
message: 'Error getting user info, check console for more details',
type: NotificationType.Error
});
}
});
@@ -48,6 +56,10 @@
dispatch('user-deleted', { userId });
} catch (e) {
console.error('Error [share-info-modal] [removeUser]', e);
notificationController.show({
message: 'Error removing user, check console for more details',
type: NotificationType.Error
});
}
}
};

View File

@@ -1,207 +1,215 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { fly } from 'svelte/transition';
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import PhotoViewer from './photo-viewer.svelte';
import DetailPanel from './detail-panel.svelte';
import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte';
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
import { browser } from '$app/env';
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import AsserViewerNavBar from './asser-viewer-nav-bar.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import PhotoViewer from './photo-viewer.svelte';
import DetailPanel from './detail-panel.svelte';
import { downloadAssets } from '$lib/stores/download';
import VideoViewer from './video-viewer.svelte';
import { api, AssetResponseDto, AssetTypeEnum } from '@api';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
const dispatch = createEventDispatcher();
export let asset: AssetResponseDto;
export let asset: AssetResponseDto;
const dispatch = createEventDispatcher();
let halfLeftHover = false;
let halfRightHover = false;
let isShowDetail = false;
let halfLeftHover = false;
let halfRightHover = false;
let isShowDetail = false;
onMount(() => {
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
});
onMount(() => {
if (browser) {
document.addEventListener('keydown', (keyInfo) => handleKeyboardPress(keyInfo.key));
}
});
onDestroy(() => {
document.removeEventListener('keydown', (e) => {});
});
const handleKeyboardPress = (key: string) => {
switch (key) {
case 'Escape':
closeViewer();
return;
case 'i':
isShowDetail = !isShowDetail;
return;
case 'ArrowLeft':
navigateAssetBackward();
return;
case 'ArrowRight':
navigateAssetForward();
return;
}
};
const handleKeyboardPress = (key: string) => {
switch (key) {
case 'Escape':
closeViewer();
return;
case 'i':
isShowDetail = !isShowDetail;
return;
case 'ArrowLeft':
navigateAssetBackward();
return;
case 'ArrowRight':
navigateAssetForward();
return;
}
};
const closeViewer = () => {
dispatch('close');
};
const closeViewer = () => {
dispatch('close');
};
const navigateAssetForward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-forward');
};
const navigateAssetForward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-forward');
};
const navigateAssetBackward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-backward');
};
const navigateAssetBackward = (e?: Event) => {
e?.stopPropagation();
dispatch('navigate-backward');
};
const showDetailInfoHandler = () => {
isShowDetail = !isShowDetail;
};
const showDetailInfoHandler = () => {
isShowDetail = !isShowDetail;
};
const downloadFile = async () => {
try {
console.log(asset.exifInfo);
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
const imageExtension = asset.originalPath.split('.')[1];
const imageFileName = imageName + '.' + imageExtension;
const downloadFile = async () => {
try {
console.log(asset.exifInfo);
const imageName = asset.exifInfo?.imageName ? asset.exifInfo?.imageName : asset.id;
const imageExtension = asset.originalPath.split('.')[1];
const imageFileName = imageName + '.' + imageExtension;
// If assets is already download -> return;
if ($downloadAssets[imageFileName]) {
return;
}
// If assets is already download -> return;
if ($downloadAssets[imageFileName]) {
return;
}
$downloadAssets[imageFileName] = 0;
$downloadAssets[imageFileName] = 0;
const {data, status} = await api.assetApi.downloadFile(
asset.deviceAssetId,
asset.deviceId,
false,
false,
{
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {
const total = progressEvent.total;
const current = progressEvent.loaded;
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
}
}
}
);
const { data, status } = await api.assetApi.downloadFile(
asset.deviceAssetId,
asset.deviceId,
false,
false,
{
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {
const total = progressEvent.total;
const current = progressEvent.loaded;
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
}
}
}
);
if (!(data instanceof Blob)) {
return;
}
if (!(data instanceof Blob)) {
return;
}
if (status === 200) {
const fileUrl = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = fileUrl;
anchor.download = imageFileName;
if (status === 200) {
const fileUrl = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = fileUrl;
anchor.download = imageFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(fileUrl);
URL.revokeObjectURL(fileUrl);
// Remove item from download list
setTimeout(() => {
const copy = $downloadAssets;
delete copy[imageFileName];
$downloadAssets = copy;
}, 2000);
}
} catch (e) {
console.log('Error downloading file ', e);
}
};
// Remove item from download list
setTimeout(() => {
const copy = $downloadAssets;
delete copy[imageFileName];
$downloadAssets = copy;
}, 2000);
}
} catch (e) {
console.error('Error downloading file ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error downloading file, check console for more details.'
});
}
};
</script>
<section
id="immich-asset-viewer"
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
id="immich-asset-viewer"
class="fixed h-screen w-screen top-0 overflow-y-hidden bg-black z-[999] grid grid-rows-[64px_1fr] grid-cols-4 "
>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AsserViewerNavBar
on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={downloadFile}
/>
</div>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AsserViewerNavBar
on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={downloadFile}
/>
</div>
<div
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
<div
class={`row-start-2 row-span-end col-start-1 col-span-2 flex place-items-center hover:cursor-pointer w-3/4 ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[999]'
}`}
on:mouseenter={() => {
on:mouseenter={() => {
halfLeftHover = true;
halfRightHover = false;
}}
on:mouseleave={() => {
on:mouseleave={() => {
halfLeftHover = false;
}}
on:click={navigateAssetBackward}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward}
>
<ChevronLeft size="36"/>
</button>
</div>
on:click={navigateAssetBackward}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 z-[1000] text-gray-500 mx-4"
class:navigation-button-hover={halfLeftHover}
on:click={navigateAssetBackward}
>
<ChevronLeft size="36" />
</button>
</div>
<div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer}/>
{:else}
<VideoViewer assetId={asset.id} on:close={closeViewer}/>
{/if}
{/key}
</div>
<div class="row-start-1 row-span-full col-start-1 col-span-4">
{#key asset.id}
{#if asset.type === AssetTypeEnum.Image}
<PhotoViewer assetId={asset.id} deviceId={asset.deviceId} on:close={closeViewer} />
{:else}
<VideoViewer assetId={asset.id} on:close={closeViewer} />
{/if}
{/key}
</div>
<div
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
<div
class={`row-start-2 row-span-full col-start-3 col-span-2 flex justify-end place-items-center hover:cursor-pointer w-3/4 justify-self-end ${
asset.type === AssetTypeEnum.Video ? '' : 'z-[500]'
}`}
on:click={navigateAssetForward}
on:mouseenter={() => {
on:click={navigateAssetForward}
on:mouseenter={() => {
halfLeftHover = false;
halfRightHover = true;
}}
on:mouseleave={() => {
on:mouseleave={() => {
halfRightHover = false;
}}
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward}
>
<ChevronRight size="36"/>
</button>
</div>
>
<button
class="rounded-full p-3 hover:bg-gray-500 hover:text-gray-700 text-gray-500 mx-4 z-[1000]"
class:navigation-button-hover={halfRightHover}
on:click={navigateAssetForward}
>
<ChevronRight size="36" />
</button>
</div>
{#if isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="bg-immich-bg w-[360px] row-span-full transition-all "
translate="yes"
>
<DetailPanel {asset} on:close={() => (isShowDetail = false)}/>
</div>
{/if}
{#if isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="bg-immich-bg w-[360px] row-span-full transition-all "
translate="yes"
>
<DetailPanel {asset} on:close={() => (isShowDetail = false)} />
</div>
{/if}
</section>
<style>
.navigation-button-hover {
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
color: rgb(55 65 81 / var(--tw-text-opacity));
transition: all 150ms;
}
.navigation-button-hover {
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
color: rgb(55 65 81 / var(--tw-text-opacity));
transition: all 150ms;
}
</style>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { createEventDispatcher, onMount } from 'svelte';
import { onMount } from 'svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api, AssetResponseDto } from '@api';
@@ -10,8 +10,6 @@
let assetInfo: AssetResponseDto;
const dispatch = createEventDispatcher();
onMount(async () => {
const { data } = await api.assetApi.getAssetById(assetId);
assetInfo = data;

View File

@@ -3,7 +3,7 @@
import { createEventDispatcher, onMount } from 'svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api, AssetResponseDto } from '@api';
import { api, AssetResponseDto, getFileUrl } from '@api';
export let assetId: string;
@@ -13,48 +13,32 @@
let videoPlayerNode: HTMLVideoElement;
let isVideoLoading = true;
let videoUrl: string;
onMount(async () => {
const { data: assetInfo } = await api.assetApi.getAssetById(assetId);
asset = assetInfo;
await loadVideoData(assetInfo);
await loadVideoData();
asset = assetInfo;
});
const loadVideoData = async () => {
const loadVideoData = async (assetInfo: AssetResponseDto) => {
isVideoLoading = true;
try {
const { data } = await api.assetApi.serveFile(
asset.deviceAssetId,
asset.deviceId,
false,
true,
{
responseType: 'blob'
}
);
videoUrl = getFileUrl(assetInfo.deviceAssetId, assetInfo.deviceId, false, true);
if (!(data instanceof Blob)) {
return;
}
return assetInfo;
};
const videoData = URL.createObjectURL(data);
videoPlayerNode.src = videoData;
const handleCanPlay = (ev: Event) => {
const playerNode = ev.target as HTMLVideoElement;
videoPlayerNode.load();
playerNode.muted = true;
playerNode.play();
playerNode.muted = false;
videoPlayerNode.oncanplay = () => {
videoPlayerNode.muted = true;
videoPlayerNode.play();
videoPlayerNode.muted = false;
isVideoLoading = false;
};
return videoData;
} catch (e) {}
isVideoLoading = false;
};
</script>
@@ -63,7 +47,13 @@
class="flex place-items-center place-content-center h-full select-none"
>
{#if asset}
<video controls class="h-full object-contain" bind:this={videoPlayerNode}>
<video
controls
class="h-full object-contain"
on:canplay={handleCanPlay}
bind:this={videoPlayerNode}
>
<source src={videoUrl} type="video/mp4" />
<track kind="captions" />
</video>

View File

@@ -2,6 +2,10 @@
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import AccountEditOutline from 'svelte-material-icons/AccountEditOutline.svelte';
import {
notificationController,
NotificationType
} from '../shared-components/notification/notification';
export let user: UserResponseDto;
@@ -29,7 +33,11 @@
dispatch('edit-success');
}
} catch (e) {
console.log('Error updating user ', e);
console.error('Error updating user ', e);
notificationController.show({
message: 'Error updating user, check console for more details',
type: NotificationType.Error
});
}
};
@@ -49,7 +57,11 @@
}
}
} catch (e) {
console.log('Error reseting user password', e);
console.error('Error reseting user password', e);
notificationController.show({
message: 'Error reseting user password, check console for more details',
type: NotificationType.Error
});
}
};
</script>

View File

@@ -8,19 +8,25 @@
export let size: number = 48;
const dispatch = createEventDispatcher();
const getUserAvatar = async () => {
try {
const { data } = await api.userApi.getProfileImage(user.id, {
responseType: 'blob'
});
if (data instanceof Blob) {
return URL.createObjectURL(data);
}
} catch (e) {
return '/favicon.png';
const getUserAvatar = async () => {
const { data } = await api.userApi.getProfileImage(user.id, {
responseType: 'blob'
});
if (data instanceof Blob) {
return URL.createObjectURL(data);
}
};
const getFirstLetter = (text?: string) => {
return text?.charAt(0).toUpperCase();
};
const getRandomeBackgroundColor = () => {
const colors = ['#DE7FB3', '#E64132', '#FFB800', '#4081EF', '#31A452'];
return colors[Math.floor(Math.random() * colors.length)];
};
</script>
{#await getUserAvatar()}
@@ -41,4 +47,17 @@
title={user.email}
/>
</button>
{:catch}
<button
on:click={() => dispatch('click')}
style:width={`${size}px`}
style:height={`${size}px`}
style:background-color={getRandomeBackgroundColor()}
alt="profile-img"
class="inline rounded-full object-cover shadow-sm text-white font-semibold"
>
<div title={user.email}>
{getFirstLetter(user.firstName)}{getFirstLetter(user.lastName)}
</div>
</button>
{/await}

View File

@@ -6,7 +6,7 @@
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
import PauseCircleOutline from 'svelte-material-icons/PauseCircleOutline.svelte';
import LoadingSpinner from './loading-spinner.svelte';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
import { api, AssetResponseDto, AssetTypeEnum, getFileUrl, ThumbnailFormat } from '@api';
const dispatch = createEventDispatcher();
@@ -18,7 +18,7 @@
export let isExisted: boolean = false;
let imageData: string;
let videoData: string;
// let videoData: string;
let mouseOver: boolean = false;
$: dispatch('mouseEvent', { isMouseOver: mouseOver, selectedGroupIndex: groupIndex });
@@ -28,7 +28,8 @@
let isThumbnailVideoPlaying = false;
let calculateVideoDurationIntervalHandler: NodeJS.Timer;
let videoProgress = '00:00';
let videoAbortController: AbortController;
// let videoAbortController: AbortController;
let videoUrl: string;
const loadImageData = async () => {
const { data } = await api.assetApi.getAssetThumbnail(asset.id, format, {
@@ -42,51 +43,8 @@
const loadVideoData = async () => {
isThumbnailVideoPlaying = false;
videoAbortController = new AbortController();
try {
const { data } = await api.assetApi.serveFile(
asset.deviceAssetId,
asset.deviceId,
false,
true,
{
responseType: 'blob',
signal: videoAbortController.signal
}
);
if (!(data instanceof Blob)) {
return;
}
videoData = URL.createObjectURL(data);
videoPlayerNode.src = videoData;
videoPlayerNode.load();
videoPlayerNode.onloadeddata = () => {
console.log('first frame load');
};
videoPlayerNode.oncanplaythrough = () => {
console.log('can play through');
};
videoPlayerNode.oncanplay = () => {
console.log('can play');
videoPlayerNode.muted = true;
videoPlayerNode.play();
isThumbnailVideoPlaying = true;
calculateVideoDurationIntervalHandler = setInterval(() => {
videoProgress = getVideoDurationInString(Math.round(videoPlayerNode.currentTime));
}, 1000);
};
return videoData;
} catch (e) {}
videoUrl = getFileUrl(asset.deviceAssetId, asset.deviceId, false, true);
};
const getVideoDurationInString = (currentTime: number) => {
@@ -136,12 +94,7 @@
const handleMouseLeaveThumbnail = () => {
mouseOver = false;
// Stop XHR download of video
videoAbortController?.abort();
// Stop video playback
URL.revokeObjectURL(videoData);
videoUrl = '';
clearInterval(calculateVideoDurationIntervalHandler);
@@ -149,6 +102,18 @@
videoProgress = '00:00';
};
const handleCanPlay = (ev: Event) => {
const playerNode = ev.target as HTMLVideoElement;
playerNode.muted = true;
playerNode.play();
isThumbnailVideoPlaying = true;
calculateVideoDurationIntervalHandler = setInterval(() => {
videoProgress = getVideoDurationInString(Math.round(playerNode.currentTime));
}, 1000);
};
$: getThumbnailBorderStyle = () => {
if (selected) {
return 'border-[20px] border-immich-primary/20';
@@ -259,17 +224,21 @@
{#if mouseOver && asset.type === AssetTypeEnum.Video}
<div class="absolute w-full h-full top-0" on:mouseenter={loadVideoData}>
<video
muted
autoplay
preload="none"
class="h-full object-cover"
width="250px"
style:width={`${thumbnailSize}px`}
bind:this={videoPlayerNode}
>
<track kind="captions" />
</video>
{#if videoUrl}
<video
muted
autoplay
preload="none"
class="h-full object-cover"
width="250px"
style:width={`${thumbnailSize}px`}
on:canplay={handleCanPlay}
bind:this={videoPlayerNode}
>
<source src={videoUrl} type="video/mp4" />
<track kind="captions" />
</video>
{/if}
</div>
{/if}
</div>

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { ImmichUser } from '$lib/models/immich-user';
import { createEventDispatcher, onMount } from 'svelte';
import { fade, fly, slide } from 'svelte/transition';
import TrayArrowUp from 'svelte-material-icons/TrayArrowUp.svelte';
import { clickOutside } from '../../utils/click-outside';
import { api } from '@api';
import { api, UserResponseDto } from '@api';
export let user: ImmichUser;
export let user: UserResponseDto;
export let shouldShowUploadButton = true;
let shouldShowAccountInfo = false;
let shouldShowProfileImage = false;
@@ -25,7 +25,6 @@
await api.userApi.getProfileImage(user.id);
shouldShowProfileImage = true;
} catch (e) {
console.log('User does not have a profile image');
shouldShowProfileImage = false;
}
};
@@ -33,10 +32,6 @@
return text?.charAt(0).toUpperCase();
};
const navigateToAdmin = () => {
goto('/admin');
};
const showAccountInfoPanel = () => {
shouldShowAccountInfoPanel = true;
};
@@ -60,7 +55,7 @@
/>
</div>
<section class="flex gap-4 place-items-center">
{#if $page.url.pathname !== '/admin'}
{#if $page.url.pathname !== '/admin' && shouldShowUploadButton}
<button
in:fly={{ x: 50, duration: 250 }}
on:click={() => dispatch('uploadClicked')}

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { tweened } from 'svelte/motion';
const progress = tweened(0, {
duration: 1000,
easing: cubicOut
});
onMount(() => {
progress.set(90);
});
</script>
<div class="absolute top-0 left-0 w-screen h-[3px] bg-white z-[999999999]">
<span class="absolute bg-immich-primary h-[3px]" style:width={`${$progress}%`} />
</div>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import CloseCircleOutline from 'svelte-material-icons/CloseCircleOutline.svelte';
import InformationOutline from 'svelte-material-icons/InformationOutline.svelte';
import {
ImmichNotification,
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { onMount } from 'svelte';
export let notificationInfo: ImmichNotification;
let infoPrimaryColor = '#4250AF';
let errorPrimaryColor = '#E64132';
$: icon =
notificationInfo.type === NotificationType.Error ? CloseCircleOutline : InformationOutline;
$: backgroundColor = () => {
if (notificationInfo.type === NotificationType.Info) {
return '#E0E2F0';
}
if (notificationInfo.type === NotificationType.Error) {
return '#FBE8E6';
}
};
$: borderStyle = () => {
if (notificationInfo.type === NotificationType.Info) {
return '1px solid #D8DDFF';
}
if (notificationInfo.type === NotificationType.Error) {
return '1px solid #F0E8E7';
}
};
$: primaryColor = () => {
if (notificationInfo.type === NotificationType.Info) {
return infoPrimaryColor;
}
if (notificationInfo.type === NotificationType.Error) {
return errorPrimaryColor;
}
};
onMount(() => {
setTimeout(() => {
notificationController.removeNotificationById(notificationInfo.id);
}, notificationInfo.timeout);
});
</script>
<div
transition:fade={{ duration: 250 }}
style:background-color={backgroundColor()}
style:border={borderStyle()}
class="min-h-[80px] w-[300px] rounded-2xl z-[999999] shadow-md p-4 mb-4"
>
<div class="flex gap-2 place-items-center">
<svelte:component this={icon} color={primaryColor()} size="20" />
<h2 style:color={primaryColor()} class="font-medium">{notificationInfo.type.toString()}</h2>
</div>
<p class="text-sm pl-[28px] pr-[16px]">{notificationInfo.message}</p>
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ImmichNotification, notificationController } from './notification';
import { fade } from 'svelte/transition';
import NotificationCard from './notification-card.svelte';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
let notificationList: ImmichNotification[] = [];
notificationController.notificationList.subscribe((list) => {
notificationList = list;
});
</script>
{#if notificationList.length > 0}
<section
transition:fade={{ duration: 250 }}
id="notification-list"
class="absolute right-5 top-[80px] z-[99999999]"
>
{#each notificationList as notificationInfo (notificationInfo.id)}
<div animate:flip={{ duration: 250, easing: quintOut }}>
<NotificationCard {notificationInfo} />
</div>
{/each}
</section>
{/if}

View File

@@ -0,0 +1,55 @@
import { writable } from 'svelte/store';
export enum NotificationType {
Info = 'Info',
Error = 'Error'
}
export class ImmichNotification {
id = new Date().getTime();
type!: NotificationType;
message!: string;
timeout = 3000;
}
export class ImmichNotificationDto {
/**
* Notification type
* @type {NotificationType} [Info, Error]
*/
type: NotificationType = NotificationType.Info;
/**
* Notification message
*/
message = '';
/**
* Timeout in miliseconds
*/
timeout?: number;
}
function createNotificationList() {
const notificationList = writable<ImmichNotification[]>([]);
const show = (notificationInfo: ImmichNotificationDto) => {
const newNotification = new ImmichNotification();
newNotification.message = notificationInfo.message;
newNotification.type = notificationInfo.type;
newNotification.timeout = notificationInfo.timeout || 3000;
notificationList.update((currentList) => [...currentList, newNotification]);
};
const removeNotificationById = (id: number) => {
notificationList.update((currentList) => currentList.filter((n) => n.id != id));
};
return {
show,
removeNotificationById,
notificationList
};
}
export const notificationController = createNotificationList();

View File

@@ -1,6 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { AppSideBarSelection } from '$lib/models/admin-sidebar-selection';
import { onMount } from 'svelte';
import { page } from '$app/stores';
@@ -24,7 +22,7 @@
</script>
<section id="sidebar" class="flex flex-col gap-1 pt-8 pr-6">
<a sveltekit:prefetch href={$page.routeId != 'photos' ? `/photos` : null}>
<a sveltekit:prefetch sveltekit:noscroll href={$page.routeId !== 'photos' ? `/photos` : null}>
<SideBarButton
title="Photos"
logo={ImageOutline}
@@ -32,7 +30,7 @@
isSelected={selectedAction === AppSideBarSelection.PHOTOS}
/></a
>
<a sveltekit:prefetch href={$page.routeId != 'sharing' ? `/sharing` : null}>
<a sveltekit:prefetch href={$page.routeId !== 'sharing' ? `/sharing` : null}>
<SideBarButton
title="Sharing"
logo={AccountMultipleOutline}
@@ -43,7 +41,7 @@
<div class="text-xs ml-5 my-4">
<p>LIBRARY</p>
</div>
<a sveltekit:prefetch href={$page.routeId != 'albums' ? `/albums` : null}>
<a sveltekit:prefetch href={$page.routeId !== 'albums' ? `/albums` : null}>
<SideBarButton
title="Albums"
logo={ImageAlbum}
@@ -51,8 +49,8 @@
isSelected={selectedAction === AppSideBarSelection.ALBUMS}
/>
</a>
<!-- Status Box -->
<!-- Status Box -->
<div class="mb-6 mt-auto">
<StatusBox />
</div>

View File

@@ -34,7 +34,7 @@
const { data: serverInfoRes } = await api.serverInfoApi.getServerInfo();
serverInfo = serverInfoRes;
} catch (e) {
console.log('Error [StatusBox] [pingServerInterval]');
console.log('Error [StatusBox] [pingServerInterval]', e);
isServerOk = false;
}
}, 10000);

View File

@@ -6,7 +6,6 @@
import WindowMinimize from 'svelte-material-icons/WindowMinimize.svelte';
import type { UploadAsset } from '$lib/models/upload-asset';
import { getAssetsInfo } from '$lib/stores/assets';
import { session } from '$app/stores';
let showDetail = true;
let uploadLength = 0;

View File

@@ -1,10 +1,13 @@
import {
notificationController,
NotificationType
} from './../components/shared-components/notification/notification';
/* @vite-ignore */
import * as exifr from 'exifr';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { UploadAsset } from '../models/upload-asset';
import { api, AssetFileUploadResponseDto } from '@api';
import { albumUploadAssetStore } from '$lib/stores/album-upload-asset';
/**
* Determine if the upload is for album or for the user general backup
* @variant GENERAL - Upload assets to the server for general backup
@@ -33,6 +36,17 @@ export const openFileUploadDialog = (uploadType: UploadType) => {
fileSelector.onchange = async (e: any) => {
const files = Array.from<File>(e.target.files);
if (files.length > 50) {
notificationController.show({
type: NotificationType.Error,
message: `Cannot upload more than 50 files at a time - you are uploading ${files.length} files.
Please use the CLI tool if you need to upload more than 50 files.`,
timeout: 5000
});
return;
}
const acceptedFile = files.filter(
(e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image'
);

View File

@@ -0,0 +1,29 @@
<script>
import { page } from '$app/stores';
</script>
<div class="h-screen w-screen flex place-items-center place-content-center flex-col">
<div class="min-w-[500px] bg-gray-300 rounded-2xl my-4 p-4">
<code class="text-xs text-red-500">Error code {$page.status}</code>
<br />
<code class="text-sm">
{$page.error.message}
</code>
<br />
<div class="mt-5">
<p class="text-sm font-medium">Verbose</p>
<pre class="text-xs">{Object.values($page.error)}</pre>
</div>
<a
href="https://github.com/immich-app/immich/issues/new/choose"
target="_blank"
rel="noopener noreferrer"
>
<button
class="px-5 py-2 rounded-lg text-sm mt-6 bg-immich-primary text-white hover:bg-immich-primary/75"
>Get help</button
>
</a>
</div>
</div>

View File

@@ -0,0 +1,26 @@
import { serverApi } from '@api';
import * as cookieParser from 'cookie';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ request }) => {
try {
const cookies = cookieParser.parse(request.headers.get('cookie') || '');
const accessToken = cookies['immich_access_token'];
if (!accessToken) {
return {
user: undefined
};
}
serverApi.setAccessToken(accessToken);
const { data: userInfo } = await serverApi.userApi.getMyUserInfo();
return {
user: userInfo
};
} catch (e) {
console.log('[ERROR] layout.server.ts [LayoutServerLoad]: ', e);
}
};

View File

@@ -1,14 +1,3 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
import { checkAppVersion } from '$lib/utils/check-app-version';
export const load: Load = async ({ url }) => {
return {
props: { url }
};
};
</script>
<script lang="ts">
import '../app.css';
@@ -17,11 +6,16 @@
import AnnouncementBox from '$lib/components/shared-components/announcement-box.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import { onMount } from 'svelte';
import { checkAppVersion } from '$lib/utils/check-app-version';
import { page } from '$app/stores';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
export let url: string;
let shouldShowAnnouncement: boolean;
let localVersion: string;
let remoteVersion: string;
let showNavigationLoadingBar = false;
onMount(async () => {
const res = await checkAppVersion();
@@ -30,16 +24,28 @@
localVersion = res.localVersion ?? 'unknown';
remoteVersion = res.remoteVersion ?? 'unknown';
});
beforeNavigate(() => {
showNavigationLoadingBar = true;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
});
</script>
<main>
{#key url}
{#key $page.url}
<div in:fade={{ duration: 100 }}>
{#if showNavigationLoadingBar}
<NavigationLoadingBar />
{/if}
<slot />
<DownloadPanel />
<UploadPanel />
<NotificationList />
{#if shouldShowAnnouncement}
<AnnouncementBox
{localVersion}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { PageData } from './$types';
export let data: PageData;
async function onGettingStartedClicked() {
data.isAdminUserExist ? await goto('/auth/login') : await goto('/auth/register');
}
</script>
<svelte:head>
<title>Welcome 🎉 - Immich</title>
<meta name="description" content="Immich Web Interface" />
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]">
<div class="flex place-items-center place-content-center ">
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo" />
</div>
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1>
<button
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
on:click={onGettingStartedClicked}
>Getting Started
</button>
</div>
</section>

20
web/src/routes/+page.ts Normal file
View File

@@ -0,0 +1,20 @@
export const prerender = false;
import { redirect } from '@sveltejs/kit';
import { api } from '@api';
import { browser } from '$app/env';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { user } = await parent();
if (user) {
throw redirect(302, '/photos');
}
if (browser) {
const { data } = await api.userApi.getUserCount();
return {
isAdminUserExist: data.userCount != 0
};
}
};

View File

@@ -0,0 +1,19 @@
import { redirect } from '@sveltejs/kit';
import { serverApi, UserResponseDto } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
} else if (!user.isAdmin) {
throw redirect(302, '/photos');
}
const { data: allUsers } = await serverApi.userApi.getAllUsers(false);
return {
user: user,
allUsers: allUsers
};
};

View File

@@ -1,47 +1,3 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
import { api, UserResponseDto } from '@api';
import { browser } from '$app/env';
export const load: Load = async ({ fetch, session }) => {
if (!browser && !session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
try {
const user: UserResponseDto = await fetch('/data/user/get-my-user-info').then((r) =>
r.json()
);
const allUsers: UserResponseDto[] = await fetch('/data/user/get-all-users?isAll=false').then(
(r) => r.json()
);
if (!user.isAdmin) {
return {
status: 302,
redirect: '/photos'
};
}
return {
status: 200,
props: {
user: user,
allUsers: allUsers
}
};
} catch (e) {
return {
status: 302,
redirect: '/auth/login'
};
}
};
</script>
<script lang="ts">
import { onMount } from 'svelte';
@@ -54,11 +10,12 @@
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import type { PageData } from './$types';
import { api, UserResponseDto } from '@api';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
export let user: UserResponseDto;
export let allUsers: UserResponseDto[];
export let data: PageData;
let editUser: UserResponseDto;
@@ -75,8 +32,8 @@
});
const onUserCreated = async () => {
const { data } = await api.userApi.getAllUsers(false);
allUsers = data;
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowCreateUserForm = false;
};
@@ -87,14 +44,14 @@
};
const onEditUserSuccess = async () => {
const { data } = await api.userApi.getAllUsers(false);
allUsers = data;
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowEditUserForm = false;
};
const onEditPasswordSuccess = async () => {
const { data } = await api.userApi.getAllUsers(false);
allUsers = data;
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowEditUserForm = false;
shouldShowInfoPanel = true;
};
@@ -104,7 +61,7 @@
<title>Administration - Immich</title>
</svelte:head>
<NavigationBar {user} />
<NavigationBar user={data.user} />
{#if shouldShowCreateUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
@@ -125,7 +82,7 @@
{#if shouldShowInfoPanel}
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
<h1 class="font-bold text-immich-primary text-lg mb-4">Password reset success</h1>
<h1 class="font-medium text-immich-primary text-lg mb-4">Password reset success</h1>
<p>
The user's password has been reset to the default <code
@@ -170,7 +127,7 @@
<section class="w-[800px] pt-4">
{#if selectedAction === AdminSideBarSelection.USER_MANAGEMENT}
<UserManagement
{allUsers}
allUsers={data.allUsers}
on:create-user={() => (shouldShowCreateUserForm = true)}
on:edit-user={editUserHandler}
/>

View File

@@ -0,0 +1,22 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { AlbumResponseDto, serverApi } from '@api';
export const load: PageServerLoad = async ({ parent }) => {
try {
const { user } = await parent();
if (!user) {
throw Error('User is not logged in');
}
const { data: albums } = await serverApi.albumApi.getAllAlbums();
return {
user: user,
albums: albums
};
} catch (e) {
throw redirect(302, '/auth/login');
}
};

View File

@@ -1,44 +1,3 @@
<script context="module" lang="ts">
export const prerender = false;
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import { ImmichUser } from '$lib/models/immich-user';
import type { Load } from '@sveltejs/kit';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import { AlbumResponseDto, api } from '@api';
export const load: Load = async ({ fetch, session }) => {
if (!browser && !session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
try {
const [user, albums] = await Promise.all([
fetch('/data/user/get-my-user-info').then((r) => r.json()),
fetch('/data/album/get-all-albums').then((r) => r.json())
]);
return {
status: 200,
props: {
user: user,
albums: albums
}
};
} catch (e) {
return {
status: 302,
redirect: '/auth/login'
};
}
};
</script>
<script lang="ts">
import AlbumCard from '$lib/components/album-page/album-card.svelte';
import { goto } from '$app/navigation';
@@ -46,27 +5,33 @@
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import { browser } from '$app/env';
import type { PageData } from './$types';
import { AlbumResponseDto, api } from '@api';
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
export let user: ImmichUser;
export let albums: AlbumResponseDto[];
export let data: PageData;
let isShowContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
let targetAlbum: AlbumResponseDto;
onMount(async () => {
const { data } = await api.albumApi.getAllAlbums();
albums = data;
const getAllAlbumsRes = await api.albumApi.getAllAlbums();
data.albums = getAllAlbumsRes.data;
// Delete album that has no photos and is named 'Untitled'
for (const album of albums) {
for (const album of data.albums) {
if (album.albumName === 'Untitled' && album.assetCount === 0) {
const isDeleted = await autoDeleteAlbum(album);
if (isDeleted) {
albums = albums.filter((a) => a.id !== album.id);
}
setTimeout(async () => {
await autoDeleteAlbum(album);
data.albums = data.albums.filter((a) => a.id !== album.id);
}, 500);
}
}
});
@@ -79,7 +44,11 @@
goto('/albums/' + newAlbum.id);
} catch (e) {
console.log('Error [createAlbum] ', e);
console.error('Error [createAlbum] ', e);
notificationController.show({
message: 'Error creating album, check console for more details',
type: NotificationType.Error
});
}
};
@@ -88,7 +57,7 @@
await api.albumApi.deleteAlbum(album.id);
return true;
} catch (e) {
console.log('Error [autoDeleteAlbum] ', e);
console.error('Error [autoDeleteAlbum] ', e);
return false;
}
};
@@ -101,9 +70,13 @@
) {
try {
await api.albumApi.deleteAlbum(targetAlbum.id);
albums = albums.filter((a) => a.id !== targetAlbum.id);
data.albums = data.albums.filter((a) => a.id !== targetAlbum.id);
} catch (e) {
console.log('Error [userDeleteMenu] ', e);
console.error('Error [userDeleteMenu] ', e);
notificationController.show({
message: 'Error deleting user, check console for more details',
type: NotificationType.Error
});
}
}
@@ -127,7 +100,7 @@
</svelte:head>
<section>
<NavigationBar {user} on:uploadClicked={() => {}} />
<NavigationBar user={data.user} shouldShowUploadButton={false} />
</section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg ">
@@ -158,7 +131,7 @@
<!-- Album Card -->
<div class="flex flex-wrap gap-8">
{#each albums as album}
{#each data.albums as album}
{#key album.id}
<a sveltekit:prefetch href={`albums/${album.id}`}>
<AlbumCard {album} on:showalbumcontextmenu={(e) => showAlbumContextMenu(e, album)} />
@@ -168,7 +141,7 @@
</div>
<!-- Empty Message -->
{#if albums.length === 0}
{#if data.albums.length === 0}
<div
class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center"
>

View File

@@ -0,0 +1,23 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { serverApi } from '@api';
export const load: PageServerLoad = async ({ parent, params }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
}
const albumId = params['albumId'];
try {
const { data: album } = await serverApi.albumApi.getAlbumInfo(albumId);
return {
album
};
} catch (e) {
throw redirect(302, '/albums');
}
};

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{data.album.albumName} - Immich</title>
</svelte:head>
<div class="immich-scrollbar">
<AlbumViewer album={data.album} />
</div>

View File

@@ -1,57 +0,0 @@
<script context="module" lang="ts">
export const prerender = false;
import type { Load } from '@sveltejs/kit';
import { AlbumResponseDto } from '@api';
export const load: Load = async ({ fetch, params, session }) => {
if (!browser && !session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
try {
const albumId = params['albumId'];
const albumInfo = await fetch(`/data/album/get-album-info?albumId=${albumId}`).then((r) =>
r.json()
);
return {
status: 200,
props: {
album: albumInfo
}
};
} catch (e: any) {
if (e.response?.status === 404) {
return {
status: 302,
redirect: '/albums'
};
}
return {
status: 302,
redirect: '/auth/login'
};
}
};
</script>
<script lang="ts">
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
import { browser } from '$app/env';
export let album: AlbumResponseDto;
</script>
<svelte:head>
<title>{album.albumName} - Immich</title>
</svelte:head>
<div class="immich-scrollbar">
<AlbumViewer {album} />
</div>

View File

@@ -1,28 +0,0 @@
<script context="module" lang="ts">
export const prerender = false;
import { browser } from '$app/env';
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ params, session }) => {
if (!browser && !session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
const albumId = params['albumId'];
if (albumId) {
return {
status: 302,
redirect: `/albums/${albumId}`
};
} else {
return {
status: 302,
redirect: `/photos`
};
}
};
</script>

View File

@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
export const prerender = false;
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
}
const albumId = params['albumId'];
if (albumId) {
throw redirect(302, `/albums/${albumId}`);
} else {
throw redirect(302, `/photos`);
}
};

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import ChangePasswordForm from '$lib/components/forms/change-password-form.svelte';
import type { PageData } from './$types';
export let data: PageData;
const onSuccessHandler = async () => {
await fetch('auth/logout', { method: 'POST' });
goto('/auth/login');
};
</script>
<svelte:head>
<title>Change Password - Immich</title>
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
<ChangePasswordForm user={data.user} on:success={onSuccessHandler} />
</div>
</section>

View File

@@ -0,0 +1,21 @@
import { api } from '@api';
import { redirect } from '@sveltejs/kit';
export const prerender = false;
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
try {
const { data: userInfo } = await api.userApi.getMyUserInfo();
if (userInfo.shouldChangePassword) {
return {
user: userInfo
};
} else {
throw redirect(302, '/photos');
}
} catch (e) {
throw redirect(302, '/auth/login');
}
};

View File

@@ -1,56 +0,0 @@
<script context="module" lang="ts">
export const prerender = false;
import type { Load } from '@sveltejs/kit';
export const load: Load = async () => {
try {
const { data: userInfo } = await api.userApi.getMyUserInfo();
if (userInfo.shouldChangePassword) {
return {
status: 200,
props: {
user: userInfo
}
};
} else {
return {
status: 302,
redirect: '/photos'
};
}
} catch (e) {
return {
status: 302,
redirect: '/auth/login'
};
}
};
</script>
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import ChangePasswordForm from '$lib/components/forms/change-password-form.svelte';
import { api, UserResponseDto } from '@api';
export let user: UserResponseDto;
const onSuccessHandler = async () => {
await fetch('auth/logout', { method: 'POST' });
goto('/auth/login');
};
</script>
<svelte:head>
<title>Change Password - Immich</title>
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
<ChangePasswordForm {user} on:success={onSuccessHandler} />
</div>
</section>

View File

@@ -3,10 +3,6 @@
import { fade } from 'svelte/transition';
import LoginForm from '$lib/components/forms/login-form.svelte';
const onLoginSuccess = async () => {
goto('/photos');
};
</script>
<svelte:head>
@@ -15,6 +11,9 @@
<section class="h-screen w-screen flex place-items-center place-content-center">
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
<LoginForm on:success={onLoginSuccess} on:first-login={() => goto('/auth/change-password')} />
<LoginForm
on:success={() => goto('/photos')}
on:first-login={() => goto('/auth/change-password')}
/>
</div>
</section>

View File

@@ -1,19 +0,0 @@
import { api, serverApi } from '@api';
import type { RequestHandler } from '@sveltejs/kit';
export const POST: RequestHandler = async () => {
api.removeAccessToken();
serverApi.removeAccessToken();
return {
headers: {
'Set-Cookie': [
'immich_is_authenticated=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;',
'immich_access_token=delete; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
]
},
body: {
ok: true
}
};
};

View File

@@ -0,0 +1,27 @@
import { json } from '@sveltejs/kit';
import { api, serverApi } from '@api';
import type { RequestHandler } from '@sveltejs/kit';
export const POST: RequestHandler = async () => {
api.removeAccessToken();
serverApi.removeAccessToken();
const headers = new Headers();
headers.append(
'set-cookie',
'immich_is_authenticated=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;'
);
headers.append(
'set-cookie',
'immich_access_token=delete; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
);
return json(
{
ok: true
},
{
headers
}
);
};

View File

@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { serverApi } from '@api';
export const load: PageServerLoad = async () => {
const { data } = await serverApi.userApi.getUserCount();
if (data.userCount != 0) {
// Admin has been registered, redirect to login
throw redirect(302, '/auth/login');
}
return;
};

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import AdminRegistrationForm from '$lib/components/forms/admin-registration-form.svelte';
</script>
<svelte:head>
<title>Admin Registration - Immich</title>
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<AdminRegistrationForm />
</section>

View File

@@ -1,31 +0,0 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit';
export const load: Load = async () => {
const { data } = await api.userApi.getUserCount();
if (data.userCount != 0) {
// Admin has been registered, redirect to login
return {
status: 302,
redirect: '/auth/login'
};
}
return {
status: 200
};
};
</script>
<script lang="ts">
import AdminRegistrationForm from '$lib/components/forms/admin-registration-form.svelte';
import { api } from '@api';
</script>
<svelte:head>
<title>Admin Registration - Immich</title>
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<AdminRegistrationForm />
</section>

View File

@@ -1 +0,0 @@
This directory contain SSR endpoints to user serverApi instance to make request directly to DNS

View File

@@ -1,18 +0,0 @@
import { AlbumResponseDto, serverApi } from '@api';
import type { RequestEvent, RequestHandlerOutput } from '@sveltejs/kit';
export const GET = async ({
url
}: RequestEvent): Promise<RequestHandlerOutput<AlbumResponseDto>> => {
try {
const albumId = url.searchParams.get('albumId') || '';
const { data } = await serverApi.albumApi.getAlbumInfo(albumId);
return {
body: data
};
} catch {
return {
status: 500
};
}
};

View File

@@ -1,18 +0,0 @@
import { AlbumResponseDto, serverApi } from '@api';
import type { RequestEvent, RequestHandler, RequestHandlerOutput } from '@sveltejs/kit';
export const GET = async ({
url
}: RequestEvent): Promise<RequestHandlerOutput<AlbumResponseDto[]>> => {
try {
const isShared = url.searchParams.get('isShared') === 'true' || undefined;
const { data } = await serverApi.albumApi.getAllAlbums(isShared);
return {
body: data
};
} catch {
return {
status: 500
};
}
};

View File

@@ -1,15 +0,0 @@
import { AssetResponseDto, serverApi } from '@api';
import type { RequestHandlerOutput } from '@sveltejs/kit';
export const GET = async (): Promise<RequestHandlerOutput<AssetResponseDto[]>> => {
try {
const { data } = await serverApi.assetApi.getAllAssets();
return {
body: data
};
} catch {
return {
status: 500
};
}
};

View File

@@ -1,17 +0,0 @@
import { serverApi, UserResponseDto } from '@api';
import type { RequestEvent, RequestHandlerOutput } from '@sveltejs/kit';
export const GET = async ({url} : RequestEvent): Promise<RequestHandlerOutput<UserResponseDto[]>> => {
try {
const isAll = url.searchParams.get('isAll') === 'true';
const { data } = await serverApi.userApi.getAllUsers(isAll);
return {
body: data
};
} catch {
return {
status: 500
};
}
};

View File

@@ -1,15 +0,0 @@
import { serverApi, UserResponseDto } from '@api';
import type { RequestHandlerOutput } from '@sveltejs/kit';
export const GET = async (): Promise<RequestHandlerOutput<UserResponseDto>> => {
try {
const { data } = await serverApi.userApi.getMyUserInfo();
return {
body: data
};
} catch {
return {
status: 500
};
}
};

View File

@@ -1,58 +0,0 @@
<script context="module" lang="ts">
export const prerender = false;
import type { Load } from '@sveltejs/kit';
import { api } from '@api';
import { browser } from '$app/env';
export const load: Load = async () => {
if (browser) {
try {
const {data: user} = await api.userApi.getMyUserInfo();
return {
status: 302,
redirect: '/photos'
};
} catch (e) {
}
const {data} = await api.userApi.getUserCount();
return {
status: 200,
props: {
isAdminUserExist: data.userCount != 0
}
};
}
};
</script>
<script lang="ts">
import { goto } from '$app/navigation';
export let isAdminUserExist: boolean;
async function onGettingStartedClicked() {
isAdminUserExist ? await goto('/auth/login') : await goto('/auth/register');
}
</script>
<svelte:head>
<title>Welcome 🎉 - Immich</title>
<meta name="description" content="Immich Web Interface"/>
</svelte:head>
<section class="h-screen w-screen flex place-items-center place-content-center">
<div class="flex flex-col place-items-center gap-8 text-center max-w-[350px]">
<div class="flex place-items-center place-content-center ">
<img class="text-center" src="immich-logo.svg" height="200" width="200" alt="immich-logo"/>
</div>
<h1 class="text-4xl text-immich-primary font-bold font-immich-title">Welcome to IMMICH Web</h1>
<button
class="border px-4 py-2 rounded-md bg-immich-primary hover:bg-immich-primary/75 text-white font-bold w-[200px]"
on:click={onGettingStartedClicked}>Getting Started
</button
>
</div>
</section>

View File

@@ -0,0 +1,21 @@
import { serverApi } from './../../api/api';
import type { PageServerLoad } from './$types';
import { redirect, error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ parent }) => {
try {
const { user } = await parent();
if (!user) {
throw error(400, 'Not logged in');
}
const { data: assets } = await serverApi.assetApi.getAllAssets();
return {
user,
assets
};
} catch (e) {
throw redirect(302, '/auth/login');
}
};

View File

@@ -1,60 +1,32 @@
<script context="module" lang="ts">
export const prerender = false;
import type { Load } from '@sveltejs/kit';
import { setAssetInfo } from '$lib/stores/assets';
export const load: Load = async ({ fetch, session }) => {
if (!browser && !session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
try {
const [userInfo, assets] = await Promise.all([
fetch('/data/user/get-my-user-info').then((r) => r.json()),
fetch('/data/asset/get-all-assets').then((r) => r.json())
]);
setAssetInfo(assets);
return {
status: 200,
props: {
user: userInfo
}
};
} catch (e) {
console.log('ERROR load photos index');
return {
status: 302,
redirect: '/auth/login'
};
}
};
</script>
<script lang="ts">
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import CheckCircle from 'svelte-material-icons/CheckCircle.svelte';
import { fly } from 'svelte/transition';
import { assetsGroupByDate, flattenAssetGroupByDate, assets } from '$lib/stores/assets';
import {
assetsGroupByDate,
flattenAssetGroupByDate,
assets,
setAssetInfo
} from '$lib/stores/assets';
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
import moment from 'moment';
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import { openFileUploadDialog, UploadType } from '$lib/utils/file-uploader';
import { api, AssetResponseDto, UserResponseDto } from '@api';
import { api, AssetResponseDto } from '@api';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import CircleOutline from 'svelte-material-icons/CircleOutline.svelte';
import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import { browser } from '$app/env';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import type { PageData } from './$types';
import { onMount } from 'svelte';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
export let user: UserResponseDto;
export let data: PageData;
let selectedGroupThumbnail: number | null;
let isMouseOverGroup: boolean;
@@ -73,6 +45,10 @@
let currentViewAssetIndex = 0;
let selectedAsset: AssetResponseDto;
onMount(() => {
setAssetInfo(data.assets);
});
const thumbnailMouseEventHandler = (event: CustomEvent) => {
const { selectedGroupIndex }: { selectedGroupIndex: number } = event.detail;
@@ -96,7 +72,10 @@
pushState(selectedAsset.id);
}
} catch (e) {
console.log('Error navigating asset forward', e);
notificationController.show({
type: NotificationType.Info,
message: 'You have reached the end'
});
}
};
@@ -108,7 +87,10 @@
pushState(selectedAsset.id);
}
} catch (e) {
console.log('Error navigating asset backward', e);
notificationController.show({
type: NotificationType.Info,
message: 'You have reached the end'
});
}
};
@@ -204,7 +186,11 @@
clearMultiSelectAssetAssetHandler();
}
} catch (e) {
console.log('Error deleteSelectedAssetHandler', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error deleting assets, check console for more details'
});
console.error('Error deleteSelectedAssetHandler', e);
}
};
</script>
@@ -234,7 +220,10 @@
{/if}
{#if !isMultiSelectionMode}
<NavigationBar {user} on:uploadClicked={() => openFileUploadDialog(UploadType.GENERAL)} />
<NavigationBar
user={data.user}
on:uploadClicked={() => openFileUploadDialog(UploadType.GENERAL)}
/>
{/if}
</section>

View File

@@ -1,20 +0,0 @@
<script context="module" lang="ts">
export const prerender = false;
import { browser } from '$app/env';
import type { Load } from '@sveltejs/kit';
export const load: Load = async ({ session }) => {
if (!browser && !session.user) {
return {
status: 302,
redirect: '/auth/login'
};
} else {
return {
status: 302,
redirect: '/photos'
};
}
};
</script>

View File

@@ -0,0 +1,14 @@
import { redirect } from '@sveltejs/kit';
export const prerender = false;
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
} else {
throw redirect(302, '/photos');
}
};

View File

@@ -0,0 +1,23 @@
import { redirect } from '@sveltejs/kit';
export const prerender = false;
import { serverApi } from '@api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
try {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
}
const { data: sharedAlbums } = await serverApi.albumApi.getAllAlbums(true);
return {
user: user,
sharedAlbums: sharedAlbums
};
} catch (e) {
throw redirect(302, '/auth/login');
}
};

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
import { goto } from '$app/navigation';
import { api } from '@api';
import type { PageData } from './$types';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
export let data: PageData;
const createSharedAlbum = async () => {
try {
const { data: newAlbum } = await api.albumApi.createAlbum({
albumName: 'Untitled'
});
goto('/albums/' + newAlbum.id);
} catch (e) {
notificationController.show({
message: 'Error creating album, check console for more details',
type: NotificationType.Error
});
console.log('Error [createAlbum] ', e);
}
};
</script>
<svelte:head>
<title>Albums - Immich</title>
</svelte:head>
<section>
<NavigationBar user={data.user} shouldShowUploadButton={false} />
</section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
<SideBar />
<section class="overflow-y-auto relative">
<section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
<!-- Main Section -->
<div class="px-4 flex justify-between place-items-center">
<div>
<p class="font-medium">Sharing</p>
</div>
<div>
<button
on:click={createSharedAlbum}
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
>
<span>
<PlusBoxOutline size="18" />
</span>
<p>Create shared album</p>
</button>
</div>
</div>
<div class="my-4">
<hr />
</div>
<!-- Share Album List -->
<div class="w-full flex flex-col place-items-center">
{#each data.sharedAlbums as album}
<a sveltekit:prefetch href={`albums/${album.id}`}>
<SharedAlbumListTile {album} user={data.user} />
</a>
{/each}
</div>
<!-- Empty List -->
{#if data.sharedAlbums.length === 0}
<div
class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src="/empty-2.svg" alt="Empty shared album" width="500" />
<p class="text-center text-immich-text-gray-500">
Create a shared album to share photos and videos with people in your network
</p>
</div>
{/if}
</section>
</section>
</section>

View File

@@ -1,120 +0,0 @@
<script context="module" lang="ts">
export const prerender = false;
import type { Load } from '@sveltejs/kit';
import { AlbumResponseDto, api, UserResponseDto } from '@api';
import { browser } from '$app/env';
export const load: Load = async ({fetch, session}) => {
if (!browser && !session.user) {
return {
status: 302,
redirect: '/auth/login'
};
}
try {
const [user, sharedAlbums] = await Promise.all([
fetch('/data/user/get-my-user-info').then((r) => r.json()),
fetch('/data/album/get-all-albums?isShared=true').then((r) => r.json())
]);
return {
status: 200,
props: {
user: user,
sharedAlbums: sharedAlbums
}
};
} catch (e) {
return {
status: 302,
redirect: '/auth/login'
};
}
};
</script>
<script lang="ts">
import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte';
import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte';
import { goto } from '$app/navigation';
export let user: UserResponseDto;
export let sharedAlbums: AlbumResponseDto[];
const createSharedAlbum = async () => {
try {
const {data: newAlbum} = await api.albumApi.createAlbum({
albumName: 'Untitled'
});
goto('/albums/' + newAlbum.id);
} catch (e) {
console.log('Error [createAlbum] ', e);
}
};
</script>
<svelte:head>
<title>Albums - Immich</title>
</svelte:head>
<section>
<NavigationBar {user} on:uploadClicked={() => {}}/>
</section>
<section class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg">
<SideBar/>
<section class="overflow-y-auto relative">
<section id="album-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg">
<!-- Main Section -->
<div class="px-4 flex justify-between place-items-center">
<div>
<p class="font-medium">Sharing</p>
</div>
<div>
<button
on:click={createSharedAlbum}
class="flex place-items-center gap-1 text-sm hover:bg-immich-primary/5 p-2 rounded-lg font-medium hover:text-gray-700"
>
<span>
<PlusBoxOutline size="18"/>
</span>
<p>Create shared album</p>
</button>
</div>
</div>
<div class="my-4">
<hr/>
</div>
<!-- Share Album List -->
<div class="w-full flex flex-col place-items-center">
{#each sharedAlbums as album}
<a sveltekit:prefetch href={`albums/${album.id}`}>
<SharedAlbumListTile {album} {user}/>
</a
>
{/each}
</div>
<!-- Empty List -->
{#if sharedAlbums.length === 0}
<div
class="border p-5 w-[50%] m-auto mt-10 bg-gray-50 rounded-3xl flex flex-col place-content-center place-items-center"
>
<img src="/empty-2.svg" alt="Empty shared album" width="500"/>
<p class="text-center text-immich-text-gray-500">
Create a shared album to share photos and videos with people in your network
</p>
</div>
{/if}
</section>
</section>
</section>