Compare commits

...

2 commits

Author SHA1 Message Date
2c6ab134f5
render the avatar upload status
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m18s
2026-09-04 20:28:54 +01:00
343538a7ea
setup the avatar 2026-09-04 20:25:06 +01:00
14 changed files with 378 additions and 92 deletions

View file

@ -67,7 +67,7 @@ public class BearerScopeTests
Scopes = userScopes,
RedirectUri = "postit://callback",
},
BusinessApiUrl = "https://example.invalid/api/v1/",
ApiUrl = "https://example.invalid/api/v1/",
};
var tokensPath = Path.Combine(
@ -266,7 +266,7 @@ public class BearerScopeTests
// private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);

View file

@ -95,7 +95,7 @@ public class PostAclDialogTests
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
@ -123,8 +123,8 @@ public class PostAclDialogTests
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
var circleClient = new CircleApiClient(api, settings.ApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);

View file

@ -89,7 +89,7 @@ public class SettingsLoadTests
settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri;
settings.BusinessApiUrl = flip
settings.ApiUrl = flip
? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/";

View file

@ -58,7 +58,7 @@ public class YavscApiClientTests
// calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
reloaded.Http.BaseAddress = new Uri(settings.ApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -118,7 +118,7 @@ public class YavscApiClientTests
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
},
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
ApiUrl = "https://127.0.0.1:5003/api/v1",
};
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -162,7 +162,7 @@ public class YavscApiClientTests
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
},
BusinessApiUrl = apiBaseUrl
ApiUrl = apiBaseUrl
};
private static async Task<YavscApiClient> LoginAndPersistAsync(
@ -175,7 +175,7 @@ public class YavscApiClientTests
// directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
// Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper.

View file

@ -24,8 +24,8 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl);
var activityClient = new ActivityApiClient(api, settings.ApiUrl);
var billingClient = new BillingApiClient(api, settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
// Vues

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@ -351,6 +352,77 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_store.Save(_tokens);
}
/// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image
/// content type before persisting it.
/// </summary>
public async Task<string> SetAvatarAsync(
Stream imageStream,
string fileName,
string? contentType = null,
CancellationToken ct = default)
{
if (imageStream is null)
throw new ArgumentNullException(nameof(imageStream));
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentException("A file name is required.", nameof(fileName));
var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var attemptUpload = async () =>
{
if (imageStream.CanSeek)
imageStream.Position = 0;
using var content = new MultipartFormDataContent();
using var fileContent = new StreamContent(imageStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content,
};
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(payload))
return "Avatar mis à jour.";
try
{
using var json = JsonDocument.Parse(payload);
if (json.RootElement.TryGetProperty("message", out var msgEl))
{
var message = msgEl.GetString();
if (!string.IsNullOrWhiteSpace(message))
return message;
}
}
catch (JsonException)
{
// Keep a user-friendly fallback when the API payload is not JSON.
}
return "Avatar mis à jour.";
}
public async Task LogoutAsync()
{
_store.Clear();

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
namespace PostIt.ViewModels.Commands;
@ -21,7 +22,6 @@ public partial class BrushViewModel : RdvViewModel
[ObservableProperty]
public partial HairPrestationDto? SelectedPrestation { get; set; }
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{

View file

@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
@ -45,7 +45,7 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
partial void OnApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty();
/// <summary>
@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
? settings.BlogsApiUrl
: legacyApiUrl ?? this.BlogsApiUrl;
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
? settings.BusinessApiUrl
: this.BusinessApiUrl;
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
? settings.ApiUrl
: this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null))
{
@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase
};
this.DarkMode = false;
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty;
}

View file

@ -33,13 +33,11 @@
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsReason}" />
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsReason}" />
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
@ -60,14 +58,12 @@
<TextBlock Grid.Row="10"
Text="Prestation"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsSinglePrestation}" />
Margin="0,0,12,8" />
<ComboBox Grid.Row="10"
Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsSinglePrestation}">
Margin="0,0,0,8">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="2">
@ -78,39 +74,14 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="11"
Text="Prestations"
VerticalAlignment="Top"
Margin="0,0,12,8"
IsVisible="{Binding ShowsMultiplePrestations}" />
<ListBox Grid.Row="11"
Grid.Column="1"
ItemsSource="{Binding MultiPrestations}"
IsVisible="{Binding ShowsMultiplePrestations}"
MaxHeight="180"
Margin="0,0,0,8">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Margin="0,0,0,8">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</CheckBox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
Margin="0,0,12,8" />
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
Margin="0,0,0,8" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"

