Sfoglia il codice sorgente

feat(web): custom stylesheets (#4602)

* add initial ui and api definitions for stylesheets

* proper saving

* make custom css work

* add textarea

* rebuild api

* run prettier

* add typecast

* update typings

* move css accordion to be sorted alphabetically

* set content-type properly

* rename stylesheets to theme

* fix server test
Wingy 1 anno fa
parent
commit
62a11283af

+ 19 - 0
cli/src/api/open-api/api.ts

@@ -3307,6 +3307,12 @@ export interface SystemConfigDto {
      * @memberof SystemConfigDto
      * @memberof SystemConfigDto
      */
      */
     'storageTemplate': SystemConfigStorageTemplateDto;
     'storageTemplate': SystemConfigStorageTemplateDto;
+    /**
+     * 
+     * @type {SystemConfigThemeDto}
+     * @memberof SystemConfigDto
+     */
+    'theme': SystemConfigThemeDto;
     /**
     /**
      * 
      * 
      * @type {SystemConfigThumbnailDto}
      * @type {SystemConfigThumbnailDto}
@@ -3741,6 +3747,19 @@ export interface SystemConfigTemplateStorageOptionDto {
      */
      */
     'yearOptions': Array<string>;
     'yearOptions': Array<string>;
 }
 }
+/**
+ * 
+ * @export
+ * @interface SystemConfigThemeDto
+ */
+export interface SystemConfigThemeDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigThemeDto
+     */
+    'customCss': string;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -135,6 +135,7 @@ doc/SystemConfigPasswordLoginDto.md
 doc/SystemConfigReverseGeocodingDto.md
 doc/SystemConfigReverseGeocodingDto.md
 doc/SystemConfigStorageTemplateDto.md
 doc/SystemConfigStorageTemplateDto.md
 doc/SystemConfigTemplateStorageOptionDto.md
 doc/SystemConfigTemplateStorageOptionDto.md
+doc/SystemConfigThemeDto.md
 doc/SystemConfigThumbnailDto.md
 doc/SystemConfigThumbnailDto.md
 doc/SystemConfigTrashDto.md
 doc/SystemConfigTrashDto.md
 doc/TagApi.md
 doc/TagApi.md
@@ -302,6 +303,7 @@ lib/model/system_config_password_login_dto.dart
 lib/model/system_config_reverse_geocoding_dto.dart
 lib/model/system_config_reverse_geocoding_dto.dart
 lib/model/system_config_storage_template_dto.dart
 lib/model/system_config_storage_template_dto.dart
 lib/model/system_config_template_storage_option_dto.dart
 lib/model/system_config_template_storage_option_dto.dart
+lib/model/system_config_theme_dto.dart
 lib/model/system_config_thumbnail_dto.dart
 lib/model/system_config_thumbnail_dto.dart
 lib/model/system_config_trash_dto.dart
 lib/model/system_config_trash_dto.dart
 lib/model/tag_response_dto.dart
 lib/model/tag_response_dto.dart
@@ -456,6 +458,7 @@ test/system_config_password_login_dto_test.dart
 test/system_config_reverse_geocoding_dto_test.dart
 test/system_config_reverse_geocoding_dto_test.dart
 test/system_config_storage_template_dto_test.dart
 test/system_config_storage_template_dto_test.dart
 test/system_config_template_storage_option_dto_test.dart
 test/system_config_template_storage_option_dto_test.dart
+test/system_config_theme_dto_test.dart
 test/system_config_thumbnail_dto_test.dart
 test/system_config_thumbnail_dto_test.dart
 test/system_config_trash_dto_test.dart
 test/system_config_trash_dto_test.dart
 test/tag_api_test.dart
 test/tag_api_test.dart

+ 1 - 0
mobile/openapi/README.md

