-
Notifications
You must be signed in to change notification settings - Fork 85
feat: add generic custom data source widget #610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| using ImmichFrame.Core.Models; | ||
|
|
||
| public interface ICustomWidgetService | ||
| { | ||
| Task<List<CustomWidgetData>> GetCustomWidgetData(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| namespace ImmichFrame.Core.Models; | ||
|
|
||
| public class CustomWidgetData | ||
| { | ||
| public string Title { get; set; } = string.Empty; | ||
| public List<CustomWidgetItem> Items { get; set; } = new(); | ||
| } | ||
|
|
||
| public class CustomWidgetItem | ||
| { | ||
| public string Label { get; set; } = string.Empty; | ||
| public string Value { get; set; } = string.Empty; | ||
| public string? Secondary { get; set; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| namespace ImmichFrame.Core.Models; | ||
|
|
||
| public class CustomWidgetSourceConfig | ||
| { | ||
| public string Url { get; set; } = string.Empty; | ||
| public int RefreshMinutes { get; set; } = 10; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| using System.Net.Http.Json; | ||
| using ImmichFrame.Core.Interfaces; | ||
| using ImmichFrame.Core.Models; | ||
| using Microsoft.Extensions.Caching.Memory; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Namespace is missing |
||
| public class CustomWidgetService : ICustomWidgetService | ||
| { | ||
| private readonly IGeneralSettings _settings; | ||
| private readonly ILogger<CustomWidgetService> _logger; | ||
| private readonly IHttpClientFactory _httpClientFactory; | ||
| private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you use the |
||
|
|
||
| public CustomWidgetService(IGeneralSettings settings, ILogger<CustomWidgetService> logger, IHttpClientFactory httpClientFactory) | ||
| { | ||
| _settings = settings; | ||
| _logger = logger; | ||
| _httpClientFactory = httpClientFactory; | ||
| } | ||
|
|
||
| public async Task<List<CustomWidgetData>> GetCustomWidgetData() | ||
| { | ||
| var results = new List<CustomWidgetData>(); | ||
|
|
||
| foreach (var source in _settings.CustomWidgetSources) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(source.Url)) | ||
| continue; | ||
|
|
||
| try | ||
| { | ||
| var cacheKey = $"customwidget_{source.Url}"; | ||
| var data = await _cache.GetOrCreateAsync(cacheKey, async entry => | ||
| { | ||
| entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(source.RefreshMinutes); | ||
| return await FetchWidgetData(source.Url); | ||
| }); | ||
|
|
||
| if (data != null) | ||
| results.Add(data); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Failed to fetch custom widget data from '{Url}'", source.Url); | ||
| } | ||
| } | ||
|
|
||
| return results; | ||
| } | ||
|
|
||
| private async Task<CustomWidgetData?> FetchWidgetData(string url) | ||
| { | ||
| var client = _httpClientFactory.CreateClient(); | ||
|
|
||
| try | ||
| { | ||
| var response = await client.GetAsync(url); | ||
|
|
||
| if (!response.IsSuccessStatusCode) | ||
| { | ||
| _logger.LogError("Custom widget source '{Url}' returned status {StatusCode}", url, response.StatusCode); | ||
| return null; | ||
| } | ||
|
|
||
| var data = await response.Content.ReadFromJsonAsync<CustomWidgetData>(); | ||
| return data; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error fetching custom widget data from '{Url}'", url); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| using ImmichFrame.Core.Interfaces; | ||
| using ImmichFrame.Core.Models; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.AspNetCore.Mvc; | ||
|
|
||
| namespace ImmichFrame.WebApi.Controllers | ||
| { | ||
| [ApiController] | ||
| [Route("api/[controller]")] | ||
| [Authorize] | ||
| public class CustomWidgetController : ControllerBase | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd rename the controller to something like 'WidgetController' - Maybe in the Future there could be more Widgets maybe? |
||
| { | ||
| private readonly ILogger<AssetController> _logger; | ||
| private readonly ICustomWidgetService _customWidgetService; | ||
|
|
||
| public CustomWidgetController(ILogger<AssetController> logger, ICustomWidgetService customWidgetService) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrong Logger-Type (AssetController) |
||
| { | ||
| _logger = logger; | ||
| _customWidgetService = customWidgetService; | ||
| } | ||
|
|
||
| [HttpGet(Name = "GetCustomWidgetData")] | ||
| public async Task<List<CustomWidgetData>> GetCustomWidgetData(string clientIdentifier = "") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Naming here is ok |
||
| { | ||
| var sanitizedClientIdentifier = clientIdentifier.SanitizeString(); | ||
| _logger.LogDebug("Custom widget data requested by '{sanitizedClientIdentifier}'", sanitizedClientIdentifier); | ||
| return await _customWidgetService.GetCustomWidgetData(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| <script lang="ts"> | ||
| import * as api from '$lib/index'; | ||
| import { onMount } from 'svelte'; | ||
| import { configStore } from '$lib/stores/config.store'; | ||
| import { clientIdentifierStore } from '$lib/stores/persist.store'; | ||
|
|
||
| api.init(); | ||
|
|
||
| let widgetData: api.CustomWidgetData[] = $state([]); | ||
|
|
||
| const positionClasses: Record<string, string> = { | ||
| 'top-left': 'top-0 left-0', | ||
| 'top-right': 'top-0 right-0', | ||
| 'bottom-left': 'bottom-0 left-0', | ||
| 'bottom-right': 'bottom-0 right-0' | ||
| }; | ||
|
|
||
| let position = $derived( | ||
| positionClasses[$configStore.customWidgetPosition ?? 'top-left'] ?? positionClasses['top-left'] | ||
| ); | ||
|
|
||
| onMount(() => { | ||
| fetchWidgetData(); | ||
| const interval = setInterval(() => fetchWidgetData(), 60 * 1000); | ||
|
|
||
| return () => { | ||
| clearInterval(interval); | ||
| }; | ||
| }); | ||
|
|
||
| async function fetchWidgetData() { | ||
| try { | ||
| let response = await api.getCustomWidgetData({ | ||
| clientIdentifier: $clientIdentifierStore | ||
| }); | ||
| if (response.status === 200) { | ||
| widgetData = response.data; | ||
| } | ||
| } catch (err) { | ||
| console.error('Error fetching custom widget data:', err); | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| {#if widgetData.length > 0} | ||
| <div | ||
| class="fixed {position} w-auto z-10 text-primary m-5 max-w-[20%] hidden lg:block md:min-w-[10%]" | ||
| > | ||
| {#each widgetData as widget} | ||
| <div class="bg-gray-600 bg-opacity-90 mb-2 rounded-md p-3"> | ||
| {#if widget.title} | ||
| <p class="text-xs font-bold mb-1">{widget.title}</p> | ||
| {/if} | ||
| {#each widget.items ?? [] as item} | ||
| <div class="flex justify-between items-baseline gap-2 py-0.5"> | ||
| <span class="text-xs opacity-80">{item.label}</span> | ||
| <span class="text-sm font-medium">{item.value}</span> | ||
| </div> | ||
| {#if item.secondary} | ||
| <p class="text-xs opacity-60 text-right -mt-0.5">{item.secondary}</p> | ||
| {/if} | ||
| {/each} | ||
| </div> | ||
| {/each} | ||
| </div> | ||
| {/if} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Namespace is missing