View file

@ -29,13 +29,11 @@
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsReason}" />
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsReason}" />
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
@ -57,13 +55,11 @@
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
Margin="0,0,12,8" />
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
Margin="0,0,0,8" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"

View file

@ -39,25 +39,41 @@
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="Business API URL"/>
<TextBlock Grid.Row="8" Text="API URL"/>
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
Text="{Binding ApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="10" Text="Dark mode"/>
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Grid.Row="12" Content="Sauver"
HorizontalAlignment="Right"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
<StackPanel Grid.Row="12" Spacing="8">
<TextBlock Text="Avatar: PNG, JPG/JPEG, WEBP, GIF. Taille max 2 MB."
Opacity="0.8"
TextWrapping="Wrap" />
<TextBlock x:Name="AvatarUploadStatusText"
Text=""
TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="ChooseAvatarButton"
Content="Choisir l'avatar"
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>
</Grid>
</ContentPage>

View file

@ -1,12 +1,116 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
namespace PostIt.Views;
public partial class SettingsPage: ContentPage
{
private const int MaxAvatarSizeMegabytes = 2;
private const string AcceptedAvatarFormats = "PNG, JPG/JPEG, WEBP, GIF";
private int _avatarStatusVersion;
public SettingsPage()
{
InitializeComponent();
}
}
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var statusVersion = Interlocked.Increment(ref _avatarStatusVersion);
ChooseAvatarButton.IsEnabled = false;
SetAvatarStatus("Sélection d'un fichier avatar...", Brushes.Gray);
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null)
{
SetAvatarStatus("Impossible d'accéder à la fenêtre active.", Brushes.IndianRed);
ChooseAvatarButton.IsEnabled = true;
return;
}
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir un avatar",
AllowMultiple = false,
FileTypeFilter = new[]
{
new FilePickerFileType("Images")
{
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif" }
}
}
});
var file = files.FirstOrDefault();
if (file is null)
{
SetAvatarStatus("Upload annulé.", Brushes.Gray);
ChooseAvatarButton.IsEnabled = true;
return;
}
try
{
SetAvatarStatus("Upload avatar en cours...", Brushes.Gray);
await using var stream = await file.OpenReadAsync();
var api = ((App)App.Current!).ServiceProvider!.GetRequiredService<YavscApiClient>();
var message = await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
SetAvatarStatus(string.IsNullOrWhiteSpace(message)
? "Avatar mis à jour."
: message, Brushes.ForestGreen);
_ = ClearSuccessStatusLaterAsync(statusVersion);
}
catch (Exception ex)
{
SetAvatarStatus($"Échec upload avatar: {ex.Message}", Brushes.IndianRed);
Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.Message}");
}
finally
{
ChooseAvatarButton.IsEnabled = true;
}
}
private async Task ClearSuccessStatusLaterAsync(int statusVersion)
{
await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(true);
if (statusVersion != _avatarStatusVersion)
return;
if (AvatarUploadStatusText.Foreground == Brushes.ForestGreen)
{
SetAvatarStatus($"Avatar prêt. Formats supportés: {AcceptedAvatarFormats}. Taille max: {MaxAvatarSizeMegabytes} MB.", Brushes.Gray);
}
}
private void SetAvatarStatus(string message, IBrush color)
{
AvatarUploadStatusText.Foreground = color;
AvatarUploadStatusText.Text = message;
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
_ => "application/octet-stream"
};
}
}

View file