@@ -318,6 +318,7 @@ Class | Method | HTTP request | Description
  - [SystemConfigReverseGeocodingDto](doc//SystemConfigReverseGeocodingDto.md)
  - [SystemConfigReverseGeocodingDto](doc//SystemConfigReverseGeocodingDto.md)
  - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
  - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
  - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
  - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
+ - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
  - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md)
  - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md)
  - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
  - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
  - [TagResponseDto](doc//TagResponseDto.md)
  - [TagResponseDto](doc//TagResponseDto.md)

+ 1 - 0
mobile/openapi/doc/SystemConfigDto.md

@@ -16,6 +16,7 @@ Name | Type | Description | Notes
 **passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) |  | 
 **passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) |  | 
 **reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) |  | 
 **reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) |  | 
 **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) |  | 
 **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) |  | 
+**theme** | [**SystemConfigThemeDto**](SystemConfigThemeDto.md) |  | 
 **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) |  | 
 **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) |  | 
 **trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) |  | 
 **trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) |  | 
 
 

+ 15 - 0
mobile/openapi/doc/SystemConfigThemeDto.md

@@ -0,0 +1,15 @@
+# openapi.model.SystemConfigThemeDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**customCss** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 0
mobile/openapi/lib/api.dart

@@ -163,6 +163,7 @@ part 'model/system_config_password_login_dto.dart';
 part 'model/system_config_reverse_geocoding_dto.dart';
 part 'model/system_config_reverse_geocoding_dto.dart';
 part 'model/system_config_storage_template_dto.dart';
 part 'model/system_config_storage_template_dto.dart';
 part 'model/system_config_template_storage_option_dto.dart';
 part 'model/system_config_template_storage_option_dto.dart';
+part 'model/system_config_theme_dto.dart';
 part 'model/system_config_thumbnail_dto.dart';
 part 'model/system_config_thumbnail_dto.dart';
 part 'model/system_config_trash_dto.dart';
 part 'model/system_config_trash_dto.dart';
 part 'model/tag_response_dto.dart';
 part 'model/tag_response_dto.dart';

+ 2 - 0
mobile/openapi/lib/api_client.dart

@@ -417,6 +417,8 @@ class ApiClient {
           return SystemConfigStorageTemplateDto.fromJson(value);
           return SystemConfigStorageTemplateDto.fromJson(value);
         case 'SystemConfigTemplateStorageOptionDto':
         case 'SystemConfigTemplateStorageOptionDto':
           return SystemConfigTemplateStorageOptionDto.fromJson(value);
           return SystemConfigTemplateStorageOptionDto.fromJson(value);
+        case 'SystemConfigThemeDto':
+          return SystemConfigThemeDto.fromJson(value);
         case 'SystemConfigThumbnailDto':
         case 'SystemConfigThumbnailDto':
           return SystemConfigThumbnailDto.fromJson(value);
           return SystemConfigThumbnailDto.fromJson(value);
         case 'SystemConfigTrashDto':
         case 'SystemConfigTrashDto':

+ 9 - 1
mobile/openapi/lib/model/system_config_dto.dart

@@ -21,6 +21,7 @@ class SystemConfigDto {
     required this.passwordLogin,
     required this.passwordLogin,
     required this.reverseGeocoding,
     required this.reverseGeocoding,
     required this.storageTemplate,
     required this.storageTemplate,
+    required this.theme,
     required this.thumbnail,
     required this.thumbnail,
     required this.trash,
     required this.trash,
   });
   });
