Country pulldown

alphons <alphons@heijden.com> 20 Jul 2026, 22:20
463cb06bf595da2192fa66876e5d3c4cbe1a79da
34 files changed
  • src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.Designer.cs
  • src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.cs
  • src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs
  • src/GitServer/Localization/ar.json
  • src/GitServer/Localization/de.json
  • src/GitServer/Localization/en.json
  • src/GitServer/Localization/es.json
  • src/GitServer/Localization/fr.json
  • src/GitServer/Localization/ja.json
  • src/GitServer/Localization/nl.json
  • src/GitServer/Localization/pt.json
  • src/GitServer/Localization/ru.json
  • src/GitServer/Localization/zh.json
  • src/GitServer/Models/AppUser.cs
  • src/GitServer/Program.cs
  • src/GitServer/Services/GitProcessService.cs
  • src/GitServer/Services/LocalizationService.cs
  • src/GitServer/Services/TimeZoneService.cs
  • src/GitServer/wwwroot/Admin/Users.cshtml
  • src/GitServer/wwwroot/Auth/Login.cshtml.cs
  • src/GitServer/wwwroot/Explore.cshtml
  • src/GitServer/wwwroot/ExploreUsers.cshtml
  • src/GitServer/wwwroot/Index.cshtml
  • src/GitServer/wwwroot/Repo/Commit.cshtml
  • src/GitServer/wwwroot/Repo/Commits.cshtml
  • src/GitServer/wwwroot/Repo/Commits.cshtml.cs
  • src/GitServer/wwwroot/Repo/Issues/Detail.cshtml
  • src/GitServer/wwwroot/Repo/Issues/Index.cshtml
  • src/GitServer/wwwroot/Shared/_ProfileHeader.cshtml
  • src/GitServer/wwwroot/User/Profile.cshtml
  • src/GitServer/wwwroot/User/Settings.cshtml
  • src/GitServer/wwwroot/User/Settings.cshtml.cs
  • src/GitServer/wwwroot/_ViewImports.cshtml
  • src/GitServer/wwwroot/css/repo.css