@ -2,6 +2,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using ImageMagick;
using Yavsc.Models;
using Yavsc.Api.Helpers;
@ -14,6 +16,17 @@ namespace Yavsc.WebApi.Controllers
[Authorize("ApiScope")]
public class ApiAccountController : Controller
{
private const long MaxAvatarSizeBytes = 2 * 1024 * 1024;
private static readonly string[] AcceptedAvatarMimeTypes =
[
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
];
readonly ApplicationDbContext _dbContext;
private readonly ILogger _logger;
@ -70,14 +83,93 @@ namespace Yavsc.WebApi.Controllers
public async Task<IActionResult> SetAvatar()
{
var user = await GetUserData(User.GetUserId());
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
if (!Request.Form.Files[0].ContentType.StartsWith("image/png"))
return new BadRequestResult();
if (!Request.HasFormContentType)
{
return BadRequest(new
{
status = "invalid_request",
message = "A multipart/form-data request is required.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
var info = user.ReceiveAvatar(Request.Form.Files[0]);
await _dbContext.SaveChangesAsync();
return Ok(info);
if (Request.Form.Files.Count != 1)
{
return BadRequest(new
{
status = "invalid_file_count",
message = "Exactly one file is required.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
var avatarFile = Request.Form.Files[0];
if (avatarFile.Length <= 0)
{
return BadRequest(new
{
status = "empty_file",
message = "The uploaded file is empty.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
if (avatarFile.Length > MaxAvatarSizeBytes)
{
return BadRequest(new
{
status = "file_too_large",
message = "Avatar is too large.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
if (!AcceptedAvatarMimeTypes.Any(m => string.Equals(m, avatarFile.ContentType, StringComparison.OrdinalIgnoreCase)))
{
return StatusCode(StatusCodes.Status415UnsupportedMediaType, new
{
status = "unsupported_media_type",
message = "Unsupported image format.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
try
{
var info = user.ReceiveAvatar(avatarFile);
await _dbContext.SaveChangesAsync();
return Ok(new
{
status = "uploaded",
message = "Avatar uploaded successfully.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
avatar = info,
});
}
catch (MagickException ex)
{
_logger.LogWarning(ex, "Avatar upload failed: invalid image data for user {UserId}", user.Id);
return BadRequest(new
{
status = "invalid_image_data",
message = "Image content could not be decoded.",
acceptedMimeTypes = AcceptedAvatarMimeTypes,
maxFileSizeBytes = MaxAvatarSizeBytes,
outputFormat = "image/png",
});
}
}
[HttpGet("identity")]

View file

@ -4,6 +4,9 @@
var apiBaseUrl = string.IsNullOrWhiteSpace(SiteSettings.Value.ApiUrl)
? string.Empty
: SiteSettings.Value.ApiUrl.TrimEnd('/');
const int maxAvatarSizeMb = 2;
var acceptedAvatarFormats = new[] { "png", "jpg", "jpeg", "webp", "gif", "bmp", "tiff" };
var acceptedAvatarMimeTypes = "image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff";
var setAvatarUrl = string.IsNullOrEmpty(apiBaseUrl)
? "/api/v1/account/set-avatar"
: $"{apiBaseUrl}/api/v1/account/set-avatar";
@ -21,31 +24,63 @@
(() => {
const bearer = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(accessToken));
const uploadUrlBase = "@setAvatarUrl";
const statusNode = document.getElementById('avatar-upload-status');
const uploadUrl = bearer
? uploadUrlBase + (uploadUrlBase.indexOf('?') >= 0 ? '&' : '?') + 'access_token=' + encodeURIComponent(bearer)
: uploadUrlBase;
function setStatus(message, isError) {
if (!statusNode) return;
statusNode.textContent = message || '';
statusNode.style.color = isError ? '#a40000' : '#0a5f1f';
}
Dropzone.options.postavatar = {
maxFilesize: 2, // MB (an avatar)
maxFilesize: @maxAvatarSizeMb, // MB
acceptedFiles: "@acceptedAvatarMimeTypes",
maxFiles: 1,
addRemoveLinks: true,
autoProcessQueue: true,
accept: function(file, done) {
if (file.name == "justinbieber.jpg") {
done("Naha, you don't.");
}
else { done(); }
},
url: uploadUrl,
init: function() {
this.on('sending', function(file, xhr) {
setStatus('Upload en cours...', false);
if (bearer) {
xhr.setRequestHeader('Authorization', 'Bearer ' + bearer);
}
});
this.on('success', function(file, response) {
const msg = response && response.message
? response.message
: 'Avatar mis a jour.';
setStatus(msg + ' Format de sortie: PNG.', false);
});
this.on('error', function(file, errorMessage, xhr) {
let message = 'Echec de l\'upload avatar.';
if (xhr && xhr.responseText) {
try {
const payload = JSON.parse(xhr.responseText);
if (payload && payload.message) {
message = payload.message;
}
} catch (_) {
}
} else if (typeof errorMessage === 'string' && errorMessage.length > 0) {
message = errorMessage;
}
setStatus(message + ' Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille max: @maxAvatarSizeMb MB.', true);
});
}
};
})();
</script>
}
<img src="@previewAvatarSrc">
<p>Formats supportes: @string.Join(", ", acceptedAvatarFormats). Taille maximale: @maxAvatarSizeMb MB. L'image finale est en PNG.</p>
<p id="avatar-upload-status" aria-live="polite"></p>
<form id="postavatar" action="@setAvatarUrl" class="dropzone" method="post" enctype="multipart/form-data">
<div class="fallback">