@@ -41,6 +42,8 @@ class SystemConfigDto {
 
 
   SystemConfigStorageTemplateDto storageTemplate;
   SystemConfigStorageTemplateDto storageTemplate;
 
 
+  SystemConfigThemeDto theme;
+
   SystemConfigThumbnailDto thumbnail;
   SystemConfigThumbnailDto thumbnail;
 
 
   SystemConfigTrashDto trash;
   SystemConfigTrashDto trash;
@@ -55,6 +58,7 @@ class SystemConfigDto {
      other.passwordLogin == passwordLogin &&
      other.passwordLogin == passwordLogin &&
      other.reverseGeocoding == reverseGeocoding &&
      other.reverseGeocoding == reverseGeocoding &&
      other.storageTemplate == storageTemplate &&
      other.storageTemplate == storageTemplate &&
+     other.theme == theme &&
      other.thumbnail == thumbnail &&
      other.thumbnail == thumbnail &&
      other.trash == trash;
      other.trash == trash;
 
 
@@ -69,11 +73,12 @@ class SystemConfigDto {
     (passwordLogin.hashCode) +
     (passwordLogin.hashCode) +
     (reverseGeocoding.hashCode) +
     (reverseGeocoding.hashCode) +
     (storageTemplate.hashCode) +
     (storageTemplate.hashCode) +
+    (theme.hashCode) +
     (thumbnail.hashCode) +
     (thumbnail.hashCode) +
     (trash.hashCode);
     (trash.hashCode);
 
 
   @override
   @override
-  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, thumbnail=$thumbnail, trash=$trash]';
+  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -85,6 +90,7 @@ class SystemConfigDto {
       json[r'passwordLogin'] = this.passwordLogin;
       json[r'passwordLogin'] = this.passwordLogin;
       json[r'reverseGeocoding'] = this.reverseGeocoding;
       json[r'reverseGeocoding'] = this.reverseGeocoding;
       json[r'storageTemplate'] = this.storageTemplate;
       json[r'storageTemplate'] = this.storageTemplate;
+      json[r'theme'] = this.theme;
       json[r'thumbnail'] = this.thumbnail;
       json[r'thumbnail'] = this.thumbnail;
       json[r'trash'] = this.trash;
       json[r'trash'] = this.trash;
     return json;
     return json;
@@ -106,6 +112,7 @@ class SystemConfigDto {
         passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
         passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
         reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
         reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!,
         storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
         storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
+        theme: SystemConfigThemeDto.fromJson(json[r'theme'])!,
         thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
         thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
         trash: SystemConfigTrashDto.fromJson(json[r'trash'])!,
         trash: SystemConfigTrashDto.fromJson(json[r'trash'])!,
       );
       );
@@ -163,6 +170,7 @@ class SystemConfigDto {
     'passwordLogin',
     'passwordLogin',
     'reverseGeocoding',
     'reverseGeocoding',
     'storageTemplate',
     'storageTemplate',
+    'theme',
     'thumbnail',
     'thumbnail',
     'trash',
     'trash',
   };
   };

+ 98 - 0
mobile/openapi/lib/model/system_config_theme_dto.dart

@@ -0,0 +1,98 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SystemConfigThemeDto {
+  /// Returns a new [SystemConfigThemeDto] instance.
+  SystemConfigThemeDto({
+    required this.customCss,
+  });
+
+  String customCss;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SystemConfigThemeDto &&
+     other.customCss == customCss;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (customCss.hashCode);
+
+  @override
+  String toString() => 'SystemConfigThemeDto[customCss=$customCss]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'customCss'] = this.customCss;
+    return json;
+  }
+
+  /// Returns a new [SystemConfigThemeDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SystemConfigThemeDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SystemConfigThemeDto(
+        customCss: mapValueOfType<String>(json, r'customCss')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SystemConfigThemeDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigThemeDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SystemConfigThemeDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SystemConfigThemeDto> mapFromJson(dynamic json) {
+    final map = <String, SystemConfigThemeDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigThemeDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SystemConfigThemeDto-objects as value to a dart map
+  static Map<String, List<SystemConfigThemeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SystemConfigThemeDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SystemConfigThemeDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'customCss',
+  };
+}
+

+ 5 - 0
mobile/openapi/test/system_config_dto_test.dart

