5 Commits

Author SHA1 Message Date
1ca6ab7117 feat: centralize frontend Vuetify styling
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s
2026-05-08 13:45:42 -04:00
e81c9f42c9 fix: scope organization access by membership
All checks were successful
deploy-socialize / image (push) Successful in 54s
deploy-socialize / deploy (push) Successful in 19s
2026-05-08 09:09:16 -04:00
c527011646 feat: add release digest controls
All checks were successful
deploy-socialize / image (push) Successful in 1m13s
deploy-socialize / deploy (push) Successful in 19s
2026-05-08 08:30:47 -04:00
0b7edb1b7f chore: add release note generator
All checks were successful
deploy-socialize / image (push) Successful in 26s
deploy-socialize / deploy (push) Successful in 18s
2026-05-08 00:38:50 -04:00
dcfdce1ec6 Simplify release notes workflow
Some checks failed
deploy-socialize / image (push) Successful in 1m9s
deploy-socialize / deploy (push) Has been cancelled
2026-05-08 00:37:14 -04:00
116 changed files with 17026 additions and 3295 deletions

View File

@@ -24,7 +24,7 @@ internal sealed class AccessScopeService(
public static bool CanAccessWorkspace(ClaimsPrincipal user, Guid workspaceId)
{
return IsManager(user) || user.GetWorkspaceScopeIds().Contains(workspaceId);
return user.GetWorkspaceScopeIds().Contains(workspaceId);
}
public static bool CanManageWorkspace(ClaimsPrincipal user, Guid workspaceId)
@@ -34,24 +34,25 @@ internal sealed class AccessScopeService(
public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
{
return IsManager(user)
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId));
return CanAccessWorkspace(user, workspaceId) &&
(IsManager(user) || user.GetClientScopeIds().Contains(clientId));
}
public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user)
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId));
return CanAccessClient(user, workspaceId, clientId) &&
(IsManager(user) || user.GetCampaignScopeIds().Contains(campaignId));
}
public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user) || (IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId));
return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId);
}
public static bool CanReviewContent(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{
return IsManager(user)
return IsManager(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
}
@@ -68,7 +69,7 @@ internal sealed class AccessScopeService(
Guid workspaceId,
CancellationToken ct)
{
return CanAccessWorkspace(user, workspaceId)
return user.GetWorkspaceScopeIds().Contains(workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
@@ -81,7 +82,7 @@ internal sealed class AccessScopeService(
Guid workspaceId,
CancellationToken ct)
{
return IsManager(user)
return CanManageWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
@@ -94,8 +95,7 @@ internal sealed class AccessScopeService(
Guid organizationId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasOrganizationPermissionAsync(
return await organizationAccessService.HasOrganizationPermissionAsync(
user,
organizationId,
OrganizationPermissions.CreateWorkspaces,
@@ -108,8 +108,7 @@ internal sealed class AccessScopeService(
Guid clientId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
@@ -128,8 +127,7 @@ internal sealed class AccessScopeService(
Guid campaignId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
@@ -149,7 +147,7 @@ internal sealed class AccessScopeService(
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
@@ -165,7 +163,7 @@ internal sealed class AccessScopeService(
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class SimplifyReleaseUpdates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Audience",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Body",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "BuildVersion",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Category",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "CommitRange",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "DeploymentLabel",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "Importance",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailAudience",
table: "ReleaseUpdates");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Audience",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Body",
table: "ReleaseUpdates",
type: "character varying(8000)",
maxLength: 8000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BuildVersion",
table: "ReleaseUpdates",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Category",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "CommitRange",
table: "ReleaseUpdates",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DeploymentLabel",
table: "ReleaseUpdates",
type: "character varying(128)",
maxLength: 128,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Importance",
table: "ReleaseUpdates",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ManualEmailAudience",
table: "ReleaseUpdates",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ReleaseUpdates_Audience",
table: "ReleaseUpdates",
column: "Audience");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddFrenchReleaseUpdateFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TitleFr",
table: "ReleaseUpdates",
type: "character varying(160)",
maxLength: 160,
nullable: false,
defaultValue: "");
migrationBuilder.Sql("""
UPDATE "ReleaseUpdates"
SET "TitleFr" = "Title",
"SummaryFr" = "Summary"
WHERE "TitleFr" = '' AND "SummaryFr" = '';
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SummaryFr",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "TitleFr",
table: "ReleaseUpdates");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class RemoveManualReleaseUpdateEmail : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "ManualEmailRecipientCount",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailSentAt",
table: "ReleaseUpdates");
migrationBuilder.DropColumn(
name: "ManualEmailSentByUserId",
table: "ReleaseUpdates");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<int>(
name: "ManualEmailRecipientCount",
table: "ReleaseUpdates",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ManualEmailSentAt",
table: "ReleaseUpdates",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ManualEmailSentByUserId",
table: "ReleaseUpdates",
type: "uuid",
nullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class ExpandReleaseUpdateDescriptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AlterColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
migrationBuilder.AlterColumn<string>(
name: "Summary",
table: "ReleaseUpdates",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AlterColumn<string>(
name: "SummaryFr",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4000)",
oldMaxLength: 4000);
migrationBuilder.AlterColumn<string>(
name: "Summary",
table: "ReleaseUpdates",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(4000)",
oldMaxLength: 4000);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddUserPreferredLanguage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<string>(
name: "PreferredLanguage",
table: "AspNetUsers",
type: "character varying(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "PreferredLanguage",
table: "AspNetUsers");
}
}
}

View File

@@ -1556,6 +1556,11 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreferredLanguage")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("RefreshToken")
.HasMaxLength(44)
.HasColumnType("character varying(44)");
@@ -1980,28 +1985,6 @@ namespace Socialize.Api.Migrations
b.Property<DateTimeOffset?>("ArchivedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Audience")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Body")
.HasMaxLength(8000)
.HasColumnType("character varying(8000)");
b.Property<string>("BuildVersion")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("CommitRange")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
@@ -2010,28 +1993,6 @@ namespace Socialize.Api.Migrations
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid");
b.Property<string>("DeploymentLabel")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Importance")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ManualEmailAudience")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ManualEmailRecipientCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ManualEmailSentAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ManualEmailSentByUserId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PublishedAt")
.HasColumnType("timestamp with time zone");
@@ -2042,21 +2003,29 @@ namespace Socialize.Api.Migrations
b.Property<string>("Summary")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("SummaryFr")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("TitleFr")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Audience");
b.HasIndex("CreatedByUserId");
b.HasIndex("PublishedAt");

View File

@@ -34,24 +34,21 @@ internal class GetCampaignsHandler(
{
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
if (!AccessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
if (clientScopeIds.Count > 0)
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
{
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
}
if (campaignScopeIds.Count > 0)
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0)
{
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
}
}
if (request.ClientId.HasValue)
{

View File

@@ -23,11 +23,8 @@ internal class GetChannelsHandler(
{
IQueryable<Channel> query = dbContext.Channels.AsQueryable();
if (!AccessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
}
if (request.WorkspaceId.HasValue)
{

View File

@@ -33,20 +33,16 @@ internal class GetClientsHandler(
{
IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (!AccessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
if (clientScopeIds.Count > 0)
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
{
query = query.Where(client => clientScopeIds.Contains(client.Id));
}
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);

View File

@@ -37,24 +37,21 @@ internal class GetContentItemsHandler(
{
IQueryable<ContentItem> query = dbContext.ContentItems.AsQueryable();
if (!AccessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
if (clientScopeIds.Count > 0)
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
{
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
}
if (campaignScopeIds.Count > 0)
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0)
{
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
}
}
if (request.WorkspaceId.HasValue)
{

View File

@@ -37,7 +37,8 @@ internal class IdentityService(
Firstname = user.Firstname,
Lastname = user.Lastname,
BirthDate = user.BirthDate,
Address = user.Address
Address = user.Address,
PreferredLanguage = user.PreferredLanguage
};
ret = userModel;

View File

@@ -13,6 +13,7 @@ internal class User : IdentityUser<Guid>
[MaxLength(2048)] public string? PortraitUrl { get; set; }
[MaxLength(256)] public string? GoogleId { get; set; }
[MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(8)] public string PreferredLanguage { get; set; } = "en";
[MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; }
public DateTimeOffset? LastAuthenticatedAt { get; set; }

View File

@@ -0,0 +1,57 @@
using FastEndpoints;
using Microsoft.AspNetCore.Identity;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Data;
namespace Socialize.Api.Modules.Identity.Handlers;
[PublicAPI]
internal record ChangePreferredLanguageRequest(string PreferredLanguage);
[PublicAPI]
internal class ChangePreferredLanguageValidator : Validator<ChangePreferredLanguageRequest>
{
public ChangePreferredLanguageValidator()
{
RuleFor(x => x.PreferredLanguage)
.Must(value => value is "en" or "fr")
.WithMessage("Preferred language must be en or fr.");
}
}
[PublicAPI]
internal class ChangePreferredLanguageHandler(UserManager userManager)
: Endpoint<ChangePreferredLanguageRequest>
{
public override void Configure()
{
Post("/api/users/preferred-language");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangePreferredLanguageRequest request,
CancellationToken ct)
{
User? user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.PreferredLanguage = request.PreferredLanguage;
IdentityResult result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -74,6 +74,7 @@ internal class GetCurrentUserQueryHandler(
Email = userModel.Email,
BirthDate = userModel.BirthDate,
Address = userModel.Address,
PreferredLanguage = userModel.PreferredLanguage,
UserRoles = roles
},
ct);

View File

@@ -17,4 +17,5 @@ internal class UserDto
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
public string PreferredLanguage { get; init; } = "en";
}

View File

@@ -12,4 +12,5 @@ internal class UserModel
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
public string PreferredLanguage { get; init; } = "en";
}

View File

@@ -56,13 +56,10 @@ internal class GetNotificationsHandler(
IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
Guid currentUserId = User.GetUserId();
if (!AccessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(notificationEvent =>
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
notificationEvent.RecipientUserId == currentUserId);
}
query = query.Where(notificationEvent =>
notificationEvent.RecipientUserId == null ||

View File

@@ -2,29 +2,16 @@ using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Organizations.Services;
internal sealed class OrganizationAccessService(
AppDbContext dbContext)
{
public static bool IsGlobalManager(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
}
public async Task<IReadOnlyCollection<Guid>> GetAccessibleOrganizationIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return await dbContext.Organizations
.Select(organization => organization.Id)
.ToArrayAsync(ct);
}
Guid userId = user.GetUserId();
Guid[] ownedOrganizationIds = await dbContext.Organizations
@@ -47,13 +34,6 @@ internal sealed class OrganizationAccessService(
ClaimsPrincipal user,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return await dbContext.Workspaces
.Select(workspace => workspace.Id)
.ToArrayAsync(ct);
}
Guid[] directWorkspaceIds = user.GetWorkspaceScopeIds().ToArray();
Guid[] organizationWorkspaceIds = await GetInheritedWorkspaceIdsAsync(user, OrganizationPermissions.AccessOwnedWorkspaces, ct);
@@ -68,11 +48,6 @@ internal sealed class OrganizationAccessService(
Guid organizationId,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid userId = user.GetUserId();
return await dbContext.Organizations.AnyAsync(
@@ -89,11 +64,6 @@ internal sealed class OrganizationAccessService(
string permission,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid userId = user.GetUserId();
bool owner = await dbContext.Organizations.AnyAsync(
@@ -117,11 +87,6 @@ internal sealed class OrganizationAccessService(
Guid organizationId,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
}
Guid userId = user.GetUserId();
bool owner = await dbContext.Organizations.AnyAsync(
@@ -150,11 +115,6 @@ internal sealed class OrganizationAccessService(
string permission,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid? organizationId = await dbContext.Workspaces
.Where(workspace => workspace.Id == workspaceId)
.Select(workspace => (Guid?)workspace.OrganizationId)

View File

@@ -5,23 +5,16 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Contracts;
internal record ReleaseUpdateDto(
Guid Id,
string Title,
string Summary,
string? Body,
string Category,
string Importance,
string Audience,
string Description,
string TitleEn,
string DescriptionEn,
string TitleFr,
string DescriptionFr,
string Status,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? PublishedAt,
DateTimeOffset? ArchivedAt,
Guid? ManualEmailSentByUserId,
DateTimeOffset? ManualEmailSentAt,
string? ManualEmailAudience,
int? ManualEmailRecipientCount,
bool IsRead);
internal record ReleaseCommitDto(
@@ -40,22 +33,21 @@ internal record ReleaseCommitDto(
DateTimeOffset ImportedAt,
DateTimeOffset UpdatedAt);
internal record ReleaseCommitImportResultDto(
int ImportedCount,
internal record ReleaseCommitRefreshResultDto(
int CreatedCount,
int UpdatedCount,
int SkippedCount,
IReadOnlyCollection<ReleaseCommitDto> Commits);
internal record ReleaseUpdateEmailSendResultDto(
int RecipientCount,
DateTimeOffset SentAt,
bool TestMode);
internal record ReleaseCommitBulkLinkResultDto(int LinkedCount);
internal record ReleaseUpdateUnreadSummaryDto(
int UnreadCount,
int ImportantUnreadCount,
IReadOnlyCollection<ReleaseUpdateDto> Updates);
internal record ReleaseUpdateDigestSendResultDto(int SentCount);
internal static class ReleaseUpdateDtoMapper
{
public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead)
@@ -64,22 +56,15 @@ internal static class ReleaseUpdateDtoMapper
update.Id,
update.Title,
update.Summary,
update.Body,
ToDisplayString(update.Category),
update.Importance.ToString(),
update.Audience.ToString(),
update.Title,
update.Summary,
update.TitleFr,
update.SummaryFr,
update.Status.ToString(),
update.DeploymentLabel,
update.BuildVersion,
update.CommitRange,
update.CreatedAt,
update.UpdatedAt,
update.PublishedAt,
update.ArchivedAt,
update.ManualEmailSentByUserId,
update.ManualEmailSentAt,
update.ManualEmailAudience,
update.ManualEmailRecipientCount,
isRead);
}
@@ -102,8 +87,4 @@ internal static class ReleaseUpdateDtoMapper
commit.UpdatedAt);
}
private static string ToDisplayString(ReleaseUpdateCategory category)
{
return category == ReleaseUpdateCategory.BreakingChange ? "Breaking Change" : category.ToString();
}
}

View File

@@ -11,19 +11,12 @@ internal static class ReleaseCommunicationsModelConfiguration
releaseUpdate.ToTable("ReleaseUpdates");
releaseUpdate.HasKey(x => x.Id);
releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.Summary).HasMaxLength(512).IsRequired();
releaseUpdate.Property(x => x.Body).HasMaxLength(8000);
releaseUpdate.Property(x => x.Category).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Importance).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Audience).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Summary).HasMaxLength(4000).IsRequired();
releaseUpdate.Property(x => x.TitleFr).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.SummaryFr).HasMaxLength(4000).IsRequired();
releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.DeploymentLabel).HasMaxLength(128);
releaseUpdate.Property(x => x.BuildVersion).HasMaxLength(128);
releaseUpdate.Property(x => x.CommitRange).HasMaxLength(256);
releaseUpdate.Property(x => x.ManualEmailAudience).HasMaxLength(64);
releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
releaseUpdate.HasIndex(x => x.Status);
releaseUpdate.HasIndex(x => x.Audience);
releaseUpdate.HasIndex(x => x.PublishedAt);
releaseUpdate.HasIndex(x => x.CreatedByUserId);
});

View File

@@ -5,22 +5,13 @@ internal class ReleaseUpdate
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string? Body { get; set; }
public ReleaseUpdateCategory Category { get; set; }
public ReleaseUpdateImportance Importance { get; set; }
public ReleaseUpdateAudience Audience { get; set; }
public string TitleFr { get; set; } = string.Empty;
public string SummaryFr { get; set; } = string.Empty;
public ReleaseUpdateStatus Status { get; set; }
public string? DeploymentLabel { get; set; }
public string? BuildVersion { get; set; }
public string? CommitRange { get; set; }
public Guid CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? PublishedAt { get; set; }
public DateTimeOffset? ArchivedAt { get; set; }
public Guid? ManualEmailSentByUserId { get; set; }
public DateTimeOffset? ManualEmailSentAt { get; set; }
public string? ManualEmailAudience { get; set; }
public int? ManualEmailRecipientCount { get; set; }
public ICollection<ReleaseUpdateReadReceipt> ReadReceipts { get; } = new List<ReleaseUpdateReadReceipt>();
}

View File

@@ -1,8 +0,0 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateAudience
{
Everyone,
OrganizationOwners,
Developers,
}

View File

@@ -1,9 +0,0 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateCategory
{
Feature,
Improvement,
Fix,
BreakingChange,
}

View File

@@ -1,7 +0,0 @@
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
internal enum ReleaseUpdateImportance
{
Normal,
Important,
}

View File

@@ -4,35 +4,24 @@ using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record CreateDeveloperReleaseUpdateRequest(
string Title,
string Summary,
string? Body,
string Category,
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
string TitleEn,
string DescriptionEn,
string TitleFr,
string DescriptionFr);
internal class CreateDeveloperReleaseUpdateRequestValidator
: Validator<CreateDeveloperReleaseUpdateRequest>
{
public CreateDeveloperReleaseUpdateRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
RuleFor(x => x.Body).MaximumLength(8000);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
RuleFor(x => x.Importance).NotEmpty().MaximumLength(32);
RuleFor(x => x.Audience).NotEmpty().MaximumLength(32);
RuleFor(x => x.DeploymentLabel).MaximumLength(128);
RuleFor(x => x.BuildVersion).MaximumLength(128);
RuleFor(x => x.CommitRange).MaximumLength(256);
RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
}
}
@@ -48,26 +37,15 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
public override async Task HandleAsync(CreateDeveloperReleaseUpdateRequest request, CancellationToken ct)
{
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
ReleaseUpdate update = new()
{
Id = Guid.NewGuid(),
Title = request.Title.Trim(),
Summary = request.Summary.Trim(),
Body = NormalizeOptional(request.Body),
Category = category,
Importance = importance,
Audience = audience,
Title = request.TitleEn.Trim(),
Summary = request.DescriptionEn.Trim(),
TitleFr = request.TitleFr.Trim(),
SummaryFr = request.DescriptionFr.Trim(),
Status = ReleaseUpdateStatus.Draft,
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
BuildVersion = NormalizeOptional(request.BuildVersion),
CommitRange = NormalizeOptional(request.CommitRange),
CreatedByUserId = User.GetUserId(),
CreatedAt = now,
UpdatedAt = now,
@@ -78,38 +56,4 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct);
}
private bool TryParseRequest(
CreateDeveloperReleaseUpdateRequest request,
out ReleaseUpdateCategory category,
out ReleaseUpdateImportance importance,
out ReleaseUpdateAudience audience)
{
bool isValid = true;
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
{
AddError(x => x.Category, "The selected release update category is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
{
AddError(x => x.Importance, "The selected release update importance is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
{
AddError(x => x.Audience, "The selected release update audience is not valid.");
isValid = false;
}
return isValid;
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}

View File

@@ -0,0 +1,28 @@
using FastEndpoints;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class ForceDeveloperReleaseUpdateDigestEmailsHandler(ReleaseUpdateEmailService emailService)
: EndpointWithoutRequest<ReleaseUpdateDigestSendResultDto>
{
public override void Configure()
{
Post("/api/developer/release-update-email-digests/force");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
int sentCount = await emailService.SendDueDigestEmailsAsync(
TimeSpan.Zero,
TimeSpan.Zero,
force: true,
ct: ct);
await SendOkAsync(new ReleaseUpdateDigestSendResultDto(sentCount), ct);
}
}

View File

@@ -20,11 +20,9 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == userId))
@@ -35,7 +33,7 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
await SendOkAsync(
new ReleaseUpdateUnreadSummaryDto(
unreadUpdates.Count,
unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important),
0,
unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
ct);
}

View File

@@ -1,156 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record ImportDeveloperReleaseCommitDto(
string Sha,
string? ShortSha,
string Subject,
string? AuthorName,
string? AuthorEmail,
DateTimeOffset? AuthoredAt,
DateTimeOffset? CommittedAt,
string? SourceBranch,
string? DeploymentLabel,
string? ExternalUrl);
internal record ImportDeveloperReleaseCommitsRequest(
string? SinceSha,
string? UntilSha,
string? SourceBranch,
string? DeploymentLabel,
DateTimeOffset? Since,
DateTimeOffset? Until,
int? Limit,
IReadOnlyCollection<ImportDeveloperReleaseCommitDto>? Commits);
internal class ImportDeveloperReleaseCommitsHandler(
AppDbContext dbContext,
ReleaseCommitRepositoryImportService repositoryImportService)
: Endpoint<ImportDeveloperReleaseCommitsRequest, ReleaseCommitImportResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/import");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(ImportDeveloperReleaseCommitsRequest request, CancellationToken ct)
{
IReadOnlyCollection<ReleaseCommit> requestedCommits;
if (request.Commits is { Count: > 0 })
{
requestedCommits = request.Commits.Select(ToReleaseCommit).ToArray();
}
else
{
try
{
ReleaseCommitRepositoryImportResult importResult = await repositoryImportService.FetchCommitsAsync(request, ct);
if (!importResult.IsSuccess)
{
AddError(importResult.ErrorMessage ?? "Repository commit import failed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
requestedCommits = importResult.Commits;
}
catch (HttpRequestException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
catch (JsonException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
}
int imported = 0;
int updated = 0;
int skipped = 0;
List<ReleaseCommit> savedCommits = [];
foreach (ReleaseCommit requestedCommit in requestedCommits)
{
if (string.IsNullOrWhiteSpace(requestedCommit.Sha) || string.IsNullOrWhiteSpace(requestedCommit.Subject))
{
skipped++;
continue;
}
ReleaseCommit? existingCommit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(
commit => commit.Sha == requestedCommit.Sha,
ct);
if (existingCommit is null)
{
dbContext.ReleaseCommits.Add(requestedCommit);
savedCommits.Add(requestedCommit);
imported++;
continue;
}
existingCommit.ShortSha = requestedCommit.ShortSha;
existingCommit.Subject = requestedCommit.Subject;
existingCommit.AuthorName = requestedCommit.AuthorName;
existingCommit.AuthorEmail = requestedCommit.AuthorEmail;
existingCommit.AuthoredAt = requestedCommit.AuthoredAt;
existingCommit.CommittedAt = requestedCommit.CommittedAt;
existingCommit.SourceBranch = requestedCommit.SourceBranch ?? existingCommit.SourceBranch;
existingCommit.DeploymentLabel = requestedCommit.DeploymentLabel ?? existingCommit.DeploymentLabel;
existingCommit.ExternalUrl = requestedCommit.ExternalUrl ?? existingCommit.ExternalUrl;
existingCommit.UpdatedAt = DateTimeOffset.UtcNow;
savedCommits.Add(existingCommit);
updated++;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
new ReleaseCommitImportResultDto(imported, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
ct);
}
private static ReleaseCommit ToReleaseCommit(ImportDeveloperReleaseCommitDto dto)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
return new ReleaseCommit
{
Sha = dto.Sha.Trim(),
ShortSha = NormalizeOptional(dto.ShortSha) ?? dto.Sha.Trim()[..Math.Min(dto.Sha.Trim().Length, 12)],
Subject = dto.Subject.Trim(),
AuthorName = NormalizeOptional(dto.AuthorName),
AuthorEmail = NormalizeOptional(dto.AuthorEmail),
AuthoredAt = ToUtc(dto.AuthoredAt),
CommittedAt = ToUtc(dto.CommittedAt),
SourceBranch = NormalizeOptional(dto.SourceBranch),
DeploymentLabel = NormalizeOptional(dto.DeploymentLabel),
ExternalUrl = NormalizeOptional(dto.ExternalUrl),
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
ImportedAt = now,
UpdatedAt = now,
};
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static DateTimeOffset? ToUtc(DateTimeOffset? value)
{
return value?.ToUniversalTime();
}
}

View File

@@ -20,11 +20,9 @@ internal class ListReleaseUpdatesHandler(AppDbContext dbContext)
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.VisibleToUsers()
.OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct);

View File

@@ -19,11 +19,9 @@ internal class MarkAllReleaseUpdatesReadHandler(AppDbContext dbContext)
public override async Task HandleAsync(CancellationToken ct)
{
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<Guid> visibleUpdateIds = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.VisibleToUsers()
.Select(update => update.Id)
.ToListAsync(ct);

View File

@@ -20,11 +20,9 @@ internal class MarkReleaseUpdateReadHandler(AppDbContext dbContext)
{
Guid id = Route<Guid>("id");
Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
bool canReadUpdate = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.VisibleToUsers()
.AnyAsync(update => update.Id == id, ct);
if (!canReadUpdate)

View File

@@ -0,0 +1,95 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal class RefreshDeveloperReleaseCommitsHandler(
AppDbContext dbContext,
ReleaseCommitRepositoryRefreshService repositoryRefreshService)
: EndpointWithoutRequest<ReleaseCommitRefreshResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/refresh");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(CancellationToken ct)
{
IReadOnlyCollection<ReleaseCommit> requestedCommits;
try
{
ReleaseCommitRepositoryRefreshResult refreshResult = await repositoryRefreshService.FetchCommitsAsync(ct);
if (!refreshResult.IsSuccess)
{
AddError(refreshResult.ErrorMessage ?? "Repository commit refresh failed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
requestedCommits = refreshResult.Commits;
}
catch (HttpRequestException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
catch (JsonException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
int created = 0;
int updated = 0;
int skipped = 0;
List<ReleaseCommit> savedCommits = [];
foreach (ReleaseCommit requestedCommit in requestedCommits)
{
if (string.IsNullOrWhiteSpace(requestedCommit.Sha) || string.IsNullOrWhiteSpace(requestedCommit.Subject))
{
skipped++;
continue;
}
ReleaseCommit? existingCommit = await dbContext.ReleaseCommits.SingleOrDefaultAsync(
commit => commit.Sha == requestedCommit.Sha,
ct);
if (existingCommit is null)
{
dbContext.ReleaseCommits.Add(requestedCommit);
savedCommits.Add(requestedCommit);
created++;
continue;
}
existingCommit.ShortSha = requestedCommit.ShortSha;
existingCommit.Subject = requestedCommit.Subject;
existingCommit.AuthorName = requestedCommit.AuthorName;
existingCommit.AuthorEmail = requestedCommit.AuthorEmail;
existingCommit.AuthoredAt = requestedCommit.AuthoredAt;
existingCommit.CommittedAt = requestedCommit.CommittedAt;
existingCommit.SourceBranch = requestedCommit.SourceBranch ?? existingCommit.SourceBranch;
existingCommit.DeploymentLabel = requestedCommit.DeploymentLabel ?? existingCommit.DeploymentLabel;
existingCommit.ExternalUrl = requestedCommit.ExternalUrl ?? existingCommit.ExternalUrl;
existingCommit.UpdatedAt = DateTimeOffset.UtcNow;
savedCommits.Add(existingCommit);
updated++;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(
new ReleaseCommitRefreshResultDto(created, updated, skipped, savedCommits.Select(commit => commit.ToDto()).ToArray()),
ct);
}
}

View File

@@ -1,55 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record SendDeveloperReleaseUpdateEmailRequest(
bool TestMode,
bool ConfirmResend);
internal class SendDeveloperReleaseUpdateEmailHandler(
AppDbContext dbContext,
ReleaseUpdateEmailService emailService)
: Endpoint<SendDeveloperReleaseUpdateEmailRequest, ReleaseUpdateEmailSendResultDto>
{
public override void Configure()
{
Post("/api/developer/release-updates/{id}/send-email");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(SendDeveloperReleaseUpdateEmailRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
ReleaseUpdate? update = await dbContext.ReleaseUpdates.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (update is null)
{
await SendNotFoundAsync(ct);
return;
}
try
{
ReleaseUpdateEmailSendResultDto result = await emailService.SendManualUpdateEmailAsync(
update,
User.GetUserId(),
request.TestMode,
request.ConfirmResend,
ct);
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(result, ct);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
}
}
}

View File

@@ -9,6 +9,8 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record LinkDeveloperReleaseCommitRequest(Guid ReleaseUpdateId);
internal record LinkFirstReleaseCommitsRequest(Guid ReleaseUpdateId);
internal abstract class ReleaseCommitStatusEndpoint(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseCommitDto>
{
@@ -67,6 +69,70 @@ internal class LinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
}
}
internal class LinkFirstReleaseCommitsHandler(AppDbContext dbContext)
: Endpoint<LinkFirstReleaseCommitsRequest, ReleaseCommitBulkLinkResultDto>
{
public override void Configure()
{
Post("/api/developer/release-commits/{sha}/link-first-release");
Roles(KnownRoles.Developer);
Options(o => o.WithTags("Release Communications"));
}
public override async Task HandleAsync(LinkFirstReleaseCommitsRequest request, CancellationToken ct)
{
string? sha = Route<string>("sha");
if (string.IsNullOrWhiteSpace(sha))
{
await SendNotFoundAsync(ct);
return;
}
bool releaseUpdateExists = await dbContext.ReleaseUpdates
.AnyAsync(update => update.Id == request.ReleaseUpdateId, ct);
ReleaseCommit? anchorCommit = await dbContext.ReleaseCommits
.SingleOrDefaultAsync(commit => commit.Sha == sha, ct);
if (!releaseUpdateExists || anchorCommit is null)
{
await SendNotFoundAsync(ct);
return;
}
if (anchorCommit.ReleaseUpdateId is not null ||
anchorCommit.CommunicationStatus != ReleaseCommitCommunicationStatus.Unreviewed)
{
AddError("The selected first release commit must be unlinked and unreviewed.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
DateTimeOffset anchorDate = CommitDate(anchorCommit);
List<ReleaseCommit> commits = await dbContext.ReleaseCommits
.Where(commit =>
commit.ReleaseUpdateId == null &&
commit.CommunicationStatus == ReleaseCommitCommunicationStatus.Unreviewed &&
(commit.CommittedAt ?? commit.AuthoredAt ?? commit.ImportedAt) <= anchorDate)
.ToListAsync(ct);
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (ReleaseCommit commit in commits)
{
commit.ReleaseUpdateId = request.ReleaseUpdateId;
commit.CommunicationStatus = ReleaseCommitCommunicationStatus.Linked;
commit.UpdatedAt = now;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(new ReleaseCommitBulkLinkResultDto(commits.Count), ct);
}
private static DateTimeOffset CommitDate(ReleaseCommit commit)
{
return commit.CommittedAt ?? commit.AuthoredAt ?? commit.ImportedAt;
}
}
internal class UnlinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
: ReleaseCommitStatusEndpoint(dbContext)
{

View File

@@ -4,35 +4,24 @@ using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record UpdateDeveloperReleaseUpdateRequest(
string Title,
string Summary,
string? Body,
string Category,
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
string TitleEn,
string DescriptionEn,
string TitleFr,
string DescriptionFr);
internal class UpdateDeveloperReleaseUpdateRequestValidator
: Validator<UpdateDeveloperReleaseUpdateRequest>
{
public UpdateDeveloperReleaseUpdateRequestValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
RuleFor(x => x.Body).MaximumLength(8000);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
RuleFor(x => x.Importance).NotEmpty().MaximumLength(32);
RuleFor(x => x.Audience).NotEmpty().MaximumLength(32);
RuleFor(x => x.DeploymentLabel).MaximumLength(128);
RuleFor(x => x.BuildVersion).MaximumLength(128);
RuleFor(x => x.CommitRange).MaximumLength(256);
RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
}
}
@@ -63,58 +52,13 @@ internal class UpdateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
return;
}
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience))
{
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
update.Title = request.Title.Trim();
update.Summary = request.Summary.Trim();
update.Body = NormalizeOptional(request.Body);
update.Category = category;
update.Importance = importance;
update.Audience = audience;
update.DeploymentLabel = NormalizeOptional(request.DeploymentLabel);
update.BuildVersion = NormalizeOptional(request.BuildVersion);
update.CommitRange = NormalizeOptional(request.CommitRange);
update.Title = request.TitleEn.Trim();
update.Summary = request.DescriptionEn.Trim();
update.TitleFr = request.TitleFr.Trim();
update.SummaryFr = request.DescriptionFr.Trim();
update.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(update.ToDto(false), ct);
}
private bool TryParseRequest(
UpdateDeveloperReleaseUpdateRequest request,
out ReleaseUpdateCategory category,
out ReleaseUpdateImportance importance,
out ReleaseUpdateAudience audience)
{
bool isValid = true;
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
{
AddError(x => x.Category, "The selected release update category is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
{
AddError(x => x.Importance, "The selected release update importance is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
{
AddError(x => x.Audience, "The selected release update audience is not valid.");
isValid = false;
}
return isValid;
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
}

View File

@@ -12,7 +12,7 @@ internal static class ModuleRegistration
builder.Services.Configure<ReleaseCommunicationRepositoryOptions>(
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
builder.Services.AddScoped<ReleaseUpdateEmailService>();
builder.Services.AddScoped<ReleaseCommitRepositoryImportService>();
builder.Services.AddScoped<ReleaseCommitRepositoryRefreshService>();
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
return builder;

View File

@@ -5,46 +5,41 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Handlers;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal sealed record ReleaseCommitRepositoryImportResult(
internal sealed record ReleaseCommitRepositoryRefreshResult(
IReadOnlyCollection<ReleaseCommit> Commits,
string? ErrorMessage)
{
public bool IsSuccess => ErrorMessage is null;
public static ReleaseCommitRepositoryImportResult Success(IReadOnlyCollection<ReleaseCommit> commits)
public static ReleaseCommitRepositoryRefreshResult Success(IReadOnlyCollection<ReleaseCommit> commits)
{
return new ReleaseCommitRepositoryImportResult(commits, null);
return new ReleaseCommitRepositoryRefreshResult(commits, null);
}
public static ReleaseCommitRepositoryImportResult Failure(string errorMessage)
public static ReleaseCommitRepositoryRefreshResult Failure(string errorMessage)
{
return new ReleaseCommitRepositoryImportResult([], errorMessage);
return new ReleaseCommitRepositoryRefreshResult([], errorMessage);
}
}
internal sealed class ReleaseCommitRepositoryImportService(
internal sealed class ReleaseCommitRepositoryRefreshService(
IHttpClientFactory httpClientFactory,
IOptionsSnapshot<ReleaseCommunicationRepositoryOptions> repositoryOptions)
{
private const int DefaultLimit = 50;
private const int MaxLimit = 100;
public async Task<ReleaseCommitRepositoryImportResult> FetchCommitsAsync(
ImportDeveloperReleaseCommitsRequest request,
public async Task<ReleaseCommitRepositoryRefreshResult> FetchCommitsAsync(
CancellationToken ct)
{
ReleaseCommunicationRepositoryOptions options = repositoryOptions.Value;
if (!TryBuildApiTarget(options.RepositoryUrl, out RepositoryApiTarget target, out string? targetError))
{
return ReleaseCommitRepositoryImportResult.Failure(targetError ?? "Repository configuration is not valid.");
return ReleaseCommitRepositoryRefreshResult.Failure(targetError ?? "Repository configuration is not valid.");
}
int limit = Math.Clamp(request.Limit ?? DefaultLimit, 1, MaxLimit);
using HttpClient httpClient = httpClientFactory.CreateClient();
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Socialize", "1.0"));
if (!string.IsNullOrWhiteSpace(options.AccessToken))
@@ -52,11 +47,11 @@ internal sealed class ReleaseCommitRepositoryImportService(
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", options.AccessToken.Trim());
}
using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target, request, limit), ct);
using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target), ct);
if (!response.IsSuccessStatusCode)
{
return ReleaseCommitRepositoryImportResult.Failure(
$"Repository commit import failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}).");
return ReleaseCommitRepositoryRefreshResult.Failure(
$"Repository commit refresh failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase}).");
}
await using Stream stream = await response.Content.ReadAsStreamAsync(ct);
@@ -73,56 +68,31 @@ internal sealed class ReleaseCommitRepositoryImportService(
}
else
{
return ReleaseCommitRepositoryImportResult.Failure("Repository API response did not include a commit list.");
return ReleaseCommitRepositoryRefreshResult.Failure("Repository API response did not include a commit list.");
}
DateTimeOffset now = DateTimeOffset.UtcNow;
List<ReleaseCommit> commits = [];
foreach (JsonElement commitElement in commitsElement.EnumerateArray())
{
ReleaseCommit? commit = ToReleaseCommit(commitElement, request, now);
ReleaseCommit? commit = ToReleaseCommit(commitElement, now);
if (commit is not null)
{
commits.Add(commit);
}
}
return ReleaseCommitRepositoryImportResult.Success(commits);
return ReleaseCommitRepositoryRefreshResult.Success(commits);
}
private static Uri BuildRequestUri(
RepositoryApiTarget target,
ImportDeveloperReleaseCommitsRequest request,
int limit)
private static Uri BuildRequestUri(RepositoryApiTarget target)
{
if (!string.IsNullOrWhiteSpace(request.SinceSha) && !string.IsNullOrWhiteSpace(request.UntilSha))
{
string baseHead = $"{request.SinceSha.Trim()}...{request.UntilSha.Trim()}";
return new Uri($"{target.ApiBaseUri}/compare/{Uri.EscapeDataString(baseHead)}");
}
Dictionary<string, string> query = new(StringComparer.Ordinal)
{
["limit"] = limit.ToString(CultureInfo.InvariantCulture),
["limit"] = DefaultLimit.ToString(CultureInfo.InvariantCulture),
["page"] = "1",
};
string? sha = NormalizeOptional(request.UntilSha) ?? NormalizeOptional(request.SourceBranch);
if (sha is not null)
{
query["sha"] = sha;
}
if (request.Since.HasValue)
{
query["since"] = request.Since.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
}
if (request.Until.HasValue)
{
query["until"] = request.Until.Value.UtcDateTime.ToString("O", CultureInfo.InvariantCulture);
}
string queryString = string.Join(
"&",
query.Select(pair => $"{WebUtility.UrlEncode(pair.Key)}={WebUtility.UrlEncode(pair.Value)}"));
@@ -140,7 +110,7 @@ internal sealed class ReleaseCommitRepositoryImportService(
if (string.IsNullOrWhiteSpace(repositoryUrl))
{
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.";
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository refresh can be used.";
return false;
}
@@ -176,7 +146,6 @@ internal sealed class ReleaseCommitRepositoryImportService(
private static ReleaseCommit? ToReleaseCommit(
JsonElement commitElement,
ImportDeveloperReleaseCommitsRequest request,
DateTimeOffset now)
{
string? sha = GetString(commitElement, "sha") ?? GetString(commitElement, "id");
@@ -211,8 +180,8 @@ internal sealed class ReleaseCommitRepositoryImportService(
AuthorEmail = authorElement.HasValue ? NormalizeOptional(GetString(authorElement.Value, "email")) : null,
AuthoredAt = authorElement.HasValue ? GetUtcDateTimeOffset(authorElement.Value, "date") : null,
CommittedAt = committerElement.HasValue ? GetUtcDateTimeOffset(committerElement.Value, "date") : null,
SourceBranch = NormalizeOptional(request.SourceBranch),
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
SourceBranch = null,
DeploymentLabel = null,
ExternalUrl = NormalizeOptional(GetString(commitElement, "html_url") ?? GetString(commitElement, "url")),
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
ImportedAt = now,

View File

@@ -40,7 +40,8 @@ internal sealed class ReleaseUpdateEmailDigestBackgroundService(
int sentCount = await emailService.SendDueDigestEmailsAsync(
TimeSpan.FromHours(options.Value.InactiveHoursBeforeDigest),
TimeSpan.FromHours(options.Value.DigestIntervalHours),
stoppingToken);
force: false,
ct: stoppingToken);
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);

View File

@@ -1,15 +1,11 @@
using System.Net;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
@@ -20,63 +16,22 @@ internal class ReleaseUpdateEmailService(
IEmailSender emailSender,
IOptionsSnapshot<WebsiteOptions> websiteOptions)
{
public async Task<ReleaseUpdateEmailSendResultDto> SendManualUpdateEmailAsync(
ReleaseUpdate update,
Guid senderUserId,
bool testMode,
bool confirmResend,
CancellationToken ct)
{
if (update.Status != ReleaseUpdateStatus.Published)
{
throw new InvalidOperationException("Only published release updates can be emailed.");
}
if (!testMode && update.ManualEmailSentAt.HasValue && !confirmResend)
{
throw new InvalidOperationException("This release update was already emailed. Confirm resend to send it again.");
}
IReadOnlyCollection<User> recipients = testMode
? await GetTestRecipientsAsync(senderUserId, ct)
: await GetAudienceRecipientsAsync(update.Audience, ct);
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (User recipient in recipients.Where(recipient => !string.IsNullOrWhiteSpace(recipient.Email)))
{
await emailSender.SendEmailAsync(
recipient.Email!,
$"What's new in Socialize: {update.Title}",
BuildSingleUpdateEmail(update));
}
if (!testMode)
{
update.ManualEmailSentByUserId = senderUserId;
update.ManualEmailSentAt = now;
update.ManualEmailAudience = update.Audience.ToString();
update.ManualEmailRecipientCount = recipients.Count;
update.UpdatedAt = now;
}
return new ReleaseUpdateEmailSendResultDto(recipients.Count, now, testMode);
}
public async Task<int> SendDueDigestEmailsAsync(
TimeSpan inactiveThreshold,
TimeSpan sendInterval,
bool force,
CancellationToken ct)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, ct);
List<User> ownerUsers = await GetReleaseNoteRecipientsAsync(ct);
int sentCount = 0;
foreach (User user in ownerUsers)
{
if (string.IsNullOrWhiteSpace(user.Email) ||
!ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))
(!force && !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)))
{
continue;
}
@@ -86,19 +41,13 @@ internal class ReleaseUpdateEmailService(
.OrderByDescending(receipt => receipt.SentAt)
.Select(receipt => (DateTimeOffset?)receipt.SentAt)
.FirstOrDefaultAsync(ct);
if (!ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
if (!force && !ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
{
continue;
}
ReleaseUpdateAudienceContext audienceContext = await ReleaseUpdateVisibility.GetAudienceContextAsync(
dbContext,
new ClaimsPrincipal(new ClaimsIdentity()),
user.Id,
ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext)
.VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == user.Id))
@@ -113,8 +62,8 @@ internal class ReleaseUpdateEmailService(
await emailSender.SendEmailAsync(
user.Email,
"What's new in Socialize",
BuildDigestEmail(unreadUpdates));
GetDigestSubject(user.PreferredLanguage),
BuildDigestEmail(unreadUpdates, user.PreferredLanguage));
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
{
@@ -130,69 +79,47 @@ internal class ReleaseUpdateEmailService(
return sentCount;
}
private async Task<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct)
private async Task<List<User>> GetReleaseNoteRecipientsAsync(CancellationToken ct)
{
User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct);
return sender is null ? [] : [sender];
return await userManager.Users
.Where(user => user.EmailConfirmed && user.Email != null)
.OrderBy(user => user.Email)
.ToListAsync(ct);
}
private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct)
{
IQueryable<User> query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null);
if (audience == ReleaseUpdateAudience.Developers)
{
IList<User> developers = await userManager.GetUsersInRoleAsync(KnownRoles.Developer);
return developers.Where(user => user.EmailConfirmed && !string.IsNullOrWhiteSpace(user.Email)).ToList();
}
if (audience == ReleaseUpdateAudience.OrganizationOwners)
{
Guid[] ownerUserIds = await dbContext.Organizations
.Select(organization => organization.OwnerUserId)
.Concat(dbContext.OrganizationMemberships
.Where(membership => membership.Role == OrganizationRoles.Owner)
.Select(membership => membership.UserId))
.Distinct()
.ToArrayAsync(ct);
query = query.Where(user => ownerUserIds.Contains(user.Id));
}
return await query.OrderBy(user => user.Email).ToListAsync(ct);
}
private string BuildSingleUpdateEmail(ReleaseUpdate update)
{
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates?updateId={update.Id}";
return $"""
<h1>{HtmlEncode(update.Title)}</h1>
<p><strong>{HtmlEncode(update.Category.ToString())}</strong></p>
<p>{HtmlEncode(update.Summary)}</p>
{FormatBody(update.Body)}
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
""";
}
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates, string? preferredLanguage)
{
bool useFrench = IsFrench(preferredLanguage);
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
string listItems = string.Join(
Environment.NewLine,
updates.Select(update => $"<li><strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}</li>"));
updates.Select(update => $"""
<li>
<strong>{HtmlEncode(useFrench ? update.TitleFr : update.Title)}</strong><br>
{HtmlEncode(useFrench ? update.SummaryFr : update.Summary)}
</li>
"""));
string heading = useFrench ? "Nouveautes dans Socialize" : "What's new in Socialize";
string linkText = useFrench ? "Ouvrir les nouveautes" : "Open What's New";
return $"""
<h1>What's new in Socialize</h1>
<h1>{HtmlEncode(heading)}</h1>
<ul>{listItems}</ul>
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
<p><a href="{HtmlEncode(updateUrl)}">{HtmlEncode(linkText)}</a></p>
""";
}
private static string FormatBody(string? body)
private static string GetDigestSubject(string? preferredLanguage)
{
return string.IsNullOrWhiteSpace(body)
? string.Empty
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>";
return IsFrench(preferredLanguage)
? "Nouveautes dans Socialize"
: "What's new in Socialize";
}
private static bool IsFrench(string? preferredLanguage)
{
return string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase);
}
private static string HtmlEncode(string? value)

View File

@@ -1,28 +0,0 @@
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateRules
{
public static bool TryParseCategory(string value, out ReleaseUpdateCategory category)
{
return TryParseEnum(value, out category);
}
public static bool TryParseImportance(string value, out ReleaseUpdateImportance importance)
{
return TryParseEnum(value, out importance);
}
public static bool TryParseAudience(string value, out ReleaseUpdateAudience audience)
{
return TryParseEnum(value, out audience);
}
private static bool TryParseEnum<TEnum>(string value, out TEnum result)
where TEnum : struct
{
string normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal);
return Enum.TryParse(normalized, ignoreCase: true, out result);
}
}

View File

@@ -1,46 +1,11 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateVisibility
{
public static async Task<ReleaseUpdateAudienceContext> GetAudienceContextAsync(
AppDbContext dbContext,
ClaimsPrincipal user,
Guid userId,
CancellationToken ct)
public static IQueryable<ReleaseUpdate> VisibleToUsers(this IQueryable<ReleaseUpdate> query)
{
bool isDeveloper = user.IsInRole(KnownRoles.Developer);
bool isOrganizationOwner = await dbContext.Organizations.AnyAsync(
organization => organization.OwnerUserId == userId,
ct)
|| await dbContext.OrganizationMemberships.AnyAsync(
membership =>
membership.UserId == userId &&
membership.Role == OrganizationRoles.Owner,
ct);
return new ReleaseUpdateAudienceContext(isDeveloper, isOrganizationOwner);
}
public static IQueryable<ReleaseUpdate> VisibleTo(
this IQueryable<ReleaseUpdate> query,
ReleaseUpdateAudienceContext context)
{
return query.Where(update =>
update.Status == ReleaseUpdateStatus.Published &&
(update.Audience == ReleaseUpdateAudience.Everyone ||
(update.Audience == ReleaseUpdateAudience.OrganizationOwners && context.IsOrganizationOwner) ||
(update.Audience == ReleaseUpdateAudience.Developers && context.IsDeveloper)));
return query.Where(update => update.Status == ReleaseUpdateStatus.Published);
}
}
internal record ReleaseUpdateAudienceContext(
bool IsDeveloper,
bool IsOrganizationOwner);

View File

@@ -6,64 +6,16 @@ namespace Socialize.Tests.ReleaseCommunications;
public class ReleaseUpdateRulesTests
{
[Theory]
[InlineData("Feature", ReleaseUpdateCategory.Feature)]
[InlineData("improvement", ReleaseUpdateCategory.Improvement)]
[InlineData("Breaking Change", ReleaseUpdateCategory.BreakingChange)]
[InlineData("BreakingChange", ReleaseUpdateCategory.BreakingChange)]
internal void TryParseCategory_accepts_supported_categories(string value, ReleaseUpdateCategory expected)
{
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out ReleaseUpdateCategory category);
Assert.True(parsed);
Assert.Equal(expected, category);
}
[Theory]
[InlineData("")]
[InlineData("Security")]
[InlineData("Maintenance")]
public void TryParseCategory_rejects_unsupported_categories(string value)
{
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out _);
Assert.False(parsed);
}
[Theory]
[InlineData("Normal", ReleaseUpdateImportance.Normal)]
[InlineData("important", ReleaseUpdateImportance.Important)]
internal void TryParseImportance_accepts_supported_importance(string value, ReleaseUpdateImportance expected)
{
bool parsed = ReleaseUpdateRules.TryParseImportance(value, out ReleaseUpdateImportance importance);
Assert.True(parsed);
Assert.Equal(expected, importance);
}
[Theory]
[InlineData("Everyone", ReleaseUpdateAudience.Everyone)]
[InlineData("Organization Owners", ReleaseUpdateAudience.OrganizationOwners)]
[InlineData("developers", ReleaseUpdateAudience.Developers)]
internal void TryParseAudience_accepts_supported_audiences(string value, ReleaseUpdateAudience expected)
{
bool parsed = ReleaseUpdateRules.TryParseAudience(value, out ReleaseUpdateAudience audience);
Assert.True(parsed);
Assert.Equal(expected, audience);
}
[Fact]
public void ToDto_formats_breaking_change_category_for_display()
public void ToDto_maps_summary_to_description()
{
ReleaseUpdate update = new()
{
Id = Guid.NewGuid(),
Title = "API change",
Summary = "A workflow API changed.",
Category = ReleaseUpdateCategory.BreakingChange,
Importance = ReleaseUpdateImportance.Important,
Audience = ReleaseUpdateAudience.Developers,
TitleFr = "Changement API",
SummaryFr = "Une API du flux de travail a change.",
Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
@@ -73,18 +25,22 @@ public class ReleaseUpdateRulesTests
ReleaseUpdateDto dto = update.ToDto(isRead: true);
Assert.Equal("Breaking Change", dto.Category);
Assert.Equal("A workflow API changed.", dto.Description);
Assert.Equal("API change", dto.TitleEn);
Assert.Equal("A workflow API changed.", dto.DescriptionEn);
Assert.Equal("Changement API", dto.TitleFr);
Assert.Equal("Une API du flux de travail a change.", dto.DescriptionFr);
Assert.True(dto.IsRead);
}
[Fact]
public void VisibleTo_returns_everyone_updates_for_any_authenticated_user()
public void VisibleToUsers_returns_published_updates()
{
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
ReleaseUpdate update = NewPublishedUpdate();
List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false))
.VisibleToUsers()
.ToList();
Assert.Same(update, Assert.Single(visibleUpdates));
@@ -93,37 +49,17 @@ public class ReleaseUpdateRulesTests
[Fact]
public void VisibleTo_rejects_unpublished_updates()
{
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
ReleaseUpdate update = NewPublishedUpdate();
update.Status = ReleaseUpdateStatus.Draft;
List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true))
.VisibleToUsers()
.ToList();
Assert.Empty(visibleUpdates);
}
[Fact]
public void VisibleTo_requires_matching_restricted_audience()
{
ReleaseUpdate ownerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.OrganizationOwners);
ReleaseUpdate developerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.Developers);
List<ReleaseUpdate> ownerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: true))
.ToList();
List<ReleaseUpdate> developerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: false))
.ToList();
Assert.Same(ownerUpdate, Assert.Single(ownerVisibleUpdates));
Assert.Same(developerUpdate, Assert.Single(developerVisibleUpdates));
}
[Fact]
public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates()
{
@@ -172,16 +108,15 @@ public class ReleaseUpdateRulesTests
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
}
private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience)
private static ReleaseUpdate NewPublishedUpdate()
{
return new ReleaseUpdate
{
Id = Guid.NewGuid(),
Title = "Update",
Summary = "Something changed.",
Category = ReleaseUpdateCategory.Improvement,
Importance = ReleaseUpdateImportance.Normal,
Audience = audience,
TitleFr = "Mise a jour",
SummaryFr = "Quelque chose a change.",
Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,

View File

@@ -0,0 +1,49 @@
using System.Security.Claims;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Tests.Security;
public class AccessScopeServiceTests
{
[Fact]
public void Manager_role_does_not_grant_workspace_access_without_workspace_scope()
{
Guid workspaceId = Guid.NewGuid();
ClaimsPrincipal user = CreateUser(KnownRoles.Manager);
Assert.False(AccessScopeService.CanAccessWorkspace(user, workspaceId));
Assert.False(AccessScopeService.CanManageWorkspace(user, workspaceId));
}
[Fact]
public void Administrator_role_does_not_grant_workspace_access_without_workspace_scope()
{
Guid workspaceId = Guid.NewGuid();
ClaimsPrincipal user = CreateUser(KnownRoles.Administrator);
Assert.False(AccessScopeService.CanAccessWorkspace(user, workspaceId));
Assert.False(AccessScopeService.CanManageWorkspace(user, workspaceId));
}
[Fact]
public void Manager_can_manage_only_workspaces_in_scope()
{
Guid workspaceId = Guid.NewGuid();
ClaimsPrincipal user = CreateUser(KnownRoles.Manager, new Claim(KnownClaims.WorkspaceScope, workspaceId.ToString()));
Assert.True(AccessScopeService.CanAccessWorkspace(user, workspaceId));
Assert.True(AccessScopeService.CanManageWorkspace(user, workspaceId));
}
private static ClaimsPrincipal CreateUser(string role, params Claim[] claims)
{
Claim[] baseClaims =
[
new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()),
new(ClaimTypes.Role, role),
];
return new ClaimsPrincipal(new ClaimsIdentity(baseClaims.Concat(claims), "Test"));
}
}

View File

@@ -112,7 +112,7 @@ The system should not rely only on the user's last login timestamp. Login is one
- Developer-only release communication back office:
- `/app/developer/updates`
- `/app/developer/updates/:id`
- `/app/developer/release-commits`
- `/app/developer/release-notes`
Feature-owned frontend code belongs under:
@@ -235,9 +235,8 @@ GET /api/developer/release-updates/{id}
PUT /api/developer/release-updates/{id}
POST /api/developer/release-updates/{id}/publish
POST /api/developer/release-updates/{id}/archive
POST /api/developer/release-updates/{id}/send-email
GET /api/developer/release-commits
POST /api/developer/release-commits/import
POST /api/developer/release-commits/refresh
POST /api/developer/release-commits/{sha}/link
POST /api/developer/release-commits/{sha}/unlink
POST /api/developer/release-commits/{sha}/internal-only

View File

@@ -0,0 +1,27 @@
# Task: Add frontend style system baseline
## Goal
Remove app-shell styling drift by routing shared chrome controls through Vuetify components and centralized theme-backed tokens.
## Scope
- Expose reusable CSS variables backed by the Vuetify theme.
- Add shared app-shell primitives for navigation buttons, icon buttons, popovers, and menu items.
- Replace native shell buttons with Vuetify controls in `App.vue`, `AppBar`, `AppSidebar`, `SidebarUserMenu`, and `WorkspaceSelector`.
- Leave feature-screen native button migration to a follow-up task because it crosses many workflows.
## Validation
```bash
cd frontend
npm run build
```
## Done
- [x] Tailwind preflight loads before Vuetify styles.
- [x] App-owned CSS loads after Vuetify styles.
- [x] Shared Vuetify defaults are centralized.
- [x] Legacy global native button/card selectors were removed.
- [x] App-shell styles use shared theme-backed tokens.

View File

@@ -0,0 +1,31 @@
# Task: Replace native feature buttons with Vuetify controls
## Goal
Move remaining interactive feature-screen buttons from native `<button>` elements to Vuetify controls so button styling consistently flows through Vuetify.
## Scope
- Replace action buttons with `v-btn`.
- Replace icon-only buttons with `v-btn` using icon-sized styling.
- Preserve specialized non-button native controls only when Vuetify would reduce capability, such as file inputs.
- Keep behavior unchanged while converting one feature area at a time.
## Likely Files
- `frontend/src/components/ImageCropperDialog.vue`
- `frontend/src/features/**/**/*.vue`
- `frontend/src/static/**/*.vue`
## Validation
```bash
cd frontend
npm run build
```
## Done
- [x] Native `<button>` elements under `frontend/src/**/*.vue` were migrated to `v-btn`.
- [x] Public SSR rendering installs the shared Vuetify plugin.
- [x] Frontend build and public prerender pass.

View File

@@ -24,7 +24,7 @@ Add the developer back-office workflow for importing shipped commits and matchin
- mark a commit internal-only
- mark a commit ignored
- Add developer-only frontend screens:
- `/app/developer/release-commits`
- `/app/developer/release-notes`
- linked commits on `/app/developer/updates/:id`
- Add repository-backed import from configured HTTPS repository settings.
- Add a selected-commit workflow to copy commit SHA/details and create a draft update entry linked to those commits.

View File

@@ -5,13 +5,14 @@
<div class="shell-sidebar-wrap">
<app-sidebar :is-expanded="isSidebarExpanded" />
<button
<v-btn
class="sidebar-boundary-toggle"
type="button"
variant="text"
:ripple="false"
@click="isSidebarExpanded = !isSidebarExpanded"
>
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
</button>
</v-btn>
</div>
</template>
@@ -69,8 +70,8 @@
background:
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(52, 211, 153, 0.16), transparent 24%),
linear-gradient(180deg, #fffaf2 0%, #f6efe2 100%);
color: #172033;
linear-gradient(180deg, var(--app-color-on-primary) 0%, #f6efe2 100%);
color: var(--app-color-on-surface);
}
.shell-main {
@@ -86,16 +87,17 @@
}
.sidebar-boundary-toggle {
@apply absolute left-full top-[1.48rem] z-40 flex h-[1.8rem] w-[1.8rem] -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
@apply absolute left-full top-[1.48rem] z-40 flex h-[1.8rem] min-w-0 w-[1.8rem] -translate-x-1/2 items-center justify-center rounded-full border p-0 normal-case transition-colors;
background: rgba(255, 250, 242, 0.98);
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
color: #44516a;
box-shadow: 0 12px 28px rgba(23, 32, 51, 0.12);
box-shadow: 0 12px 28px var(--app-border-subtle);
letter-spacing: 0;
}
.sidebar-boundary-toggle:hover {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.sidebar-boundary-toggle :deep(.v-icon) {

View File

@@ -132,6 +132,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-update-email-digests/force": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersForceDeveloperReleaseUpdateDigestEmailsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-updates/{id}": {
parameters: {
query?: never;
@@ -164,22 +180,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-commits/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits": {
parameters: {
query?: never;
@@ -260,7 +260,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-updates/{id}/send-email": {
"/api/developer/release-commits/refresh": {
parameters: {
query?: never;
header?: never;
@@ -269,7 +269,7 @@ export interface paths {
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"];
post: operations["SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
@@ -292,6 +292,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/link-first-release": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/developer/release-commits/{sha}/unlink": {
parameters: {
query?: never;
@@ -580,6 +596,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/preferred-language": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/confirm-email-change": {
parameters: {
query?: never;
@@ -1471,15 +1503,12 @@ export interface components {
/** Format: guid */
id?: string;
title?: string;
summary?: string;
body?: string | null;
category?: string;
importance?: string;
audience?: string;
description?: string;
titleEn?: string;
descriptionEn?: string;
titleFr?: string;
descriptionFr?: string;
status?: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
/** Format: date-time */
createdAt?: string;
/** Format: date-time */
@@ -1488,25 +1517,17 @@ export interface components {
publishedAt?: string | null;
/** Format: date-time */
archivedAt?: string | null;
/** Format: guid */
manualEmailSentByUserId?: string | null;
/** Format: date-time */
manualEmailSentAt?: string | null;
manualEmailAudience?: string | null;
/** Format: int32 */
manualEmailRecipientCount?: number | null;
isRead?: boolean;
};
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
title: string;
summary: string;
body?: string | null;
category: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
titleEn: string;
descriptionEn: string;
titleFr: string;
descriptionFr: string;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto: {
/** Format: int32 */
sentCount?: number;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */
@@ -1515,15 +1536,6 @@ export interface components {
importantUnreadCount?: number;
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto: {
/** Format: int32 */
importedCount?: number;
/** Format: int32 */
updatedCount?: number;
/** Format: int32 */
skippedCount?: number;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
sha?: string;
shortSha?: string;
@@ -1545,52 +1557,32 @@ export interface components {
/** Format: date-time */
updatedAt?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: {
sinceSha?: string | null;
untilSha?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto"][] | null;
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto: {
sha?: string;
shortSha?: string | null;
subject?: string;
authorName?: string | null;
authorEmail?: string | null;
/** Format: date-time */
authoredAt?: string | null;
/** Format: date-time */
committedAt?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
externalUrl?: string | null;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto: {
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto: {
/** Format: int32 */
recipientCount?: number;
/** Format: date-time */
sentAt?: string;
testMode?: boolean;
};
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: {
testMode?: boolean;
confirmResend?: boolean;
createdCount?: number;
/** Format: int32 */
updatedCount?: number;
/** Format: int32 */
skippedCount?: number;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
/** Format: guid */
releaseUpdateId?: string;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto: {
/** Format: int32 */
linkedCount?: number;
};
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest: {
/** Format: guid */
releaseUpdateId?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
title: string;
summary: string;
body?: string | null;
category: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
titleEn: string;
descriptionEn: string;
titleFr: string;
descriptionFr: string;
};
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
/** Format: guid */
@@ -1727,6 +1719,9 @@ export interface components {
/** Format: binary */
file: string;
};
SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest: {
preferredLanguage?: string;
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
message?: string;
};
@@ -1752,6 +1747,7 @@ export interface components {
/** Format: date-time */
birthDate?: string | null;
address?: string | null;
preferredLanguage?: string;
};
SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & {
canTimeout?: boolean;
@@ -2759,6 +2755,40 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersForceDeveloperReleaseUpdateDigestEmailsHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
parameters: {
query?: never;
@@ -2871,44 +2901,6 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
@@ -3058,20 +3050,14 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: {
SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
};
};
requestBody?: never;
responses: {
/** @description Success */
200: {
@@ -3079,7 +3065,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"];
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto"];
};
};
/** @description Unauthorized */
@@ -3138,6 +3124,46 @@ export interface operations {
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsHandler: {
parameters: {
query?: never;
header?: never;
path: {
sha: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
parameters: {
query?: never;
@@ -3837,6 +3863,44 @@ export interface operations {
};
};
};
SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: {
parameters: {
query: {

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
@@ -23,139 +24,110 @@ body,
background: #f4f6f3;
}
html {
--app-color-background: rgb(var(--v-theme-background));
--app-color-on-background: rgb(var(--v-theme-on-background));
--app-color-surface: rgb(var(--v-theme-surface));
--app-color-surface-muted: rgb(var(--v-theme-surface-muted));
--app-color-on-surface: rgb(var(--v-theme-on-surface));
--app-color-control: rgb(var(--v-theme-control));
--app-color-control-hover: rgb(var(--v-theme-control-hover));
--app-color-control-focus: rgb(var(--v-theme-control-focus));
--app-color-border: rgb(var(--v-theme-border));
--app-color-border-strong: rgb(var(--v-theme-border-strong));
--app-color-primary: rgb(var(--v-theme-primary));
--app-color-on-primary: rgb(var(--v-theme-on-primary));
--app-color-secondary: rgb(var(--v-theme-secondary));
--app-color-on-secondary: rgb(var(--v-theme-on-secondary));
--app-color-tertiary: rgb(var(--v-theme-tertiary));
--app-color-on-tertiary: rgb(var(--v-theme-on-tertiary));
--app-color-accent: rgb(var(--v-theme-accent));
--app-color-accent-strong: rgb(var(--v-theme-accent-strong));
--app-color-highlight: rgb(var(--v-theme-highlight));
--app-color-danger: rgb(var(--v-theme-error));
--app-color-on-danger: rgb(var(--v-theme-on-error));
--app-text-muted: #526178;
--app-text-subtle: #7a8799;
--app-border-subtle: rgba(23, 32, 51, 0.08);
--app-border-muted: rgba(23, 32, 51, 0.06);
--app-surface-glass: rgba(251, 250, 246, 0.84);
--app-surface-raised: #ffffff;
--app-control-subtle: rgb(var(--v-theme-control));
--app-control-hover: rgb(var(--v-theme-control-hover));
--app-control-active: rgba(23, 32, 51, 0.1);
--app-danger-muted: rgb(var(--v-theme-error));
--app-shadow-popover: 0 18px 40px var(--app-border-subtle);
}
a {
color: inherit;
}
input::placeholder,
textarea::placeholder {
color: #68778a;
opacity: 1;
}
.v-application {
background: rgb(var(--v-theme-background)) !important;
color: rgb(var(--v-theme-on-background));
}
.v-card,
.v-sheet,
.v-list,
.v-menu > .v-overlay__content,
.v-dialog > .v-overlay__content {
background-color: rgb(var(--v-theme-surface)) !important;
border: 1px solid rgb(var(--v-theme-border));
}
.v-field {
background-color: rgb(var(--v-theme-control)) !important;
color: rgb(var(--v-theme-on-surface));
}
.v-field:hover {
background-color: rgb(var(--v-theme-control-hover)) !important;
}
.v-field--focused {
background-color: rgb(var(--v-theme-control-focus)) !important;
}
.v-field__outline {
color: rgb(var(--v-theme-border-strong));
}
.v-field--focused .v-field__outline {
color: rgb(var(--v-theme-highlight));
}
.v-field__input,
.v-field-label {
color: rgb(var(--v-theme-on-surface));
}
.v-select .v-field .v-field__input > input,
.v-select .v-field .v-field__input > input::placeholder {
color: transparent !important;
caret-color: transparent;
}
.panel,
[class$='-panel'],
[class$='-card'],
div.card {
border-color: rgb(var(--v-theme-border)) !important;
}
@layer components {
.btn {
@apply min-w-24 w-full;
@apply p-4;
@apply flex flex-nowrap gap-4 items-center justify-center;
@apply rounded-lg;
@apply capitalize text-base font-sans font-medium;
@apply px-10;
@apply cursor-pointer;
.app-sidebar .sidebar-control {
@apply flex min-w-0 items-center gap-3 rounded-[1.1rem] px-4 py-3 text-sm font-semibold no-underline transition-colors;
background: transparent;
color: #44516a;
}
button.primary {
@apply min-w-24 w-full;
@apply p-4;
@apply flex flex-nowrap gap-4 items-center justify-center;
@apply rounded-lg;
@apply capitalize text-base font-sans font-medium;
@apply px-10;
@apply cursor-pointer;
@apply bg-hPrimary text-hOnPrimary;
@apply hover:brightness-125;
.app-sidebar .sidebar-control:hover {
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
button.secondary {
@apply min-w-24 w-full;
@apply p-4;
@apply flex flex-nowrap gap-4 items-center justify-center;
@apply rounded-lg;
@apply capitalize text-base font-sans font-medium;
@apply px-10;
@apply cursor-pointer;
@apply bg-hSecondary text-hOnSecondary;
@apply hover:brightness-125;
.app-sidebar .sidebar-control-active {
background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), rgba(239, 68, 68, 0.1));
box-shadow: inset 0 0 0 1px rgba(255, 138, 61, 0.2);
color: var(--app-color-on-surface);
}
div.dialog {
@apply max-h-[90vh];
@apply place-self-center;
.app-sidebar .sidebar-icon-button {
@apply flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1rem] transition-colors no-underline;
background: transparent;
color: var(--app-text-muted);
}
div.card {
@apply w-full max-w-[1024px];
@apply rounded-xl p-4;
@apply flex flex-col gap-4;
@apply bg-hSurface text-hOnSurface;
.app-sidebar .sidebar-icon-button:hover {
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
/* Specific styling for dialog cards */
div.card.dialog {
@apply bg-hSurface text-hOnSurface;
@apply rounded-xl;
@apply shadow-lg;
.app-sidebar .sidebar-menu-surface {
@apply z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
background: var(--app-surface-raised);
border-color: var(--app-border-subtle);
box-shadow: var(--app-shadow-popover);
}
div.card-title {
@apply font-sans font-bold text-2xl;
@apply p-2;
@apply text-hOnSurface;
.app-sidebar .sidebar-menu-option {
@apply flex items-center gap-3 rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
color: var(--app-color-on-surface);
}
div.card-content {
@apply flex flex-col gap-4;
@apply p-2;
@apply text-hOnSurface;
@apply overflow-y-auto max-h-[60vh];
.app-sidebar .sidebar-menu-option:hover {
background: var(--app-control-hover);
}
div.card-actions {
@apply p-2;
@apply flex flex-row gap-4 justify-end;
.app-sidebar .sidebar-menu-option .v-icon {
@apply text-base;
color: var(--app-text-muted);
}
div.card-actions > * {
@apply w-fit;
@apply sm:min-w-40 min-w-0;
.app-sidebar .sidebar-menu-option-danger {
color: var(--app-danger-muted);
}
.app-sidebar .sidebar-menu-option-danger .v-icon {
color: var(--app-danger-muted);
}
.app-sidebar .sidebar-menu-separator {
@apply my-1;
border-top: 1px solid var(--app-border-subtle);
}
}

View File

@@ -0,0 +1,2 @@
@import "vuetify/styles";
@import "./main.css";

View File

@@ -59,7 +59,7 @@
.avatar {
@apply inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-black uppercase;
background: linear-gradient(135deg, rgba(15, 118, 110, 0.16) 0%, rgba(255, 138, 61, 0.18) 100%);
color: #172033;
color: var(--app-color-on-surface);
}
.avatar img {

View File

@@ -148,13 +148,13 @@
<div class="cropper-eyebrow">Image editor</div>
<h2>{{ title }}</h2>
</div>
<button
<v-btn variant="text" :ripple="false"
class="plain-button"
:disabled="isSaving"
@click="closeDialog"
>
Close
</button>
</v-btn>
</div>
<div class="cropper-actions">
@@ -178,42 +178,42 @@
variant="outlined"
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="isSaving"
@click="loadImageFromUrl"
>
{{ loadLabel }}
</button>
</v-btn>
</div>
<button
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="zoom(1.15)"
>
Zoom in
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="zoom(0.85)"
>
Zoom out
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="rotate(-90)"
>
Rotate left
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="!isReady || isSaving"
@click="rotate(90)"
>
Rotate right
</button>
</v-btn>
</div>
<div
@@ -242,14 +242,14 @@
</div>
<div class="footer-actions">
<button
<v-btn variant="text" :ripple="false"
class="action-button secondary"
:disabled="isSaving"
@click="closeDialog"
>
Cancel
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="action-button"
:disabled="!isReady || isSaving"
@click="saveCrop"
@@ -261,7 +261,7 @@
:width="2"
/>
<span>{{ isSaving ? 'Saving...' : confirmLabel }}</span>
</button>
</v-btn>
</div>
</div>
</v-dialog>
@@ -271,8 +271,8 @@
@reference "@/assets/main.css";
.cropper-card {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-surface-raised);
border-color: var(--app-border-subtle);
}
.cropper-header {
@@ -281,12 +281,12 @@
.cropper-eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.cropper-header h2 {
@apply mt-2 text-2xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.cropper-actions,
@@ -300,9 +300,9 @@
.url-input {
@apply min-w-0 flex-1 rounded-full border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.92);
color: #172033;
color: var(--app-color-on-surface);
}
.footer-actions {
@@ -315,22 +315,22 @@
}
.action-button {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.action-button.secondary,
.plain-button {
background: rgba(255, 255, 255, 0.84);
color: #172033;
border: 1px solid rgba(23, 32, 51, 0.12);
color: var(--app-color-on-surface);
border: 1px solid var(--app-border-subtle);
}
.cropper-stage {
@apply overflow-hidden rounded-[1.5rem] border;
height: 28rem;
border-color: rgba(23, 32, 51, 0.08);
background: #fffaf2;
border-color: var(--app-border-subtle);
background: var(--app-color-on-primary);
}
.empty-state,
@@ -339,14 +339,14 @@
}
.empty-state {
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
background: rgba(255, 250, 242, 0.9);
}
.error-message {
border-color: rgba(185, 28, 28, 0.12);
color: #b91c1c;
color: var(--app-danger-muted);
background: rgba(254, 226, 226, 0.75);
}

View File

@@ -12,7 +12,8 @@ import ProductFeaturePage from '@/static/views/ProductFeaturePage.vue';
import PricingPage from '@/static/views/PricingPage.vue';
import BlogsPage from '@/static/views/BlogsPage.vue';
import GuidesPage from '@/static/views/GuidesPage.vue';
import './assets/main.css';
import { createSocializeVuetify } from '@/plugins/vuetify.js';
import './assets/styles.css';
const publicRoutes = [
{ path: '/', component: Landing },
@@ -45,6 +46,7 @@ export async function render(routePath) {
render: () => h(RouterView),
});
app.use(createSocializeVuetify());
app.use(createPinia());
app.use(router);
app.use(head);

View File

@@ -18,16 +18,16 @@
:callback="googleCallback"
popup-type="TOKEN"
>
<button class="secondary">
<v-btn variant="text" :ripple="false" class="secondary">
<v-icon
:icon="mdiGoogle"
class="mr-2"
/>
{{ t('continueWithGoogle') }}
</button>
</v-btn>
</google-login>
<button
<v-btn variant="text" :ripple="false"
class="secondary"
type="button"
@click="handleFacebookLogin"
@@ -37,7 +37,7 @@
class="mr-2"
/>
{{ t('continueWithFacebook') }}
</button>
</v-btn>
</div>
<div class="my-4 flex items-center">
@@ -225,13 +225,13 @@
.login-card {
@apply flex min-h-0 w-full flex-1 flex-col justify-center gap-10 bg-white/80 px-5 py-8 sm:flex-none sm:rounded-[1.5rem] sm:border sm:p-8;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
box-shadow: none;
}
@media (min-width: 640px) {
.login-card {
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px var(--app-border-subtle);
}
}

View File

@@ -137,7 +137,7 @@
.content-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.hero {
@@ -146,7 +146,7 @@
.breadcrumb-row {
@apply flex items-center gap-2 text-sm;
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.breadcrumb,
@@ -157,18 +157,18 @@
.status-row small,
.status-row em {
@apply text-sm leading-6 not-italic;
color: #526178;
color: var(--app-text-muted);
}
.breadcrumb {
@apply font-bold uppercase tracking-[0.16em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.hero h1,
.section-header strong,
.content-card strong {
color: #172033;
color: var(--app-color-on-surface);
}
.hero h1 {
@@ -182,8 +182,8 @@
.meta-chip,
.version-chip {
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.section-header {
@@ -196,8 +196,8 @@
.scope-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.scope-button:hover {
@@ -223,11 +223,11 @@
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -178,14 +178,14 @@
</div>
<div class="panel-actions">
<button
<v-btn variant="text" :ripple="false"
class="secondary"
:disabled="campaignsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="primary"
:disabled="campaignsStore.isCreating"
@click="submitForm"
@@ -197,7 +197,7 @@
:width="2"
/>
<span>{{ campaignsStore.isCreating ? t('common.creating') : t('campaigns.createTitle') }}</span>
</button>
</v-btn>
</div>
</div>
@@ -250,12 +250,12 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.header p,
@@ -264,7 +264,7 @@
.campaign-meta span,
.campaign-meta em {
@apply text-sm leading-6 not-italic;
color: #526178;
color: var(--app-text-muted);
}
.primary,
@@ -273,20 +273,20 @@
}
.primary {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.create-panel,
.campaign-row {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.create-panel {
@@ -299,7 +299,7 @@
.panel-header strong,
.campaign-row strong {
color: #172033;
color: var(--app-color-on-surface);
}
.form-grid {
@@ -308,7 +308,7 @@
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field-wide {
@@ -317,16 +317,16 @@
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.95);
color: #172033;
color: var(--app-color-on-surface);
}
.field textarea {
@apply min-h-28 rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.95);
color: #172033;
color: var(--app-color-on-surface);
resize: vertical;
}
@@ -353,11 +353,11 @@
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -134,7 +134,7 @@
</div>
<div class="network-tabs">
<button
<v-btn variant="text" :ripple="false"
v-for="network in networkOptions"
:key="network.value"
type="button"
@@ -144,7 +144,7 @@
>
<v-icon :icon="network.icon" />
<span>{{ network.value }}</span>
</button>
</v-btn>
</div>
<div
@@ -173,21 +173,21 @@
</div>
<div class="panel-actions">
<button
<v-btn variant="text" :ripple="false"
class="secondary"
type="button"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="primary"
type="button"
:disabled="channelsStore.isCreating"
@click="submitForm"
>
{{ channelsStore.isCreating ? t('common.saving') : t('channels.createTitle') }}
</button>
</v-btn>
</div>
</div>
@@ -241,7 +241,7 @@
</article>
</div>
<button
<v-btn variant="text" :ripple="false"
v-else
type="button"
class="empty-state"
@@ -249,7 +249,7 @@
>
<v-icon :icon="mdiPlus" />
<span>{{ t('channels.emptyAction', { network: activeNetwork }) }}</span>
</button>
</v-btn>
</section>
</template>
@@ -261,7 +261,7 @@
.header h1 {
@apply text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.header p,
@@ -273,7 +273,7 @@
.page-message,
.empty-state span {
@apply text-sm leading-6 not-italic;
color: #526178;
color: var(--app-text-muted);
}
.network-tabs {
@@ -282,16 +282,16 @@
.network-tab {
@apply inline-flex items-center gap-2 rounded-full border px-4 py-3 transition;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.95);
color: #526178;
color: var(--app-text-muted);
}
.network-tab.active,
.network-tab:hover {
border-color: rgba(255, 138, 61, 0.28);
background: rgba(255, 138, 61, 0.1);
color: #172033;
color: var(--app-color-on-surface);
}
.channel-grid {
@@ -303,7 +303,7 @@
.empty-state {
@apply flex flex-col gap-5 rounded-[1.5rem] border p-5;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.empty-state {
@@ -317,13 +317,13 @@
}
.primary {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.panel-header {
@@ -334,12 +334,12 @@
.field,
.channel-header strong,
.channel-metrics strong {
color: #172033;
color: var(--app-color-on-surface);
}
.panel-header span {
@apply text-sm font-semibold;
color: #526178;
color: var(--app-text-muted);
}
.form-grid {
@@ -352,7 +352,7 @@
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.95);
}
@@ -371,8 +371,8 @@
.channel-metrics {
@apply grid grid-cols-3 gap-3 rounded-[1rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.channel-metrics div {
@@ -386,10 +386,10 @@
.page-message {
@apply rounded-[1.25rem] border p-4 font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -220,13 +220,13 @@
<div class="section details-section">
<div class="section-header">
<strong>Client details</strong>
<button
<v-btn variant="text" :ripple="false"
v-if="authStore.isManager"
class="scope-button scope-button-secondary"
@click="isEditFormVisible ? (isEditFormVisible = false) : openEditForm()"
>
{{ isEditFormVisible ? 'Close editor' : 'Edit details' }}
</button>
</v-btn>
</div>
<div
@@ -311,20 +311,20 @@
<small>Use a local file or a remote image URL, then crop and scale it.</small>
</div>
<div class="image-picker-actions">
<button
<v-btn variant="text" :ripple="false"
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating"
@click="openPortraitDialog('client')"
>
Change image
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating || !form.portraitUrl"
@click="clearPortrait('client')"
>
Remove
</button>
</v-btn>
</div>
</div>
</div>
@@ -359,34 +359,34 @@
<small>Use a local file or a remote image URL, then crop and scale it.</small>
</div>
<div class="image-picker-actions">
<button
<v-btn variant="text" :ripple="false"
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating"
@click="openPortraitDialog('contact')"
>
Change image
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating || !form.primaryContactPortraitUrl"
@click="clearPortrait('contact')"
>
Remove
</button>
</v-btn>
</div>
</div>
</div>
</div>
<div class="panel-actions">
<button
<v-btn variant="text" :ripple="false"
class="scope-button scope-button-secondary"
:disabled="clientsStore.isUpdating"
@click="isEditFormVisible = false"
>
Cancel
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="scope-button"
:disabled="clientsStore.isUpdating"
@click="submitEditForm"
@@ -398,7 +398,7 @@
:width="2"
/>
<span>{{ clientsStore.isUpdating ? 'Saving...' : 'Save client' }}</span>
</button>
</v-btn>
</div>
</template>
</div>
@@ -489,7 +489,7 @@
.campaign-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.hero {
@@ -500,7 +500,7 @@
.stat-card strong,
.campaign-card strong,
.contact-card strong {
color: #172033;
color: var(--app-color-on-surface);
}
.hero-main h1 {
@@ -515,12 +515,12 @@
.campaign-card em,
.section-header span {
@apply text-sm leading-6 not-italic;
color: #526178;
color: var(--app-text-muted);
}
.breadcrumb {
@apply font-bold uppercase tracking-[0.18em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.hero-meta {
@@ -530,7 +530,7 @@
.hero-status {
@apply inline-flex items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.18em];
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.stats-grid {
@@ -552,7 +552,7 @@
.details-section {
@apply rounded-[1.5rem] border p-5;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.scope-actions {
@@ -561,8 +561,8 @@
.scope-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold no-underline transition;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.scope-button:hover {
@@ -571,12 +571,12 @@
.scope-button-secondary {
background: rgba(255, 255, 255, 0.92);
color: #172033;
border: 1px solid rgba(23, 32, 51, 0.12);
color: var(--app-color-on-surface);
border: 1px solid var(--app-border-subtle);
}
.scope-button-secondary:hover {
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
}
.details-grid {
@@ -585,8 +585,8 @@
.detail-row {
@apply flex flex-col gap-2 rounded-[1.25rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.detail-row-wide {
@@ -595,7 +595,7 @@
.detail-row small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.identity-row {
@@ -608,7 +608,7 @@
.identity-row strong {
@apply truncate text-base font-bold;
color: #172033;
color: var(--app-color-on-surface);
}
.form-grid {
@@ -617,7 +617,7 @@
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field-wide {
@@ -627,9 +627,9 @@
.field input,
.field select {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
background: white;
color: #172033;
color: var(--app-color-on-surface);
}
.image-field {
@@ -638,8 +638,8 @@
.image-picker-card {
@apply flex flex-col gap-4 rounded-[1.25rem] border p-4 lg:flex-row lg:items-center lg:justify-between;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.image-picker-copy {
@@ -647,12 +647,12 @@
}
.image-picker-copy strong {
color: #172033;
color: var(--app-color-on-surface);
}
.image-picker-copy small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.image-picker-actions {
@@ -669,7 +669,7 @@
.section-header strong {
@apply text-lg font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.campaign-list {
@@ -699,11 +699,11 @@
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -75,13 +75,13 @@
</div>
<div class="action-row">
<button
<v-btn variant="text" :ripple="false"
v-if="authStore.isManager"
class="create-button"
@click="openCreateForm"
>
{{ t('clients.newClient') }}
</button>
</v-btn>
</div>
<div
@@ -151,14 +151,14 @@
</div>
<div class="panel-actions">
<button
<v-btn variant="text" :ripple="false"
class="secondary"
:disabled="clientsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="primary"
:disabled="clientsStore.isCreating"
@click="submitForm"
@@ -170,7 +170,7 @@
:width="2"
/>
<span>{{ clientsStore.isCreating ? t('common.creating') : t('clients.createTitle') }}</span>
</button>
</v-btn>
</div>
</div>
@@ -227,17 +227,17 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.header p {
@apply mt-2 text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.header {
@@ -261,7 +261,7 @@
.create-button,
.primary {
background: #172033;
background: var(--app-color-on-surface);
color: white;
}
@@ -272,14 +272,14 @@
.secondary {
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.12);
color: #172033;
border: 1px solid var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.create-panel {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.panel-header {
@@ -288,12 +288,12 @@
.panel-header strong {
@apply text-lg font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.panel-header span {
@apply text-sm font-semibold;
color: #526178;
color: var(--app-text-muted);
}
.form-grid {
@@ -302,7 +302,7 @@
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field.field-wide {
@@ -311,9 +311,9 @@
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
background: white;
color: #172033;
color: var(--app-color-on-surface);
}
.panel-actions {
@@ -327,18 +327,18 @@
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
.client-card {
@apply flex flex-col gap-3 rounded-[1.5rem] border p-5;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
text-decoration: none;
}
@@ -348,7 +348,7 @@
.client-card strong {
@apply text-xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.client-card span {
@@ -358,7 +358,7 @@
.client-card em {
@apply text-sm leading-6 not-italic;
color: #526178;
color: var(--app-text-muted);
}
.client-card small {

View File

@@ -24,7 +24,7 @@
<template>
<div class="color-palette">
<button
<v-btn variant="text" :ripple="false"
v-for="color in props.colors"
:key="color"
class="color-option"
@@ -44,10 +44,10 @@
gap: 0.5rem;
width: max-content;
padding: 0.5rem;
border: 1px solid rgba(23, 32, 51, 0.1);
border: 1px solid var(--app-control-active);
border-radius: 0.75rem;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.14);
}
@@ -57,13 +57,13 @@
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 9999px;
border-color: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 0 1px rgba(23, 32, 51, 0.12);
box-shadow: 0 0 0 1px var(--app-border-subtle);
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.color-option:hover,
.color-option.active {
transform: scale(1.08);
box-shadow: 0 0 0 2px #172033;
box-shadow: 0 0 0 2px var(--app-color-on-surface);
}
</style>

View File

@@ -255,7 +255,7 @@
class="approval-step"
:class="`is-${step.status}`"
>
<button
<v-btn variant="text" :ripple="false"
class="step-circle"
type="button"
:disabled="step.kind !== 'approval' || !canRecordDecision(step.approval) || isSubmittingDecision"
@@ -263,7 +263,7 @@
@click="submitDecision(step.approval.id)"
>
{{ index + 1 }}
</button>
</v-btn>
<div class="step-popover">
<div class="popover-heading">
@@ -342,7 +342,7 @@
.popover-heading strong,
.popover-meta strong,
.decision-row strong {
color: #172033;
color: var(--app-color-on-surface);
}
.approval-empty span,
@@ -352,7 +352,7 @@
.decision-row span,
.decision-row small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.approval-stepper,
@@ -378,9 +378,9 @@
.step-circle {
@apply relative z-10 flex h-9 w-9 items-center justify-center rounded-full border text-xs font-black transition;
background: #fffdf8;
background: var(--app-color-surface);
border-color: rgba(23, 32, 51, 0.16);
color: #526178;
color: var(--app-text-muted);
}
button.step-circle:not(:disabled) {
@@ -397,37 +397,37 @@
}
.step-circle.is-muted {
background: rgba(23, 32, 51, 0.04);
background: var(--app-control-subtle);
}
.approval-step.is-approved .step-circle {
background: #0f766e;
border-color: #0f766e;
color: #fffaf2;
background: var(--app-color-on-tertiary);
border-color: var(--app-color-on-tertiary);
color: var(--app-color-on-primary);
}
.approval-step.is-scheduled .step-circle {
background: #b45309;
border-color: #b45309;
color: #fffaf2;
color: var(--app-color-on-primary);
}
.approval-step.is-published .step-circle {
background: #7c3aed;
border-color: #7c3aed;
color: #fffaf2;
color: var(--app-color-on-primary);
}
.approval-step.is-current .step-circle {
background: #172033;
border-color: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
border-color: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.step-popover {
@apply pointer-events-none absolute left-[calc(100%+0.75rem)] top-0 z-20 flex w-[18rem] translate-y-2 flex-col gap-3 rounded-[1rem] border p-4 opacity-0 shadow-xl transition;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
}
.approval-step:hover .step-popover,
@@ -447,7 +447,7 @@
.decision-row {
@apply flex items-start gap-3 rounded-[0.875rem] border p-3;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.decision-row div {

View File

@@ -114,13 +114,13 @@
<span>Replying to</span>
<strong>{{ replyTarget.authorDisplayName }}</strong>
</div>
<button
<v-btn variant="text" :ripple="false"
type="button"
title="Cancel reply"
@click="emit('cancel-reply')"
>
<v-icon :icon="mdiClose" />
</button>
</v-btn>
</div>
<div class="comment-composer-main">
@@ -148,20 +148,20 @@
class="selected-media-file"
>
<span>{{ form.mediaFile.name }}</span>
<button
<v-btn variant="text" :ripple="false"
type="button"
title="Remove selected media"
@click="clearMediaFile"
>
<v-icon :icon="mdiClose" />
</button>
</v-btn>
</div>
<div
v-if="form.showMentionPicker"
class="mention-picker"
>
<button
<v-btn variant="text" :ripple="false"
v-for="member in members"
:key="member.id"
class="mention-option"
@@ -174,7 +174,7 @@
size="sm"
/>
<span>{{ member.displayName }}</span>
</button>
</v-btn>
<div
v-if="!members.length"
@@ -208,7 +208,7 @@
hide-details
@update:model-value="selectMediaFile"
/>
<button
<v-btn variant="text" :ripple="false"
class="icon-tool-button"
type="button"
title="Mention a member"
@@ -216,8 +216,8 @@
@click="toggleMentionPicker"
>
<v-icon :icon="mdiAt" />
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="post-button"
type="button"
:disabled="!canSubmit"
@@ -225,7 +225,7 @@
>
<v-icon :icon="mdiSend" />
{{ isPosting ? 'Posting...' : 'Post' }}
</button>
</v-btn>
</div>
</div>
</div>
@@ -235,8 +235,8 @@
@reference "@/assets/main.css";
.comment-composer {
@apply flex flex-col gap-3 rounded-[1.25rem] border p-4;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.12);
background: var(--app-color-surface);
border-color: var(--app-border-subtle);
}
.comment-composer.reply {
@@ -259,28 +259,28 @@
}
.reply-context span {
color: #526178;
color: var(--app-text-muted);
}
.reply-context strong {
@apply truncate;
color: #172033;
color: var(--app-color-on-surface);
}
.reply-context button {
@apply inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition;
color: #526178;
color: var(--app-text-muted);
}
.reply-context button:hover,
.reply-context button:focus-visible {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.comment-textarea {
@apply min-h-24 flex-1 resize-y border-0 bg-transparent text-sm leading-6;
color: #172033;
color: var(--app-color-on-surface);
outline: none;
}
@@ -291,49 +291,49 @@
.selected-media-file {
@apply flex items-center justify-between gap-3 rounded-[1rem] border px-3 py-2 text-sm;
background: rgba(23, 32, 51, 0.03);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.selected-media-file span {
@apply min-w-0 truncate font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.selected-media-file button {
@apply inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition;
color: #526178;
color: var(--app-text-muted);
}
.selected-media-file button:hover,
.selected-media-file button:focus-visible {
background: rgba(185, 28, 28, 0.1);
color: #b91c1c;
color: var(--app-danger-muted);
}
.mention-picker {
@apply grid max-h-52 gap-2 overflow-y-auto rounded-[1rem] border p-2;
background: rgba(23, 32, 51, 0.03);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.mention-option {
@apply flex items-center gap-3 rounded-[0.875rem] px-2 py-2 text-left text-sm font-semibold transition;
color: #172033;
color: var(--app-color-on-surface);
}
.mention-option:hover {
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.empty-note {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.comment-composer-toolbar {
@apply flex items-center justify-end gap-2 border-t pt-3;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.internal-toggle {
@@ -355,20 +355,20 @@
.icon-tool-button {
@apply w-10;
background: rgba(23, 32, 51, 0.06);
color: #526178;
background: var(--app-control-hover);
color: var(--app-text-muted);
}
.icon-tool-button:hover,
.icon-tool-button.active {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.post-button {
@apply px-4;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.post-button:disabled {

View File

@@ -85,28 +85,28 @@
</div>
<div class="comment-actions">
<button
<v-btn variant="text" :ripple="false"
class="comment-action-button"
type="button"
title="Add reaction"
>
<v-icon :icon="mdiEmoticonPlusOutline" />
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="comment-action-button"
type="button"
title="Resolve"
>
<v-icon :icon="mdiCheckCircleOutline" />
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="comment-action-button"
type="button"
title="Reply"
@click="activeReplyCommentId = thread.comment.id"
>
<v-icon :icon="mdiReplyOutline" />
</button>
</v-btn>
<details class="comment-more-menu">
<summary
class="comment-action-button"
@@ -115,20 +115,20 @@
<v-icon :icon="mdiDotsVertical" />
</summary>
<div class="comment-action-menu">
<button
<v-btn variant="text" :ripple="false"
class="comment-menu-item"
type="button"
>
<v-icon :icon="mdiPencilOutline" />
Edit
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="comment-menu-item danger"
type="button"
>
<v-icon :icon="mdiDeleteOutline" />
Delete
</button>
</v-btn>
</div>
</details>
</div>
@@ -219,22 +219,22 @@
.empty-note {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.comment-row {
@apply relative flex flex-col gap-3 rounded-[1rem] border p-4 transition;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
outline: none;
}
.comment-row:hover,
.comment-row:focus-within,
.comment-row:focus {
background: #fffdf8;
background: var(--app-color-surface);
border-color: rgba(15, 118, 110, 0.24);
box-shadow: 0 16px 34px rgba(23, 32, 51, 0.08);
box-shadow: 0 16px 34px var(--app-border-subtle);
}
.comment-row-header {
@@ -251,7 +251,7 @@
.comment-author strong {
@apply truncate text-sm;
color: #172033;
color: var(--app-color-on-surface);
}
.comment-author small {
@@ -262,7 +262,7 @@
.comment-actions {
@apply absolute right-3 top-3 z-20 flex items-center gap-1 rounded-full border px-1 py-1 opacity-0 shadow-lg transition;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
pointer-events: none;
backdrop-filter: blur(10px);
}
@@ -276,13 +276,13 @@
.comment-action-button {
@apply inline-flex h-8 w-8 items-center justify-center rounded-full transition;
color: #526178;
color: var(--app-text-muted);
}
.comment-action-button:hover,
.comment-action-button:focus-visible {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.comment-more-menu {
@@ -299,7 +299,7 @@
.comment-more-menu[open] .comment-action-button {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.comment-more-menu[open] .comment-action-menu,
@@ -311,38 +311,38 @@
.comment-action-menu {
@apply absolute right-0 top-[calc(100%+0.375rem)] z-20 hidden min-w-36 flex-col gap-1 rounded-[0.875rem] border p-1 shadow-xl;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
}
.comment-menu-item {
@apply flex items-center gap-2 rounded-[0.625rem] px-3 py-2 text-sm font-semibold transition;
color: #172033;
color: var(--app-color-on-surface);
}
.comment-menu-item:hover,
.comment-menu-item:focus-visible {
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.comment-menu-item.danger {
color: #b91c1c;
color: var(--app-danger-muted);
}
.comment-menu-item.danger:hover,
.comment-menu-item.danger:focus-visible {
background: rgba(185, 28, 28, 0.1);
color: #b91c1c;
color: var(--app-danger-muted);
}
.comment-body {
@apply whitespace-pre-line text-sm leading-6;
color: #172033;
color: var(--app-color-on-surface);
}
.comment-attachment {
@apply block w-fit max-w-full overflow-hidden rounded-[0.875rem] border;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
background: #ffffff;
}
@@ -372,7 +372,7 @@
.reply-meta strong {
@apply truncate text-sm;
color: #172033;
color: var(--app-color-on-surface);
}
.reply-meta small {
@@ -382,6 +382,6 @@
.reply-row p {
@apply whitespace-pre-line text-sm leading-6;
color: #172033;
color: var(--app-color-on-surface);
}
</style>

View File

@@ -583,7 +583,7 @@
}
function calendarEventColor(event) {
return calendarEventSource(event)?.color ?? '#0f766e';
return calendarEventSource(event)?.color ?? 'var(--app-color-on-tertiary)';
}
function formatDateTime(value) {
@@ -704,14 +704,14 @@
<template>
<section class="editor-shell">
<button
<v-btn variant="text" :ripple="false"
class="back-button"
type="button"
@click="navigateBackToContent"
>
<v-icon :icon="mdiArrowLeft" />
Back to content
</button>
</v-btn>
<div
v-if="!isCreateMode && detailStore.isLoading"
@@ -749,7 +749,7 @@
<span class="meta-chip">{{ item.currentRevisionLabel }}</span>
</div>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
:disabled="contentItemsStore.isCreating || detailStore.actions.revision"
@click="saveContent"
@@ -757,7 +757,7 @@
{{ isCreateMode
? (contentItemsStore.isCreating ? 'Creating...' : 'Create content')
: (detailStore.actions.revision ? 'Saving...' : 'Save revision') }}
</button>
</v-btn>
</div>
</div>
@@ -815,7 +815,7 @@
<div class="date-context field-wide">
<div class="date-context-days">
<button
<v-btn variant="text" :ripple="false"
v-for="day in dateContextDays"
:key="day.key"
class="date-context-day"
@@ -828,14 +828,14 @@
>
<span>{{ formatContextDay(day.date) }}</span>
<strong>{{ day.events.length }}</strong>
</button>
</v-btn>
</div>
<div
v-if="selectedDateCalendarEvents.length"
class="date-context-panel"
>
<button
<v-btn variant="text" :ripple="false"
v-for="event in selectedDateCalendarEvents"
:key="event.id"
class="calendar-context-pill"
@@ -844,7 +844,7 @@
@click="showCalendarEvent(event)"
>
{{ event.title }}
</button>
</v-btn>
</div>
<div
@@ -910,20 +910,20 @@
<div class="content-section">
<div class="section-title-row">
<strong>Channels and variants</strong>
<button
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
@click="addPlacement()"
>
Add channel
</button>
</v-btn>
</div>
<div
v-if="groupedChannels.length"
class="channel-suggestions"
>
<button
<v-btn variant="text" :ripple="false"
v-for="group in groupedChannels"
:key="group.network"
class="network-pill"
@@ -931,7 +931,7 @@
@click="addPlacement(group.channels[0])"
>
{{ group.network }}
</button>
</v-btn>
</div>
<div
@@ -948,13 +948,13 @@
<strong>{{ placement.channelName || placement.network || 'Channel' }}</strong>
<span>{{ placement.variantLabel || 'Custom variant' }}</span>
</div>
<button
<v-btn variant="text" :ripple="false"
class="link-button"
type="button"
@click="removePlacement(placement.id)"
>
Remove
</button>
</v-btn>
</div>
<div class="form-grid compact-grid">
@@ -1018,13 +1018,13 @@
<div class="media-section">
<div class="section-title-row">
<strong>Media</strong>
<button
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
@click="addMedia(placement)"
>
Add media
</button>
</v-btn>
</div>
<div
@@ -1063,13 +1063,13 @@
/>
</div>
<button
<v-btn variant="text" :ripple="false"
class="link-button"
type="button"
@click="removeMedia(placement, media.id)"
>
Remove media
</button>
</v-btn>
</div>
</div>
@@ -1108,7 +1108,7 @@
<template v-else>
<div class="tab-strip">
<button
<v-btn variant="text" :ripple="false"
v-for="tab in productionTabs"
:key="tab.key"
class="tab-button"
@@ -1118,7 +1118,7 @@
>
{{ tab.label }}
<span>{{ tab.count }}</span>
</button>
</v-btn>
</div>
<template v-if="activeProductionTab === 'comments'">
@@ -1200,13 +1200,13 @@
variant="outlined"
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="primary-button field-wide"
:disabled="detailStore.actions.asset"
@click="linkGoogleDriveAsset"
>
{{ detailStore.actions.asset ? 'Linking...' : 'Link asset' }}
</button>
</v-btn>
</div>
<div class="timeline-list">
@@ -1262,13 +1262,13 @@
variant="outlined"
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="secondary-button"
:disabled="detailStore.actions.assetRevision"
@click="addAssetRevision(asset)"
>
{{ detailStore.actions.assetRevision ? 'Adding...' : 'Add revision' }}
</button>
</v-btn>
</div>
</article>
@@ -1319,13 +1319,13 @@
<span>{{ activeCalendarEvent.source?.displayTitle ?? t('contentItems.calendar.importedEvent') }}</span>
<strong>{{ activeCalendarEvent.title }}</strong>
</div>
<button
<v-btn variant="text" :ripple="false"
class="link-button"
type="button"
@click="closeCalendarEvent"
>
{{ t('close') }}
</button>
</v-btn>
</div>
<p>{{ formatCalendarDate(activeCalendarEvent.startDate) }}</p>
@@ -1333,13 +1333,13 @@
<p v-if="activeCalendarEvent.location">{{ activeCalendarEvent.location }}</p>
<div class="calendar-event-actions">
<button
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
@click="navigateToCalendarDay(activeCalendarEvent)"
>
{{ t('contentItems.dateContext.viewDay') }}
</button>
</v-btn>
</div>
</div>
</div>
@@ -1356,28 +1356,28 @@
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
.editor-header {
@apply flex flex-col gap-4 rounded-[1.75rem] border p-6 lg:flex-row lg:items-start lg:justify-between;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.editor-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.editor-header p,
@@ -1388,7 +1388,7 @@
.placement-header span,
.section-title-row span {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.header-actions,
@@ -1398,8 +1398,8 @@
.meta-chip {
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.back-button,
@@ -1411,23 +1411,23 @@
.back-button {
@apply w-fit border;
background: rgba(255, 255, 255, 0.88);
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.back-button:hover {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.primary-button {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary-button {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.editor-grid {
@@ -1437,13 +1437,13 @@
.work-panel {
@apply grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] items-start gap-4 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.panel {
@apply flex min-h-0 flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.side-panel {
@@ -1464,7 +1464,7 @@
.section-title-row strong,
.placement-header strong,
.timeline-row strong {
color: #172033;
color: var(--app-color-on-surface);
}
.panel-stack,
@@ -1495,16 +1495,16 @@
.field span {
@apply text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field input,
.field select,
.field textarea {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
background: var(--app-color-surface);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
outline: none;
}
@@ -1526,8 +1526,8 @@
.date-context-day {
@apply flex min-h-14 flex-col items-start justify-between rounded-[0.875rem] border px-3 py-2 text-left transition;
background: rgba(255, 253, 248, 0.9);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.date-context-day span {
@@ -1536,25 +1536,25 @@
.date-context-day strong {
@apply h-5 min-w-5 rounded-full px-1.5 text-center text-xs leading-5;
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.date-context-day.marked {
border-color: rgba(15, 118, 110, 0.32);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.date-context-day.marked strong,
.date-context-day.active strong {
background: #0f766e;
color: #fffaf2;
background: var(--app-color-on-tertiary);
color: var(--app-color-on-primary);
}
.date-context-day.active {
background: #172033;
border-color: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
border-color: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.date-context-panel {
@@ -1565,23 +1565,23 @@
@apply rounded-full border px-3 py-1.5 text-xs font-bold transition;
background: color-mix(in srgb, var(--calendar-color) 12%, white);
border-color: color-mix(in srgb, var(--calendar-color) 34%, white);
color: #172033;
color: var(--app-color-on-surface);
}
.calendar-context-pill:hover {
background: var(--calendar-color);
color: #fffaf2;
color: var(--app-color-on-primary);
}
.date-context-empty {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.hashtags-input-shell {
@apply flex flex-wrap items-center gap-2 rounded-[1rem] border px-4 py-3;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.12);
background: var(--app-color-surface);
border-color: var(--app-border-subtle);
}
.hashtags-editor {
@@ -1590,7 +1590,7 @@
.hashtags-inline-input {
@apply min-w-[12rem] flex-1 border-0 bg-transparent p-0 text-sm;
color: #172033;
color: var(--app-color-on-surface);
outline: none;
}
@@ -1600,9 +1600,9 @@
.network-pill {
@apply rounded-full border px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-control-subtle);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.channel-suggestions {
@@ -1612,8 +1612,8 @@
.placement-card,
.media-card {
@apply rounded-[1.25rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.identity-row {
@@ -1627,7 +1627,7 @@
.timeline-row {
@apply flex flex-col gap-3 rounded-[1rem] border p-4;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.timeline-row-header {
@@ -1637,7 +1637,7 @@
.timeline-row p,
.asset-revision-row p {
@apply text-sm leading-6;
color: #172033;
color: var(--app-color-on-surface);
}
.timeline-actions {
@@ -1654,14 +1654,14 @@
.tab-button {
@apply flex items-center justify-between gap-2 rounded-[1rem] border px-3 py-2 text-sm font-bold transition;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-control-subtle);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.tab-button.active {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.tab-button span {
@@ -1675,25 +1675,25 @@
.asset-card {
@apply flex flex-col gap-4 rounded-[1rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.asset-card .timeline-row-header span,
.asset-revision-row small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.asset-link {
@apply w-fit text-sm font-semibold;
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.revision-pill {
@apply rounded-full px-3 py-1 text-xs font-bold;
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.asset-revisions {
@@ -1703,12 +1703,12 @@
.asset-revision-row {
@apply rounded-[0.875rem] border px-3 py-2;
background: rgba(255, 255, 255, 0.7);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.asset-revision-row span {
@apply mr-2 text-sm font-bold;
color: #172033;
color: var(--app-color-on-surface);
}
.compact-form {
@@ -1717,7 +1717,7 @@
.link-button {
@apply text-sm font-semibold;
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.calendar-event-popover {
@@ -1726,8 +1726,8 @@
.calendar-event-card {
@apply flex w-full max-w-md flex-col gap-4 rounded-[1.25rem] border p-5 shadow-2xl;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-surface);
border-color: var(--app-border-subtle);
}
.calendar-event-header {
@@ -1741,12 +1741,12 @@
.calendar-event-header span,
.calendar-event-card p {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.calendar-event-header strong {
@apply text-xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.calendar-event-actions {

View File

@@ -484,6 +484,12 @@
calendarStore.toggleSourceVisibility(sourceId);
}
function setSourceVisibility(sourceId, visible) {
if (sourceIsVisible(sourceId) !== visible) {
calendarStore.toggleSourceVisibility(sourceId);
}
}
function toggleColorPalette(sourceId) {
activeColorSourceId.value = activeColorSourceId.value === sourceId ? '' : sourceId;
}
@@ -866,26 +872,26 @@
>
<div class="calendar-toolbar">
<div class="range-selector">
<button
<v-btn variant="text" :ripple="false"
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'month' }"
type="button"
@click="setView('month')"
>
{{ t('dashboard.month') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'week' }"
type="button"
@click="setView('week')"
>
{{ t('dashboard.week') }}
</button>
</v-btn>
</div>
<div class="calendar-nav">
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
:title="previousPeriodLabel"
@@ -893,11 +899,11 @@
@click="shiftPeriod(-1)"
>
<v-icon :icon="mdiChevronLeft" />
</button>
</v-btn>
<div class="calendar-period">{{ periodLabel }}</div>
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
:title="nextPeriodLabel"
@@ -905,20 +911,20 @@
@click="shiftPeriod(1)"
>
<v-icon :icon="mdiChevronRight" />
</button>
</v-btn>
</div>
<button
<v-btn variant="text" :ripple="false"
v-if="!isTodayVisible"
class="text-button"
type="button"
@click="jumpToToday"
>
{{ t('dashboard.today') }}
</button>
</v-btn>
<div class="calendar-selector">
<button
<v-btn variant="text" :ripple="false"
class="calendar-selector-button"
type="button"
@click="isCalendarSelectorOpen = !isCalendarSelectorOpen"
@@ -926,7 +932,7 @@
<span>{{ t('contentItems.calendar.calendars') }}</span>
<strong>{{ visibleCalendarSourceCount }}/{{ availableCalendarSources.length }}</strong>
<v-icon :icon="mdiChevronDown" />
</button>
</v-btn>
<div
v-if="isCalendarSelectorOpen"
@@ -938,7 +944,7 @@
class="calendar-selector-row"
>
<span class="source-color-control">
<button
<v-btn variant="text" :ripple="false"
class="source-swatch-button"
type="button"
:disabled="source.isReadOnly"
@@ -949,7 +955,7 @@
class="source-swatch"
:style="{ background: source.color }"
/>
</button>
</v-btn>
<ColorPalette
v-if="activeColorSourceId === source.id"
@@ -960,19 +966,23 @@
@update:model-value="color => updateSourceColor(source, color)"
/>
</span>
<button
<v-btn variant="text" :ripple="false"
class="calendar-selector-title"
type="button"
@click="toggleSource(source.id)"
>
{{ source.displayTitle }}
</button>
<button
</v-btn>
<v-switch
class="visibility-switch"
:class="{ active: sourceIsVisible(source.id) }"
type="button"
:model-value="sourceIsVisible(source.id)"
:aria-label="source.displayTitle"
@click="toggleSource(source.id)"
color="primary"
density="compact"
hide-details
inset
@click.stop
@update:model-value="visible => setSourceVisibility(source.id, visible)"
/>
</div>
@@ -983,14 +993,14 @@
{{ t('contentItems.calendar.noCalendars') }}
</div>
<button
<v-btn variant="text" :ripple="false"
class="calendar-selector-add"
type="button"
@click="openAddCalendar"
>
<v-icon :icon="mdiCalendarPlus" />
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
</button>
</v-btn>
</div>
</div>
</div>
@@ -1031,7 +1041,7 @@
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
:key="`${entry.type}-${entry.id}`"
>
<button
<v-btn variant="text" :ripple="false"
v-if="entry.type === 'imported-calendar'"
class="calendar-entry calendar-context-entry"
:class="entry.tone"
@@ -1041,7 +1051,7 @@
@click="createFromImportedEvent(entry)"
>
<span class="calendar-event-chip">{{ entry.title }}</span>
</button>
</v-btn>
<router-link
v-else-if="entry.type === 'content'"
@@ -1136,7 +1146,7 @@
v-for="entry in upcomingEntries"
:key="`${entry.type}-${entry.id}`"
>
<button
<v-btn variant="text" :ripple="false"
v-if="entry.type === 'imported-calendar'"
class="item-card calendar-upcoming-card"
:style="entryStyle(entry)"
@@ -1150,7 +1160,7 @@
<em>{{ entry.timeLabel }}</em>
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
</div>
</button>
</v-btn>
<router-link
v-else-if="entry.type === 'content'"
@@ -1274,32 +1284,32 @@
<div class="calendar-dialog">
<div class="dialog-header">
<strong>{{ t('contentItems.calendar.addCalendar') }}</strong>
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
@click="isAddCalendarOpen = false"
>
<v-icon :icon="mdiClose" />
</button>
</v-btn>
</div>
<div class="add-mode-toggle">
<button
<v-btn variant="text" :ripple="false"
class="toggle-button"
:class="{ 'toggle-button-active': activeAddMode === 'catalog' }"
type="button"
@click="activeAddMode = 'catalog'"
>
{{ t('contentItems.calendar.catalog') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="toggle-button"
:class="{ 'toggle-button-active': activeAddMode === 'custom' }"
type="button"
@click="activeAddMode = 'custom'"
>
{{ t('contentItems.calendar.customIcs') }}
</button>
</v-btn>
</div>
<v-radio-group
@@ -1344,18 +1354,18 @@
hide-details
:placeholder="t('contentItems.calendar.category')"
/>
<button
<v-btn variant="text" :ripple="false"
class="text-button"
type="button"
@click="searchCatalog"
>
<v-icon :icon="mdiMagnify" />
<span>{{ t('contentItems.calendar.search') }}</span>
</button>
</v-btn>
</div>
<div class="catalog-results">
<button
<v-btn variant="text" :ripple="false"
v-for="entry in calendarStore.catalogEntries"
:key="entry.id"
class="catalog-entry"
@@ -1374,7 +1384,7 @@
? t('contentItems.calendar.alreadyAdded')
: `${entry.providerName} · ${entry.category}` }}
</span>
</button>
</v-btn>
</div>
</div>
@@ -1413,13 +1423,13 @@
hide-details
:placeholder="t('contentItems.calendar.category')"
/>
<button
<v-btn variant="text" :ripple="false"
class="text-button"
type="submit"
>
<v-icon :icon="mdiPlus" />
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
</button>
</v-btn>
</div>
</v-form>
@@ -1444,13 +1454,13 @@
.status-row em,
.status-row small {
@apply text-sm leading-6 not-italic;
color: #526178;
color: var(--app-text-muted);
}
.range-selector {
@apply inline-flex w-fit rounded-full border p-1;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
}
.calendar-selector {
@@ -1460,26 +1470,26 @@
.calendar-selector-button {
@apply inline-flex min-h-11 w-full items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm font-bold transition sm:w-auto;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
}
.calendar-selector-button strong {
@apply rounded-full px-2 py-0.5 text-xs;
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.calendar-selector-menu {
@apply absolute right-0 top-[calc(100%+0.5rem)] z-30 flex w-full min-w-72 flex-col gap-1 rounded-[1rem] border p-2 shadow-xl sm:w-80;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
}
.calendar-selector-row,
.calendar-selector-add {
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
color: #172033;
color: var(--app-color-on-surface);
}
.calendar-selector-row:hover,
@@ -1500,7 +1510,7 @@
}
.source-swatch-button:not(:disabled):hover {
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
}
.source-swatch-button:disabled {
@@ -1513,32 +1523,17 @@
.calendar-selector-empty {
@apply px-3 py-2 text-sm;
color: #526178;
color: var(--app-text-muted);
}
.calendar-selector-add {
@apply border-t;
border-color: rgba(23, 32, 51, 0.08);
color: #0f766e;
border-color: var(--app-border-subtle);
color: var(--app-color-on-tertiary);
}
.visibility-switch {
@apply relative h-6 w-10 shrink-0 rounded-full transition;
background: rgba(148, 163, 184, 0.35);
}
.visibility-switch::after {
@apply absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition;
content: '';
box-shadow: 0 1px 4px rgba(23, 32, 51, 0.2);
}
.visibility-switch.active {
background: #0f766e;
}
.visibility-switch.active::after {
transform: translateX(1rem);
@apply shrink-0;
}
.toggle-button,
@@ -1546,8 +1541,8 @@
.text-button {
@apply inline-flex items-center justify-center rounded-full border px-3 py-2 text-sm font-semibold transition;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
}
.toggle-button {
@@ -1555,7 +1550,7 @@
}
.toggle-button-active {
background: #172033;
background: var(--app-color-on-surface);
color: #ffffff;
}
@@ -1573,16 +1568,16 @@
.item-card {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.page-message {
@apply p-5 text-sm;
color: #526178;
color: var(--app-text-muted);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
.calendar-card {
@@ -1604,7 +1599,7 @@
.calendar-period {
@apply min-w-0 px-2 text-base font-bold md:text-lg;
color: #172033;
color: var(--app-color-on-surface);
}
.source-swatch {
@@ -1622,12 +1617,12 @@
.weekday-label {
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
color: #526178;
color: var(--app-text-muted);
}
.calendar-day {
@apply min-h-[8rem] overflow-visible rounded-[1rem] border p-2.5;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.calendar-day-week {
@@ -1645,7 +1640,7 @@
.day-number {
@apply mb-2 text-sm font-bold;
color: #172033;
color: var(--app-color-on-surface);
}
.day-entries,
@@ -1674,12 +1669,12 @@
.calendar-entry strong,
.item-card strong {
@apply text-sm font-bold;
color: #172033;
color: var(--app-color-on-surface);
}
.calendar-entry span {
@apply text-xs leading-5;
color: #526178;
color: var(--app-text-muted);
}
.content-calendar-entry,
@@ -1707,13 +1702,13 @@
}
.campaign-chip {
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.hashtag-chip {
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.content-preview {
@@ -1734,7 +1729,7 @@
.content-preview span {
@apply text-xs leading-5;
-webkit-line-clamp: 2;
color: #526178;
color: var(--app-text-muted);
}
.content-meta-row {
@@ -1747,7 +1742,7 @@
.planned-time {
@apply gap-1 text-[0.7rem] font-bold uppercase;
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.planned-time i,
@@ -1757,12 +1752,12 @@
.channel-icons {
@apply justify-end gap-1;
color: #526178;
color: var(--app-text-muted);
}
.entry-time {
@apply text-[0.7rem] font-bold uppercase tracking-[0.12em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.calendar-event-chip {
@@ -1773,7 +1768,7 @@
.entry-more,
.day-empty {
@apply px-1 text-xs font-semibold;
color: #526178;
color: var(--app-text-muted);
}
.calendar-entry.production {
@@ -1825,7 +1820,7 @@
.calendar-dialog {
@apply flex flex-col gap-4 rounded-[1.5rem] border bg-white p-5;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
}
.dialog-header,
@@ -1842,13 +1837,13 @@
.dialog-header strong {
@apply text-lg font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.scope-option {
@apply inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
}
.catalog-panel,
@@ -1860,8 +1855,8 @@
.catalog-search input,
.custom-calendar-form input {
@apply min-h-11 rounded-[0.75rem] border px-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.catalog-search input[type='search'],
@@ -1876,7 +1871,7 @@
.catalog-entry {
@apply grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 rounded-[0.75rem] border px-3 py-2 text-left transition;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: #ffffff;
}
@@ -1895,17 +1890,17 @@
.catalog-entry strong {
@apply text-sm font-bold;
color: #172033;
color: var(--app-color-on-surface);
}
.catalog-entry span:last-child {
@apply col-start-2 text-xs;
color: #526178;
color: var(--app-text-muted);
}
.dialog-error {
@apply text-sm font-semibold;
color: #b91c1c;
color: var(--app-danger-muted);
}
.item-grid {
@@ -1918,8 +1913,8 @@
.version-chip {
@apply w-fit rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.version-chip.calendar-event-chip {
@@ -1933,39 +1928,39 @@
.content-table-shell {
@apply overflow-x-auto rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.94);
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-surface-glass);
border-color: var(--app-border-subtle);
}
.content-table {
@apply w-full min-w-[52rem] border-collapse text-left text-sm;
color: #526178;
color: var(--app-text-muted);
}
.content-table th {
@apply px-5 py-4 text-xs font-black uppercase tracking-[0.14em];
background: #f8fafc;
color: #172033;
color: var(--app-color-on-surface);
}
.content-table td {
@apply border-t px-5 py-4 align-middle;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.content-table-title {
@apply font-bold no-underline;
color: #172033;
color: var(--app-color-on-surface);
}
.content-table-title:hover {
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.table-status {
@apply inline-flex rounded-full px-3 py-1 text-xs font-bold;
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
@media (max-width: 960px) {

View File

@@ -109,7 +109,7 @@
.panel,
.status-panel {
@apply rounded-[1.75rem] border;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.9);
}
@@ -117,17 +117,17 @@
@apply p-6 md:p-8;
background:
radial-gradient(circle at top left, rgba(14, 165, 164, 0.18), transparent 45%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 255, 0.92));
linear-gradient(135deg, var(--app-surface-raised), rgba(240, 249, 255, 0.92));
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.hero-copy h1 {
@apply mt-3 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.hero-copy p,
@@ -138,7 +138,7 @@
.status-copy p,
.status-label span {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.hero-card {
@@ -158,13 +158,13 @@
.media-type-item {
@apply w-fit rounded-full px-3 py-2;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.hero-card strong,
.panel-header strong,
.status-copy strong {
color: #172033;
color: var(--app-color-on-surface);
}
.hero-card strong {
@@ -196,16 +196,16 @@
.media-type-item,
.workflow-item {
@apply rounded-[1.1rem] border px-4 py-3;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(248, 250, 252, 0.9);
}
.workflow-icon {
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.status-panel {
background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), var(--app-surface-raised));
}
.status-copy {
@@ -214,7 +214,7 @@
.status-label {
@apply text-xs font-bold uppercase tracking-[0.2em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.status-copy strong {

View File

@@ -14,7 +14,7 @@
class="feedback-entry"
data-feedback-ui="true"
>
<button
<v-btn variant="text" :ripple="false"
class="feedback-entry-button"
type="button"
:title="t('feedback.open')"
@@ -22,7 +22,7 @@
>
<v-icon :icon="mdiMessageAlertOutline" />
<span>{{ t('feedback.button') }}</span>
</button>
</v-btn>
<FeedbackSubmissionDialog v-model="isDialogOpen" />
</div>

View File

@@ -136,7 +136,7 @@
await nextTick();
const target = document.querySelector('.shell-container') ?? document.body;
const canvas = await html2canvas(target, {
backgroundColor: '#fffaf2',
backgroundColor: 'var(--app-color-on-primary)',
height: window.innerHeight,
ignoreElements: element => element.dataset?.feedbackUi === 'true',
scale: Math.min(window.devicePixelRatio || 1, 2),
@@ -484,14 +484,14 @@
<p>{{ t('feedback.eyebrow') }}</p>
<h2>{{ t('feedback.title') }}</h2>
</div>
<button
<v-btn variant="text" :ripple="false"
class="feedback-icon-button"
type="button"
:title="t('close')"
@click="requestClose"
>
<v-icon :icon="mdiClose" />
</button>
</v-btn>
</header>
<div class="feedback-dialog-body">
@@ -549,7 +549,7 @@
class="feedback-editor"
>
<div class="feedback-toolstrip">
<button
<v-btn variant="text" :ripple="false"
v-for="tool in annotationTools"
:key="tool.value"
class="feedback-tool-button"
@@ -559,24 +559,24 @@
@click="selectedTool = tool.value"
>
<v-icon :icon="tool.icon" />
</button>
</v-btn>
<span class="feedback-tool-divider"></span>
<button
<v-btn variant="text" :ripple="false"
class="feedback-tool-button"
type="button"
:title="t('feedback.tools.undo')"
@click="undoAnnotation"
>
<v-icon :icon="mdiUndoVariant" />
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="feedback-tool-button"
type="button"
:title="t('feedback.tools.clear')"
@click="clearAnnotations"
>
<v-icon :icon="mdiRedoVariant" />
</button>
</v-btn>
</div>
<canvas
@@ -623,26 +623,26 @@
@reference "@/assets/main.css";
.feedback-dialog {
@apply overflow-hidden rounded-lg border;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.feedback-dialog-header,
.feedback-dialog-footer {
@apply flex items-center justify-between gap-4 px-5 py-4;
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
border-bottom: 1px solid var(--app-border-subtle);
}
.feedback-dialog-footer {
@apply justify-end;
border-bottom: 0;
border-top: 1px solid rgba(23, 32, 51, 0.08);
border-top: 1px solid var(--app-border-subtle);
}
.feedback-dialog-header p {
@apply text-xs font-black uppercase;
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.feedback-dialog-header h2 {
@@ -652,15 +652,15 @@
.feedback-icon-button,
.feedback-tool-button {
@apply flex h-10 w-10 items-center justify-center rounded-full transition-colors;
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.feedback-icon-button:hover,
.feedback-tool-button:hover,
.feedback-tool-button-active {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.feedback-dialog-body {
@@ -694,7 +694,7 @@
@apply block w-full rounded-md border;
max-height: 58vh;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
cursor: crosshair;
object-fit: contain;
}
@@ -703,7 +703,7 @@
@apply flex min-h-[18rem] flex-col items-center justify-center gap-3 rounded-md border border-dashed text-sm;
background: rgba(23, 32, 51, 0.03);
border-color: rgba(23, 32, 51, 0.16);
color: #526178;
color: var(--app-text-muted);
}
.feedback-empty-preview i {

View File

@@ -184,14 +184,14 @@
<template>
<section class="feedback-detail-page">
<button
<v-btn variant="text" :ripple="false"
class="back-button"
type="button"
@click="router.push({ name: 'developer-feedback' })"
>
<v-icon :icon="mdiArrowLeft" />
{{ t('feedback.review.detail.back') }}
</button>
</v-btn>
<div
v-if="feedbackStore.isDetailLoading"
@@ -242,7 +242,7 @@
<section class="panel">
<div class="panel-header">
<strong>{{ t('feedback.review.detail.screenshot') }}</strong>
<button
<v-btn variant="text" :ripple="false"
v-if="report.screenshot"
class="small-button"
type="button"
@@ -250,8 +250,8 @@
>
<v-icon :icon="mdiDownloadOutline" />
{{ t('feedback.review.detail.download') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
v-if="report.screenshot"
class="small-button"
type="button"
@@ -259,7 +259,7 @@
>
<v-icon :icon="mdiOpenInNew" />
{{ t('feedback.review.detail.openOriginal') }}
</button>
</v-btn>
</div>
<div
@@ -329,13 +329,13 @@
variant="outlined"
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="submit"
:disabled="!canSubmitComment"
>
{{ feedbackStore.isCommenting ? t('feedback.review.detail.commenting') : t('feedback.review.detail.addComment') }}
</button>
</v-btn>
</v-form>
</section>
</main>
@@ -384,14 +384,14 @@
</template>
</v-combobox>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="button"
:disabled="feedbackStore.isSaving"
@click="saveReviewChanges"
>
{{ feedbackStore.isSaving ? t('common.saving') : t('save') }}
</button>
</v-btn>
</section>
<section class="panel">
@@ -459,20 +459,20 @@
.back-button,
.small-button {
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.88);
color: #172033;
color: var(--app-color-on-surface);
}
.back-button:hover,
.small-button:hover {
background: #172033;
background: var(--app-color-on-surface);
color: white;
}
.primary-button {
border-color: #0f766e;
background: #0f766e;
border-color: var(--app-color-on-tertiary);
background: var(--app-color-on-tertiary);
color: white;
}
@@ -482,18 +482,18 @@
.detail-header {
@apply rounded-lg border p-5;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.88);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.detail-header h1 {
@apply mt-2 line-clamp-3 text-2xl font-black md:text-3xl;
color: #172033;
color: var(--app-color-on-surface);
}
.header-meta,
@@ -505,7 +505,7 @@
.file-meta span {
@apply rounded-md px-2.5 py-1 text-xs font-bold;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.detail-grid {
@@ -519,7 +519,7 @@
.panel {
@apply rounded-lg border p-5;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.9);
}
@@ -529,7 +529,7 @@
.panel-header strong {
@apply text-base font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.description {
@@ -545,7 +545,7 @@
.screenshot-frame {
@apply overflow-hidden rounded-lg border;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
background: #0f172a;
}
@@ -556,14 +556,14 @@
.empty-block,
.page-message {
@apply rounded-lg border p-4 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(248, 250, 252, 0.9);
color: #526178;
color: var(--app-text-muted);
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
color: var(--app-danger-muted);
}
.timeline {
@@ -572,7 +572,7 @@
.timeline-item {
@apply rounded-lg border p-4;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(248, 250, 252, 0.78);
}
@@ -586,7 +586,7 @@
.timeline-item strong {
@apply text-sm font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.timeline-item span,
@@ -619,6 +619,6 @@
.info-list dd {
@apply mt-1 break-words text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
</style>

View File

@@ -70,14 +70,14 @@
<p>{{ t('feedback.review.description') }}</p>
</div>
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
:title="t('feedback.review.refresh')"
@click="feedbackStore.loadReports"
>
<v-icon :icon="mdiRefresh" />
</button>
</v-btn>
</header>
<section class="metric-grid">
@@ -186,14 +186,14 @@
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="filter-reset"
type="button"
:title="t('feedback.review.filters.clear')"
@click="feedbackStore.resetFilters"
>
<v-icon :icon="mdiFilterOffOutline" />
</button>
</v-btn>
</section>
<div
@@ -214,7 +214,7 @@
v-else
class="report-table"
>
<button
<v-btn variant="text" :ripple="false"
v-for="report in feedbackStore.filteredReports"
:key="report.id"
class="report-row"
@@ -263,7 +263,7 @@
/>
</small>
</span>
</button>
</v-btn>
<div
v-if="!feedbackStore.filteredReports.length"
@@ -287,30 +287,30 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.review-header h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: #172033;
color: var(--app-color-on-surface);
}
.review-header p {
@apply mt-2 max-w-3xl text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.icon-button,
.filter-reset {
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border transition-colors;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.92);
color: #172033;
color: var(--app-color-on-surface);
}
.icon-button:hover,
.filter-reset:hover {
background: #172033;
background: var(--app-color-on-surface);
color: white;
}
@@ -320,7 +320,7 @@
.metric {
@apply rounded-lg border p-4;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.86);
}
@@ -331,12 +331,12 @@
.metric strong {
@apply mt-2 block text-3xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.filter-panel {
@apply grid gap-3 rounded-lg border p-4 lg:grid-cols-[minmax(15rem,1.5fr)_repeat(4,minmax(9rem,1fr))_repeat(2,minmax(8rem,0.8fr))_minmax(10rem,1fr)_auto];
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.9);
}
@@ -345,7 +345,7 @@
@apply flex h-10 items-center gap-2 rounded-lg border px-3 text-sm;
border-color: rgba(23, 32, 51, 0.16);
background: white;
color: #172033;
color: var(--app-color-on-surface);
}
.filter-search input {
@@ -362,7 +362,7 @@
.report-row {
@apply grid gap-4 rounded-lg border p-4 text-left transition-colors lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.8fr)_minmax(12rem,0.8fr)_minmax(12rem,0.7fr)] lg:items-center;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.86);
}
@@ -383,13 +383,13 @@
.report-title strong {
@apply text-sm font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.report-title em {
@apply rounded-md px-2 py-1 text-xs font-bold not-italic;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.status-dot {
@@ -402,7 +402,7 @@
}
.status-planned {
background: #0f766e;
background: var(--app-color-on-tertiary);
}
.status-resolved {
@@ -416,7 +416,7 @@
.report-description {
@apply line-clamp-2 text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.report-tags {
@@ -425,7 +425,7 @@
.report-tags span {
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
color: #44516a;
}
@@ -433,7 +433,7 @@
.report-context,
.report-activity strong {
@apply text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.report-secondary small,
@@ -449,13 +449,13 @@
.page-message {
@apply rounded-lg border p-4 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.86);
color: #526178;
color: var(--app-text-muted);
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -100,14 +100,14 @@
<template>
<section class="feedback-detail-page">
<button
<v-btn variant="text" :ripple="false"
class="back-button"
type="button"
@click="router.push({ name: 'my-feedback' })"
>
<v-icon :icon="mdiArrowLeft" />
{{ t('feedback.mine.detail.back') }}
</button>
</v-btn>
<div
v-if="feedbackStore.isDetailLoading"
@@ -134,7 +134,7 @@
</div>
</div>
<button
<v-btn variant="text" :ripple="false"
v-if="canCancel"
class="cancel-button"
type="button"
@@ -143,7 +143,7 @@
>
<v-icon :icon="mdiCancel" />
{{ feedbackStore.isCancelling ? t('common.saving') : t('feedback.mine.detail.cancel') }}
</button>
</v-btn>
</header>
<div class="detail-grid">
@@ -230,14 +230,14 @@
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="button"
:disabled="!canSubmitComment"
@click="submitComment"
>
{{ feedbackStore.isCommenting ? t('feedback.review.detail.commenting') : t('feedback.review.detail.addComment') }}
</button>
</v-btn>
</section>
</aside>
</div>
@@ -258,19 +258,19 @@
}
.back-button {
color: #0f766e;
color: var(--app-color-on-tertiary);
background: rgba(15, 118, 110, 0.08);
}
.cancel-button {
border: 1px solid rgba(220, 38, 38, 0.24);
color: #b91c1c;
color: var(--app-danger-muted);
background: white;
}
.primary-button {
@apply mt-3 justify-center;
background: #172033;
background: var(--app-color-on-surface);
color: white;
}
@@ -285,12 +285,12 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.detail-header h1 {
@apply mt-2 max-w-4xl text-2xl font-black leading-tight md:text-4xl;
color: #172033;
color: var(--app-color-on-surface);
}
.header-meta {
@@ -300,7 +300,7 @@
.header-meta span {
@apply rounded-md px-2 py-1;
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
}
.detail-grid {
@@ -315,7 +315,7 @@
.panel,
.page-message {
@apply rounded-lg border p-4;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.9);
}
@@ -325,18 +325,18 @@
.panel-header strong {
@apply text-sm font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.description {
@apply whitespace-pre-wrap text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.path-link {
@apply mt-3 inline-flex max-w-full items-center gap-2 truncate rounded-md px-2 py-1 text-sm font-semibold;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.tag-row {
@@ -345,7 +345,7 @@
.tag-row span {
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
color: #44516a;
}
@@ -360,7 +360,7 @@
.timeline li {
@apply rounded-lg border p-3;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(248, 250, 252, 0.75);
}
@@ -372,7 +372,7 @@
.timeline strong {
@apply text-sm font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.timeline span,
@@ -385,11 +385,11 @@
.timeline p {
@apply my-2 whitespace-pre-wrap text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -43,14 +43,14 @@
<p>{{ t('feedback.mine.description') }}</p>
</div>
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
:title="t('feedback.mine.refresh')"
@click="feedbackStore.loadReports"
>
<v-icon :icon="mdiRefresh" />
</button>
</v-btn>
</header>
<section class="metric-grid">
@@ -98,14 +98,14 @@
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
:title="t('feedback.review.filters.clear')"
@click="feedbackStore.resetFilters"
>
<v-icon :icon="mdiFilterOffOutline" />
</button>
</v-btn>
</section>
<div
@@ -126,7 +126,7 @@
v-else
class="report-list"
>
<button
<v-btn variant="text" :ripple="false"
v-for="report in feedbackStore.filteredReports"
:key="report.id"
class="report-row"
@@ -160,7 +160,7 @@
<span>{{ t('feedback.review.lastActivity') }}</span>
<strong>{{ formatDate(report.lastActivityAt) }}</strong>
</span>
</button>
</v-btn>
<div
v-if="!feedbackStore.filteredReports.length"
@@ -184,17 +184,17 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.page-header h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: #172033;
color: var(--app-color-on-surface);
}
.page-header p {
@apply mt-2 max-w-3xl text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.metric-grid {
@@ -206,7 +206,7 @@
.report-row,
.page-message {
@apply rounded-lg border;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.9);
}
@@ -221,7 +221,7 @@
.metric strong {
@apply mt-2 block text-3xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.filter-panel {
@@ -230,13 +230,13 @@
.icon-button {
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-lg border transition-colors;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.92);
color: #172033;
color: var(--app-color-on-surface);
}
.icon-button:hover {
background: #172033;
background: var(--app-color-on-surface);
color: white;
}
@@ -256,7 +256,7 @@
.unread-dot {
@apply h-2.5 w-2.5 rounded-full;
background: #0f766e;
background: var(--app-color-on-tertiary);
}
.report-main,
@@ -271,18 +271,18 @@
.report-title strong,
.report-activity strong {
@apply text-sm font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.report-title em {
@apply rounded-md px-2 py-1 text-xs font-bold not-italic;
background: rgba(15, 118, 110, 0.08);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.report-description {
@apply line-clamp-2 text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.report-tags {
@@ -291,7 +291,7 @@
.report-tags span {
@apply inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold;
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
color: #44516a;
}
@@ -302,11 +302,11 @@
.page-message {
@apply p-4 text-sm font-semibold;
color: #526178;
color: var(--app-text-muted);
}
.page-message-error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -245,7 +245,7 @@
.hero,
.panel {
@apply rounded-lg border;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.92);
}
@@ -255,18 +255,18 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.hero h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: #172033;
color: var(--app-color-on-surface);
}
.hero p,
.panel-header span {
@apply mt-2 text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.onboarding-grid {
@@ -283,12 +283,12 @@
.panel-header .v-icon {
@apply mt-1;
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.panel-header strong {
@apply block text-xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.form-stack {
@@ -297,13 +297,13 @@
.page-message {
@apply rounded-lg border p-3 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(23, 32, 51, 0.04);
color: #526178;
border-color: var(--app-border-subtle);
background: var(--app-control-subtle);
color: var(--app-text-muted);
}
.page-message.error {
border-color: rgba(220, 38, 38, 0.24);
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -253,7 +253,7 @@
<div class="eyebrow">{{ t('organizationSettings.eyebrow') }}</div>
<p>{{ t('organizationSettings.description') }}</p>
<div class="organization-title-line">
<button
<v-btn variant="text" :ripple="false"
v-if="organization"
class="organization-logo-button"
type="button"
@@ -267,7 +267,7 @@
:src="organization.logoUrl"
size="lg"
/>
</button>
</v-btn>
<v-form
v-if="organization && isEditingName"
class="title-edit-form"
@@ -282,7 +282,7 @@
maxlength="256"
variant="outlined"
/>
<button
<v-btn variant="text" :ripple="false"
class="icon-action"
type="submit"
:disabled="organizationStore.isSaving"
@@ -290,8 +290,8 @@
:title="t('organizationSettings.saveName')"
>
<v-icon :icon="mdiCheck" />
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="icon-action secondary"
type="button"
:disabled="organizationStore.isSaving"
@@ -300,14 +300,14 @@
@click="cancelEditingName"
>
<v-icon :icon="mdiClose" />
</button>
</v-btn>
</v-form>
<div
v-else
class="title-row"
>
<h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1>
<button
<v-btn variant="text" :ripple="false"
v-if="organization && canManageSettings"
class="icon-action secondary"
type="button"
@@ -316,7 +316,7 @@
@click="startEditingName"
>
<v-icon :icon="mdiPencilOutline" />
</button>
</v-btn>
</div>
</div>
<div class="hero-status">
@@ -358,7 +358,7 @@
class="settings-tabs"
aria-label="Organization settings sections"
>
<button
<v-btn variant="text" :ripple="false"
v-for="section in visibleSections"
:key="section.key"
class="settings-tab"
@@ -368,7 +368,7 @@
>
<v-icon :icon="section.icon" />
<span>{{ t(`organizationSettings.sections.${section.key}.title`) }}</span>
</button>
</v-btn>
</nav>
<div
@@ -419,13 +419,13 @@
hide-details
/>
<div class="form-actions">
<button
<v-btn variant="text" :ripple="false"
class="primary-action"
type="submit"
:disabled="organizationStore.isAddingMember"
>
{{ organizationStore.isAddingMember ? t('organizationSettings.addingMember') : t('organizationSettings.addMember') }}
</button>
</v-btn>
</div>
</v-form>
<div
@@ -561,7 +561,7 @@
.settings-hero h1,
.title-edit-form input {
@apply min-w-0 text-3xl font-black md:text-4xl;
color: #172033;
color: var(--app-color-on-surface);
}
.settings-hero p,
@@ -570,7 +570,7 @@
.placeholder-panel span,
.empty-state {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.organization-title-line {
@@ -579,11 +579,11 @@
.organization-logo-button {
@apply inline-flex size-14 flex-shrink-0 items-center justify-center rounded-[0.75rem] border bg-white transition-colors md:size-16;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
}
.organization-logo-button:hover:not(:disabled) {
border-color: #0f766e;
border-color: var(--app-color-on-tertiary);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
@@ -601,24 +601,24 @@
}
.title-edit-form input:focus {
border-color: #0f766e;
border-color: var(--app-color-on-tertiary);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
.icon-action {
@apply inline-flex size-9 flex-shrink-0 items-center justify-center rounded-[0.5rem] transition-colors;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.icon-action.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.icon-action:hover:not(:disabled) {
background: #0f766e;
color: #fffaf2;
background: var(--app-color-on-tertiary);
color: var(--app-color-on-primary);
}
.icon-action:disabled {
@@ -635,22 +635,22 @@
.settings-tabs {
@apply flex flex-wrap gap-2 border-b pb-3;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
}
.settings-tab {
@apply inline-flex h-10 items-center gap-2 rounded-[0.75rem] px-3 text-sm font-semibold transition-colors;
color: #526178;
color: var(--app-text-muted);
}
.settings-tab:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.settings-tab-active {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.settings-tab :deep(.v-icon) {
@@ -667,13 +667,13 @@
.section-heading h2 {
@apply text-2xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.content-card {
@apply flex flex-col gap-4 rounded-[0.75rem] border p-5;
background: rgba(255, 255, 255, 0.94);
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-surface-glass);
border-color: var(--app-border-subtle);
}
.table-list {
@@ -682,7 +682,7 @@
.table-row {
@apply flex items-center justify-between gap-4 rounded-[0.75rem] px-4 py-3;
background: rgba(23, 32, 51, 0.04);
background: var(--app-control-subtle);
}
.table-row {
@@ -694,7 +694,7 @@
}
.table-row-button:hover {
background: rgba(23, 32, 51, 0.08);
background: var(--app-border-subtle);
}
.table-row div {
@@ -704,7 +704,7 @@
.table-row strong,
.placeholder-panel strong {
@apply font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.table-row small {
@@ -715,18 +715,18 @@
.placeholder-panel,
.empty-state {
@apply rounded-[0.75rem] px-4 py-4;
background: rgba(23, 32, 51, 0.04);
background: var(--app-control-subtle);
}
.settings-form {
@apply flex flex-col gap-4 rounded-[0.75rem] p-4;
background: rgba(23, 32, 51, 0.04);
background: var(--app-control-subtle);
}
.usage-plan span,
.usage-row-heading span {
@apply text-sm;
color: #526178;
color: var(--app-text-muted);
}
.field-error {
@@ -734,7 +734,7 @@
}
.field-success {
color: #0f766e !important;
color: var(--app-color-on-tertiary) !important;
}
.invite-form {
@@ -743,7 +743,7 @@
.settings-form label {
@apply flex min-w-0 flex-col gap-2 text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.settings-form input,
@@ -751,12 +751,12 @@
@apply h-11 w-full rounded-[0.5rem] border px-3 text-sm outline-none transition-colors;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.14);
color: #172033;
color: var(--app-color-on-surface);
}
.settings-form input:focus,
.settings-form select:focus {
border-color: #0f766e;
border-color: var(--app-color-on-tertiary);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
@@ -766,12 +766,12 @@
.primary-action {
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.primary-action:hover:not(:disabled) {
background: #0f766e;
background: var(--app-color-on-tertiary);
}
.primary-action:disabled {
@@ -789,7 +789,7 @@
.settings-alert.success {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.placeholder-panel {
@@ -802,13 +802,13 @@
.tier-form {
@apply grid gap-3 rounded-[0.75rem] p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end;
background: rgba(23, 32, 51, 0.04);
background: var(--app-control-subtle);
}
.usage-plan,
.usage-row {
@apply rounded-[0.75rem] p-4;
background: rgba(23, 32, 51, 0.04);
background: var(--app-control-subtle);
}
.usage-plan {
@@ -825,12 +825,12 @@
.usage-meter {
@apply h-2 overflow-hidden rounded-full;
background: rgba(23, 32, 51, 0.1);
background: var(--app-control-active);
}
.usage-meter span {
@apply block h-full rounded-full;
background: #0f766e;
background: var(--app-color-on-tertiary);
}
</style>

View File

@@ -0,0 +1,54 @@
export function formatReleaseDescription(value) {
const lines = String(value ?? '').replace(/\r\n?/g, '\n').split('\n');
const blocks = [];
let paragraphLines = [];
let listItems = [];
function flushParagraph() {
if (!paragraphLines.length) {
return;
}
blocks.push({
type: 'paragraph',
text: paragraphLines.join(' ').trim(),
});
paragraphLines = [];
}
function flushList() {
if (!listItems.length) {
return;
}
blocks.push({
type: 'list',
items: listItems,
});
listItems = [];
}
lines.forEach(line => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
return;
}
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
if (bullet) {
flushParagraph();
listItems.push(bullet[1].trim());
return;
}
flushList();
paragraphLines.push(trimmed);
});
flushParagraph();
flushList();
return blocks;
}

View File

@@ -3,17 +3,9 @@ import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
const DEFAULT_COMMIT_FILTERS = Object.freeze({
status: '',
updateId: '',
author: '',
search: '',
inclusion: 'notIncluded',
});
export const RELEASE_UPDATE_CATEGORIES = ['Feature', 'Improvement', 'Fix', 'Breaking Change'];
export const RELEASE_UPDATE_IMPORTANCE = ['Normal', 'Important'];
export const RELEASE_UPDATE_AUDIENCES = ['Everyone', 'OrganizationOwners', 'Developers'];
export const RELEASE_COMMIT_STATUSES = ['Unreviewed', 'Linked', 'InternalOnly', 'Ignored'];
export const useReleaseCommunicationsStore = defineStore('release-communications', () => {
const client = useClient();
const updates = ref([]);
@@ -21,11 +13,12 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
const developerUpdates = ref([]);
const selectedUpdate = ref(null);
const commits = ref([]);
const selectedCommitShas = ref([]);
const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS });
const isLoading = ref(false);
const isSaving = ref(false);
const isSendingEmail = ref(false);
const isImporting = ref(false);
const isRefreshingCommits = ref(false);
const isForcingDigestEmails = ref(false);
const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
@@ -35,40 +28,15 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
);
const filteredCommits = computed(() => {
const query = commitFilters.value.search.trim().toLowerCase();
const author = commitFilters.value.author.trim().toLowerCase();
return commits.value.filter(commit => {
if (commitFilters.value.status && commit.communicationStatus !== commitFilters.value.status) {
if (commitFilters.value.inclusion === 'included' && !commit.releaseUpdateId) {
return false;
}
if (commitFilters.value.updateId && commit.releaseUpdateId !== commitFilters.value.updateId) {
if (commitFilters.value.inclusion === 'notIncluded' && commit.releaseUpdateId) {
return false;
}
if (author) {
const authorText = `${commit.authorName ?? ''} ${commit.authorEmail ?? ''}`.toLowerCase();
if (!authorText.includes(author)) {
return false;
}
}
if (query) {
const haystack = [
commit.sha,
commit.shortSha,
commit.subject,
commit.authorName,
commit.authorEmail,
commit.deploymentLabel,
commit.sourceBranch,
].filter(Boolean).join(' ').toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
return true;
});
});
@@ -159,28 +127,29 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
return response.data;
}
async function sendUpdateEmail(id, payload) {
isSendingEmail.value = true;
try {
return (await client.post(`/api/developer/release-updates/${id}/send-email`, payload)).data;
} finally {
isSendingEmail.value = false;
}
}
async function loadCommits() {
const response = await client.get('/api/developer/release-commits');
commits.value = response.data ?? [];
}
async function importCommits(payload) {
isImporting.value = true;
async function refreshCommits() {
isRefreshingCommits.value = true;
try {
const response = await client.post('/api/developer/release-commits/import', payload);
const response = await client.post('/api/developer/release-commits/refresh');
await loadCommits();
return response.data;
} finally {
isImporting.value = false;
isRefreshingCommits.value = false;
}
}
async function forceDigestEmails() {
isForcingDigestEmails.value = true;
try {
const response = await client.post('/api/developer/release-update-email-digests/force');
return response.data;
} finally {
isForcingDigestEmails.value = false;
}
}
@@ -194,6 +163,12 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
await Promise.all([loadCommits(), loadDeveloperUpdates()]);
}
async function linkFirstReleaseCommits(anchorSha, releaseUpdateId) {
const response = await client.post(`/api/developer/release-commits/${anchorSha}/link-first-release`, { releaseUpdateId });
await Promise.all([loadCommits(), loadDeveloperUpdates()]);
return response.data;
}
async function unlinkCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/unlink`);
await loadCommits();
@@ -213,12 +188,23 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
commitFilters.value = { ...DEFAULT_COMMIT_FILTERS };
}
function setCommitSelected(sha, selected) {
selectedCommitShas.value = selected
? [...new Set([...selectedCommitShas.value, sha])]
: selectedCommitShas.value.filter(selectedSha => selectedSha !== sha);
}
function clearSelectedCommits() {
selectedCommitShas.value = [];
}
return {
updates,
unreadSummary,
developerUpdates,
selectedUpdate,
commits,
selectedCommitShas,
commitFilters,
filteredCommits,
unreadCount,
@@ -226,8 +212,8 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
unreviewedCommitCount,
isLoading,
isSaving,
isSendingEmail,
isImporting,
isRefreshingCommits,
isForcingDigestEmails,
error,
loadUserUpdates,
loadUnreadSummary,
@@ -238,14 +224,17 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
saveDeveloperUpdate,
publishUpdate,
archiveUpdate,
sendUpdateEmail,
loadCommits,
importCommits,
refreshCommits,
forceDigestEmails,
linkCommit,
linkCommitsToUpdate,
linkFirstReleaseCommits,
unlinkCommit,
markCommitInternalOnly,
ignoreCommit,
resetCommitFilters,
setCommitSelected,
clearSelectedCommits,
};
});

View File

@@ -1,246 +0,0 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RELEASE_UPDATE_AUDIENCES,
RELEASE_UPDATE_CATEGORIES,
RELEASE_UPDATE_IMPORTANCE,
useReleaseCommunicationsStore,
} from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const store = useReleaseCommunicationsStore();
const editingId = ref(null);
const form = reactive({
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
const emailTestMode = ref(true);
const confirmResend = ref(false);
const emailResult = ref(null);
const linkedCommits = computed(() =>
editingId.value
? store.commits.filter(commit => commit.releaseUpdateId === editingId.value)
: []
);
onMounted(async () => {
await Promise.all([store.loadDeveloperUpdates(), store.loadCommits()]);
});
function editUpdate(update) {
editingId.value = update.id;
Object.assign(form, {
title: update.title ?? '',
summary: update.summary ?? '',
body: update.body ?? '',
category: update.category ?? 'Feature',
importance: update.importance ?? 'Normal',
audience: update.audience ?? 'Everyone',
deploymentLabel: update.deploymentLabel ?? '',
buildVersion: update.buildVersion ?? '',
commitRange: update.commitRange ?? '',
});
store.selectedUpdate = update;
}
function newUpdate() {
editingId.value = null;
Object.assign(form, {
title: '',
summary: '',
body: '',
category: 'Feature',
importance: 'Normal',
audience: 'Everyone',
deploymentLabel: '',
buildVersion: '',
commitRange: '',
});
emailResult.value = null;
}
async function save() {
await store.saveDeveloperUpdate({ ...form }, editingId.value);
editingId.value = store.selectedUpdate?.id ?? editingId.value;
}
async function sendEmail() {
if (!editingId.value || !window.confirm(t('releaseCommunications.developer.confirmEmail'))) {
return;
}
emailResult.value = await store.sendUpdateEmail(editingId.value, {
testMode: emailTestMode.value,
confirmResend: confirmResend.value,
});
await store.loadDeveloperUpdate(editingId.value);
await store.loadDeveloperUpdates();
}
function formatDate(value) {
return value ? new Date(value).toLocaleString() : t('releaseCommunications.emptyValue');
}
</script>
<template>
<section class="developer-updates-page">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.developer.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.developer.title') }}</h1>
</div>
<v-btn @click="newUpdate">{{ t('releaseCommunications.developer.newUpdate') }}</v-btn>
</header>
<section class="editor-grid">
<form
class="editor-panel"
@submit.prevent="save"
>
<v-text-field v-model="form.title" :label="t('title')" density="compact" variant="outlined" />
<v-textarea v-model="form.summary" :label="t('releaseCommunications.summary')" rows="2" variant="outlined" />
<v-textarea v-model="form.body" :label="t('releaseCommunications.body')" rows="5" variant="outlined" />
<div class="form-row">
<v-select v-model="form.category" :items="RELEASE_UPDATE_CATEGORIES" :label="t('releaseCommunications.category')" density="compact" variant="outlined" />
<v-select v-model="form.importance" :items="RELEASE_UPDATE_IMPORTANCE" :label="t('releaseCommunications.importance')" density="compact" variant="outlined" />
<v-select v-model="form.audience" :items="RELEASE_UPDATE_AUDIENCES" :label="t('releaseCommunications.audience')" density="compact" variant="outlined" />
</div>
<div class="form-row">
<v-text-field v-model="form.deploymentLabel" :label="t('releaseCommunications.deploymentLabel')" density="compact" variant="outlined" />
<v-text-field v-model="form.buildVersion" :label="t('releaseCommunications.buildVersion')" density="compact" variant="outlined" />
<v-text-field v-model="form.commitRange" :label="t('releaseCommunications.commitRange')" density="compact" variant="outlined" />
</div>
<div class="actions">
<v-btn type="submit" :loading="store.isSaving">{{ t('save') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.publishUpdate(editingId)">{{ t('releaseCommunications.developer.publish') }}</v-btn>
<v-btn v-if="editingId" variant="outlined" @click="store.archiveUpdate(editingId)">{{ t('releaseCommunications.developer.archive') }}</v-btn>
</div>
<div
v-if="editingId"
class="email-panel"
>
<strong>{{ t('releaseCommunications.developer.pushEmail') }}</strong>
<v-checkbox v-model="emailTestMode" :label="t('releaseCommunications.developer.testMode')" density="compact" hide-details />
<v-checkbox v-model="confirmResend" :label="t('releaseCommunications.developer.confirmResend')" density="compact" hide-details />
<v-btn variant="outlined" :loading="store.isSendingEmail" @click="sendEmail">{{ t('releaseCommunications.developer.sendEmail') }}</v-btn>
<small v-if="emailResult">{{ t('releaseCommunications.developer.emailResult', { count: emailResult.recipientCount }) }}</small>
</div>
</form>
<aside class="updates-panel">
<button
v-for="update in store.developerUpdates"
:key="update.id"
class="update-row"
type="button"
@click="editUpdate(update)"
>
<strong>{{ update.title }}</strong>
<span>{{ update.status }} / {{ update.audience }}</span>
<small>{{ formatDate(update.publishedAt ?? update.createdAt) }}</small>
</button>
</aside>
</section>
<section
v-if="editingId"
class="linked-commits"
>
<h2>{{ t('releaseCommunications.developer.linkedCommits') }}</h2>
<div v-if="!linkedCommits.length" class="page-message">{{ t('releaseCommunications.developer.noLinkedCommits') }}</div>
<div
v-for="commit in linkedCommits"
:key="commit.sha"
class="commit-chip"
>
<code>{{ commit.shortSha }}</code>
<span>{{ commit.subject }}</span>
</div>
</section>
</section>
</template>
<style scoped>
.developer-updates-page {
display: grid;
gap: 20px;
padding: 24px;
}
.page-header,
.actions,
.form-row {
display: flex;
gap: 12px;
}
.page-header {
align-items: center;
justify-content: space-between;
}
.eyebrow {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.editor-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.7fr);
gap: 16px;
}
.editor-panel,
.updates-panel,
.linked-commits {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
}
.update-row {
display: grid;
width: 100%;
gap: 3px;
border: 0;
border-bottom: 1px solid #e2e8f0;
background: transparent;
padding: 10px 0;
text-align: left;
}
.email-panel {
display: grid;
gap: 8px;
margin-top: 16px;
border-top: 1px solid #e2e8f0;
padding-top: 16px;
}
.commit-chip {
display: flex;
gap: 10px;
padding: 8px 0;
}
@media (max-width: 900px) {
.editor-grid,
.form-row {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>

View File

@@ -2,9 +2,10 @@
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { formatReleaseDescription } from '@/features/release-communications/formatReleaseDescription.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n();
const { locale, t } = useI18n();
const route = useRoute();
const store = useReleaseCommunicationsStore();
@@ -12,31 +13,46 @@
onMounted(async () => {
await store.loadUserUpdates();
if (highlightedId.value) {
await store.markRead(highlightedId.value);
if (store.updates.some(update => !update.isRead)) {
await store.markAllRead();
}
});
function formatDate(value) {
return value ? new Date(value).toLocaleString() : '';
if (!value) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'long',
}).format(new Date(value));
}
function updateTitle(update) {
return locale.value.startsWith('fr')
? update.titleFr || update.title
: update.titleEn || update.title;
}
function updateDescription(update) {
return locale.value.startsWith('fr')
? update.descriptionFr || update.description
: update.descriptionEn || update.description;
}
function updateDescriptionBlocks(update) {
return formatReleaseDescription(updateDescription(update));
}
</script>
<template>
<section class="updates-page">
<header class="updates-header">
<header class="page-header">
<div>
<div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.user.title') }}</h1>
<p>{{ t('releaseCommunications.user.description') }}</p>
</div>
<v-btn
variant="outlined"
:disabled="!store.unreadCount"
@click="store.markAllRead"
>
{{ t('releaseCommunications.user.markAllRead') }}
</v-btn>
</header>
<div
@@ -55,21 +71,25 @@
:key="update.id"
class="update-entry"
:class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }"
@click="!update.isRead && store.markRead(update.id)"
>
<div class="update-meta">
<span>{{ update.category }}</span>
<span>{{ update.importance }}</span>
<h2>{{ updateTitle(update) }}</h2>
<div class="release-description">
<template
v-for="(block, index) in updateDescriptionBlocks(update)"
:key="index"
>
<p v-if="block.type === 'paragraph'">{{ block.text }}</p>
<ul v-else>
<li
v-for="item in block.items"
:key="item"
>
{{ item }}
</li>
</ul>
</template>
</div>
<time>{{ formatDate(update.publishedAt) }}</time>
</div>
<h2>{{ update.title }}</h2>
<p>{{ update.summary }}</p>
<div
v-if="update.body"
class="update-body"
>
{{ update.body }}
</div>
</article>
<div
@@ -83,81 +103,83 @@
</template>
<style scoped>
@reference "@/assets/main.css";
.updates-page {
display: grid;
gap: 20px;
padding: 24px;
@apply mx-auto flex w-full max-w-6xl flex-col gap-5 px-5 py-8 md:px-8;
}
.updates-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
.page-header {
@apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
}
.eyebrow,
.update-meta {
color: rgb(var(--v-theme-primary));
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.22em];
color: var(--app-color-on-tertiary);
}
.updates-header h1 {
margin: 4px 0;
font-size: 1.75rem;
.page-header h1 {
@apply mt-2 text-3xl font-black md:text-4xl;
color: var(--app-color-on-surface);
}
.updates-header p {
margin: 0;
color: #64748b;
.page-header p {
@apply mt-2 max-w-3xl text-sm leading-6;
color: var(--app-text-muted);
}
.updates-list {
display: grid;
gap: 12px;
}
.update-entry {
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
padding: 16px;
display: grid;
gap: 8px;
border-bottom: 1px solid #d8dee8;
padding: 18px 0;
}
.update-entry-unread {
border-color: rgb(var(--v-theme-primary));
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
cursor: pointer;
.update-entry:first-child {
padding-top: 0;
}
.update-entry:last-of-type {
border-bottom: 0;
}
.update-entry-highlight {
outline: 2px solid rgb(var(--v-theme-primary));
}
.update-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #64748b;
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
padding-left: 12px;
}
.update-entry h2 {
margin: 0 0 6px;
font-size: 1.1rem;
}
.update-entry p {
margin: 0;
color: #334155;
font-size: 1.1rem;
font-weight: 800;
color: var(--app-color-on-surface);
}
.update-body {
margin-top: 12px;
color: #475569;
white-space: pre-line;
.release-description {
display: grid;
gap: 8px;
color: #334155;
font-size: 0.95rem;
line-height: 1.55;
}
.release-description p,
.release-description ul {
margin: 0;
}
.release-description ul {
padding-left: 20px;
}
.update-entry time {
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
}
.page-message {

View File

@@ -50,17 +50,17 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.header p {
@apply mt-3 max-w-2xl text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.queue-list {
@@ -70,26 +70,26 @@
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.queue-row {
@apply flex flex-col justify-between gap-4 rounded-[1.5rem] border p-5 lg:flex-row lg:items-center;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.queue-row strong {
@apply block text-xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.queue-row span,
.queue-meta span,
.queue-meta small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.queue-meta {

View File

@@ -48,12 +48,12 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.page-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.page-header p,
@@ -61,13 +61,13 @@
.placeholder-block span,
.placeholder-block small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.panel {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.panel-heading {
@@ -76,7 +76,7 @@
.panel-heading strong,
.placeholder-block strong {
color: #172033;
color: var(--app-color-on-surface);
}
.panel-heading strong {
@@ -85,8 +85,8 @@
.placeholder-block {
@apply flex flex-col gap-2 rounded-[1.25rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.placeholder-block strong {

View File

@@ -56,7 +56,7 @@
.settings-nav {
@apply flex h-fit flex-col gap-2 rounded-[1.75rem] border p-4;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.settings-nav-header {
@@ -65,27 +65,27 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.settings-nav-header h1 {
@apply mt-2 text-2xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.settings-link {
@apply rounded-[1rem] px-4 py-3 text-sm font-semibold no-underline transition;
color: #526178;
color: var(--app-text-muted);
}
.settings-link:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.settings-link.router-link-active {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.settings-content {

View File

@@ -3,12 +3,14 @@ import {defineStore} from 'pinia'
import {useAuthStore} from "@/features/auth/stores/authStore.js";
import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core";
import {useLanguageStore} from "@/stores/languageStore.js";
export const useUserProfileStore = defineStore(
'user-profile',
() => {
const authStore = useAuthStore()
const languageStore = useLanguageStore()
const isUpdating = ref(false)
const isUploadingPortrait = ref(false)
const isLoadingCalendarFeed = ref(false)
@@ -72,6 +74,7 @@ export const useUserProfileStore = defineStore(
const client = useClient()
const userResponse = await client.get("/api/users/profile");
value.value = userResponse.data
languageStore.setLocale(userResponse.data?.preferredLanguage ?? 'en')
} catch (fetchError) {
console.error(fetchError)
}
@@ -170,6 +173,28 @@ export const useUserProfileStore = defineStore(
}
}
async function changePreferredLanguage(preferredLanguage) {
isUpdating.value = true
error.value = null
try {
const client = useClient()
await client.post(
`/api/users/preferred-language`,
{
preferredLanguage: preferredLanguage
})
value.value.preferredLanguage = preferredLanguage;
languageStore.setLocale(preferredLanguage);
} catch (updateError) {
console.error(updateError)
error.value = 'Failed to update profile.'
throw updateError
} finally {
isUpdating.value = false
}
}
async function changeAddress(address) {
try {
const client = useClient()
@@ -278,6 +303,7 @@ export const useUserProfileStore = defineStore(
changeBirthday,
changePhone,
changeEmail,
changePreferredLanguage,
changeAddress,
changePortrait,
fetchCalendarExportFeed,

View File

@@ -19,12 +19,17 @@
lastname: '',
alias: '',
email: '',
preferredLanguage: 'en',
});
const email = computed(() => userProfileStore.user?.email || t('userSettings.noEmail'));
const alias = computed(() => userProfileStore.alias);
const fullname = computed(() => userProfileStore.fullname);
const canSave = computed(() => Boolean(form.email.trim()) && !userProfileStore.isUpdating);
const languageOptions = computed(() => [
{ title: t('releaseCommunications.english'), value: 'en' },
{ title: t('releaseCommunications.french'), value: 'fr' },
]);
const calendarFeedUrl = computed(() => {
const feedUrl = userProfileStore.calendarExportFeed?.feedUrl;
@@ -42,6 +47,7 @@
form.lastname = user?.lastname ?? '';
form.alias = user?.alias ?? '';
form.email = user?.email ?? '';
form.preferredLanguage = user?.preferredLanguage ?? 'en';
}
async function submitSettings() {
@@ -56,6 +62,7 @@
const nextLastname = form.lastname.trim();
const nextAlias = form.alias.trim();
const nextEmail = form.email.trim();
const nextPreferredLanguage = form.preferredLanguage;
settingsError.value = null;
settingsStatus.value = null;
@@ -69,6 +76,10 @@
await userProfileStore.changeAlias(nextAlias || null);
}
if (nextPreferredLanguage !== (user.preferredLanguage ?? 'en')) {
await userProfileStore.changePreferredLanguage(nextPreferredLanguage);
}
let emailChangeRequested = false;
if (nextEmail !== (user.email ?? '')) {
await userProfileStore.changeEmail(nextEmail);
@@ -177,13 +188,13 @@
</div>
</div>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="button"
@click="isPortraitDialogOpen = true"
>
{{ t('userSettings.updatePortrait') }}
</button>
</v-btn>
</div>
<div class="panel">
@@ -248,16 +259,25 @@
variant="outlined"
hide-details
/>
<v-select
v-model="form.preferredLanguage"
:items="languageOptions"
:label="t('userSettings.preferredLanguage')"
:disabled="userProfileStore.isUpdating"
variant="outlined"
hide-details
/>
</div>
<div class="form-actions">
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="submit"
:disabled="!canSave"
>
{{ userProfileStore.isUpdating ? t('common.saving') : t('userSettings.saveDetails') }}
</button>
</v-btn>
</div>
</v-form>
</div>
@@ -298,7 +318,7 @@
</div>
<div class="calendar-feed-actions">
<button
<v-btn variant="text" :ripple="false"
v-if="!userProfileStore.calendarExportFeed?.isEnabled"
class="primary-button"
type="button"
@@ -306,33 +326,33 @@
@click="enableCalendarFeed"
>
{{ t('userSettings.calendarFeed.enable') }}
</button>
</v-btn>
<template v-else>
<button
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
:disabled="!calendarFeedUrl"
@click="copyCalendarFeedUrl"
>
{{ t('userSettings.calendarFeed.copy') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
:disabled="userProfileStore.isUpdatingCalendarFeed"
@click="regenerateCalendarFeed"
>
{{ t('userSettings.calendarFeed.regenerate') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="danger-button"
type="button"
:disabled="userProfileStore.isUpdatingCalendarFeed"
@click="revokeCalendarFeed"
>
{{ t('userSettings.calendarFeed.revoke') }}
</button>
</v-btn>
</template>
</div>
</div>
@@ -357,12 +377,12 @@
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.page-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.page-header p,
@@ -370,13 +390,13 @@
.hero-identity span,
.hero-identity small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.panel {
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.hero-panel {
@@ -389,7 +409,7 @@
.hero-identity strong,
.panel-heading strong {
color: #172033;
color: var(--app-color-on-surface);
}
.hero-identity strong {
@@ -418,14 +438,14 @@
.field span {
@apply text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field input {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.field input:disabled {
@@ -440,19 +460,19 @@
@apply rounded-[1rem] border px-4 py-3 text-sm font-semibold;
background: rgba(15, 118, 110, 0.08);
border-color: rgba(15, 118, 110, 0.18);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.page-message.error {
background: rgba(185, 28, 28, 0.08);
border-color: rgba(185, 28, 28, 0.16);
color: #b91c1c;
color: var(--app-danger-muted);
}
.primary-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-5 py-3 text-sm font-bold transition;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary-button,
@@ -461,13 +481,13 @@
}
.secondary-button {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.danger-button {
background: rgba(185, 28, 28, 0.08);
color: #b91c1c;
color: var(--app-danger-muted);
}
.primary-button:disabled,
@@ -479,20 +499,20 @@
.calendar-feed-box {
@apply flex flex-col gap-2 rounded-[1rem] border p-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.calendar-feed-box span,
.calendar-feed-empty {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.calendar-feed-box code {
@apply overflow-x-auto rounded-[0.75rem] px-3 py-2 text-sm;
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.calendar-feed-actions {

View File

@@ -148,7 +148,7 @@
<span>{{ labels.description }}</span>
</div>
<button
<v-btn variant="text" :ripple="false"
type="button"
class="secondary-button"
:disabled="disabled"
@@ -156,7 +156,7 @@
>
<v-icon :icon="mdiPlus" />
<span>{{ labels.addStep }}</span>
</button>
</v-btn>
</div>
<div
@@ -182,30 +182,30 @@
</div>
<div class="approval-step-actions">
<button
<v-btn variant="text" :ripple="false"
type="button"
:aria-label="labels.moveUp"
:disabled="disabled || index === 0"
@click="moveStep(index, -1)"
>
<v-icon :icon="mdiArrowUp" />
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
type="button"
:aria-label="labels.moveDown"
:disabled="disabled || index === modelValue.length - 1"
@click="moveStep(index, 1)"
>
<v-icon :icon="mdiArrowDown" />
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
type="button"
:aria-label="labels.removeStep"
:disabled="disabled"
@click="removeStep(index)"
>
<v-icon :icon="mdiDeleteOutline" />
</button>
</v-btn>
</div>
</div>
@@ -321,8 +321,8 @@
.approval-editor-header {
@apply flex flex-col gap-3 rounded-[1rem] border px-4 py-4 sm:flex-row sm:items-center sm:justify-between;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.approval-editor-header div,
@@ -332,14 +332,14 @@
.approval-editor-header strong,
.approval-step-heading strong {
color: #172033;
color: var(--app-color-on-surface);
}
.approval-editor-header span,
.approval-empty,
.approval-step-heading small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.approval-step-list {
@@ -349,8 +349,8 @@
.approval-empty,
.approval-step-card {
@apply rounded-[1rem] border px-4 py-4;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.approval-step-card {
@@ -367,8 +367,8 @@
.approval-step-actions button {
@apply inline-flex h-9 w-9 items-center justify-center rounded-full;
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.approval-step-actions button:disabled {
@@ -382,8 +382,8 @@
.secondary-button {
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-semibold;
background: rgba(23, 32, 51, 0.08);
color: #172033;
background: var(--app-border-subtle);
color: var(--app-color-on-surface);
}
.secondary-button:disabled {
@@ -397,25 +397,25 @@
.field span {
@apply text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field input,
.field select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
background: var(--app-color-surface);
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
outline: none;
}
.field-error {
@apply text-sm leading-6;
color: #b91c1c;
color: var(--app-danger-muted);
}
.field-help {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
</style>

View File

@@ -274,51 +274,51 @@
>
<div class="calendar-toolbar">
<div class="calendar-nav">
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
@click="shiftPeriod(-1)"
>
<v-icon :icon="mdiChevronLeft" />
</button>
</v-btn>
<div class="calendar-period">{{ periodLabel }}</div>
<button
<v-btn variant="text" :ripple="false"
class="icon-button"
type="button"
@click="shiftPeriod(1)"
>
<v-icon :icon="mdiChevronRight" />
</button>
</v-btn>
</div>
<div class="calendar-controls">
<button
<v-btn variant="text" :ripple="false"
class="text-button"
type="button"
@click="jumpToToday"
>
{{ t('today') }}
</button>
</v-btn>
<div class="view-toggle">
<button
<v-btn variant="text" :ripple="false"
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'month' }"
type="button"
@click="setView('month')"
>
{{ t('dashboard.month') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'week' }"
type="button"
@click="setView('week')"
>
{{ t('dashboard.week') }}
</button>
</v-btn>
</div>
</div>
</div>
@@ -396,19 +396,19 @@
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.88);
border-color: rgba(23, 32, 51, 0.08);
color: #526178;
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
.calendar-card {
@apply rounded-[1.75rem] border p-4 md:p-5;
background: rgba(255, 255, 255, 0.94);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
background: var(--app-surface-glass);
border-color: var(--app-border-subtle);
box-shadow: 0 18px 40px var(--app-control-hover);
}
.calendar-toolbar {
@@ -426,7 +426,7 @@
.calendar-period {
@apply min-w-0 px-2 text-base font-bold md:text-lg;
color: #172033;
color: var(--app-color-on-surface);
}
.icon-button,
@@ -434,8 +434,8 @@
.toggle-button {
@apply inline-flex items-center justify-center rounded-full border px-3 py-2 text-sm font-semibold transition;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
}
.icon-button {
@@ -451,7 +451,7 @@
.view-toggle {
@apply inline-flex rounded-full border p-1;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
}
.toggle-button {
@@ -459,7 +459,7 @@
}
.toggle-button-active {
background: #172033;
background: var(--app-color-on-surface);
color: #ffffff;
}
@@ -474,13 +474,13 @@
.weekday-label {
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
color: #526178;
color: var(--app-text-muted);
}
.calendar-day {
@apply min-h-[8.5rem] rounded-[1.25rem] border p-3;
background: linear-gradient(180deg, rgba(255, 253, 248, 0.8) 0%, rgba(255, 255, 255, 0.96) 100%);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.calendar-day-week {
@@ -498,7 +498,7 @@
.day-number {
@apply mb-3 text-sm font-bold;
color: #172033;
color: var(--app-color-on-surface);
}
.day-entries {
@@ -515,23 +515,23 @@
.calendar-entry strong {
@apply text-sm font-bold;
color: #172033;
color: var(--app-color-on-surface);
}
.calendar-entry span {
@apply text-xs leading-5;
color: #526178;
color: var(--app-text-muted);
}
.entry-time {
@apply text-[0.7rem] font-bold uppercase tracking-[0.12em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.entry-more,
.day-empty {
@apply px-1 text-xs font-semibold;
color: #526178;
color: var(--app-text-muted);
}
.calendar-entry.production {

View File

@@ -224,7 +224,7 @@
<div class="panel-kicker">{{ t('overview.workspacesKicker') }}</div>
<div class="panel-title">{{ t('overview.workspaceRollup') }}</div>
<div class="workspace-stack">
<button
<v-btn variant="text" :ripple="false"
v-for="workspace in workspaceStats"
:key="workspace.id"
class="workspace-row"
@@ -240,7 +240,7 @@
<small>{{ workspace.upcomingCount }} {{ t('overview.labels.upcoming') }}</small>
<small>{{ workspace.blockingCount }} {{ t('overview.labels.blocked') }}</small>
</div>
</button>
</v-btn>
</div>
</article>
@@ -324,7 +324,7 @@
.page-header h1 {
@apply mt-2 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.page-header p,
@@ -333,13 +333,13 @@
.workspace-row span,
.empty-state {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.eyebrow,
.panel-kicker {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.stats-grid {
@@ -354,8 +354,8 @@
.panel {
@apply rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.92);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
border-color: var(--app-border-subtle);
box-shadow: 0 18px 40px var(--app-control-hover);
}
.panel {
@@ -365,7 +365,7 @@
.panel-title,
.workspace-row strong,
.list-row strong {
color: #172033;
color: var(--app-color-on-surface);
}
.panel-title {
@@ -374,7 +374,7 @@
.stat-card strong {
@apply mt-3 block text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.workspace-stack {
@@ -384,8 +384,8 @@
.workspace-row,
.list-row {
@apply flex items-start justify-between gap-4 rounded-[1.1rem] border p-4 text-left no-underline;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
}
.workspace-row.alert,
@@ -401,17 +401,17 @@
.workspace-meta small,
.list-row em {
@apply text-sm font-semibold not-italic;
color: #172033;
color: var(--app-color-on-surface);
}
.page-message,
.empty-state {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.page-message.error {
color: #b91c1c;
color: var(--app-danger-muted);
}
</style>

View File

@@ -113,21 +113,21 @@
/>
<div class="panel-actions field-wide">
<button
<v-btn variant="text" :ripple="false"
class="secondary"
type="button"
:disabled="workspaceStore.isCreating"
@click="cancel"
>
{{ t('common.cancel') }}
</button>
<button
</v-btn>
<v-btn variant="text" :ripple="false"
class="primary"
type="submit"
:disabled="workspaceStore.isCreating"
>
{{ workspaceStore.isCreating ? t('common.creating') : t('workspaceCreate.createAction') }}
</button>
</v-btn>
</div>
</v-form>
</article>
@@ -143,7 +143,7 @@
.hero-copy,
.create-card {
@apply rounded-[1.75rem] border;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.92);
}
@@ -151,7 +151,7 @@
@apply p-6 md:p-8;
background:
radial-gradient(circle at top left, rgba(255, 138, 61, 0.16), transparent 38%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(255, 247, 237, 0.92));
linear-gradient(135deg, var(--app-surface-raised), rgba(255, 247, 237, 0.92));
}
.eyebrow {
@@ -161,14 +161,14 @@
.hero-copy h1 {
@apply mt-3 text-4xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.hero-copy p,
.card-header span,
.field small {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.create-card {
@@ -176,7 +176,7 @@
}
.card-header strong {
color: #172033;
color: var(--app-color-on-surface);
}
.card-header {
@@ -201,15 +201,15 @@
.field span {
@apply text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field input,
.field select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
background: var(--app-color-surface);
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
outline: none;
}
@@ -223,12 +223,12 @@
}
.primary {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
</style>

View File

@@ -417,7 +417,7 @@
class="tab-strip"
aria-label="Workspace settings sections"
>
<button
<v-btn variant="text" :ripple="false"
v-for="tab in settingsTabs"
:key="tab.key"
type="button"
@@ -427,7 +427,7 @@
>
<v-icon :icon="tab.icon" />
<span>{{ tab.label }}</span>
</button>
</v-btn>
</nav>
<div class="tab-content">
@@ -486,14 +486,14 @@
{{ logoStatus }}
</small>
</div>
<button
<v-btn variant="text" :ripple="false"
class="secondary-button"
type="button"
:disabled="workspaceStore.isUploadingLogo"
@click="isLogoDialogOpen = true"
>
{{ workspaceStore.isUploadingLogo ? t('common.saving') : t('workspaceSettings.logo.changeAction') }}
</button>
</v-btn>
</div>
<v-text-field
@@ -512,13 +512,13 @@
/>
</label>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="submit"
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
>
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
</button>
</v-btn>
</v-form>
</article>
@@ -558,12 +558,12 @@
hide-details
/>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="submit"
>
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
</button>
</v-btn>
</v-form>
</article>
@@ -733,14 +733,14 @@
</span>
</div>
<button
<v-btn variant="text" :ripple="false"
class="primary-button"
type="button"
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
@click="submitWorkspaceSettings"
>
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.approvals.saveAction') }}
</button>
</v-btn>
</div>
</article>
@@ -857,8 +857,8 @@
.settings-card {
@apply flex flex-col gap-5 rounded-[0.75rem] border p-5;
background: rgba(255, 255, 255, 0.94);
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-surface-glass);
border-color: var(--app-border-subtle);
}
.section-copy {
@@ -867,22 +867,22 @@
.tab-strip {
@apply flex flex-wrap gap-2 border-b pb-3;
border-color: rgba(23, 32, 51, 0.1);
border-color: var(--app-control-active);
}
.tab-button {
@apply inline-flex h-10 items-center gap-2 rounded-[0.75rem] px-3 text-sm font-semibold transition-colors;
color: #526178;
color: var(--app-text-muted);
}
.tab-button:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.tab-button-active {
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.tab-button :deep(.v-icon) {
@@ -895,7 +895,7 @@
.tab-heading h2 {
@apply text-2xl font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.section-kicker {
@@ -910,7 +910,7 @@
.connector-status,
.workflow-rule strong,
.workflow-step-copy strong {
color: #172033;
color: var(--app-color-on-surface);
}
.section-copy h1 {
@@ -927,13 +927,13 @@
.workflow-rule span,
.workflow-step-copy span {
@apply text-sm leading-6;
color: #526178;
color: var(--app-text-muted);
}
.logo-picker-card {
@apply flex flex-col gap-4 rounded-[0.75rem] border p-4 sm:flex-row sm:items-center;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-control-subtle);
border-color: var(--app-border-subtle);
}
.logo-picker-copy {
@@ -941,7 +941,7 @@
}
.logo-picker-copy strong {
color: #172033;
color: var(--app-color-on-surface);
}
.logo-picker-copy small,
@@ -951,11 +951,11 @@
}
.field-error {
color: #b91c1c;
color: var(--app-danger-muted);
}
.field-success {
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.form-stack {
@@ -968,43 +968,43 @@
.field span {
@apply text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.field input,
.field select {
@apply h-11 rounded-[0.5rem] border px-3 text-sm outline-none transition-colors;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
border-color: var(--app-control-active);
color: var(--app-color-on-surface);
}
.field input:focus,
.field select:focus {
border-color: #0f766e;
border-color: var(--app-color-on-tertiary);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
.primary-button {
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary-button {
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] border px-4 text-sm font-bold transition-colors;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.14);
color: #172033;
color: var(--app-color-on-surface);
}
.primary-button:hover:not(:disabled) {
background: #0f766e;
background: var(--app-color-on-tertiary);
}
.secondary-button:hover:not(:disabled) {
border-color: #0f766e;
color: #0f766e;
border-color: var(--app-color-on-tertiary);
color: var(--app-color-on-tertiary);
}
.primary-button:disabled,
@@ -1027,8 +1027,8 @@
.workflow-toggle,
.workflow-step {
@apply rounded-[0.75rem] border px-4 py-4;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-control-subtle);
border-color: var(--app-border-subtle);
}
.invite-row {
@@ -1064,7 +1064,7 @@
.workflow-step-icon {
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[0.75rem];
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
color: var(--app-color-on-tertiary);
}
.connector-status {
@@ -1074,11 +1074,11 @@
.connector-link {
@apply inline-flex h-11 w-fit items-center gap-3 rounded-[0.5rem] px-4 text-sm font-bold no-underline transition;
background: #172033;
color: #fffaf2;
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.connector-link:hover {
background: #0f766e;
background: var(--app-color-on-tertiary);
}
</style>

View File

@@ -3,20 +3,26 @@
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
import WorkspaceSelector from './WorkspaceSelector.vue';
import {
mdiCalendar,
mdiChevronDown,
mdiCogOutline,
mdiEmailOutline,
mdiEyeOffOutline,
mdiFlagVariantOutline,
mdiFormatListBulleted,
mdiLogin,
mdiPlus,
mdiRefresh,
mdiTable,
} from '@mdi/js';
const route = useRoute();
const { t } = useI18n();
const authStore = useAuthStore();
const releaseCommunicationsStore = useReleaseCommunicationsStore();
const isContentViewMenuOpen = ref(false);
const contentViewActions = computed(() => {
@@ -74,6 +80,17 @@
contentViewActions.value.find(action => action.active) ?? contentViewActions.value[0]
);
async function forceReleaseDigestEmails() {
if (!window.confirm(t('releaseCommunications.developer.forceDigestConfirm'))) {
return;
}
const result = await releaseCommunicationsStore.forceDigestEmails();
window.alert(t('releaseCommunications.developer.forceDigestResult', {
count: result?.sentCount ?? 0,
}));
}
const appBarActions = computed(() => {
if (!authStore.isAuthenticated) {
return [];
@@ -104,6 +121,74 @@
icon: mdiPlus,
route: { name: 'channels', query: { create: 'true' } },
}];
case 'developer-release-notes':
return route.query.tab === 'release-notes'
? [
{
key: 'force-release-digest',
label: t('releaseCommunications.developer.forceDigest'),
icon: mdiEmailOutline,
loading: releaseCommunicationsStore.isForcingDigestEmails,
handler: forceReleaseDigestEmails,
},
]
: [
{
key: 'refresh-release-commits',
label: t('releaseCommunications.commits.refresh'),
icon: mdiRefresh,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
refreshCommits: 'true',
},
},
},
{
key: 'exclude-release-commits',
label: t('releaseCommunications.commits.exclude'),
icon: mdiEyeOffOutline,
disabled: releaseCommunicationsStore.selectedCommitShas.length === 0,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
excludeCommits: 'true',
},
},
},
{
key: 'create-first-release',
label: t('releaseCommunications.developer.createFirstRelease'),
icon: mdiFlagVariantOutline,
disabled: releaseCommunicationsStore.selectedCommitShas.length !== 1,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
createFirstRelease: 'true',
},
},
},
{
key: 'create-release-note',
label: t('releaseCommunications.developer.createReleaseNote'),
icon: mdiPlus,
disabled: releaseCommunicationsStore.selectedCommitShas.length === 0,
route: {
name: 'developer-release-notes',
query: {
...route.query,
tab: 'git-log',
createReleaseNote: 'true',
},
},
},
];
case 'workspace-settings':
case 'settings-user-information':
case 'settings-workspaces':
@@ -131,21 +216,25 @@
<div class="side-menu-items side-menu-right">
<template v-if="!authStore.isAuthenticated">
<router-link to="/login">
<button class="menu-item-action">
<v-btn
to="/login"
class="menu-item-action"
variant="text"
:ripple="false"
>
<v-icon :icon="mdiLogin" />
<span class="label">{{ t('nav.signIn') }}</span>
</button>
</router-link>
</v-btn>
</template>
<div
v-if="contentViewActions.length"
class="view-selector"
>
<button
<v-btn
class="menu-item-action view-selector-button"
type="button"
variant="text"
:ripple="false"
@click="isContentViewMenuOpen = !isContentViewMenuOpen"
>
<v-icon :icon="activeContentViewAction.icon" />
@@ -154,42 +243,73 @@
class="selector-chevron"
:icon="mdiChevronDown"
/>
</button>
</v-btn>
<div
v-if="isContentViewMenuOpen"
class="view-selector-menu"
>
<router-link
<v-btn
v-for="action in contentViewActions"
:key="action.key"
:to="action.route"
class="menu-action-link"
@click="isContentViewMenuOpen = false"
>
<button
class="view-selector-option"
class="view-selector-option menu-action-link"
:class="{ 'view-selector-option-active': action.active }"
type="button"
variant="text"
:ripple="false"
@click="isContentViewMenuOpen = false"
>
<v-icon :icon="action.icon" />
<span>{{ action.label }}</span>
</button>
</router-link>
</v-btn>
</div>
</div>
<router-link
<template
v-for="action in appBarActions"
:key="action.key"
:to="action.route"
class="menu-action-link"
>
<button class="menu-item-action">
<v-btn
v-if="action.disabled"
class="menu-item-action"
variant="text"
:ripple="false"
disabled
>
<v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span>
</button>
</router-link>
</v-btn>
<v-btn
v-else-if="action.handler"
class="menu-item-action"
variant="text"
:ripple="false"
:disabled="action.loading"
@click="action.handler"
>
<v-progress-circular
v-if="action.loading"
indeterminate
size="18"
width="2"
/>
<v-icon
v-else
:icon="action.icon"
/>
<span class="label">{{ action.label }}</span>
</v-btn>
<v-btn
v-else
:to="action.route"
class="menu-item-action menu-action-link"
variant="text"
:ripple="false"
>
<v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span>
</v-btn>
</template>
</div>
</div>
</nav>
@@ -201,7 +321,7 @@
@apply sticky top-0 z-20 flex flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between;
background: rgba(255, 250, 242, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
border-bottom: 1px solid var(--app-border-subtle);
isolation: isolate;
}
@@ -236,8 +356,8 @@
.view-selector-menu {
@apply absolute right-0 top-[calc(100%+0.5rem)] z-30 flex min-w-52 flex-col gap-1 rounded-[1rem] border p-2 shadow-xl;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
background: var(--app-surface-raised);
border-color: var(--app-border-subtle);
}
.label {
@@ -245,29 +365,46 @@
}
.menu-item-action {
@apply flex h-11 items-center gap-3 rounded-full px-4 transition-colors;
@apply flex h-11 items-center justify-start gap-3 rounded-full px-4 normal-case transition-colors;
background: rgba(255, 255, 255, 0.8);
color: #172033;
border: 1px solid rgba(23, 32, 51, 0.06);
color: var(--app-color-on-surface);
border: 1px solid var(--app-border-muted);
letter-spacing: 0;
}
.menu-item-action:hover {
background: #172033;
color: #fffaf2;
background: var(--app-color-primary);
color: var(--app-color-on-primary);
}
.menu-item-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.menu-item-action:disabled:hover {
background: rgba(255, 255, 255, 0.8);
color: var(--app-color-on-surface);
}
.view-selector-option {
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
color: #172033;
@apply flex h-auto min-h-11 w-full items-center justify-start gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold normal-case transition;
color: var(--app-color-on-surface);
letter-spacing: 0;
}
.view-selector-option:hover,
.view-selector-option-active {
background: #172033;
color: #fffaf2;
background: var(--app-color-primary);
color: var(--app-color-on-primary);
}
.menu-item-action i {
.menu-item-action :deep(.v-btn__content),
.view-selector-option :deep(.v-btn__content) {
@apply flex min-w-0 items-center justify-start gap-3;
}
.menu-item-action :deep(.v-icon) {
@apply text-xl;
}

View File

@@ -52,17 +52,20 @@
const collapsedSearchInputRef = ref(null);
const collapsedSearchPanelStyle = ref({});
const filterVisibleLinks = links =>
links.filter(link => !link.roles || authStore.hasAnyRole(link.roles));
const primaryLinks = [
{ to: '/app/dashboard', labelKey: 'nav.overview', icon: mdiHomeOutline },
{ to: '/app/media-library', labelKey: 'nav.mediaLibrary', icon: mdiImageMultipleOutline },
{ to: '/app/developer/release-notes', labelKey: 'nav.releaseNotes', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
];
const bottomLinks = [
{ to: '/app/updates', labelKey: 'nav.whatsNew', icon: mdiBullhornOutline, badge: 'updates' },
{ to: '/app/feedback', labelKey: 'nav.feedbackReview', icon: mdiBugOutline, roles: ['developer'] },
{ to: '/app/developer/updates', labelKey: 'nav.releaseUpdates', icon: mdiBullhornOutline, roles: ['developer'] },
{ to: '/app/developer/release-commits', labelKey: 'nav.releaseCommits', icon: mdiSourceCommit, roles: ['developer'], badge: 'commits' },
];
const visiblePrimaryLinks = computed(() =>
primaryLinks.filter(link => !link.roles || authStore.hasAnyRole(link.roles))
);
const visiblePrimaryLinks = computed(() => filterVisibleLinks(primaryLinks));
const visibleBottomLinks = computed(() => filterVisibleLinks(bottomLinks));
const openSections = ref({
channels: false,
@@ -305,11 +308,14 @@
:title="!isExpanded ? 'Search' : null"
@click="openCollapsedSearch"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<v-text-field
v-if="isExpanded"
v-model="searchQuery"
class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search"
density="compact"
variant="plain"
@@ -329,11 +335,14 @@
v-if="!isExpanded"
class="sidebar-search sidebar-search-panel-input"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<v-text-field
ref="collapsedSearchInputRef"
v-model="searchQuery"
class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search"
density="compact"
variant="plain"
@@ -347,15 +356,17 @@
class="sidebar-search-group"
>
<strong>Campaigns</strong>
<button
<v-btn
v-for="result in campaignResults"
:key="`campaign-${result.id}`"
class="sidebar-search-result"
variant="text"
:ripple="false"
@click="openSearchResult(result)"
>
<span>{{ result.label }}</span>
<small>{{ result.description }}</small>
</button>
</v-btn>
</div>
<div
@@ -363,15 +374,17 @@
class="sidebar-search-group"
>
<strong>Content items</strong>
<button
<v-btn
v-for="result in contentResults"
:key="`content-${result.id}`"
class="sidebar-search-result"
variant="text"
:ripple="false"
@click="openSearchResult(result)"
>
<span>{{ result.label }}</span>
<small>{{ result.description }}</small>
</button>
</v-btn>
</div>
<div
@@ -387,9 +400,10 @@
ref="notificationsRef"
class="sidebar-notifications-wrap"
>
<button
class="sidebar-link sidebar-utility-link"
type="button"
<v-btn
class="sidebar-link sidebar-control sidebar-utility-link"
variant="text"
:ripple="false"
@click.stop="toggleNotifications"
>
<span class="sidebar-link-main">
@@ -409,7 +423,7 @@
{{ t('notifications.title') }}
</span>
</span>
</button>
</v-btn>
<div
v-if="isExpanded && isNotificationsOpen"
@@ -434,17 +448,19 @@
{{ notificationsStore.error }}
</div>
<button
<v-btn
v-for="notification in notificationsStore.recentItems"
:key="notification.id"
class="sidebar-notification-row"
:class="{ 'sidebar-notification-row-unread': !notification.readAt }"
variant="text"
:ripple="false"
@click="openNotification(notification)"
>
<strong>{{ formatNotificationTitle(notification) }}</strong>
<span>{{ notification.message }}</span>
<small>{{ formatNotificationDate(notification.createdAt) }}</small>
</button>
</v-btn>
<div
v-if="!notificationsStore.isLoading && !notificationsStore.recentItems.length"
@@ -456,13 +472,15 @@
</div>
</div>
<div class="sidebar-section">
<router-link
<div class="sidebar-section sidebar-primary-links">
<v-btn
v-for="link in visiblePrimaryLinks"
:key="link.to"
:to="link.to"
class="sidebar-link"
active-class="sidebar-link-active"
class="sidebar-link sidebar-control"
active-class="sidebar-link-active sidebar-control-active"
variant="text"
:ripple="false"
:title="!isExpanded ? t(link.labelKey) : null"
>
<span class="sidebar-link-icon-wrap">
@@ -486,15 +504,17 @@
>
{{ t(link.labelKey) }}
</span>
</router-link>
</v-btn>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
<v-btn
to="/app/content"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
class="sidebar-link sidebar-control sidebar-link-section"
active-class="sidebar-link-active sidebar-control-active"
variant="text"
:ripple="false"
:title="!isExpanded ? t('nav.content') : null"
>
<span class="sidebar-link-main">
@@ -506,25 +526,29 @@
{{ t('nav.content') }}
</span>
</span>
</router-link>
</v-btn>
<router-link
<v-btn
v-if="isExpanded"
:to="{ name: 'content-item-create' }"
class="sidebar-section-action"
class="sidebar-section-action sidebar-icon-button"
variant="text"
:ripple="false"
:title="t('contentItems.newItem')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</v-btn>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
<v-btn
to="/app/campaigns"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
class="sidebar-link sidebar-control sidebar-link-section"
active-class="sidebar-link-active sidebar-control-active"
variant="text"
:ripple="false"
:title="!isExpanded ? t('nav.campaigns') : null"
@click="toggleSection('campaigns')"
>
@@ -543,16 +567,18 @@
:class="{ 'sidebar-chevron-open': openSections.campaigns }"
/>
</span>
</router-link>
</v-btn>
<router-link
<v-btn
v-if="isExpanded"
to="/app/campaigns?create=true"
class="sidebar-section-action"
class="sidebar-section-action sidebar-icon-button"
variant="text"
:ripple="false"
:title="t('campaigns.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</v-btn>
</div>
<div
@@ -588,10 +614,12 @@
<div class="sidebar-section">
<div class="sidebar-section-header">
<router-link
<v-btn
to="/app/channels"
class="sidebar-link sidebar-link-section"
active-class="sidebar-link-active"
class="sidebar-link sidebar-control sidebar-link-section"
active-class="sidebar-link-active sidebar-control-active"
variant="text"
:ripple="false"
:title="!isExpanded ? t('nav.channels') : null"
@click="toggleSection('channels')"
>
@@ -610,16 +638,18 @@
:class="{ 'sidebar-chevron-open': openSections.channels }"
/>
</span>
</router-link>
</v-btn>
<router-link
<v-btn
v-if="isExpanded"
to="/app/channels?create=true"
class="sidebar-section-action"
class="sidebar-section-action sidebar-icon-button"
variant="text"
:ripple="false"
:title="t('channels.createTitle')"
>
<v-icon :icon="mdiPlus" />
</router-link>
</v-btn>
</div>
<div
@@ -655,6 +685,38 @@
</div>
<div
v-if="authStore.isAuthenticated && visibleBottomLinks.length"
class="sidebar-section sidebar-bottom-links"
>
<v-btn
v-for="link in visibleBottomLinks"
:key="link.to"
:to="link.to"
class="sidebar-link sidebar-control"
active-class="sidebar-link-active sidebar-control-active"
variant="text"
:ripple="false"
:title="!isExpanded ? t(link.labelKey) : null"
>
<span class="sidebar-link-icon-wrap">
<v-icon :icon="link.icon" />
<span
v-if="link.badge === 'updates' && releaseCommunicationsStore.unreadCount"
class="sidebar-notification-badge"
>
{{ Math.min(releaseCommunicationsStore.unreadCount, 9) }}
</span>
</span>
<span
v-if="isExpanded"
class="sidebar-link-label"
>
{{ t(link.labelKey) }}
</span>
</v-btn>
</div>
<SidebarUserMenu
v-if="authStore.isAuthenticated"
:is-expanded="isExpanded"
@@ -666,7 +728,7 @@
@reference "@/assets/main.css";
.app-sidebar {
@apply flex h-full w-[19rem] flex-shrink-0 flex-col px-4 pt-4 transition-[width,padding] duration-200;
border-right: 1px solid rgba(23, 32, 51, 0.08);
border-right: 1px solid var(--app-border-subtle);
}
.app-sidebar-scroll {
@@ -675,7 +737,7 @@
.brand-block {
@apply flex items-center gap-3 pb-4;
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
border-bottom: 1px solid var(--app-border-subtle);
}
.brand-link {
@@ -705,7 +767,7 @@
.brand-name {
@apply min-w-0 text-lg font-black uppercase tracking-[0.18em];
color: rgb(var(--v-theme-primary));
color: var(--app-color-primary);
line-height: 2.75rem;
}
@@ -724,7 +786,11 @@
}
.sidebar-utilities {
@apply gap-3 pb-1;
@apply gap-3;
}
.sidebar-primary-links {
margin-top: -0.5rem;
}
.sidebar-search-wrap,
@@ -734,36 +800,41 @@
.sidebar-search {
@apply flex h-11 items-center gap-3 rounded-[1.1rem] border px-4 transition-colors;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.06);
color: #526178;
background: var(--app-control-subtle);
border-color: var(--app-border-muted);
color: var(--app-text-muted);
}
.sidebar-search-open,
.sidebar-search:focus-within {
background: rgba(255, 255, 255, 0.96);
border-color: rgba(23, 32, 51, 0.1);
background: var(--app-color-control-focus);
border-color: var(--app-border-subtle);
}
.sidebar-search-icon {
@apply h-5 w-5 flex-shrink-0 text-xl;
color: var(--app-text-muted);
}
.sidebar-search-input {
@apply min-w-0 flex-1 border-0 bg-transparent p-0 text-sm;
color: #172033;
color: var(--app-color-on-surface);
outline: none;
}
.sidebar-search-input :deep(.v-field__input) {
@apply min-h-0 p-0;
}
.sidebar-search-input::placeholder {
color: #7a8799;
color: var(--app-text-subtle);
}
.sidebar-floating-panel {
@apply absolute left-0 right-0 top-[calc(100%+0.6rem)] z-40 flex flex-col gap-3 rounded-[1.25rem] border p-3;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
background: var(--app-surface-raised);
border-color: var(--app-border-subtle);
box-shadow: var(--app-shadow-popover);
}
.sidebar-search-panel-collapsed {
@@ -780,22 +851,27 @@
.sidebar-search-group strong {
@apply px-2 text-xs font-black uppercase tracking-[0.18em];
color: #5d6b82;
color: var(--app-text-muted);
}
.sidebar-search-result {
@apply flex flex-col gap-1 rounded-[0.95rem] px-3 py-3 text-left transition-colors;
color: #172033;
@apply flex h-auto min-h-0 flex-col items-stretch gap-1 rounded-[0.95rem] px-3 py-3 text-left normal-case transition-colors;
color: var(--app-color-on-surface);
letter-spacing: 0;
}
.sidebar-search-result:hover {
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
}
.sidebar-search-result :deep(.v-btn__content) {
@apply flex min-w-0 flex-col items-start gap-1 whitespace-normal;
}
.sidebar-search-result small,
.sidebar-search-empty {
@apply text-xs leading-5;
color: #526178;
color: var(--app-text-muted);
}
.sidebar-search-empty {
@@ -817,7 +893,7 @@
.sidebar-notification-badge {
@apply absolute -right-2 -top-2 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1 text-[10px] font-black;
background: #ef4444;
color: #fffaf2;
color: var(--app-color-on-primary);
}
.sidebar-notifications-panel {
@@ -830,7 +906,7 @@
.sidebar-notifications-header strong {
@apply text-sm font-black;
color: #172033;
color: var(--app-color-on-surface);
}
.sidebar-notifications-header span,
@@ -838,7 +914,7 @@
.sidebar-notification-row span,
.sidebar-notification-row small {
@apply text-xs leading-5;
color: #526178;
color: var(--app-text-muted);
}
.sidebar-notifications-empty {
@@ -846,43 +922,55 @@
}
.sidebar-notification-row {
@apply flex flex-col gap-1 rounded-[0.9rem] px-3 py-3 text-left transition-colors;
@apply flex h-auto min-h-0 flex-col items-stretch gap-1 rounded-[0.9rem] px-3 py-3 text-left normal-case transition-colors;
letter-spacing: 0;
}
.sidebar-notification-row:hover {
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
}
.sidebar-notification-row :deep(.v-btn__content) {
@apply flex min-w-0 flex-col items-start gap-1 whitespace-normal;
}
.sidebar-notification-row-unread {
background: rgba(15, 118, 110, 0.08);
background: color-mix(in srgb, var(--app-color-highlight) 14%, transparent);
}
.sidebar-notification-row strong {
@apply text-sm font-semibold;
color: #172033;
color: var(--app-color-on-surface);
}
.sidebar-section {
@apply flex flex-col gap-2;
}
.sidebar-bottom-links {
@apply flex-shrink-0 pb-4;
}
.sidebar-section-header {
@apply flex items-center gap-2;
}
.sidebar-link {
@apply flex min-w-0 items-center gap-3 rounded-[1.1rem] px-4 py-3 text-sm font-semibold no-underline transition-colors;
@apply flex h-auto min-h-11 min-w-0 items-center gap-3 rounded-[1.1rem] px-4 py-3 text-sm font-semibold no-underline normal-case transition-colors;
color: #44516a;
background: transparent;
letter-spacing: 0;
justify-content: flex-start;
}
.sidebar-link:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.sidebar-link-active {
background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), rgba(239, 68, 68, 0.1));
color: #172033;
color: var(--app-color-on-surface);
box-shadow: inset 0 0 0 1px rgba(255, 138, 61, 0.2);
}
@@ -899,13 +987,14 @@
}
.sidebar-section-action {
@apply ml-auto flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1rem] transition-colors no-underline;
color: #526178;
@apply ml-auto flex h-11 min-w-0 w-11 flex-shrink-0 items-center justify-center rounded-[1rem] normal-case transition-colors no-underline;
color: var(--app-text-muted);
letter-spacing: 0;
}
.sidebar-section-action:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.sidebar-chevron {
@@ -916,24 +1005,34 @@
transform: rotate(180deg);
}
.sidebar-search :deep(.v-icon),
.sidebar-link :deep(.v-icon),
.sidebar-section-action :deep(.v-icon) {
@apply h-5 w-5 flex-shrink-0 text-xl;
}
.sidebar-link :deep(.v-btn__content),
.sidebar-section-action :deep(.v-btn__content) {
@apply flex min-w-0 items-center justify-start gap-3;
}
.sidebar-section-action :deep(.v-btn__content) {
@apply justify-center;
}
.sidebar-sublist {
@apply flex flex-col gap-1 pl-4;
}
.sidebar-sublink {
@apply flex flex-col rounded-[1rem] px-4 py-3 text-sm no-underline transition-colors;
color: #526178;
color: var(--app-text-muted);
}
.sidebar-sublink:hover,
.sidebar-sublink-active {
background: rgba(23, 32, 51, 0.05);
color: #172033;
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.sidebar-sublink strong {
@@ -943,7 +1042,7 @@
.sidebar-sublink small,
.sidebar-empty {
@apply text-xs;
color: #7a8799;
color: var(--app-text-subtle);
}
.app-sidebar-collapsed {
@@ -967,7 +1066,7 @@
}
.app-sidebar-collapsed .sidebar-search:hover {
background: rgba(23, 32, 51, 0.07);
background: var(--app-control-active);
}
.app-sidebar-collapsed .sidebar-search-panel-input {

View File

@@ -33,10 +33,15 @@
isUserMenuOpen.value = !isUserMenuOpen.value;
}
function toggleLanguage() {
async function toggleLanguage() {
const nextLocale = locale.value === 'en' ? 'fr' : 'en';
languageStore.setLocale(nextLocale);
locale.value = nextLocale;
try {
await userProfileStore.changePreferredLanguage(nextLocale);
} catch (error) {
console.error('Failed to save preferred language:', error);
}
isUserMenuOpen.value = false;
}
@@ -85,9 +90,10 @@
class="sidebar-workspace sidebar-workspace-bottom"
:class="{ 'sidebar-workspace-collapsed': !isExpanded }"
>
<button
<v-btn
class="sidebar-workspace-trigger"
type="button"
variant="text"
:ripple="false"
:title="!isExpanded ? userProfileStore.alias : null"
@click.stop="toggleUserMenu"
>
@@ -108,46 +114,50 @@
class="sidebar-workspace-icon"
:class="{ 'sidebar-workspace-icon-open': isUserMenuOpen }"
/>
</button>
</v-btn>
<div
v-if="isUserMenuOpen"
class="sidebar-workspace-menu"
class="sidebar-workspace-menu sidebar-menu-surface"
>
<button
class="sidebar-workspace-option"
type="button"
<v-btn
class="sidebar-workspace-option sidebar-menu-option"
variant="text"
:ripple="false"
@click="openMyFeedback"
>
<v-icon :icon="mdiBugOutline" />
<span>{{ t('nav.myFeedback') }}</span>
</button>
<div class="sidebar-workspace-separator" />
<button
class="sidebar-workspace-option"
type="button"
</v-btn>
<div class="sidebar-workspace-separator sidebar-menu-separator" />
<v-btn
class="sidebar-workspace-option sidebar-menu-option"
variant="text"
:ripple="false"
@click="openProfile"
>
<v-icon :icon="mdiAccountCircleOutline" />
<span>{{ t('nav.profile') }}</span>
</button>
<button
class="sidebar-workspace-option"
type="button"
</v-btn>
<v-btn
class="sidebar-workspace-option sidebar-menu-option"
variant="text"
:ripple="false"
@click="toggleLanguage"
>
<v-icon :icon="mdiTranslate" />
<span>{{ t('nav.language') }}</span>
</button>
<div class="sidebar-workspace-separator" />
<button
class="sidebar-workspace-option sidebar-workspace-option-danger"
type="button"
</v-btn>
<div class="sidebar-workspace-separator sidebar-menu-separator" />
<v-btn
class="sidebar-workspace-option sidebar-menu-option sidebar-workspace-option-danger sidebar-menu-option-danger"
variant="text"
:ripple="false"
@click="handleLogout"
>
<v-icon :icon="mdiLogout" />
<span>{{ t('nav.signOut') }}</span>
</button>
</v-btn>
</div>
</div>
</template>
@@ -160,26 +170,31 @@
.sidebar-workspace-bottom {
@apply py-4;
border-top: 1px solid rgba(23, 32, 51, 0.08);
border-top: 1px solid var(--app-border-subtle);
}
.sidebar-workspace-trigger {
@apply flex w-full items-center gap-3 rounded-[1.1rem] px-4 py-3 text-left transition-colors;
background: rgba(23, 32, 51, 0.04);
color: #172033;
@apply flex h-auto min-h-11 w-full items-center justify-start gap-3 rounded-[1.1rem] px-4 py-3 text-left text-sm font-semibold normal-case transition-colors;
background: transparent;
color: var(--app-color-on-surface);
letter-spacing: 0;
}
.sidebar-workspace-trigger:hover {
background: rgba(23, 32, 51, 0.07);
background: var(--app-control-hover);
}
.sidebar-workspace-trigger :deep(.v-btn__content) {
@apply flex min-w-0 flex-1 items-center justify-start gap-3;
}
.sidebar-workspace-label {
@apply flex-1 truncate text-sm font-semibold;
@apply flex-1 truncate;
}
.sidebar-workspace-icon {
@apply text-base transition-transform;
color: #5d6b82;
color: var(--app-text-muted);
}
.sidebar-workspace-icon-open {
@@ -187,12 +202,9 @@
}
.sidebar-workspace-menu {
@apply absolute bottom-[calc(100%+0.5rem)] left-0 right-0 z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
@apply absolute bottom-[calc(100%+0.5rem)] left-0 right-0;
isolation: isolate;
background: #fffdf8;
background-clip: padding-box;
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-workspace-collapsed {
@@ -203,35 +215,25 @@
@apply h-11 w-11 justify-center rounded-[1rem] p-0;
}
.sidebar-workspace-collapsed .sidebar-workspace-trigger :deep(.v-btn__content) {
@apply flex-none justify-center;
}
.sidebar-workspace-collapsed .sidebar-workspace-menu {
@apply left-[calc(100%+0.75rem)] right-auto w-56;
bottom: 1rem;
}
.sidebar-workspace-option {
@apply flex items-center gap-3 rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
@apply h-auto min-h-11 normal-case;
letter-spacing: 0;
}
.sidebar-workspace-option .v-icon {
.sidebar-workspace-option :deep(.v-btn__content) {
@apply flex min-w-0 items-center justify-start gap-3;
}
.sidebar-workspace-option :deep(.v-icon) {
@apply text-base;
color: #5d6b82;
}
.sidebar-workspace-option:hover {
background: rgba(23, 32, 51, 0.05);
}
.sidebar-workspace-separator {
@apply my-1;
border-top: 1px solid rgba(23, 32, 51, 0.08);
}
.sidebar-workspace-option-danger {
color: #b91c1c;
}
.sidebar-workspace-option-danger .v-icon {
color: #b91c1c;
}
</style>

View File

@@ -161,9 +161,11 @@
ref="workspaceMenuRef"
class="user-menu-wrap"
>
<button
<v-btn
class="menu-item-action workspace-trigger"
:class="{ 'workspace-trigger-static': !canOpenWorkspaceMenu }"
variant="text"
:ripple="false"
@click.stop="toggleWorkspaceMenu"
>
<AppAvatar
@@ -178,17 +180,18 @@
class="user-trigger-icon"
:class="{ 'user-trigger-icon-open': isWorkspaceMenuOpen }"
/>
</button>
</v-btn>
<div
v-if="isWorkspaceMenuOpen"
class="user-menu"
>
<button
<v-btn
v-if="canSelectAllWorkspaces"
class="user-menu-item all-workspaces-item"
:class="{ 'user-menu-item-active': workspaceStore.isAllWorkspacesSelected }"
type="button"
variant="text"
:ripple="false"
@click="chooseAllWorkspaces"
>
<AppAvatar
@@ -199,7 +202,7 @@
<span>{{ t('workspaceSelector.allWorkspaces') }}</span>
<small>{{ t('workspaceSelector.allWorkspacesDescription') }}</small>
</span>
</button>
</v-btn>
<div
v-for="workspace in visibleWorkspaces"
@@ -210,9 +213,10 @@
'workspace-menu-row-muted': workspaceStore.isAllWorkspacesSelected && !workspaceStore.isWorkspaceVisible(workspace.id),
}"
>
<button
<v-btn
class="user-menu-item workspace-menu-select"
type="button"
variant="text"
:ripple="false"
@click="chooseWorkspace(workspace.id)"
>
<AppAvatar
@@ -224,47 +228,51 @@
<span>{{ workspace.name }}</span>
<small>{{ workspace.timeZone }}</small>
</span>
</button>
</v-btn>
<button
<v-btn
v-if="canSelectAllWorkspaces"
class="workspace-visibility-button"
type="button"
variant="text"
:ripple="false"
:aria-label="workspaceStore.isWorkspaceVisible(workspace.id) ? t('workspaceSelector.hideWorkspace') : t('workspaceSelector.showWorkspace')"
@click.stop="toggleWorkspaceVisibility(workspace.id)"
>
<v-icon :icon="workspaceStore.isWorkspaceVisible(workspace.id) ? mdiEyeOutline : mdiEyeOffOutline" />
</button>
</v-btn>
<button
<v-btn
v-if="canManageWorkspaces"
class="workspace-settings-button"
type="button"
variant="text"
:ripple="false"
:aria-label="t('workspaceSelector.workspaceSettings')"
@click="openWorkspaceSettings(workspace.id)"
>
<v-icon :icon="mdiCogOutline" />
</button>
</v-btn>
</div>
<button
<v-btn
v-if="canManageWorkspaces"
class="user-menu-item user-menu-item-create"
type="button"
variant="text"
:ripple="false"
@click="openCreateWorkspace"
>
<span>{{ t('workspaceSelector.createAction') }}</span>
<v-icon :icon="mdiPlus" />
</button>
</v-btn>
<div
v-if="activeOrganization"
class="organization-switcher"
>
<div class="organization-current-row">
<button
<v-btn
class="user-menu-item organization-current"
type="button"
variant="text"
:ripple="false"
@click="openOrganizationSettings(activeOrganization.id)"
>
<AppAvatar
@@ -280,29 +288,31 @@
:icon="mdiCogOutline"
class="organization-action-icon"
/>
</button>
</v-btn>
</div>
<button
<v-btn
v-if="canSwitchOrganizations"
class="organization-swap-button"
type="button"
variant="text"
:ripple="false"
:aria-expanded="isOrganizationListOpen"
@click="toggleOrganizationList"
>
<span>Change organization</span>
<v-icon :icon="mdiSwapHorizontal" />
</button>
</v-btn>
<div
v-if="isOrganizationListOpen"
class="organization-options"
>
<button
<v-btn
v-for="organization in switchableOrganizations"
:key="organization.id"
class="user-menu-item organization-option"
type="button"
variant="text"
:ripple="false"
@click="chooseOrganization(organization.id)"
>
<AppAvatar
@@ -314,7 +324,7 @@
<span>{{ organization.name }}</span>
<small>{{ t('workspaceSelector.organizationLabel') }}</small>
</span>
</button>
</v-btn>
</div>
</div>
</div>
@@ -328,15 +338,20 @@
}
.menu-item-action {
@apply flex h-11 items-center gap-3 rounded-full px-4 transition-colors;
@apply flex h-11 items-center justify-start gap-3 rounded-full px-4 normal-case transition-colors;
background: rgba(255, 255, 255, 0.8);
color: #172033;
border: 1px solid rgba(23, 32, 51, 0.06);
color: var(--app-color-on-surface);
border: 1px solid var(--app-border-muted);
letter-spacing: 0;
}
.menu-item-action:hover {
background: #172033;
color: #fffaf2;
background: var(--app-color-primary);
color: var(--app-color-on-primary);
}
.menu-item-action :deep(.v-btn__content) {
@apply flex min-w-0 items-center justify-start gap-3;
}
.user-menu-wrap {
@@ -369,21 +384,22 @@
width: max(100%, 17rem);
max-width: min(24rem, calc(100vw - 2rem));
isolation: isolate;
background: #fffdf8;
background: var(--app-surface-raised);
background-clip: padding-box;
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
box-shadow: var(--app-shadow-popover);
z-index: 40;
}
.user-menu-item {
@apply flex items-center gap-3 rounded-[0.9rem] px-3 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
@apply flex h-auto min-h-11 items-center justify-start gap-3 rounded-[0.9rem] px-3 py-3 text-left text-sm font-semibold normal-case transition-colors;
color: var(--app-color-on-surface);
letter-spacing: 0;
}
.user-menu-item:hover,
.workspace-menu-row:hover {
background: rgba(23, 32, 51, 0.06);
background: var(--app-control-hover);
}
.user-menu-item-active {
@@ -393,7 +409,7 @@
.workspace-menu-row {
@apply flex min-w-0 items-center rounded-[0.9rem] transition-colors;
color: #172033;
color: var(--app-color-on-surface);
}
.workspace-menu-row-muted {
@@ -402,8 +418,8 @@
.all-workspaces-item {
@apply mb-1 border;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(23, 32, 51, 0.03);
border-color: var(--app-border-subtle);
background: var(--app-control-subtle);
}
.workspace-menu-select {
@@ -415,19 +431,21 @@
}
.workspace-settings-button {
@apply mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors;
color: #526178;
@apply mr-2 flex h-8 min-w-0 w-8 flex-shrink-0 items-center justify-center rounded-full normal-case transition-colors;
color: var(--app-text-muted);
letter-spacing: 0;
}
.workspace-visibility-button {
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full transition-colors;
color: #526178;
@apply flex h-8 min-w-0 w-8 flex-shrink-0 items-center justify-center rounded-full normal-case transition-colors;
color: var(--app-text-muted);
letter-spacing: 0;
}
.workspace-visibility-button:hover,
.workspace-settings-button:hover {
background: rgba(23, 32, 51, 0.1);
color: #172033;
background: var(--app-control-active);
color: var(--app-color-on-surface);
}
.workspace-visibility-button :deep(.v-icon),
@@ -446,17 +464,17 @@
.user-menu-item-copy small {
@apply text-xs font-medium;
color: #526178;
color: var(--app-text-muted);
}
.user-menu-item-create {
@apply justify-between border border-dashed;
border-color: rgba(23, 32, 51, 0.12);
border-color: var(--app-border-subtle);
}
.organization-switcher {
@apply mt-2 flex flex-col gap-1 border-t pt-2;
border-color: rgba(23, 32, 51, 0.08);
border-color: var(--app-border-subtle);
}
.organization-current-row {
@@ -465,31 +483,46 @@
.organization-current {
@apply w-full min-w-0 border;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
background: var(--app-control-subtle);
border-color: var(--app-border-subtle);
}
.organization-current:hover {
background: rgba(23, 32, 51, 0.07);
background: var(--app-control-active);
}
.organization-action-icon {
@apply flex-shrink-0 text-base;
color: #526178;
color: var(--app-text-muted);
}
.organization-swap-button {
@apply flex w-full items-center justify-between gap-3 rounded-[0.9rem] border px-3 py-2.5 text-sm font-semibold transition-colors;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
color: #172033;
@apply flex h-auto min-h-11 w-full items-center justify-between gap-3 rounded-[0.9rem] border px-3 py-2.5 text-sm font-semibold normal-case transition-colors;
background: var(--app-control-subtle);
border-color: var(--app-border-subtle);
color: var(--app-color-on-surface);
letter-spacing: 0;
}
.organization-swap-button:hover {
background: rgba(23, 32, 51, 0.08);
background: var(--app-control-active);
}
.organization-options {
@apply flex flex-col gap-1;
}
.user-menu-item :deep(.v-btn__content),
.organization-swap-button :deep(.v-btn__content) {
@apply flex min-w-0 items-center justify-start gap-3;
}
.organization-swap-button :deep(.v-btn__content) {
@apply w-full justify-between;
}
.workspace-visibility-button :deep(.v-btn__content),
.workspace-settings-button :deep(.v-btn__content) {
@apply justify-center;
}
</style>

Some files were not shown because too many files have changed in this diff Show More