[backend/database] [frontend] [shared] Rename emoji aliases to tags (ISH-717)
This commit is contained in:
parent
6391e5f185
commit
0442f676e1
13 changed files with 136 additions and 110 deletions
|
@ -34,19 +34,19 @@ public class EmojiController(
|
|||
public async Task<IEnumerable<EmojiResponse>> GetAllEmoji()
|
||||
{
|
||||
return await db.Emojis
|
||||
.Where(p => p.Host == null)
|
||||
.Select(p => new EmojiResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Aliases = p.Aliases,
|
||||
Category = p.Category,
|
||||
PublicUrl = p.GetAccessUrl(instance.Value),
|
||||
License = p.License,
|
||||
Sensitive = p.Sensitive
|
||||
})
|
||||
.ToListAsync();
|
||||
.Where(p => p.Host == null)
|
||||
.Select(p => new EmojiResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Tags = p.Tags,
|
||||
Category = p.Category,
|
||||
PublicUrl = p.GetAccessUrl(instance.Value),
|
||||
License = p.License,
|
||||
Sensitive = p.Sensitive
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
[HttpGet("remote")]
|
||||
|
@ -56,20 +56,20 @@ public class EmojiController(
|
|||
public async Task<PaginationWrapper<List<EmojiResponse>>> GetRemoteEmoji(PaginationQuery pq)
|
||||
{
|
||||
var res = await db.Emojis
|
||||
.Where(p => p.Host != null)
|
||||
.Select(p => new EmojiResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Aliases = p.Aliases,
|
||||
Category = p.Host,
|
||||
PublicUrl = p.GetAccessUrl(instance.Value),
|
||||
License = p.License,
|
||||
Sensitive = p.Sensitive
|
||||
})
|
||||
.Paginate(pq, ControllerContext)
|
||||
.ToListAsync();
|
||||
.Where(p => p.Host != null)
|
||||
.Select(p => new EmojiResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Tags = p.Tags,
|
||||
Category = p.Host,
|
||||
PublicUrl = p.GetAccessUrl(instance.Value),
|
||||
License = p.License,
|
||||
Sensitive = p.Sensitive
|
||||
})
|
||||
.Paginate(pq, ControllerContext)
|
||||
.ToListAsync();
|
||||
|
||||
return HttpContext.CreatePaginationWrapper(pq, res);
|
||||
}
|
||||
|
@ -81,20 +81,20 @@ public class EmojiController(
|
|||
public async Task<PaginationWrapper<List<EmojiResponse>>> GetRemoteEmojiByHost(string host, PaginationQuery pq)
|
||||
{
|
||||
var res = await db.Emojis
|
||||
.Where(p => p.Host == host)
|
||||
.Select(p => new EmojiResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Aliases = p.Aliases,
|
||||
Category = p.Host,
|
||||
PublicUrl = p.GetAccessUrl(instance.Value),
|
||||
License = p.License,
|
||||
Sensitive = p.Sensitive
|
||||
})
|
||||
.Paginate(pq, ControllerContext)
|
||||
.ToListAsync();
|
||||
.Where(p => p.Host == host)
|
||||
.Select(p => new EmojiResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Tags = p.Tags,
|
||||
Category = p.Host,
|
||||
PublicUrl = p.GetAccessUrl(instance.Value),
|
||||
License = p.License,
|
||||
Sensitive = p.Sensitive
|
||||
})
|
||||
.Paginate(pq, ControllerContext)
|
||||
.ToListAsync();
|
||||
|
||||
return HttpContext.CreatePaginationWrapper(pq, res);
|
||||
}
|
||||
|
@ -107,11 +107,11 @@ public class EmojiController(
|
|||
{
|
||||
pq.MinId ??= "";
|
||||
var res = await db.Emojis.Where(p => p.Host != null)
|
||||
.Select(p => new EntityWrapper<string> { Entity = p.Host!, Id = p.Host! })
|
||||
.Distinct()
|
||||
.Paginate(pq, ControllerContext)
|
||||
.ToListAsync()
|
||||
.ContinueWithResult(p => p.NotNull());
|
||||
.Select(p => new EntityWrapper<string> { Entity = p.Host!, Id = p.Host! })
|
||||
.Distinct()
|
||||
.Paginate(pq, ControllerContext)
|
||||
.ToListAsync()
|
||||
.ContinueWithResult(p => p.NotNull());
|
||||
|
||||
return res;
|
||||
}
|
||||
|
@ -122,14 +122,14 @@ public class EmojiController(
|
|||
public async Task<EmojiResponse> GetEmoji(string id)
|
||||
{
|
||||
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id)
|
||||
?? throw GracefulException.NotFound("Emoji not found");
|
||||
?? throw GracefulException.NotFound("Emoji not found");
|
||||
|
||||
return new EmojiResponse
|
||||
{
|
||||
Id = emoji.Id,
|
||||
Name = emoji.Name,
|
||||
Uri = emoji.Uri,
|
||||
Aliases = emoji.Aliases,
|
||||
Tags = emoji.Tags,
|
||||
Category = emoji.Category,
|
||||
PublicUrl = emoji.GetAccessUrl(instance.Value),
|
||||
License = emoji.License,
|
||||
|
@ -151,7 +151,7 @@ public class EmojiController(
|
|||
Id = emoji.Id,
|
||||
Name = emoji.Name,
|
||||
Uri = emoji.Uri,
|
||||
Aliases = [],
|
||||
Tags = [],
|
||||
Category = null,
|
||||
PublicUrl = emoji.GetAccessUrl(instance.Value),
|
||||
License = null,
|
||||
|
@ -177,7 +177,7 @@ public class EmojiController(
|
|||
Id = cloned.Id,
|
||||
Name = cloned.Name,
|
||||
Uri = cloned.Uri,
|
||||
Aliases = [],
|
||||
Tags = [],
|
||||
Category = null,
|
||||
PublicUrl = cloned.GetAccessUrl(instance.Value),
|
||||
License = null,
|
||||
|
@ -203,16 +203,16 @@ public class EmojiController(
|
|||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
public async Task<EmojiResponse> UpdateEmoji(string id, UpdateEmojiRequest request)
|
||||
{
|
||||
var emoji = await emojiSvc.UpdateLocalEmojiAsync(id, request.Name, request.Aliases, request.Category,
|
||||
request.License, request.Sensitive)
|
||||
?? throw GracefulException.NotFound("Emoji not found");
|
||||
var emoji = await emojiSvc.UpdateLocalEmojiAsync(id, request.Name, request.Tags, request.Category,
|
||||
request.License, request.Sensitive)
|
||||
?? throw GracefulException.NotFound("Emoji not found");
|
||||
|
||||
return new EmojiResponse
|
||||
{
|
||||
Id = emoji.Id,
|
||||
Name = emoji.Name,
|
||||
Uri = emoji.Uri,
|
||||
Aliases = emoji.Aliases,
|
||||
Tags = emoji.Tags,
|
||||
Category = emoji.Category,
|
||||
PublicUrl = emoji.GetAccessUrl(instance.Value),
|
||||
License = emoji.License,
|
||||
|
|
|
@ -76,8 +76,8 @@ public class NoteRenderer(
|
|||
var attachments =
|
||||
(data?.Attachments ?? await GetAttachmentsAsync([note])).Where(p => note.FileIds.Contains(p.Id));
|
||||
var reactions = (data?.Reactions ?? await GetReactionsAsync([note], user)).Where(p => p.NoteId == note.Id);
|
||||
var liked = data?.LikedNotes?.Contains(note.Id) ??
|
||||
await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user);
|
||||
var liked = data?.LikedNotes?.Contains(note.Id)
|
||||
?? await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user);
|
||||
var emoji = data?.Emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([note]);
|
||||
var poll = (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.NoteId == note.Id);
|
||||
|
||||
|
@ -138,10 +138,12 @@ public class NoteRenderer(
|
|||
.Select(p => new NoteReactionSchema
|
||||
{
|
||||
NoteId = p.First().NoteId,
|
||||
Count = (int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
|
||||
Reacted = db.NoteReactions.Any(i => i.NoteId == p.First().NoteId &&
|
||||
i.Reaction == p.First().Reaction &&
|
||||
i.User == user),
|
||||
Count =
|
||||
(int)counts[p.First().NoteId].GetValueOrDefault(p.First().Reaction, 1),
|
||||
Reacted =
|
||||
db.NoteReactions.Any(i => i.NoteId == p.First().NoteId
|
||||
&& i.Reaction == p.First().Reaction
|
||||
&& i.User == user),
|
||||
Name = p.First().Reaction,
|
||||
Url = null,
|
||||
Sensitive = false
|
||||
|
@ -194,7 +196,7 @@ public class NoteRenderer(
|
|||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Aliases = p.Aliases,
|
||||
Tags = p.Tags,
|
||||
Category = p.Category,
|
||||
PublicUrl = p.GetAccessUrl(config.Value),
|
||||
License = p.License,
|
||||
|
@ -206,8 +208,8 @@ public class NoteRenderer(
|
|||
private async Task<List<NotePollSchema>> GetPollsAsync(IEnumerable<Note> notes, User? user)
|
||||
{
|
||||
var polls = await db.Polls
|
||||
.Where(p => notes.Contains(p.Note))
|
||||
.ToListAsync();
|
||||
.Where(p => notes.Contains(p.Note))
|
||||
.ToListAsync();
|
||||
|
||||
var votes = user != null
|
||||
? await db.PollVotes
|
||||
|
|
|
@ -51,7 +51,10 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
|
|||
var bannerAlt = await GetBannerAltAsync([user]);
|
||||
var data = new UserRendererDto
|
||||
{
|
||||
Emojis = emojis, InstanceData = instanceData, AvatarAlt = avatarAlt, BannerAlt = bannerAlt
|
||||
Emojis = emojis,
|
||||
InstanceData = instanceData,
|
||||
AvatarAlt = avatarAlt,
|
||||
BannerAlt = bannerAlt
|
||||
};
|
||||
|
||||
return Render(user, data);
|
||||
|
@ -107,7 +110,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
|
|||
Id = p.Id,
|
||||
Name = p.Name,
|
||||
Uri = p.Uri,
|
||||
Aliases = p.Aliases,
|
||||
Tags = p.Tags,
|
||||
Category = p.Category,
|
||||
PublicUrl = p.GetAccessUrl(config.Value),
|
||||
License = p.License,
|
||||
|
|
|
@ -19,7 +19,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
|
|||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.1")
|
||||
.HasAnnotation("ProductVersion", "9.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "antenna_src_enum", new[] { "home", "all", "users", "list", "group", "instances" });
|
||||
|
@ -978,13 +978,6 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
|
|||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Aliases")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("character varying(128)[]")
|
||||
.HasColumnName("aliases")
|
||||
.HasDefaultValueSql("'{}'::character varying[]");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
|
@ -1029,6 +1022,13 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
|
|||
.HasColumnType("boolean")
|
||||
.HasColumnName("sensitive");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Tags")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("character varying(128)[]")
|
||||
.HasColumnName("tags")
|
||||
.HasDefaultValueSql("'{}'::character varying[]");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(DatabaseContext))]
|
||||
[Migration("20250304222123_RenameEmojiTagsColumn")]
|
||||
public partial class RenameEmojiTagsColumn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "aliases",
|
||||
table: "emoji",
|
||||
newName: "tags");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "tags",
|
||||
table: "emoji",
|
||||
newName: "aliases");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,8 +30,8 @@ public class Emoji
|
|||
|
||||
[Column("type")] [StringLength(64)] public string? Type { get; set; }
|
||||
|
||||
[Column("aliases", TypeName = "character varying(128)[]")]
|
||||
public List<string> Aliases { get; set; } = [];
|
||||
[Column("tags", TypeName = "character varying(128)[]")]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
[Column("category")]
|
||||
[StringLength(128)]
|
||||
|
@ -75,7 +75,7 @@ public class Emoji
|
|||
{
|
||||
public void Configure(EntityTypeBuilder<Emoji> entity)
|
||||
{
|
||||
entity.Property(e => e.Aliases).HasDefaultValueSql("'{}'::character varying[]");
|
||||
entity.Property(e => e.Tags).HasDefaultValueSql("'{}'::character varying[]");
|
||||
entity.Property(e => e.Height).HasComment("Image height");
|
||||
entity.Property(e => e.RawPublicUrl).HasDefaultValueSql("''::character varying");
|
||||
entity.Property(e => e.Width).HasComment("Image width");
|
||||
|
|
|
@ -28,7 +28,7 @@ public partial class EmojiService(
|
|||
});
|
||||
|
||||
public async Task<Emoji> CreateEmojiFromStreamAsync(
|
||||
Stream input, string fileName, string mimeType, List<string>? aliases = null,
|
||||
Stream input, string fileName, string mimeType, List<string>? tags = null,
|
||||
string? category = null
|
||||
)
|
||||
{
|
||||
|
@ -51,7 +51,7 @@ public partial class EmojiService(
|
|||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Aliases = aliases ?? [],
|
||||
Tags = tags ?? [],
|
||||
Category = category,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
OriginalUrl = driveFile.Url,
|
||||
|
@ -224,7 +224,7 @@ public partial class EmojiService(
|
|||
}
|
||||
|
||||
public async Task<Emoji?> UpdateLocalEmojiAsync(
|
||||
string id, string? name, List<string>? aliases, string? category, string? license, bool? sensitive
|
||||
string id, string? name, List<string>? tags, string? category, string? license, bool? sensitive
|
||||
)
|
||||
{
|
||||
var emoji = await db.Emojis.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
@ -241,8 +241,8 @@ public partial class EmojiService(
|
|||
emoji.Uri = emoji.GetPublicUri(config.Value);
|
||||
}
|
||||
|
||||
if (aliases != null)
|
||||
emoji.Aliases = aliases.Select(p => p.Trim()).Where(p => !string.IsNullOrWhiteSpace(p)).ToList();
|
||||
if (tags != null)
|
||||
emoji.Tags = tags.Select(p => p.Trim()).Where(p => !string.IsNullOrWhiteSpace(p)).ToList();
|
||||
|
||||
// If category is provided but empty reset to null
|
||||
if (category != null) emoji.Category = string.IsNullOrEmpty(category) ? null : category;
|
||||
|
|
|
@ -13,9 +13,9 @@
|
|||
<div class="emoji-details">
|
||||
<span class="emoji-name">@Emoji.Name</span>
|
||||
<span>
|
||||
@foreach (var alias in Emoji.Aliases)
|
||||
@foreach (var tag in Emoji.Tags)
|
||||
{
|
||||
<span class="emoji-alias">@alias</span>
|
||||
<span class="emoji-tag">@tag</span>
|
||||
}
|
||||
</span>
|
||||
<span class="labels">
|
||||
|
@ -58,8 +58,8 @@
|
|||
</MenuElement>
|
||||
}
|
||||
|
||||
<MenuElement Icon="Icons.TextAa" OnSelect="SetAliases">
|
||||
<Text>@Loc["Set aliases"]</Text>
|
||||
<MenuElement Icon="Icons.TextAa" OnSelect="SetTags">
|
||||
<Text>@Loc["Set tags"]</Text>
|
||||
</MenuElement>
|
||||
<MenuElement Icon="Icons.Folder" OnSelect="SetCategory">
|
||||
<Text>@Loc["Set category"]</Text>
|
||||
|
@ -143,19 +143,19 @@
|
|||
|
||||
private async Task MarkAsSensitive() => await MarkSensitive(true);
|
||||
|
||||
private async Task SetAliases() =>
|
||||
await Global.PromptDialog?.Prompt(new EventCallback<string?>(this, SetAliasesCallback), Loc["Set aliases (separated by new line)"], "one\ntwo\nthree", string.Join("\n", Emoji.Aliases), true, true)!;
|
||||
private async Task SetTags() =>
|
||||
await Global.PromptDialog?.Prompt(new EventCallback<string?>(this, SetTagsCallback), Loc["Set tags (separated by new line)"], "one\ntwo\nthree", string.Join("\n", Emoji.Tags), true, true)!;
|
||||
|
||||
private async Task SetAliasesCallback(string? aliases)
|
||||
private async Task SetTagsCallback(string? tags)
|
||||
{
|
||||
if (aliases == null) return;
|
||||
if (tags == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var res = await Api.Emoji.UpdateEmojiAsync(Emoji.Id, new UpdateEmojiRequest { Aliases = string.IsNullOrWhiteSpace(aliases) ? [] : aliases.Replace(" ", "").Split("\n").ToList() });
|
||||
var res = await Api.Emoji.UpdateEmojiAsync(Emoji.Id, new UpdateEmojiRequest { Tags = string.IsNullOrWhiteSpace(tags) ? [] : tags.Replace(" ", "").Split("\n").ToList() });
|
||||
if (res != null)
|
||||
{
|
||||
Emoji.Aliases = res.Aliases;
|
||||
Emoji.Tags = res.Tags;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,21 +27,11 @@
|
|||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.emoji-alias {
|
||||
.emoji-tag {
|
||||
opacity: 0.7;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.emoji-alias::before {
|
||||
display: inline;
|
||||
content: ":";
|
||||
}
|
||||
|
||||
.emoji-alias::after {
|
||||
display: inline;
|
||||
content: ": ";
|
||||
}
|
||||
|
||||
::deep {
|
||||
.labels .ph {
|
||||
display: inline-block;
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
private void FilterEmojis()
|
||||
{
|
||||
Categories = EmojiList
|
||||
.Where(p => p.Name.Contains(EmojiFilter.StripLeadingTrailingSpaces()) || p.Aliases.Count(a => a.Contains(EmojiFilter.StripLeadingTrailingSpaces())) != 0)
|
||||
.Where(p => p.Name.Contains(EmojiFilter.StripLeadingTrailingSpaces()) || p.Tags.Count(a => a.Contains(EmojiFilter.StripLeadingTrailingSpaces())) != 0)
|
||||
.OrderBy(p => p.Name)
|
||||
.ThenBy(p => p.Id)
|
||||
.GroupBy(p => p.Category)
|
||||
|
|
|
@ -128,7 +128,7 @@
|
|||
{
|
||||
if (Source == "remote" && _displayType == DisplayType.All || Source == "local")
|
||||
{
|
||||
DisplayedEmojis = StoredRemoteEmojis.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Aliases.Count(a => a.Contains(EmojiFilter.Trim())) > 0).ToList();
|
||||
DisplayedEmojis = StoredRemoteEmojis.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Tags.Count(a => a.Contains(EmojiFilter.Trim())) > 0).ToList();
|
||||
}
|
||||
|
||||
if (Source == "remote" && _displayType == DisplayType.Categories)
|
||||
|
@ -139,7 +139,7 @@
|
|||
if (Source == "local" && _displayType == DisplayType.Categories)
|
||||
{
|
||||
LocalEmojiCategories = StoredLocalEmojis
|
||||
.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Aliases.Count(a => a.Contains(EmojiFilter.Trim())) != 0)
|
||||
.Where(p => p.Name.Contains(EmojiFilter.Trim()) || p.Tags.Count(a => a.Contains(EmojiFilter.Trim())) != 0)
|
||||
.OrderBy(p => p.Name)
|
||||
.ThenBy(p => p.Id)
|
||||
.GroupBy(p => p.Category)
|
||||
|
|
|
@ -7,7 +7,7 @@ public class EmojiResponse : IIdentifiable
|
|||
public required string Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string? Uri { get; set; }
|
||||
public required List<string> Aliases { get; set; }
|
||||
public required List<string> Tags { get; set; }
|
||||
public required string? Category { get; set; }
|
||||
public required string PublicUrl { get; set; }
|
||||
public required string? License { get; set; }
|
||||
|
|
|
@ -3,7 +3,7 @@ namespace Iceshrimp.Shared.Schemas.Web;
|
|||
public class UpdateEmojiRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public List<string>? Aliases { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? License { get; set; }
|
||||
public bool? Sensitive { get; set; }
|
||||
|
|
Loading…
Add table
Reference in a new issue