diff --git a/src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.Designer.cs b/src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.Designer.cs
new file mode 100644
index 0000000..3c98e1e
--- /dev/null
+++ b/src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.Designer.cs
@@ -0,0 +1,536 @@
+// <auto-generated />
+using System;
+using GitServer.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace GitServer.Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260720194232_AddUserTimeZone")]
+ partial class AddUserTimeZone
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("GitServer.Models.AppUser", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AvatarUrl")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Bio")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CompanyName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Country")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DisplayName")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsAdmin")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastLoginAt")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTimeOffset?>("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("PreferredLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("TimeZoneId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("GitServer.Models.BlockedEmailPattern", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Pattern")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("BlockedEmailPatterns");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Issue", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AuthorId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Body")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsClosed")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("RepositoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Title")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.HasIndex("RepositoryId");
+
+ b.ToTable("Issues");
+ });
+
+ modelBuilder.Entity("GitServer.Models.IssueComment", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AuthorId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Body")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("IssueId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorId");
+
+ b.HasIndex("IssueId");
+
+ b.ToTable("IssueComments");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Repository", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DefaultBranch")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Description")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsPrivate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OwnerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Repositories");
+ });
+
+ modelBuilder.Entity("GitServer.Models.RepositoryAccess", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Level")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("RepositoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("RepositoryId", "UserId")
+ .IsUnique();
+
+ b.ToTable("RepositoryAccesses");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("RoleId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ClaimType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ClaimValue")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
+ {
+ b.Property<string>("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderDisplayName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
+ {
+ b.Property<string>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("RoleId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
+ {
+ b.Property<string>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("LoginProvider")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("GitServer.Models.Issue", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.Repository", "Repository")
+ .WithMany("Issues")
+ .HasForeignKey("RepositoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+
+ b.Navigation("Repository");
+ });
+
+ modelBuilder.Entity("GitServer.Models.IssueComment", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.Issue", "Issue")
+ .WithMany("Comments")
+ .HasForeignKey("IssueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Author");
+
+ b.Navigation("Issue");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Repository", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", "Owner")
+ .WithMany("Repositories")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("GitServer.Models.RepositoryAccess", b =>
+ {
+ b.HasOne("GitServer.Models.Repository", "Repository")
+ .WithMany("Accesses")
+ .HasForeignKey("RepositoryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.AppUser", "User")
+ .WithMany("RepositoryAccesses")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Repository");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("GitServer.Models.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
+ {
+ b.HasOne("GitServer.Models.AppUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("GitServer.Models.AppUser", b =>
+ {
+ b.Navigation("Repositories");
+
+ b.Navigation("RepositoryAccesses");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Issue", b =>
+ {
+ b.Navigation("Comments");
+ });
+
+ modelBuilder.Entity("GitServer.Models.Repository", b =>
+ {
+ b.Navigation("Accesses");
+
+ b.Navigation("Issues");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.cs b/src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.cs
new file mode 100644
index 0000000..3b63236
--- /dev/null
+++ b/src/GitServer/Data/Migrations/20260720194232_AddUserTimeZone.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace GitServer.Data.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddUserTimeZone : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<string>(
+ name: "TimeZoneId",
+ table: "AspNetUsers",
+ type: "TEXT",
+ nullable: true);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "TimeZoneId",
+ table: "AspNetUsers");
+ }
+ }
+}
diff --git a/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs b/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs
index 6305948..7676251 100644
--- a/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/GitServer/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -90,6 +90,9 @@ namespace GitServer.Data.Migrations
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
+ b.Property<string>("TimeZoneId")
+ .HasColumnType("TEXT");
+
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
diff --git a/src/GitServer/Localization/ar.json b/src/GitServer/Localization/ar.json
index 4ae1b11..4a5e6a7 100644
--- a/src/GitServer/Localization/ar.json
+++ b/src/GitServer/Localization/ar.json
@@ -158,10 +158,14 @@
"bio_label": "نبذة شخصية",
"avatar_url_label": "رابط الصورة الرمزية",
"country_label": "الدولة",
+ "country_default": "بلا",
"company_name_label": "اسم الشركة",
"preferred_language_label": "اللغة المفضلة",
"preferred_language_default": "افتراضي (الإنجليزية)",
"preferred_language_hint": "يتم اختيارها تلقائيًا عند تسجيل الدخول.",
+ "timezone_label": "المنطقة الزمنية",
+ "timezone_default": "افتراضي (UTC)",
+ "timezone_hint": "تُستخدم لتحويل التواريخ والأوقات على الموقع إلى توقيتك المحلي.",
"settings_save_profile": "حفظ الملف الشخصي",
"settings_change_password": "تغيير كلمة المرور",
"settings_set_password": "تعيين كلمة المرور",
diff --git a/src/GitServer/Localization/de.json b/src/GitServer/Localization/de.json
index 8fbde6e..65fea7f 100644
--- a/src/GitServer/Localization/de.json
+++ b/src/GitServer/Localization/de.json
@@ -158,10 +158,14 @@
"bio_label": "Biografie",
"avatar_url_label": "Avatar-URL",
"country_label": "Land",
+ "country_default": "Keine",
"company_name_label": "Firma",
"preferred_language_label": "Bevorzugte Sprache",
"preferred_language_default": "Standard (Englisch)",
"preferred_language_hint": "Wird beim Anmelden automatisch ausgewählt.",
+ "timezone_label": "Zeitzone",
+ "timezone_default": "Standard (UTC)",
+ "timezone_hint": "Wird verwendet, um Daten und Zeiten auf der Website in deine lokale Zeit umzurechnen.",
"settings_save_profile": "Profil speichern",
"settings_change_password": "Passwort ändern",
"settings_set_password": "Passwort festlegen",
diff --git a/src/GitServer/Localization/en.json b/src/GitServer/Localization/en.json
index e7b3a03..8950ddb 100644
--- a/src/GitServer/Localization/en.json
+++ b/src/GitServer/Localization/en.json
@@ -159,10 +159,14 @@
"bio_label": "Bio",
"avatar_url_label": "Avatar URL",
"country_label": "Country",
+ "country_default": "None",
"company_name_label": "Company",
"preferred_language_label": "Preferred language",
"preferred_language_default": "Default (English)",
"preferred_language_hint": "Automatically selected when you sign in.",
+ "timezone_label": "Time zone",
+ "timezone_default": "Default (UTC)",
+ "timezone_hint": "Used to convert dates and times on the site to your local time.",
"settings_save_profile": "Save profile",
"settings_change_password": "Change password",
"settings_set_password": "Set password",
diff --git a/src/GitServer/Localization/es.json b/src/GitServer/Localization/es.json
index aa9db48..ed0d512 100644
--- a/src/GitServer/Localization/es.json
+++ b/src/GitServer/Localization/es.json
@@ -158,10 +158,14 @@
"bio_label": "Biografía",
"avatar_url_label": "URL del avatar",
"country_label": "País",
+ "country_default": "Ninguno",
"company_name_label": "Empresa",
"preferred_language_label": "Idioma preferido",
"preferred_language_default": "Predeterminado (inglés)",
"preferred_language_hint": "Se selecciona automáticamente al iniciar sesión.",
+ "timezone_label": "Zona horaria",
+ "timezone_default": "Predeterminada (UTC)",
+ "timezone_hint": "Se usa para convertir las fechas y horas del sitio a tu hora local.",
"settings_save_profile": "Guardar perfil",
"settings_change_password": "Cambiar contraseña",
"settings_set_password": "Establecer contraseña",
diff --git a/src/GitServer/Localization/fr.json b/src/GitServer/Localization/fr.json
index ce4a45d..86d1839 100644
--- a/src/GitServer/Localization/fr.json
+++ b/src/GitServer/Localization/fr.json
@@ -158,10 +158,14 @@
"bio_label": "Biographie",
"avatar_url_label": "URL de l'avatar",
"country_label": "Pays",
+ "country_default": "Aucun",
"company_name_label": "Société",
"preferred_language_label": "Langue préférée",
"preferred_language_default": "Par défaut (anglais)",
"preferred_language_hint": "Sélectionnée automatiquement lors de la connexion.",
+ "timezone_label": "Fuseau horaire",
+ "timezone_default": "Par défaut (UTC)",
+ "timezone_hint": "Utilisé pour convertir les dates et heures du site vers votre heure locale.",
"settings_save_profile": "Enregistrer le profil",
"settings_change_password": "Changer le mot de passe",
"settings_set_password": "Définir le mot de passe",
diff --git a/src/GitServer/Localization/ja.json b/src/GitServer/Localization/ja.json
index 0868180..ac790ea 100644
--- a/src/GitServer/Localization/ja.json
+++ b/src/GitServer/Localization/ja.json
@@ -158,10 +158,14 @@
"bio_label": "自己紹介",
"avatar_url_label": "アバター URL",
"country_label": "国",
+ "country_default": "なし",
"company_name_label": "会社名",
"preferred_language_label": "優先言語",
"preferred_language_default": "デフォルト(英語)",
"preferred_language_hint": "ログイン時に自動的に選択されます。",
+ "timezone_label": "タイムゾーン",
+ "timezone_default": "デフォルト (UTC)",
+ "timezone_hint": "サイト上の日時をあなたのローカル時間に変換するために使用されます。",
"settings_save_profile": "プロフィールを保存",
"settings_change_password": "パスワードを変更",
"settings_set_password": "パスワードを設定",
diff --git a/src/GitServer/Localization/nl.json b/src/GitServer/Localization/nl.json
index 52f3242..91ae4ea 100644
--- a/src/GitServer/Localization/nl.json
+++ b/src/GitServer/Localization/nl.json
@@ -159,10 +159,14 @@
"bio_label": "Bio",
"avatar_url_label": "Avatar URL",
"country_label": "Land",
+ "country_default": "Geen",
"company_name_label": "Bedrijfsnaam",
"preferred_language_label": "Voorkeurstaal",
"preferred_language_default": "Standaard (Engels)",
"preferred_language_hint": "Wordt automatisch geselecteerd bij het inloggen.",
+ "timezone_label": "Tijdzone",
+ "timezone_default": "Standaard (UTC)",
+ "timezone_hint": "Wordt gebruikt om datums en tijden op de site om te zetten naar jouw lokale tijd.",
"settings_save_profile": "Profiel opslaan",
"settings_change_password": "Wachtwoord wijzigen",
"settings_set_password": "Wachtwoord instellen",
diff --git a/src/GitServer/Localization/pt.json b/src/GitServer/Localization/pt.json
index 4b45416..84faed3 100644
--- a/src/GitServer/Localization/pt.json
+++ b/src/GitServer/Localization/pt.json
@@ -158,10 +158,14 @@
"bio_label": "Biografia",
"avatar_url_label": "URL do avatar",
"country_label": "País",
+ "country_default": "Nenhum",
"company_name_label": "Empresa",
"preferred_language_label": "Idioma preferido",
"preferred_language_default": "Padrão (inglês)",
"preferred_language_hint": "Selecionado automaticamente ao entrar.",
+ "timezone_label": "Fuso horário",
+ "timezone_default": "Padrão (UTC)",
+ "timezone_hint": "Usado para converter datas e horas do site para o seu horário local.",
"settings_save_profile": "Salvar perfil",
"settings_change_password": "Alterar senha",
"settings_set_password": "Definir senha",
diff --git a/src/GitServer/Localization/ru.json b/src/GitServer/Localization/ru.json
index b08fbaf..7de6a44 100644
--- a/src/GitServer/Localization/ru.json
+++ b/src/GitServer/Localization/ru.json
@@ -158,10 +158,14 @@
"bio_label": "Биография",
"avatar_url_label": "URL аватара",
"country_label": "Страна",
+ "country_default": "Нет",
"company_name_label": "Компания",
"preferred_language_label": "Предпочитаемый язык",
"preferred_language_default": "По умолчанию (английский)",
"preferred_language_hint": "Выбирается автоматически при входе.",
+ "timezone_label": "Часовой пояс",
+ "timezone_default": "По умолчанию (UTC)",
+ "timezone_hint": "Используется для преобразования дат и времени на сайте в ваше локальное время.",
"settings_save_profile": "Сохранить профиль",
"settings_change_password": "Изменить пароль",
"settings_set_password": "Установить пароль",
diff --git a/src/GitServer/Localization/zh.json b/src/GitServer/Localization/zh.json
index e5ec97c..97ea24c 100644
--- a/src/GitServer/Localization/zh.json
+++ b/src/GitServer/Localization/zh.json
@@ -158,10 +158,14 @@
"bio_label": "个人简介",
"avatar_url_label": "头像链接",
"country_label": "国家",
+ "country_default": "无",
"company_name_label": "公司名称",
"preferred_language_label": "首选语言",
"preferred_language_default": "默认(英语)",
"preferred_language_hint": "登录时会自动选择该语言。",
+ "timezone_label": "时区",
+ "timezone_default": "默认 (UTC)",
+ "timezone_hint": "用于将网站上的日期和时间转换为你的本地时间。",
"settings_save_profile": "保存资料",
"settings_change_password": "修改密码",
"settings_set_password": "设置密码",
diff --git a/src/GitServer/Models/AppUser.cs b/src/GitServer/Models/AppUser.cs
index e133ce9..f0879fb 100644
--- a/src/GitServer/Models/AppUser.cs
+++ b/src/GitServer/Models/AppUser.cs
@@ -12,6 +12,7 @@ public class AppUser : IdentityUser
public string? Country { get; set; }
public string? CompanyName { get; set; }
public string? PreferredLanguage { get; set; }
+ public string? TimeZoneId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }
public bool IsAdmin { get; set; }
diff --git a/src/GitServer/Program.cs b/src/GitServer/Program.cs
index dde0bdb..0706d62 100644
--- a/src/GitServer/Program.cs
+++ b/src/GitServer/Program.cs
@@ -14,7 +14,8 @@ var gitOptions = builder.Configuration
// request-size cap and minimum-throughput timeout so they aren't dropped mid-transfer.
builder.WebHost.ConfigureKestrel(o =>
{
- o.Limits.MaxRequestBodySize = gitOptions.MaxPushSizeMb.HasValue ? gitOptions.MaxPushSizeMb * 1024 * 1024 : null;
+ o.Limits.MaxRequestBodySize = gitOptions.MaxPushSizeMb.HasValue
+ ? gitOptions.MaxPushSizeMb * 1024 * 1024 : null;
o.Limits.MinRequestBodyDataRate = null;
});
@@ -38,6 +39,7 @@ builder.Services.AddScoped<GitProcessService>();
builder.Services.AddScoped<RepositoryService>();
builder.Services.AddScoped<MarkdownService>();
builder.Services.AddScoped<LocalizationService>();
+builder.Services.AddScoped<TimeZoneService>();
// MVC + Razor Pages
builder.Services.AddControllersWithViews();
diff --git a/src/GitServer/Services/GitProcessService.cs b/src/GitServer/Services/GitProcessService.cs
index 790e92f..bfdd4d0 100644
--- a/src/GitServer/Services/GitProcessService.cs
+++ b/src/GitServer/Services/GitProcessService.cs
@@ -3,7 +3,7 @@ using System.Diagnostics;
namespace GitServer.Services;
-public record CommitInfo(string Sha, string ShortSha, string Message, string Author, string Email, DateTime Date);
+public record CommitInfo(string Sha, string ShortSha, string Message, string Author, string Email, DateTime Date, string Tree, List<string> Parents);
public record CommitDetail(CommitInfo Info, string Diff, List<string> ChangedFiles);
public record TreeEntry(string Mode, string Type, string Sha, string Name, string Path);
@@ -177,10 +177,13 @@ public class GitProcessService(IOptions<GitServerOptions> options, ILogger<GitPr
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync();
- // "git version 2.45.1.windows.1" -> "2.45.1.windows.1"
+ // "git version 2.45.1.windows.1" -> "2.45.1"
var prefix = "git version ";
var text = stdout.Trim();
- return text.StartsWith(prefix) ? text[prefix.Length..] : text;
+ var version = text.StartsWith(prefix) ? text[prefix.Length..] : text;
+
+ var windowsSuffixIndex = version.IndexOf(".windows.", StringComparison.OrdinalIgnoreCase);
+ return windowsSuffixIndex >= 0 ? version[..windowsSuffixIndex] : version;
}
public async Task<bool> IsEmpty(string repoPath)
@@ -216,7 +219,7 @@ public class GitProcessService(IOptions<GitServerOptions> options, ILogger<GitPr
public async Task<List<CommitInfo>> GetCommitLog(string repoPath, string branch, int skip, int take)
{
- var format = "--format=%H%n%h%n%s%n%an%n%ae%n%aI%n---COMMIT---";
+ var format = "--format=%H%n%h%n%s%n%an%n%ae%n%aI%n%T%n%P%n---COMMIT---";
var result = await RunGitAsync(repoPath, $"log {format} --skip={skip} --max-count={take} {branch} --");
var commits = new List<CommitInfo>();
@@ -227,7 +230,9 @@ public class GitProcessService(IOptions<GitServerOptions> options, ILogger<GitPr
var lines = block.Split('\n');
if (lines.Length < 6) continue;
var date = DateTime.TryParse(lines[5].Trim(), out var d) ? d : DateTime.UtcNow;
- commits.Add(new CommitInfo(lines[0].Trim(), lines[1].Trim(), lines[2].Trim(), lines[3].Trim(), lines[4].Trim(), date));
+ var tree = lines.ElementAtOrDefault(6)?.Trim() ?? "";
+ var parents = lines.ElementAtOrDefault(7)?.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList() ?? [];
+ commits.Add(new CommitInfo(lines[0].Trim(), lines[1].Trim(), lines[2].Trim(), lines[3].Trim(), lines[4].Trim(), date, tree, parents));
}
return commits;
@@ -235,7 +240,7 @@ public class GitProcessService(IOptions<GitServerOptions> options, ILogger<GitPr
public async Task<CommitDetail> GetCommitDetail(string repoPath, string sha)
{
- var infoResult = await RunGitAsync(repoPath, $"log -1 --format=%H%n%h%n%s%n%an%n%ae%n%aI {sha}");
+ var infoResult = await RunGitAsync(repoPath, $"log -1 --format=%H%n%h%n%s%n%an%n%ae%n%aI%n%T%n%P {sha}");
var lines = infoResult.Split('\n');
var date = DateTime.TryParse(lines.ElementAtOrDefault(5)?.Trim(), out var d) ? d : DateTime.UtcNow;
var info = new CommitInfo(
@@ -244,7 +249,9 @@ public class GitProcessService(IOptions<GitServerOptions> options, ILogger<GitPr
lines.ElementAtOrDefault(2)?.Trim() ?? "",
lines.ElementAtOrDefault(3)?.Trim() ?? "",
lines.ElementAtOrDefault(4)?.Trim() ?? "",
- date);
+ date,
+ lines.ElementAtOrDefault(6)?.Trim() ?? "",
+ lines.ElementAtOrDefault(7)?.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList() ?? []);
var diff = await RunGitAsync(repoPath, $"show --stat --patch {sha}");
var changedFiles = await RunGitAsync(repoPath, $"diff-tree --no-commit-id -r --name-only {sha}");
diff --git a/src/GitServer/Services/LocalizationService.cs b/src/GitServer/Services/LocalizationService.cs
index 7532edf..a5a4e0d 100644
--- a/src/GitServer/Services/LocalizationService.cs
+++ b/src/GitServer/Services/LocalizationService.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Globalization;
using System.Text.Json;
namespace GitServer.Services;
@@ -9,6 +10,22 @@ public class LocalizationService(IHttpContextAccessor httpContextAccessor, IWebH
private readonly string _localizationPath = Path.Combine(env.ContentRootPath, "Localization");
private static readonly ConcurrentDictionary<string, Dictionary<string, string>> _cache = new();
+ private static readonly ConcurrentDictionary<string, List<(string Code, string Name)>> _countryCache = new();
+
+ // Maps each ISO 3166-1 region code to one representative "xx-CC" culture name for that region
+ // (e.g. "NL" -> "nl-NL"). CultureInfo.DisplayName translates properly per CurrentUICulture,
+ // unlike RegionInfo.DisplayName, which always returns the region's own native name regardless
+ // of CurrentUICulture.
+ private static readonly Lazy<Dictionary<string, string>> _regionToCulture = new(() =>
+ CultureInfo.GetCultures(CultureTypes.SpecificCultures)
+ .Select(c =>
+ {
+ try { return (Culture: c.Name, Region: new RegionInfo(c.Name).TwoLetterISORegionName); }
+ catch { return (Culture: (string?)null, Region: (string?)null); }
+ })
+ .Where(x => x.Culture != null && x.Region != null)
+ .GroupBy(x => x.Region!)
+ .ToDictionary(g => g.Key, g => g.OrderBy(x => x.Culture, StringComparer.Ordinal).First().Culture!));
public string CurrentLanguage
{
@@ -46,6 +63,63 @@ public class LocalizationService(IHttpContextAccessor httpContextAccessor, IWebH
yield return (code, name);
}
+ /// <summary>All known countries as (ISO 3166-1 alpha-2 code, localized name), sorted by localized name.</summary>
+ public IEnumerable<(string Code, string Name)> AvailableCountries() =>
+ _countryCache.GetOrAdd(CurrentLanguage, lang =>
+ {
+ var culture = GetCultureOrDefault(lang);
+ var list = _regionToCulture.Value.Keys
+ .Select(code => (Code: code, Name: LocalizedRegionName(code, culture)))
+ .Where(x => x.Name != null)
+ .Select(x => (x.Code, Name: x.Name!))
+ .ToList();
+ list.Sort((a, b) => string.Compare(a.Name, b.Name, culture, CompareOptions.None));
+ return list;
+ });
+
+ /// <summary>Localized display name for a stored ISO 3166-1 alpha-2 country code, or the code itself if unknown.</summary>
+ public string CountryName(string? code)
+ {
+ if (string.IsNullOrEmpty(code)) return "";
+ var culture = GetCultureOrDefault(CurrentLanguage);
+ return LocalizedRegionName(code, culture) ?? code;
+ }
+
+ private static CultureInfo GetCultureOrDefault(string lang)
+ {
+ try { return CultureInfo.GetCultureInfo(lang); }
+ catch (CultureNotFoundException) { return CultureInfo.GetCultureInfo("en"); }
+ }
+
+ private static string? LocalizedRegionName(string regionCode, CultureInfo culture)
+ {
+ if (!_regionToCulture.Value.TryGetValue(regionCode, out var representativeCulture))
+ return null;
+
+ var original = CultureInfo.CurrentUICulture;
+ try
+ {
+ CultureInfo.CurrentUICulture = culture;
+ var displayName = CultureInfo.GetCultureInfo(representativeCulture).DisplayName;
+
+ // "Niederländisch (Niederlande)" -> "Niederlande"
+ var open = displayName.IndexOf('(');
+ var close = displayName.LastIndexOf(')');
+ if (open >= 0 && close > open)
+ return displayName[(open + 1)..close];
+
+ return new RegionInfo(regionCode).EnglishName;
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+ finally
+ {
+ CultureInfo.CurrentUICulture = original;
+ }
+ }
+
public string this[string key]
{
get
diff --git a/src/GitServer/Services/TimeZoneService.cs b/src/GitServer/Services/TimeZoneService.cs
new file mode 100644
index 0000000..1d8457e
--- /dev/null
+++ b/src/GitServer/Services/TimeZoneService.cs
@@ -0,0 +1,44 @@
+namespace GitServer.Services;
+
+public class TimeZoneService(IHttpContextAccessor httpContextAccessor)
+{
+ private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor;
+
+ public const string CookieName = "tz";
+
+ public string CurrentTimeZoneId
+ {
+ get
+ {
+ var ctx = _httpContextAccessor.HttpContext;
+ if (ctx?.Request.Cookies.TryGetValue(CookieName, out var tz) == true && !string.IsNullOrEmpty(tz))
+ return tz;
+ return "UTC";
+ }
+ }
+
+ private TimeZoneInfo CurrentTimeZone
+ {
+ get
+ {
+ try { return TimeZoneInfo.FindSystemTimeZoneById(CurrentTimeZoneId); }
+ catch (TimeZoneNotFoundException) { return TimeZoneInfo.Utc; }
+ catch (InvalidTimeZoneException) { return TimeZoneInfo.Utc; }
+ }
+ }
+
+ /// <summary>Converts a UTC DateTime to the current user's local time zone.</summary>
+ public DateTime ToLocal(DateTime utc)
+ {
+ var utcKind = utc.Kind == DateTimeKind.Utc ? utc : DateTime.SpecifyKind(utc, DateTimeKind.Utc);
+ return TimeZoneInfo.ConvertTimeFromUtc(utcKind, CurrentTimeZone);
+ }
+
+ public IEnumerable<(string Id, string DisplayName)> AvailableTimeZones() =>
+ TimeZoneInfo.GetSystemTimeZones()
+ .OrderBy(tz => tz.BaseUtcOffset)
+ .Select(tz => (tz.Id, $"{FormatOffset(tz.BaseUtcOffset)} {tz.DisplayName}"));
+
+ private static string FormatOffset(TimeSpan offset) =>
+ $"(UTC{(offset < TimeSpan.Zero ? "-" : "+")}{offset:hh\\:mm})";
+}
diff --git a/src/GitServer/wwwroot/Admin/Users.cshtml b/src/GitServer/wwwroot/Admin/Users.cshtml
index befe6f3..5a9881b 100644
--- a/src/GitServer/wwwroot/Admin/Users.cshtml
+++ b/src/GitServer/wwwroot/Admin/Users.cshtml
@@ -42,8 +42,8 @@
</td>
<td>@user.DisplayName</td>
<td>@user.Email</td>
- <td>@user.CreatedAt.ToString("d MMM yyyy")</td>
- <td>@(user.LastLoginAt.HasValue ? user.LastLoginAt.Value.ToString("d MMM yyyy HH:mm") : L["admin_never_logged_in"])</td>
+ <td>@TZ.ToLocal(user.CreatedAt).ToString("d MMM yyyy")</td>
+ <td>@(user.LastLoginAt.HasValue ? TZ.ToLocal(user.LastLoginAt.Value).ToString("d MMM yyyy HH:mm") : L["admin_never_logged_in"])</td>
<td>
@if (user.IsDisabled)
{
diff --git a/src/GitServer/wwwroot/Auth/Login.cshtml.cs b/src/GitServer/wwwroot/Auth/Login.cshtml.cs
index 710f66e..4d973c9 100644
--- a/src/GitServer/wwwroot/Auth/Login.cshtml.cs
+++ b/src/GitServer/wwwroot/Auth/Login.cshtml.cs
@@ -49,6 +49,17 @@ public class LoginModel(
});
}
+ if (!string.IsNullOrEmpty(user.TimeZoneId))
+ {
+ Response.Cookies.Append(TimeZoneService.CookieName, user.TimeZoneId, new CookieOptions
+ {
+ Expires = DateTimeOffset.UtcNow.AddYears(1),
+ IsEssential = true,
+ SameSite = SameSiteMode.Lax,
+ HttpOnly = true
+ });
+ }
+
return LocalRedirect(returnUrl ?? "/");
}
diff --git a/src/GitServer/wwwroot/Explore.cshtml b/src/GitServer/wwwroot/Explore.cshtml
index 246cba3..db85b91 100644
--- a/src/GitServer/wwwroot/Explore.cshtml
+++ b/src/GitServer/wwwroot/Explore.cshtml
@@ -37,8 +37,8 @@
<p class="repo-description">@repo.Description</p>
}
<div class="repo-meta">
- <span class="text-muted">@L["updated_prefix"] @repo.UpdatedAt.ToString("d MMM yyyy HH:mm")</span>
- <span class="text-muted">@L["created_prefix"] @repo.CreatedAt.ToString("d MMM yyyy HH:mm")</span>
+ <span class="text-muted">@L["updated_prefix"] @TZ.ToLocal(repo.UpdatedAt).ToString("d MMM yyyy HH:mm")</span>
+ <span class="text-muted">@L["created_prefix"] @TZ.ToLocal(repo.CreatedAt).ToString("d MMM yyyy HH:mm")</span>
</div>
</div>
}
diff --git a/src/GitServer/wwwroot/ExploreUsers.cshtml b/src/GitServer/wwwroot/ExploreUsers.cshtml
index f6ef41b..b0fa307 100644
--- a/src/GitServer/wwwroot/ExploreUsers.cshtml
+++ b/src/GitServer/wwwroot/ExploreUsers.cshtml
@@ -44,7 +44,7 @@
}
@if (!string.IsNullOrEmpty(user.Country))
{
- <span>&#127760; @user.Country</span>
+ <span>&#127760; @L.CountryName(user.Country)</span>
}
</p>
}
diff --git a/src/GitServer/wwwroot/Index.cshtml b/src/GitServer/wwwroot/Index.cshtml
index c9a9a90..fd67ab5 100644
--- a/src/GitServer/wwwroot/Index.cshtml
+++ b/src/GitServer/wwwroot/Index.cshtml
@@ -39,8 +39,8 @@
<p class="repo-description">@repo.Description</p>
}
<div class="repo-meta">
- <span class="text-muted">@L["updated_prefix"] @repo.UpdatedAt.ToString("d MMM yyyy HH:mm")</span>
- <span class="text-muted">@L["created_prefix"] @repo.CreatedAt.ToString("d MMM yyyy HH:mm")</span>
+ <span class="text-muted">@L["updated_prefix"] @TZ.ToLocal(repo.UpdatedAt).ToString("d MMM yyyy HH:mm")</span>
+ <span class="text-muted">@L["created_prefix"] @TZ.ToLocal(repo.CreatedAt).ToString("d MMM yyyy HH:mm")</span>
</div>
</div>
}
diff --git a/src/GitServer/wwwroot/Repo/Commit.cshtml b/src/GitServer/wwwroot/Repo/Commit.cshtml
index 2e56e64..b77b4a1 100644
--- a/src/GitServer/wwwroot/Repo/Commit.cshtml
+++ b/src/GitServer/wwwroot/Repo/Commit.cshtml
@@ -21,7 +21,7 @@
<div class="commit-meta">
<span class="commit-author">@c.Author</span>
<span class="text-muted">&lt;@c.Email&gt;</span>
- <span class="text-muted">@c.Date.ToString("d MMM yyyy, HH:mm")</span>
+ <span class="text-muted">@TZ.ToLocal(c.Date).ToString("d MMM yyyy, HH:mm")</span>
</div>
<div class="commit-sha-full">
<code>@c.Sha</code>
diff --git a/src/GitServer/wwwroot/Repo/Commits.cshtml b/src/GitServer/wwwroot/Repo/Commits.cshtml
index fc3bc7a..62a6adb 100644
--- a/src/GitServer/wwwroot/Repo/Commits.cshtml
+++ b/src/GitServer/wwwroot/Repo/Commits.cshtml
@@ -20,14 +20,38 @@
<div class="commit-list">
@foreach (var commit in Model.Commits)
{
+ var committer = Model.AuthorsByEmail.GetValueOrDefault(commit.Email);
<div class="commit-item">
- <div class="commit-message">
- <a href="/@Model.UserName/@Model.RepoName/commit/@commit.Sha">@commit.Message</a>
+ <div class="commit-main">
+ <div class="commit-message">
+ <a href="/@Model.UserName/@Model.RepoName/commit/@commit.Sha">@commit.Message</a>
+ </div>
+ <div class="commit-meta">
+ <span class="commit-author">@commit.Author</span>
+ <span class="commit-date text-muted">@TZ.ToLocal(commit.Date).ToString("d MMM yyyy, HH:mm")</span>
+ <a class="commit-sha" href="/@Model.UserName/@Model.RepoName/commit/@commit.Sha">@commit.ShortSha</a>
+ </div>
</div>
- <div class="commit-meta">
- <span class="commit-author">@commit.Author</span>
- <span class="commit-date text-muted">@commit.Date.ToString("d MMM yyyy, HH:mm")</span>
- <a class="commit-sha" href="/@Model.UserName/@Model.RepoName/commit/@commit.Sha">@commit.ShortSha</a>
+ <div class="commit-side">
+ <div class="commit-committer">
+ <img class="commit-committer-avatar" src="@(committer?.EffectiveAvatarUrl ?? GravatarService.GetUrl(commit.Email))" alt="@(committer?.DisplayName ?? commit.Author)" />
+ @if (committer != null)
+ {
+ <a href="/User/@committer.UserName">@committer.DisplayName</a>
+ }
+ else
+ {
+ <span>@commit.Author</span>
+ }
+ </div>
+ <div class="commit-tree text-muted"><span class="commit-side-label">Tree</span> <code>@commit.Tree[..7]</code></div>
+ <div class="commit-parents text-muted">
+ <span class="commit-side-label">Parents</span>
+ @foreach (var parent in commit.Parents)
+ {
+ <a class="commit-sha" href="/@Model.UserName/@Model.RepoName/commit/@parent">@parent[..7]</a>
+ }
+ </div>
</div>
</div>
}
diff --git a/src/GitServer/wwwroot/Repo/Commits.cshtml.cs b/src/GitServer/wwwroot/Repo/Commits.cshtml.cs
index 6c7e7a0..f0c4617 100644
--- a/src/GitServer/wwwroot/Repo/Commits.cshtml.cs
+++ b/src/GitServer/wwwroot/Repo/Commits.cshtml.cs
@@ -3,6 +3,7 @@ using GitServer.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
namespace GitServer.wwwroot.Repo;
@@ -18,6 +19,7 @@ public class CommitsModel(
public new int Page { get; set; }
public int TotalCount { get; set; }
public List<CommitInfo> Commits { get; set; } = new();
+ public Dictionary<string, AppUser> AuthorsByEmail { get; set; } = new();
public async Task<IActionResult> OnGetAsync(string user, string repo, string? branch, int page = 0)
{
@@ -40,6 +42,11 @@ public class CommitsModel(
TotalCount = await git.GetCommitCount(repoPath, Branch);
Commits = await git.GetCommitLog(repoPath, Branch, page * 25, 25);
+ var emails = Commits.Select(c => c.Email).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ AuthorsByEmail = await userManager.Users
+ .Where(u => emails.Contains(u.Email!))
+ .ToDictionaryAsync(u => u.Email!, u => u, StringComparer.OrdinalIgnoreCase);
+
return Page();
}
}
diff --git a/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml b/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml
index ec5b755..cc5352c 100644
--- a/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml
+++ b/src/GitServer/wwwroot/Repo/Issues/Detail.cshtml
@@ -24,7 +24,7 @@
@(issue.IsClosed ? L["issues_closed"] : L["issues_open"])
</span>
<span class="text-muted">
- @L["issues_opened_by"] @issue.Author.UserName @L["issues_on"] @issue.CreatedAt.ToString("d MMM yyyy")
+ @L["issues_opened_by"] @issue.Author.UserName @L["issues_on"] @TZ.ToLocal(issue.CreatedAt).ToString("d MMM yyyy")
</span>
</div>
@@ -34,7 +34,7 @@
<div class="timeline-body">
<div class="timeline-header">
<strong>@issue.Author.UserName</strong>
- <span class="text-muted">@issue.CreatedAt.ToString("d MMM yyyy, HH:mm")</span>
+ <span class="text-muted">@TZ.ToLocal(issue.CreatedAt).ToString("d MMM yyyy, HH:mm")</span>
</div>
<div class="markdown-body">@Html.Raw(Markdown.Render(issue.Body))</div>
</div>
@@ -47,7 +47,7 @@
<div class="timeline-body">
<div class="timeline-header">
<strong>@comment.Author.UserName</strong>
- <span class="text-muted">@comment.CreatedAt.ToString("d MMM yyyy, HH:mm")</span>
+ <span class="text-muted">@TZ.ToLocal(comment.CreatedAt).ToString("d MMM yyyy, HH:mm")</span>
</div>
<div class="markdown-body">@Html.Raw(Markdown.Render(comment.Body))</div>
</div>
diff --git a/src/GitServer/wwwroot/Repo/Issues/Index.cshtml b/src/GitServer/wwwroot/Repo/Issues/Index.cshtml
index b5ccd73..67ebd56 100644
--- a/src/GitServer/wwwroot/Repo/Issues/Index.cshtml
+++ b/src/GitServer/wwwroot/Repo/Issues/Index.cshtml
@@ -46,7 +46,7 @@
@issue.Title
</a>
<span class="issue-meta text-muted">
- #@issue.Id @L["issues_opened_by"] @issue.Author.UserName @L["issues_on"] @issue.CreatedAt.ToString("d MMM yyyy")
+ #@issue.Id @L["issues_opened_by"] @issue.Author.UserName @L["issues_on"] @TZ.ToLocal(issue.CreatedAt).ToString("d MMM yyyy")
&nbsp;&middot; @issue.Comments.Count @L["issues_comments"]
</span>
</div>
diff --git a/src/GitServer/wwwroot/Shared/_ProfileHeader.cshtml b/src/GitServer/wwwroot/Shared/_ProfileHeader.cshtml
index fd05047..c563c41 100644
--- a/src/GitServer/wwwroot/Shared/_ProfileHeader.cshtml
+++ b/src/GitServer/wwwroot/Shared/_ProfileHeader.cshtml
@@ -20,10 +20,10 @@
}
@if (!string.IsNullOrEmpty(Model.Country))
{
- <span>&#127760; @Model.Country</span>
+ <span>&#127760; @L.CountryName(Model.Country)</span>
}
</p>
}
- <p class="text-muted">@L["profile_member_since"] @Model.CreatedAt.ToString("MMMM yyyy")</p>
+ <p class="text-muted">@L["profile_member_since"] @TZ.ToLocal(Model.CreatedAt).ToString("MMMM yyyy")</p>
</div>
</div>
diff --git a/src/GitServer/wwwroot/User/Profile.cshtml b/src/GitServer/wwwroot/User/Profile.cshtml
index a006a66..626e6f8 100644
--- a/src/GitServer/wwwroot/User/Profile.cshtml
+++ b/src/GitServer/wwwroot/User/Profile.cshtml
@@ -60,8 +60,8 @@
</p>
}
<div class="repo-meta">
- <span class="text-muted">@L["updated_prefix"] @repo.UpdatedAt.ToString("d MMM yyyy HH:mm")</span>
- <span class="text-muted">@L["created_prefix"] @repo.CreatedAt.ToString("d MMM yyyy HH:mm")</span>
+ <span class="text-muted">@L["updated_prefix"] @TZ.ToLocal(repo.UpdatedAt).ToString("d MMM yyyy HH:mm")</span>
+ <span class="text-muted">@L["created_prefix"] @TZ.ToLocal(repo.CreatedAt).ToString("d MMM yyyy HH:mm")</span>
</div>
</div>
}
diff --git a/src/GitServer/wwwroot/User/Settings.cshtml b/src/GitServer/wwwroot/User/Settings.cshtml
index 24bd5f9..1ea53b5 100644
--- a/src/GitServer/wwwroot/User/Settings.cshtml
+++ b/src/GitServer/wwwroot/User/Settings.cshtml
@@ -35,8 +35,13 @@
</div>
<div class="form-group">
<label for="country">@L["country_label"]</label>
- <input id="country" name="NewCountry" type="text" class="form-control"
- value="@Model.Country" />
+ <select id="country" name="NewCountry" class="form-control">
+ <option value="">@L["country_default"]</option>
+ @foreach (var (code, name) in L.AvailableCountries())
+ {
+ <option value="@code" selected="@(code == Model.Country)">@name</option>
+ }
+ </select>
</div>
<div class="form-group">
<label for="companyName">@L["company_name_label"]</label>
@@ -54,6 +59,17 @@
</select>
<span class="form-hint">@L["preferred_language_hint"]</span>
</div>
+ <div class="form-group">
+ <label for="timeZoneId">@L["timezone_label"]</label>
+ <select id="timeZoneId" name="NewTimeZoneId" class="form-control">
+ <option value="">@L["timezone_default"]</option>
+ @foreach (var (id, name) in TZ.AvailableTimeZones())
+ {
+ <option value="@id" selected="@(id == Model.TimeZoneId)">@name</option>
+ }
+ </select>
+ <span class="form-hint">@L["timezone_hint"]</span>
+ </div>
<button type="submit" class="btn btn-primary">@L["settings_save_profile"]</button>
</form>
</section>
diff --git a/src/GitServer/wwwroot/User/Settings.cshtml.cs b/src/GitServer/wwwroot/User/Settings.cshtml.cs
index f86e7df..f1671c2 100644
--- a/src/GitServer/wwwroot/User/Settings.cshtml.cs
+++ b/src/GitServer/wwwroot/User/Settings.cshtml.cs
@@ -17,6 +17,7 @@ public class UserSettingsModel(UserManager<AppUser> userManager, LocalizationSer
public string? Country { get; set; }
public string? CompanyName { get; set; }
public string? PreferredLanguage { get; set; }
+ public string? TimeZoneId { get; set; }
public string? Message { get; set; }
public bool IsError { get; set; }
public bool HasPassword { get; set; }
@@ -27,6 +28,7 @@ public class UserSettingsModel(UserManager<AppUser> userManager, LocalizationSer
[BindProperty] public string? NewCountry { get; set; }
[BindProperty] public string? NewCompanyName { get; set; }
[BindProperty] public string? NewPreferredLanguage { get; set; }
+ [BindProperty] public string? NewTimeZoneId { get; set; }
[BindProperty] public string CurrentPassword { get; set; } = "";
[BindProperty] public string NewPassword { get; set; } = "";
@@ -39,6 +41,7 @@ public class UserSettingsModel(UserManager<AppUser> userManager, LocalizationSer
Country = user.Country;
CompanyName = user.CompanyName;
PreferredLanguage = user.PreferredLanguage;
+ TimeZoneId = user.TimeZoneId;
}
public async Task OnGetAsync()
@@ -60,11 +63,23 @@ public class UserSettingsModel(UserManager<AppUser> userManager, LocalizationSer
user.Country = string.IsNullOrWhiteSpace(NewCountry) ? null : NewCountry.Trim();
user.CompanyName = string.IsNullOrWhiteSpace(NewCompanyName) ? null : NewCompanyName.Trim();
user.PreferredLanguage = string.IsNullOrWhiteSpace(NewPreferredLanguage) ? null : NewPreferredLanguage;
+ user.TimeZoneId = string.IsNullOrWhiteSpace(NewTimeZoneId) ? null : NewTimeZoneId;
var result = await userManager.UpdateAsync(user);
Message = result.Succeeded ? L["success_profile_saved"] : string.Join(" ", result.Errors.Select(e => e.Description));
IsError = !result.Succeeded;
+ if (result.Succeeded && !string.IsNullOrEmpty(user.TimeZoneId))
+ {
+ Response.Cookies.Append(TimeZoneService.CookieName, user.TimeZoneId, new CookieOptions
+ {
+ Expires = DateTimeOffset.UtcNow.AddYears(1),
+ IsEssential = true,
+ SameSite = SameSiteMode.Lax,
+ HttpOnly = true
+ });
+ }
+
LoadFrom(user);
HasPassword = await userManager.HasPasswordAsync(user);
return Page();
diff --git a/src/GitServer/wwwroot/_ViewImports.cshtml b/src/GitServer/wwwroot/_ViewImports.cshtml
index 1711f7e..5feb025 100644
--- a/src/GitServer/wwwroot/_ViewImports.cshtml
+++ b/src/GitServer/wwwroot/_ViewImports.cshtml
@@ -4,3 +4,4 @@
@namespace GitServer
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@inject LocalizationService L
+@inject TimeZoneService TZ
diff --git a/src/GitServer/wwwroot/css/repo.css b/src/GitServer/wwwroot/css/repo.css
index f8524d7..eaa8867 100644
--- a/src/GitServer/wwwroot/css/repo.css
+++ b/src/GitServer/wwwroot/css/repo.css
@@ -236,10 +236,56 @@
.commit-list { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.commit-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
+.commit-main { min-width: 0; flex: 1; }
+
+.commit-side {
+ flex-shrink: 0;
+ text-align: right;
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.commit-committer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.375rem;
+}
+
+.commit-committer-avatar {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+}
+
+.commit-side-label {
+ color: var(--text-muted);
+ margin-right: 0.25rem;
+}
+
+.commit-tree code, .commit-parents .commit-sha {
+ font-size: 0.75rem;
+}
+
+.commit-parents {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.25rem;
+ flex-wrap: wrap;
+}
+
.commit-item:last-child { border-bottom: none; }
.commit-item:hover { background: var(--surface2); }
@@ -389,6 +435,10 @@
.repo-actions { width: 100%; flex-wrap: wrap; }
.clone-input { max-width: none; width: 100%; }
.commit-meta { flex-direction: column; gap: 0.25rem; }
+ .commit-item { flex-direction: column; }
+ .commit-side { text-align: left; width: 100%; }
+ .commit-committer { justify-content: flex-start; }
+ .commit-parents { justify-content: flex-start; }
.diff-line { font-size: 0.75rem; }
.branch-item { flex-direction: column; align-items: flex-start; }
.file-breadcrumb { font-size: 0.8125rem; }