Refactor Home UI controls and add dice to campaign log entries
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
@page "/"
|
||||
@implements IAsyncDisposable
|
||||
@using RpgRoller.Components.Pages.HomeControls
|
||||
|
||||
<div class="rr-app">
|
||||
<div class="@AppCssClass">
|
||||
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
|
||||
|
||||
@if (!IsInitialized)
|
||||
@@ -122,130 +123,39 @@
|
||||
@if (CurrentScreen == "play")
|
||||
{
|
||||
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||
<section class="card character-panel">
|
||||
<div class="section-head"><h2>Character Context</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
|
||||
}
|
||||
else if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="character-picker" role="tablist" aria-label="Character picker">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
var isSelectedCharacter = SelectedCharacterId == character.Id;
|
||||
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => SelectCharacter(character.Id)">
|
||||
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
|
||||
<span class="icon-tab-text">@character.Name</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (SelectedCharacter is not null)
|
||||
{
|
||||
<article class="character-sheet">
|
||||
<h3>@SelectedCharacter.Name</h3>
|
||||
<p>Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)</p>
|
||||
<p>Campaign: @SelectedCampaign.Name</p>
|
||||
@if (SelectedCharacter.Id == ActiveCharacterId)
|
||||
{
|
||||
<span class="badge active">Active</span>
|
||||
}
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => OpenEditCharacterModal(SelectedCharacter)">Edit Character</button>
|
||||
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(SelectedCharacter))" @onclick="() => ActivateCharacterAsync(SelectedCharacter.Id)">Activate Character</button>
|
||||
</div>
|
||||
</article>
|
||||
<article class="skills-section">
|
||||
<div class="section-head">
|
||||
<h3>Skills</h3>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
|
||||
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (SelectedCharacterSkills.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills for this character yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in SelectedCharacterSkills)
|
||||
{
|
||||
var isSelectedSkill = SelectedSkillId == skill.Id;
|
||||
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SelectSkill(skill.Id)">
|
||||
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<form class="roll-panel" @onsubmit="RollSelectedSkillAsync" @onsubmit:preventDefault>
|
||||
<label for="roll-visibility">Visibility</label>
|
||||
<select id="roll-visibility" @bind="RollVisibility">
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
|
||||
</form>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
<article class="last-roll">
|
||||
<h3>Last Roll</h3>
|
||||
@if (LastRoll is null)
|
||||
{
|
||||
<p class="empty">No roll yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="roll-total">@LastRoll.Result</p>
|
||||
@if (LastRoll.Dice.Count > 0)
|
||||
{
|
||||
<div class="roll-dice-strip" aria-label="Rolled dice">
|
||||
@foreach (var die in LastRoll.Dice)
|
||||
{
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p>@LastRoll.Breakdown</p>
|
||||
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
<CharacterPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
SelectedCampaign="SelectedCampaign"
|
||||
SelectedCharacterId="SelectedCharacterId"
|
||||
SelectedCharacter="SelectedCharacter"
|
||||
IsMutating="IsMutating"
|
||||
SelectedCharacterSkills="SelectedCharacterSkills"
|
||||
SelectedSkillId="SelectedSkillId"
|
||||
SelectedSkill="SelectedSkill"
|
||||
RollVisibility="RollVisibility"
|
||||
RollVisibilityChanged="OnRollVisibilityChanged"
|
||||
LastRoll="LastRoll"
|
||||
OwnerLabel="OwnerLabel"
|
||||
SkillDefinitionLabel="SkillDefinitionLabel"
|
||||
CanEditCharacter="CanEditCharacter"
|
||||
CanEditSkill="CanEditSkill"
|
||||
CanRollSkill="CanRollSkill"
|
||||
CharacterSelected="SelectCharacterAsync"
|
||||
SkillSelected="SelectSkill"
|
||||
EditCharacterRequested="OpenEditCharacterModal"
|
||||
CreateSkillRequested="OpenCreateSkillModal"
|
||||
EditSkillRequested="OpenEditSkillModal"
|
||||
RollRequested="RollSelectedSkillAsync" />
|
||||
|
||||
<aside class="card log-panel">
|
||||
<div class="section-head"><h2>Campaign Log</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
|
||||
}
|
||||
else if (CampaignLog.Count == 0)
|
||||
{
|
||||
<p class="empty">No log entries yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="log-list">
|
||||
@foreach (var entry in CampaignLog)
|
||||
{
|
||||
<li class="log-entry @LogEntryCssClass(entry)">
|
||||
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
|
||||
<p>@entry.Breakdown</p>
|
||||
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</aside>
|
||||
<CampaignLogPanel
|
||||
IsCampaignDataLoading="IsCampaignDataLoading"
|
||||
CampaignLog="CampaignLog"
|
||||
RollerLabel="RollerLabel"
|
||||
SkillLabel="SkillLabel"
|
||||
CharacterLabel="CharacterLabel"
|
||||
LogEntryCssClass="LogEntryCssClass"
|
||||
VisibilityLabel="VisibilityLabel"
|
||||
VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
|
||||
@@ -337,7 +247,6 @@
|
||||
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button>
|
||||
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(character))" @onclick="() => ActivateCharacterAsync(character.Id)">Activate</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public partial class Home
|
||||
private string? SelectedCampaignName => SelectedCampaign?.Name;
|
||||
private CharacterSummary? SelectedCharacter => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
|
||||
private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
|
||||
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == ActiveCharacterId)?.Name;
|
||||
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
|
||||
private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
|
||||
private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
@@ -103,6 +103,8 @@ public partial class Home
|
||||
_ => "offline"
|
||||
};
|
||||
|
||||
private string AppCssClass => User is not null && CurrentScreen == "play" ? "rr-app app-play" : "rr-app";
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
HasInteractiveRenderStarted = true;
|
||||
@@ -264,6 +266,7 @@ public partial class Home
|
||||
CampaignLog = (await RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{SelectedCampaignId.Value}/log")).ToList();
|
||||
SyncSelectedCharacter();
|
||||
SyncSelectedSkill();
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
@@ -619,27 +622,6 @@ public partial class Home
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ActivateCharacterAsync(Guid characterId)
|
||||
{
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
await RequestWithoutPayloadAsync("POST", $"/api/characters/{characterId}/activate");
|
||||
ActiveCharacterId = characterId;
|
||||
SelectedCharacterId = characterId;
|
||||
await RefreshCampaignScopeAsync();
|
||||
SetStatus("Active character updated.", false);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenCreateSkillModal()
|
||||
{
|
||||
SkillForm.Name = string.Empty;
|
||||
@@ -808,10 +790,17 @@ public partial class Home
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectCharacter(Guid characterId)
|
||||
private Task OnRollVisibilityChanged(string visibility)
|
||||
{
|
||||
RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SelectCharacterAsync(Guid characterId)
|
||||
{
|
||||
SelectedCharacterId = characterId;
|
||||
SyncSelectedSkill();
|
||||
await EnsureSelectedCharacterActiveAsync();
|
||||
}
|
||||
|
||||
private void SelectSkill(Guid skillId)
|
||||
@@ -829,6 +818,35 @@ public partial class Home
|
||||
return User is not null && character.OwnerUserId == User.Id;
|
||||
}
|
||||
|
||||
private async Task EnsureSelectedCharacterActiveAsync()
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
|
||||
if (character is null || !CanActivateCharacter(character))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (ActiveCharacterId == character.Id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
|
||||
ActiveCharacterId = character.Id;
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
SetStatus(ex.Message, true);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanEditSkill(SkillSummary skill)
|
||||
{
|
||||
if (SelectedCampaign is null)
|
||||
@@ -1033,82 +1051,6 @@ public partial class Home
|
||||
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumble}";
|
||||
}
|
||||
|
||||
private static string RollDieGlyph(int roll)
|
||||
{
|
||||
return roll switch
|
||||
{
|
||||
1 => "\u2680",
|
||||
2 => "\u2681",
|
||||
3 => "\u2682",
|
||||
4 => "\u2683",
|
||||
5 => "\u2684",
|
||||
6 => "\u2685",
|
||||
_ => roll.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieCssClass(RollDieResult die)
|
||||
{
|
||||
var classes = new List<string> { "die-chip" };
|
||||
if (die.Wild)
|
||||
{
|
||||
classes.Add("wild");
|
||||
}
|
||||
|
||||
if (die.Crit)
|
||||
{
|
||||
classes.Add("crit");
|
||||
}
|
||||
|
||||
if (die.Fumble)
|
||||
{
|
||||
classes.Add("fumble");
|
||||
}
|
||||
|
||||
if (die.Removed)
|
||||
{
|
||||
classes.Add("removed");
|
||||
}
|
||||
|
||||
if (die.Added)
|
||||
{
|
||||
classes.Add("added");
|
||||
}
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
private static string RollDieTitle(RollDieResult die)
|
||||
{
|
||||
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||
if (die.Wild)
|
||||
{
|
||||
labels.Add("wild");
|
||||
}
|
||||
|
||||
if (die.Crit)
|
||||
{
|
||||
labels.Add("critical");
|
||||
}
|
||||
|
||||
if (die.Fumble)
|
||||
{
|
||||
labels.Add("fumble");
|
||||
}
|
||||
|
||||
if (die.Removed)
|
||||
{
|
||||
labels.Add("removed");
|
||||
}
|
||||
|
||||
if (die.Added)
|
||||
{
|
||||
labels.Add("added");
|
||||
}
|
||||
|
||||
return string.Join(", ", labels);
|
||||
}
|
||||
|
||||
private string RollerLabel(CampaignLogEntry entry)
|
||||
{
|
||||
if (User is not null && entry.RollerUserId == User.Id)
|
||||
@@ -1184,22 +1126,6 @@ public partial class Home
|
||||
return "private-generic";
|
||||
}
|
||||
|
||||
private static string InitialsFor(string value)
|
||||
{
|
||||
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (words.Length == 0)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
if (words.Length == 1)
|
||||
{
|
||||
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
|
||||
}
|
||||
|
||||
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
|
||||
}
|
||||
|
||||
private void ClearAuthenticatedState()
|
||||
{
|
||||
User = null;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
<aside class="card log-panel">
|
||||
<div class="section-head"><h2>Campaign Log</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
|
||||
}
|
||||
else if (CampaignLog.Count == 0)
|
||||
{
|
||||
<p class="empty">No log entries yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="log-list">
|
||||
@foreach (var entry in CampaignLog)
|
||||
{
|
||||
<li class="log-entry @LogEntryCssClass(entry)">
|
||||
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
|
||||
<p class="roll-total inline">@entry.Result</p>
|
||||
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice" />
|
||||
<p>@entry.Breakdown</p>
|
||||
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</aside>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool IsCampaignDataLoading { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
|
||||
}
|
||||
186
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor
Normal file
186
RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor
Normal file
@@ -0,0 +1,186 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
<section class="card character-panel">
|
||||
<div class="section-head"><h2>Character Context</h2></div>
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
|
||||
}
|
||||
else if (SelectedCampaign is null)
|
||||
{
|
||||
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
|
||||
}
|
||||
else if (SelectedCampaign.Characters.Count == 0)
|
||||
{
|
||||
<p class="empty">No characters in this campaign yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="character-picker" role="tablist" aria-label="Character picker">
|
||||
@foreach (var character in SelectedCampaign.Characters)
|
||||
{
|
||||
var isSelectedCharacter = SelectedCharacterId == character.Id;
|
||||
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
|
||||
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
|
||||
<span class="icon-tab-text">@character.Name</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (SelectedCharacter is not null)
|
||||
{
|
||||
<article class="character-sheet">
|
||||
<h3>@SelectedCharacter.Name</h3>
|
||||
<p>Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)</p>
|
||||
<p>Campaign: @SelectedCampaign.Name</p>
|
||||
<span class="badge active">Active</span>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character</button>
|
||||
</div>
|
||||
</article>
|
||||
<article class="skills-section">
|
||||
<div class="section-head">
|
||||
<h3>Skills</h3>
|
||||
<div class="inline-actions">
|
||||
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="CreateSkillRequested">Create Skill</button>
|
||||
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="EditSkillRequested">Edit Skill</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (SelectedCharacterSkills.Count == 0)
|
||||
{
|
||||
<p class="empty">No skills for this character yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="skill-list">
|
||||
@foreach (var skill in SelectedCharacterSkills)
|
||||
{
|
||||
var isSelectedSkill = SelectedSkillId == skill.Id;
|
||||
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SkillSelected.InvokeAsync(skill.Id)">
|
||||
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<form class="roll-panel" @onsubmit="OnRollSubmitAsync" @onsubmit:preventDefault>
|
||||
<label for="roll-visibility">Visibility</label>
|
||||
<select id="roll-visibility" value="@RollVisibility" @onchange="OnRollVisibilityChangedAsync">
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
|
||||
</form>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
<article class="last-roll">
|
||||
<h3>Last Roll</h3>
|
||||
@if (LastRoll is null)
|
||||
{
|
||||
<p class="empty">No roll yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="roll-total">@LastRoll.Result</p>
|
||||
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice" />
|
||||
<p>@LastRoll.Breakdown</p>
|
||||
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool IsCampaignDataLoading { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public CampaignDetails? SelectedCampaign { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Guid? SelectedCharacterId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public CharacterSummary? SelectedCharacter { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsMutating { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public Guid? SelectedSkillId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public SkillSummary? SelectedSkill { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string RollVisibility { get; set; } = "public";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> RollVisibilityChanged { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RollResult? LastRoll { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
|
||||
|
||||
[Parameter]
|
||||
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
|
||||
|
||||
[Parameter]
|
||||
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> CharacterSelected { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> SkillSelected { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CreateSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback EditSkillRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback RollRequested { get; set; }
|
||||
|
||||
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
var selectedVisibility = args.Value?.ToString() ?? "public";
|
||||
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
|
||||
}
|
||||
|
||||
private async Task OnRollSubmitAsync()
|
||||
{
|
||||
await RollRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private static string InitialsFor(string value)
|
||||
{
|
||||
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (words.Length == 0)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
|
||||
if (words.Length == 1)
|
||||
{
|
||||
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
|
||||
}
|
||||
|
||||
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
97
RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor
Normal file
97
RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor
Normal file
@@ -0,0 +1,97 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Contracts
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
@if (Dice.Count > 0)
|
||||
{
|
||||
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
||||
@foreach (var die in Dice)
|
||||
{
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string AriaLabel { get; set; } = "Rolled dice";
|
||||
|
||||
private static string RollDieGlyph(int roll)
|
||||
{
|
||||
return roll switch
|
||||
{
|
||||
1 => "\u2680",
|
||||
2 => "\u2681",
|
||||
3 => "\u2682",
|
||||
4 => "\u2683",
|
||||
5 => "\u2684",
|
||||
6 => "\u2685",
|
||||
_ => roll.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieCssClass(RollDieResult die)
|
||||
{
|
||||
var classes = new List<string> { "die-chip" };
|
||||
if (die.Wild)
|
||||
{
|
||||
classes.Add("wild");
|
||||
}
|
||||
|
||||
if (die.Crit)
|
||||
{
|
||||
classes.Add("crit");
|
||||
}
|
||||
|
||||
if (die.Fumble)
|
||||
{
|
||||
classes.Add("fumble");
|
||||
}
|
||||
|
||||
if (die.Removed)
|
||||
{
|
||||
classes.Add("removed");
|
||||
}
|
||||
|
||||
if (die.Added)
|
||||
{
|
||||
classes.Add("added");
|
||||
}
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
private static string RollDieTitle(RollDieResult die)
|
||||
{
|
||||
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||
if (die.Wild)
|
||||
{
|
||||
labels.Add("wild");
|
||||
}
|
||||
|
||||
if (die.Crit)
|
||||
{
|
||||
labels.Add("critical");
|
||||
}
|
||||
|
||||
if (die.Fumble)
|
||||
{
|
||||
labels.Add("fumble");
|
||||
}
|
||||
|
||||
if (die.Removed)
|
||||
{
|
||||
labels.Add("removed");
|
||||
}
|
||||
|
||||
if (die.Added)
|
||||
{
|
||||
labels.Add("added");
|
||||
}
|
||||
|
||||
return string.Join(", ", labels);
|
||||
}
|
||||
}
|
||||
@@ -52,4 +52,5 @@ public sealed record CampaignLogEntry(
|
||||
string Visibility,
|
||||
int Result,
|
||||
string Breakdown,
|
||||
IReadOnlyList<RollDieResult> Dice,
|
||||
DateTimeOffset TimestampUtc);
|
||||
|
||||
@@ -69,6 +69,7 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Visibility).HasConversion<string>().IsRequired();
|
||||
entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256);
|
||||
entity.Property(x => x.Dice).IsRequired();
|
||||
entity.Property(x => x.TimestampUtc).IsRequired();
|
||||
entity.HasIndex(x => x.CampaignId);
|
||||
entity.HasIndex(x => x.RollerUserId);
|
||||
|
||||
@@ -66,6 +66,7 @@ public sealed class RollLogEntry
|
||||
public required RollVisibility Visibility { get; init; }
|
||||
public required int Result { get; init; }
|
||||
public required string Breakdown { get; init; }
|
||||
public required string Dice { get; init; }
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
}
|
||||
|
||||
|
||||
216
RpgRoller/Migrations/20260226100000_AddRollLogDice.Designer.cs
generated
Normal file
216
RpgRoller/Migrations/20260226100000_AddRollLogDice.Designer.cs
generated
Normal file
@@ -0,0 +1,216 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using RpgRoller.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
[DbContext(typeof(RpgRollerDbContext))]
|
||||
[Migration("20260226100000_AddRollLogDice")]
|
||||
partial class AddRollLogDice
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GmUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Ruleset")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Version")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GmUserId");
|
||||
|
||||
b.ToTable("Campaigns");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId");
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Breakdown")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Dice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Result")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("RollerUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SkillId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("TimestampUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Visibility")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RollerUserId");
|
||||
|
||||
b.HasIndex("SkillId");
|
||||
|
||||
b.ToTable("RollLogEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AllowFumble")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DiceRollDefinition")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WildDice")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.ToTable("Skills");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ActiveCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UsernameNormalized")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UsernameNormalized")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
|
||||
{
|
||||
b.Property<string>("Token")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Token");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Sessions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
29
RpgRoller/Migrations/20260226100000_AddRollLogDice.cs
Normal file
29
RpgRoller/Migrations/20260226100000_AddRollLogDice.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RpgRoller.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRollLogDice : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Dice",
|
||||
table: "RollLogEntries",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Dice",
|
||||
table: "RollLogEntries");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,10 @@ namespace RpgRoller.Migrations
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Dice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Result")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Data;
|
||||
using RpgRoller.Domain;
|
||||
@@ -8,6 +9,7 @@ namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameService : IGameService
|
||||
{
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly object m_Gate = new();
|
||||
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
|
||||
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
|
||||
@@ -550,6 +552,7 @@ public sealed class GameService : IGameService
|
||||
Visibility = parsedVisibility.Value,
|
||||
Result = roll.Total,
|
||||
Breakdown = roll.Breakdown,
|
||||
Dice = SerializeDice(roll.Dice),
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
@@ -810,6 +813,8 @@ public sealed class GameService : IGameService
|
||||
|
||||
private static CampaignLogEntry ToLogEntry(RollLogEntry entry)
|
||||
{
|
||||
var dice = DeserializeDice(entry.Dice);
|
||||
|
||||
return new CampaignLogEntry(
|
||||
entry.Id,
|
||||
entry.CampaignId,
|
||||
@@ -819,9 +824,32 @@ public sealed class GameService : IGameService
|
||||
entry.Visibility == RollVisibility.Public ? "public" : "private",
|
||||
entry.Result,
|
||||
entry.Breakdown,
|
||||
dice,
|
||||
entry.TimestampUtc);
|
||||
}
|
||||
|
||||
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
|
||||
{
|
||||
return JsonSerializer.Serialize(dice, DiceJsonOptions);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serializedDice))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<IReadOnlyList<RollDieResult>>(serializedDice, DiceJsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign)
|
||||
{
|
||||
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
|
||||
@@ -1063,6 +1091,7 @@ public sealed class GameService : IGameService
|
||||
Visibility = entry.Visibility,
|
||||
Result = entry.Result,
|
||||
Breakdown = entry.Breakdown,
|
||||
Dice = entry.Dice,
|
||||
TimestampUtc = entry.TimestampUtc
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -47,6 +48,12 @@ h3 {
|
||||
padding: 1rem 1rem 4.5rem;
|
||||
}
|
||||
|
||||
.rr-app.app-play {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading-shell,
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
@@ -65,6 +72,12 @@ h3 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-play .workspace-shell {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -212,6 +225,11 @@ select:focus-visible {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(19rem, 1fr);
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-play .play-screen {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.management-screen {
|
||||
@@ -266,6 +284,19 @@ select:focus-visible {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.app-play .character-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-play .log-panel {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
@@ -297,6 +328,10 @@ select:focus-visible {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roll-total.inline {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.roll-dice-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -564,4 +599,8 @@ select:focus-visible {
|
||||
.mobile-bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.rr-app.app-play {
|
||||
padding-bottom: 4.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user