@@ -56,6 +56,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // SystemConfigThemeDto theme
+    test('to test the property `theme`', () async {
+      // TODO
+    });
+
     // SystemConfigThumbnailDto thumbnail
     // SystemConfigThumbnailDto thumbnail
     test('to test the property `thumbnail`', () async {
     test('to test the property `thumbnail`', () async {
       // TODO
       // TODO

+ 27 - 0
mobile/openapi/test/system_config_theme_dto_test.dart

@@ -0,0 +1,27 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SystemConfigThemeDto
+void main() {
+  // final instance = SystemConfigThemeDto();
+
+  group('test SystemConfigThemeDto', () {
+    // String customCss
+    test('to test the property `customCss`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 16 - 1
server/immich-openapi-specs.json

@@ -8060,6 +8060,9 @@
           "storageTemplate": {
           "storageTemplate": {
             "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
             "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
           },
           },
+          "theme": {
+            "$ref": "#/components/schemas/SystemConfigThemeDto"
+          },
           "thumbnail": {
           "thumbnail": {
             "$ref": "#/components/schemas/SystemConfigThumbnailDto"
             "$ref": "#/components/schemas/SystemConfigThumbnailDto"
           },
           },
@@ -8077,7 +8080,8 @@
           "storageTemplate",
           "storageTemplate",
           "job",
           "job",
           "thumbnail",
           "thumbnail",
-          "trash"
+          "trash",
+          "theme"
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
@@ -8404,6 +8408,17 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "SystemConfigThemeDto": {
+        "properties": {
+          "customCss": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "customCss"
+        ],
+        "type": "object"
+      },
       "SystemConfigThumbnailDto": {
       "SystemConfigThumbnailDto": {
         "properties": {
         "properties": {
           "colorspace": {
           "colorspace": {

+ 6 - 0
server/src/domain/system-config/dto/system-config-theme.dto.ts

@@ -0,0 +1,6 @@
+import { IsString } from 'class-validator';
+
+export class SystemConfigThemeDto {
+  @IsString()
+  customCss!: string;
+}

+ 6 - 0
server/src/domain/system-config/dto/system-config.dto.ts

@@ -9,6 +9,7 @@ import { SystemConfigOAuthDto } from './system-config-oauth.dto';
 import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
 import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
 import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto';
 import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto';
 import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
 import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
+import { SystemConfigThemeDto } from './system-config-theme.dto';
 import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto';
 import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto';
 import { SystemConfigTrashDto } from './system-config-trash.dto';
 import { SystemConfigTrashDto } from './system-config-trash.dto';
 
 
@@ -62,6 +63,11 @@ export class SystemConfigDto implements SystemConfig {
   @ValidateNested()
   @ValidateNested()
   @IsObject()
   @IsObject()
   trash!: SystemConfigTrashDto;
   trash!: SystemConfigTrashDto;
+
+  @Type(() => SystemConfigThemeDto)
+  @ValidateNested()
+  @IsObject()
+  theme!: SystemConfigThemeDto;
 }
 }
 
 
 export function mapConfig(config: SystemConfig): SystemConfigDto {
 export function mapConfig(config: SystemConfig): SystemConfigDto {

+ 3 - 0
server/src/domain/system-config/system-config.core.ts

@@ -114,6 +114,9 @@ export const defaults = Object.freeze<SystemConfig>({
     enabled: true,
     enabled: true,
     days: 30,
     days: 30,
   },
   },
+  theme: {
+    customCss: '',
+  },
 });
 });
 
 
 export enum FeatureFlag {
 export enum FeatureFlag {

+ 3 - 0
server/src/domain/system-config/system-config.service.spec.ts

@@ -115,6 +115,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
     enabled: true,
     enabled: true,
     days: 10,
     days: 10,
   },
   },
+  theme: {
+    customCss: '',
+  },
 });
 });
 
 
 describe(SystemConfigService.name, () => {
 describe(SystemConfigService.name, () => {

+ 5 - 0
server/src/infra/entities/system-config.entity.ts

@@ -90,6 +90,8 @@ export enum SystemConfigKey {
 
 
   TRASH_ENABLED = 'trash.enabled',
   TRASH_ENABLED = 'trash.enabled',
   TRASH_DAYS = 'trash.days',
   TRASH_DAYS = 'trash.days',
+
+  THEME_CUSTOM_CSS = 'theme.customCss',
 }
 }
 
 
 export enum TranscodePolicy {
 export enum TranscodePolicy {
@@ -221,4 +223,7 @@ export interface SystemConfig {
     enabled: boolean;
     enabled: boolean;
     days: number;
     days: number;
   };
   };
+  theme: {
+    customCss: string;
+  };
 }
 }

+ 19 - 0
web/src/api/open-api/api.ts

@@ -3307,6 +3307,12 @@ export interface SystemConfigDto {
      * @memberof SystemConfigDto
      * @memberof SystemConfigDto
      */
      */
     'storageTemplate': SystemConfigStorageTemplateDto;
     'storageTemplate': SystemConfigStorageTemplateDto;
+    /**
+     * 
+     * @type {SystemConfigThemeDto}
+     * @memberof SystemConfigDto
+     */
+    'theme': SystemConfigThemeDto;
     /**
     /**
      * 
      * 
      * @type {SystemConfigThumbnailDto}
      * @type {SystemConfigThumbnailDto}
@@ -3741,6 +3747,19 @@ export interface SystemConfigTemplateStorageOptionDto {
      */
      */
     'yearOptions': Array<string>;
     'yearOptions': Array<string>;
 }
 }
+/**
+ * 
+ * @export
+ * @interface SystemConfigThemeDto
+ */
+export interface SystemConfigThemeDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigThemeDto
+     */
+    'customCss': string;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export

+ 53 - 0
web/src/lib/components/admin-page/settings/setting-textarea.svelte

@@ -0,0 +1,53 @@
+<script lang="ts">
+  import { quintOut } from 'svelte/easing';
+  import { fly } from 'svelte/transition';
+
+  export let value: string;
+  export let label = '';
+  export let desc = '';
+  export let required = false;
+  export let disabled = false;
+  export let isEdited = false;
+
+  const handleInput = (e: Event) => {
+    value = (e.target as HTMLInputElement).value;
+  };
+</script>
+
+<div class="mb-4 w-full">
+  <div class={`flex h-[26px] place-items-center gap-1`}>
+    <label class={`immich-form-label text-sm`} for={label}>{label}</label>
+    {#if required}
+      <div class="text-red-400">*</div>
+    {/if}
+
+    {#if isEdited}
+      <div
+        transition:fly={{ x: 10, duration: 200, easing: quintOut }}
+        class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
+      >
+        Unsaved change
+      </div>
+    {/if}
+  </div>
+
+  {#if desc}
+    <p class="immich-form-label pb-2 text-sm" id="{label}-desc">
+      {desc}
+    </p>
+  {:else}
+    <slot name="desc" />
+  {/if}
+
+  <textarea
+    class="immich-form-input w-full pb-2"
+    aria-describedby={desc ? `${label}-desc` : undefined}
+    aria-labelledby="{label}-label"
+    id={label}
+    name={label}
+    {required}
+    {value}
+    on:input={handleInput}
+    {disabled}
+  />
+</div>

+ 98 - 0
web/src/lib/components/admin-page/settings/theme/theme-settings.svelte

@@ -0,0 +1,98 @@
+<script lang="ts">
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
+  import { api, SystemConfigThemeDto } from '@api';
+  import { isEqual } from 'lodash-es';
+  import { fade } from 'svelte/transition';
+  import SettingButtonsRow from '../setting-buttons-row.svelte';
+  import SettingTextarea from '../setting-textarea.svelte';
+
+  export let themeConfig: SystemConfigThemeDto; // this is the config that is being edited
+  export let disabled = false;
+
+  let savedConfig: SystemConfigThemeDto;
+  let defaultConfig: SystemConfigThemeDto;
+
+  async function getConfigs() {
+    [savedConfig, defaultConfig] = await Promise.all([
+      api.systemConfigApi.getConfig().then((res) => res.data.theme),
+      api.systemConfigApi.getDefaults().then((res) => res.data.theme),
+    ]);
+  }
+
+  async function saveSetting() {
+    try {
+      const { data: current } = await api.systemConfigApi.getConfig();
+
+      const { data: updated } = await api.systemConfigApi.updateConfig({
+        systemConfigDto: {
+          ...current,
+          theme: themeConfig,
+        },
+      });
+
+      themeConfig = { ...updated.theme };
+      savedConfig = { ...updated.theme };
+
+      notificationController.show({ message: 'Theme saved', type: NotificationType.Info });
+    } catch (error) {
+      handleError(error, 'Unable to save settings');
+    }
+  }
+
+  async function reset() {
+    const { data: resetConfig } = await api.systemConfigApi.getConfig();
+
+    themeConfig = { ...resetConfig.theme };
+    savedConfig = { ...resetConfig.theme };
+
+    notificationController.show({
+      message: 'Reset theme to the recent saved theme',
+      type: NotificationType.Info,
+    });
+  }
+
+  async function resetToDefault() {
+    const { data: configs } = await api.systemConfigApi.getDefaults();
+
+    themeConfig = { ...configs.theme };
+    defaultConfig = { ...configs.theme };
+
+    notificationController.show({
+      message: 'Reset theme to default',
+      type: NotificationType.Info,
+    });
+  }
+</script>
+
+<div>
+  {#await getConfigs() then}
+    <div in:fade={{ duration: 500 }}>
+      <form autocomplete="off" on:submit|preventDefault>
+        <div class="ml-4 mt-4 flex flex-col gap-4">
+          <div class="ml-4">
+            <SettingTextarea
+              {disabled}
+              label="Custom CSS"
+              desc="Cascading Style Sheets allow the design of Immich to be customized."
+              bind:value={themeConfig.customCss}
+              required={true}
+              isEdited={themeConfig.customCss !== savedConfig.customCss}
+            />
+
+            <SettingButtonsRow
+              on:reset={reset}
+              on:save={saveSetting}
+              on:reset-to-default={resetToDefault}
+              showResetToDefault={!isEqual(savedConfig, defaultConfig)}
+              {disabled}
+            />
+          </div>
+        </div>
+      </form>
+    </div>
+  {/await}
+</div>

+ 1 - 0
web/src/routes/+layout.svelte

@@ -67,6 +67,7 @@
 <svelte:head>
 <svelte:head>
   <title>{$page.data.meta?.title || 'Web'} - Immich</title>
   <title>{$page.data.meta?.title || 'Web'} - Immich</title>
   <link rel="manifest" href="/manifest.json" />
   <link rel="manifest" href="/manifest.json" />
+  <link rel="stylesheet" href="/custom.css" />
   <meta name="theme-color" content="currentColor" />
   <meta name="theme-color" content="currentColor" />
   <FaviconHeader />
   <FaviconHeader />
   <AppleHeader />
   <AppleHeader />

+ 5 - 0
web/src/routes/admin/system-settings/+page.svelte

@@ -10,6 +10,7 @@
   import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
   import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
   import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
   import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
   import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte';
   import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte';
+  import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
   import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
   import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import { downloadManager } from '$lib/stores/download';
   import { downloadManager } from '$lib/stores/download';
@@ -96,6 +97,10 @@
         />
         />
       </SettingAccordion>
       </SettingAccordion>
 
 
+      <SettingAccordion title="Theme Settings" subtitle="Manage customization of the Immich web interface">
+        <ThemeSettings disabled={$featureFlags.configFile} themeConfig={configs.theme} />
+      </SettingAccordion>
+
       <SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
       <SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
         <ThumbnailSettings disabled={$featureFlags.configFile} thumbnailConfig={configs.thumbnail} />
         <ThumbnailSettings disabled={$featureFlags.configFile} thumbnailConfig={configs.thumbnail} />
       </SettingAccordion>
       </SettingAccordion>

+ 9 - 0
web/src/routes/custom.css/+server.ts

@@ -0,0 +1,9 @@
+import { RequestHandler, text } from '@sveltejs/kit';
+export const GET = (async ({ locals: { api } }) => {
+  const { customCss } = await api.systemConfigApi.getConfig().then((res) => res.data.theme);
+  return text(customCss, {
+    headers: {
+      'Content-Type': 'text/css',
+    },
+  });
+}) satisfies RequestHandler;