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) 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) 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) public static bool CanAccessClient(ClaimsPrincipal user, Guid workspaceId, Guid clientId)
{ {
return IsManager(user) return CanAccessWorkspace(user, workspaceId) &&
|| (CanAccessWorkspace(user, workspaceId) && user.GetClientScopeIds().Contains(clientId)); (IsManager(user) || user.GetClientScopeIds().Contains(clientId));
} }
public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId) public static bool CanAccessCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid campaignId)
{ {
return IsManager(user) return CanAccessClient(user, workspaceId, clientId) &&
|| (CanAccessClient(user, workspaceId, clientId) && user.GetCampaignScopeIds().Contains(campaignId)); (IsManager(user) || user.GetCampaignScopeIds().Contains(campaignId));
} }
public static bool CanContributeToCampaign(ClaimsPrincipal user, Guid workspaceId, Guid clientId, Guid 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) 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) || IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId); || IsClient(user) && CanAccessClient(user, workspaceId, clientId);
} }
@@ -68,7 +69,7 @@ internal sealed class AccessScopeService(
Guid workspaceId, Guid workspaceId,
CancellationToken ct) CancellationToken ct)
{ {
return CanAccessWorkspace(user, workspaceId) return user.GetWorkspaceScopeIds().Contains(workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
@@ -81,7 +82,7 @@ internal sealed class AccessScopeService(
Guid workspaceId, Guid workspaceId,
CancellationToken ct) CancellationToken ct)
{ {
return IsManager(user) return CanManageWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
@@ -94,8 +95,7 @@ internal sealed class AccessScopeService(
Guid organizationId, Guid organizationId,
CancellationToken ct) CancellationToken ct)
{ {
return IsManager(user) return await organizationAccessService.HasOrganizationPermissionAsync(
|| await organizationAccessService.HasOrganizationPermissionAsync(
user, user,
organizationId, organizationId,
OrganizationPermissions.CreateWorkspaces, OrganizationPermissions.CreateWorkspaces,
@@ -108,8 +108,7 @@ internal sealed class AccessScopeService(
Guid clientId, Guid clientId,
CancellationToken ct) CancellationToken ct)
{ {
if (IsManager(user) || if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces, OrganizationPermissions.AccessOwnedWorkspaces,
@@ -128,8 +127,7 @@ internal sealed class AccessScopeService(
Guid campaignId, Guid campaignId,
CancellationToken ct) CancellationToken ct)
{ {
if (IsManager(user) || if (await organizationAccessService.HasInheritedWorkspacePermissionAsync(
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces, OrganizationPermissions.AccessOwnedWorkspaces,
@@ -149,7 +147,7 @@ internal sealed class AccessScopeService(
Guid campaignId, Guid campaignId,
CancellationToken ct) CancellationToken ct)
{ {
return IsManager(user) return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, workspaceId,
@@ -165,7 +163,7 @@ internal sealed class AccessScopeService(
Guid campaignId, Guid campaignId,
CancellationToken ct) CancellationToken ct)
{ {
return IsManager(user) return IsManager(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync( || await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user, user,
workspaceId, 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) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("PreferredLanguage")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("RefreshToken") b.Property<string>("RefreshToken")
.HasMaxLength(44) .HasMaxLength(44)
.HasColumnType("character varying(44)"); .HasColumnType("character varying(44)");
@@ -1980,28 +1985,6 @@ namespace Socialize.Api.Migrations
b.Property<DateTimeOffset?>("ArchivedAt") b.Property<DateTimeOffset?>("ArchivedAt")
.HasColumnType("timestamp with time zone"); .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") b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
@@ -2010,28 +1993,6 @@ namespace Socialize.Api.Migrations
b.Property<Guid>("CreatedByUserId") b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid"); .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") b.Property<DateTimeOffset?>("PublishedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -2042,21 +2003,29 @@ namespace Socialize.Api.Migrations
b.Property<string>("Summary") b.Property<string>("Summary")
.IsRequired() .IsRequired()
.HasMaxLength(512) .HasMaxLength(4000)
.HasColumnType("character varying(512)"); .HasColumnType("character varying(4000)");
b.Property<string>("SummaryFr")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Title") b.Property<string>("Title")
.IsRequired() .IsRequired()
.HasMaxLength(160) .HasMaxLength(160)
.HasColumnType("character varying(160)"); .HasColumnType("character varying(160)");
b.Property<string>("TitleFr")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Audience");
b.HasIndex("CreatedByUserId"); b.HasIndex("CreatedByUserId");
b.HasIndex("PublishedAt"); b.HasIndex("PublishedAt");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ internal class User : IdentityUser<Guid>
[MaxLength(2048)] public string? PortraitUrl { get; set; } [MaxLength(2048)] public string? PortraitUrl { get; set; }
[MaxLength(256)] public string? GoogleId { get; set; } [MaxLength(256)] public string? GoogleId { get; set; }
[MaxLength(256)] public string? FacebookId { get; set; } [MaxLength(256)] public string? FacebookId { get; set; }
[MaxLength(8)] public string PreferredLanguage { get; set; } = "en";
[MaxLength(44)] public string? RefreshToken { get; set; } [MaxLength(44)] public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpiryTime { get; set; } public DateTime RefreshTokenExpiryTime { get; set; }
public DateTimeOffset? LastAuthenticatedAt { 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, Email = userModel.Email,
BirthDate = userModel.BirthDate, BirthDate = userModel.BirthDate,
Address = userModel.Address, Address = userModel.Address,
PreferredLanguage = userModel.PreferredLanguage,
UserRoles = roles UserRoles = roles
}, },
ct); ct);

View File

@@ -17,4 +17,5 @@ internal class UserDto
public string? PhoneNumber { get; init; } public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; } public DateTime? BirthDate { get; init; }
public string? Address { 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 string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; } public DateTime? BirthDate { get; init; }
public string? Address { 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(); IQueryable<NotificationEvent> query = dbContext.NotificationEvents.AsQueryable();
Guid currentUserId = User.GetUserId(); Guid currentUserId = User.GetUserId();
if (!AccessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(notificationEvent => query = query.Where(notificationEvent =>
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) || workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
notificationEvent.RecipientUserId == currentUserId); notificationEvent.RecipientUserId == currentUserId);
}
query = query.Where(notificationEvent => query = query.Where(notificationEvent =>
notificationEvent.RecipientUserId == null || notificationEvent.RecipientUserId == null ||

View File

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

View File

@@ -5,23 +5,16 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Contracts;
internal record ReleaseUpdateDto( internal record ReleaseUpdateDto(
Guid Id, Guid Id,
string Title, string Title,
string Summary, string Description,
string? Body, string TitleEn,
string Category, string DescriptionEn,
string Importance, string TitleFr,
string Audience, string DescriptionFr,
string Status, string Status,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt, DateTimeOffset UpdatedAt,
DateTimeOffset? PublishedAt, DateTimeOffset? PublishedAt,
DateTimeOffset? ArchivedAt, DateTimeOffset? ArchivedAt,
Guid? ManualEmailSentByUserId,
DateTimeOffset? ManualEmailSentAt,
string? ManualEmailAudience,
int? ManualEmailRecipientCount,
bool IsRead); bool IsRead);
internal record ReleaseCommitDto( internal record ReleaseCommitDto(
@@ -40,22 +33,21 @@ internal record ReleaseCommitDto(
DateTimeOffset ImportedAt, DateTimeOffset ImportedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
internal record ReleaseCommitImportResultDto( internal record ReleaseCommitRefreshResultDto(
int ImportedCount, int CreatedCount,
int UpdatedCount, int UpdatedCount,
int SkippedCount, int SkippedCount,
IReadOnlyCollection<ReleaseCommitDto> Commits); IReadOnlyCollection<ReleaseCommitDto> Commits);
internal record ReleaseUpdateEmailSendResultDto( internal record ReleaseCommitBulkLinkResultDto(int LinkedCount);
int RecipientCount,
DateTimeOffset SentAt,
bool TestMode);
internal record ReleaseUpdateUnreadSummaryDto( internal record ReleaseUpdateUnreadSummaryDto(
int UnreadCount, int UnreadCount,
int ImportantUnreadCount, int ImportantUnreadCount,
IReadOnlyCollection<ReleaseUpdateDto> Updates); IReadOnlyCollection<ReleaseUpdateDto> Updates);
internal record ReleaseUpdateDigestSendResultDto(int SentCount);
internal static class ReleaseUpdateDtoMapper internal static class ReleaseUpdateDtoMapper
{ {
public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead) public static ReleaseUpdateDto ToDto(this ReleaseUpdate update, bool isRead)
@@ -64,22 +56,15 @@ internal static class ReleaseUpdateDtoMapper
update.Id, update.Id,
update.Title, update.Title,
update.Summary, update.Summary,
update.Body, update.Title,
ToDisplayString(update.Category), update.Summary,
update.Importance.ToString(), update.TitleFr,
update.Audience.ToString(), update.SummaryFr,
update.Status.ToString(), update.Status.ToString(),
update.DeploymentLabel,
update.BuildVersion,
update.CommitRange,
update.CreatedAt, update.CreatedAt,
update.UpdatedAt, update.UpdatedAt,
update.PublishedAt, update.PublishedAt,
update.ArchivedAt, update.ArchivedAt,
update.ManualEmailSentByUserId,
update.ManualEmailSentAt,
update.ManualEmailAudience,
update.ManualEmailRecipientCount,
isRead); isRead);
} }
@@ -102,8 +87,4 @@ internal static class ReleaseUpdateDtoMapper
commit.UpdatedAt); 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.ToTable("ReleaseUpdates");
releaseUpdate.HasKey(x => x.Id); releaseUpdate.HasKey(x => x.Id);
releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired(); releaseUpdate.Property(x => x.Title).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.Summary).HasMaxLength(512).IsRequired(); releaseUpdate.Property(x => x.Summary).HasMaxLength(4000).IsRequired();
releaseUpdate.Property(x => x.Body).HasMaxLength(8000); releaseUpdate.Property(x => x.TitleFr).HasMaxLength(160).IsRequired();
releaseUpdate.Property(x => x.Category).HasConversion<string>().HasMaxLength(32).IsRequired(); releaseUpdate.Property(x => x.SummaryFr).HasMaxLength(4000).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.Status).HasConversion<string>().HasMaxLength(32).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.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
releaseUpdate.HasIndex(x => x.Status); releaseUpdate.HasIndex(x => x.Status);
releaseUpdate.HasIndex(x => x.Audience);
releaseUpdate.HasIndex(x => x.PublishedAt); releaseUpdate.HasIndex(x => x.PublishedAt);
releaseUpdate.HasIndex(x => x.CreatedByUserId); releaseUpdate.HasIndex(x => x.CreatedByUserId);
}); });

View File

@@ -5,22 +5,13 @@ internal class ReleaseUpdate
public Guid Id { get; set; } public Guid Id { get; set; }
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty;
public string? Body { get; set; } public string TitleFr { get; set; } = string.Empty;
public ReleaseUpdateCategory Category { get; set; } public string SummaryFr { get; set; } = string.Empty;
public ReleaseUpdateImportance Importance { get; set; }
public ReleaseUpdateAudience Audience { get; set; }
public ReleaseUpdateStatus Status { get; set; } 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 Guid CreatedByUserId { get; set; }
public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? PublishedAt { get; set; } public DateTimeOffset? PublishedAt { get; set; }
public DateTimeOffset? ArchivedAt { 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>(); 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.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts; using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers; namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record CreateDeveloperReleaseUpdateRequest( internal record CreateDeveloperReleaseUpdateRequest(
string Title, string TitleEn,
string Summary, string DescriptionEn,
string? Body, string TitleFr,
string Category, string DescriptionFr);
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
internal class CreateDeveloperReleaseUpdateRequestValidator internal class CreateDeveloperReleaseUpdateRequestValidator
: Validator<CreateDeveloperReleaseUpdateRequest> : Validator<CreateDeveloperReleaseUpdateRequest>
{ {
public CreateDeveloperReleaseUpdateRequestValidator() public CreateDeveloperReleaseUpdateRequestValidator()
{ {
RuleFor(x => x.Title).NotEmpty().MaximumLength(160); RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512); RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.Body).MaximumLength(8000); RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32); RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
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);
} }
} }
@@ -48,26 +37,15 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
public override async Task HandleAsync(CreateDeveloperReleaseUpdateRequest request, CancellationToken ct) 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; DateTimeOffset now = DateTimeOffset.UtcNow;
ReleaseUpdate update = new() ReleaseUpdate update = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = request.Title.Trim(), Title = request.TitleEn.Trim(),
Summary = request.Summary.Trim(), Summary = request.DescriptionEn.Trim(),
Body = NormalizeOptional(request.Body), TitleFr = request.TitleFr.Trim(),
Category = category, SummaryFr = request.DescriptionFr.Trim(),
Importance = importance,
Audience = audience,
Status = ReleaseUpdateStatus.Draft, Status = ReleaseUpdateStatus.Draft,
DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
BuildVersion = NormalizeOptional(request.BuildVersion),
CommitRange = NormalizeOptional(request.CommitRange),
CreatedByUserId = User.GetUserId(), CreatedByUserId = User.GetUserId(),
CreatedAt = now, CreatedAt = now,
UpdatedAt = now, UpdatedAt = now,
@@ -78,38 +56,4 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct); 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) public override async Task HandleAsync(CancellationToken ct)
{ {
Guid userId = User.GetUserId(); Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt => .Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id && receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == userId)) receipt.UserId == userId))
@@ -35,7 +33,7 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
await SendOkAsync( await SendOkAsync(
new ReleaseUpdateUnreadSummaryDto( new ReleaseUpdateUnreadSummaryDto(
unreadUpdates.Count, unreadUpdates.Count,
unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important), 0,
unreadUpdates.Select(update => update.ToDto(false)).ToArray()), unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
ct); 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) public override async Task HandleAsync(CancellationToken ct)
{ {
Guid userId = User.GetUserId(); Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates List<ReleaseUpdate> updates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.OrderByDescending(update => update.PublishedAt) .OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt) .ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);

View File

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

View File

@@ -20,11 +20,9 @@ internal class MarkReleaseUpdateReadHandler(AppDbContext dbContext)
{ {
Guid id = Route<Guid>("id"); Guid id = Route<Guid>("id");
Guid userId = User.GetUserId(); Guid userId = User.GetUserId();
ReleaseUpdateAudienceContext audienceContext =
await ReleaseUpdateVisibility.GetAudienceContextAsync(dbContext, User, userId, ct);
bool canReadUpdate = await dbContext.ReleaseUpdates bool canReadUpdate = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.AnyAsync(update => update.Id == id, ct); .AnyAsync(update => update.Id == id, ct);
if (!canReadUpdate) 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 LinkDeveloperReleaseCommitRequest(Guid ReleaseUpdateId);
internal record LinkFirstReleaseCommitsRequest(Guid ReleaseUpdateId);
internal abstract class ReleaseCommitStatusEndpoint(AppDbContext dbContext) internal abstract class ReleaseCommitStatusEndpoint(AppDbContext dbContext)
: EndpointWithoutRequest<ReleaseCommitDto> : 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) internal class UnlinkDeveloperReleaseCommitHandler(AppDbContext dbContext)
: ReleaseCommitStatusEndpoint(dbContext) : ReleaseCommitStatusEndpoint(dbContext)
{ {

View File

@@ -4,35 +4,24 @@ using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Contracts; using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Contracts; using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Api.Modules.ReleaseCommunications.Handlers; namespace Socialize.Api.Modules.ReleaseCommunications.Handlers;
internal record UpdateDeveloperReleaseUpdateRequest( internal record UpdateDeveloperReleaseUpdateRequest(
string Title, string TitleEn,
string Summary, string DescriptionEn,
string? Body, string TitleFr,
string Category, string DescriptionFr);
string Importance,
string Audience,
string? DeploymentLabel,
string? BuildVersion,
string? CommitRange);
internal class UpdateDeveloperReleaseUpdateRequestValidator internal class UpdateDeveloperReleaseUpdateRequestValidator
: Validator<UpdateDeveloperReleaseUpdateRequest> : Validator<UpdateDeveloperReleaseUpdateRequest>
{ {
public UpdateDeveloperReleaseUpdateRequestValidator() public UpdateDeveloperReleaseUpdateRequestValidator()
{ {
RuleFor(x => x.Title).NotEmpty().MaximumLength(160); RuleFor(x => x.TitleEn).NotEmpty().MaximumLength(160);
RuleFor(x => x.Summary).NotEmpty().MaximumLength(512); RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000);
RuleFor(x => x.Body).MaximumLength(8000); RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160);
RuleFor(x => x.Category).NotEmpty().MaximumLength(32); RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000);
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);
} }
} }
@@ -63,58 +52,13 @@ internal class UpdateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
return; return;
} }
if (!TryParseRequest(request, out ReleaseUpdateCategory category, out ReleaseUpdateImportance importance, out ReleaseUpdateAudience audience)) update.Title = request.TitleEn.Trim();
{ update.Summary = request.DescriptionEn.Trim();
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); update.TitleFr = request.TitleFr.Trim();
return; update.SummaryFr = request.DescriptionFr.Trim();
}
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.UpdatedAt = DateTimeOffset.UtcNow; update.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(ct); await dbContext.SaveChangesAsync(ct);
await SendOkAsync(update.ToDto(false), 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.Services.Configure<ReleaseCommunicationRepositoryOptions>(
builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName)); builder.Configuration.GetSection(ReleaseCommunicationRepositoryOptions.SectionName));
builder.Services.AddScoped<ReleaseUpdateEmailService>(); builder.Services.AddScoped<ReleaseUpdateEmailService>();
builder.Services.AddScoped<ReleaseCommitRepositoryImportService>(); builder.Services.AddScoped<ReleaseCommitRepositoryRefreshService>();
builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>(); builder.Services.AddHostedService<ReleaseUpdateEmailDigestBackgroundService>();
return builder; return builder;

View File

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

View File

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

View File

@@ -1,15 +1,11 @@
using System.Net; using System.Net;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Socialize.Api.Data; using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Configuration; using Socialize.Api.Infrastructure.Configuration;
using Socialize.Api.Infrastructure.Emailer.Contracts; using Socialize.Api.Infrastructure.Emailer.Contracts;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Identity.Data; using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data; using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services; namespace Socialize.Api.Modules.ReleaseCommunications.Services;
@@ -20,63 +16,22 @@ internal class ReleaseUpdateEmailService(
IEmailSender emailSender, IEmailSender emailSender,
IOptionsSnapshot<WebsiteOptions> websiteOptions) 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( public async Task<int> SendDueDigestEmailsAsync(
TimeSpan inactiveThreshold, TimeSpan inactiveThreshold,
TimeSpan sendInterval, TimeSpan sendInterval,
bool force,
CancellationToken ct) CancellationToken ct)
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold); DateTimeOffset inactiveBefore = now.Subtract(inactiveThreshold);
DateTimeOffset lastSentBefore = now.Subtract(sendInterval); DateTimeOffset lastSentBefore = now.Subtract(sendInterval);
List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, ct); List<User> ownerUsers = await GetReleaseNoteRecipientsAsync(ct);
int sentCount = 0; int sentCount = 0;
foreach (User user in ownerUsers) foreach (User user in ownerUsers)
{ {
if (string.IsNullOrWhiteSpace(user.Email) || if (string.IsNullOrWhiteSpace(user.Email) ||
!ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)) (!force && !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore)))
{ {
continue; continue;
} }
@@ -86,19 +41,13 @@ internal class ReleaseUpdateEmailService(
.OrderByDescending(receipt => receipt.SentAt) .OrderByDescending(receipt => receipt.SentAt)
.Select(receipt => (DateTimeOffset?)receipt.SentAt) .Select(receipt => (DateTimeOffset?)receipt.SentAt)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (!ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore)) if (!force && !ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore))
{ {
continue; continue;
} }
ReleaseUpdateAudienceContext audienceContext = await ReleaseUpdateVisibility.GetAudienceContextAsync(
dbContext,
new ClaimsPrincipal(new ClaimsIdentity()),
user.Id,
ct);
List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates List<ReleaseUpdate> unreadUpdates = await dbContext.ReleaseUpdates
.VisibleTo(audienceContext) .VisibleToUsers()
.Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt => .Where(update => !dbContext.ReleaseUpdateReadReceipts.Any(receipt =>
receipt.ReleaseUpdateId == update.Id && receipt.ReleaseUpdateId == update.Id &&
receipt.UserId == user.Id)) receipt.UserId == user.Id))
@@ -113,8 +62,8 @@ internal class ReleaseUpdateEmailService(
await emailSender.SendEmailAsync( await emailSender.SendEmailAsync(
user.Email, user.Email,
"What's new in Socialize", GetDigestSubject(user.PreferredLanguage),
BuildDigestEmail(unreadUpdates)); BuildDigestEmail(unreadUpdates, user.PreferredLanguage));
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
{ {
@@ -130,69 +79,47 @@ internal class ReleaseUpdateEmailService(
return sentCount; 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 await userManager.Users
return sender is null ? [] : [sender]; .Where(user => user.EmailConfirmed && user.Email != null)
.OrderBy(user => user.Email)
.ToListAsync(ct);
} }
private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct) private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates, string? preferredLanguage)
{
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)
{ {
bool useFrench = IsFrench(preferredLanguage);
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates"; string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
string listItems = string.Join( string listItems = string.Join(
Environment.NewLine, 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 $""" return $"""
<h1>What's new in Socialize</h1> <h1>{HtmlEncode(heading)}</h1>
<ul>{listItems}</ul> <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) return IsFrench(preferredLanguage)
? string.Empty ? "Nouveautes dans Socialize"
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>"; : "What's new in Socialize";
}
private static bool IsFrench(string? preferredLanguage)
{
return string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase);
} }
private static string HtmlEncode(string? value) 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; using Socialize.Api.Modules.ReleaseCommunications.Data;
namespace Socialize.Api.Modules.ReleaseCommunications.Services; namespace Socialize.Api.Modules.ReleaseCommunications.Services;
internal static class ReleaseUpdateVisibility internal static class ReleaseUpdateVisibility
{ {
public static async Task<ReleaseUpdateAudienceContext> GetAudienceContextAsync( public static IQueryable<ReleaseUpdate> VisibleToUsers(this IQueryable<ReleaseUpdate> query)
AppDbContext dbContext,
ClaimsPrincipal user,
Guid userId,
CancellationToken ct)
{ {
bool isDeveloper = user.IsInRole(KnownRoles.Developer); return query.Where(update => update.Status == ReleaseUpdateStatus.Published);
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)));
} }
} }
internal record ReleaseUpdateAudienceContext(
bool IsDeveloper,
bool IsOrganizationOwner);

View File

@@ -6,64 +6,16 @@ namespace Socialize.Tests.ReleaseCommunications;
public class ReleaseUpdateRulesTests 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] [Fact]
public void ToDto_formats_breaking_change_category_for_display() public void ToDto_maps_summary_to_description()
{ {
ReleaseUpdate update = new() ReleaseUpdate update = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = "API change", Title = "API change",
Summary = "A workflow API changed.", Summary = "A workflow API changed.",
Category = ReleaseUpdateCategory.BreakingChange, TitleFr = "Changement API",
Importance = ReleaseUpdateImportance.Important, SummaryFr = "Une API du flux de travail a change.",
Audience = ReleaseUpdateAudience.Developers,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
@@ -73,18 +25,22 @@ public class ReleaseUpdateRulesTests
ReleaseUpdateDto dto = update.ToDto(isRead: true); 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); Assert.True(dto.IsRead);
} }
[Fact] [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 } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false)) .VisibleToUsers()
.ToList(); .ToList();
Assert.Same(update, Assert.Single(visibleUpdates)); Assert.Same(update, Assert.Single(visibleUpdates));
@@ -93,37 +49,17 @@ public class ReleaseUpdateRulesTests
[Fact] [Fact]
public void VisibleTo_rejects_unpublished_updates() public void VisibleTo_rejects_unpublished_updates()
{ {
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone); ReleaseUpdate update = NewPublishedUpdate();
update.Status = ReleaseUpdateStatus.Draft; update.Status = ReleaseUpdateStatus.Draft;
List<ReleaseUpdate> visibleUpdates = new[] { update } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true)) .VisibleToUsers()
.ToList(); .ToList();
Assert.Empty(visibleUpdates); 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] [Fact]
public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates() 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)); Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
} }
private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience) private static ReleaseUpdate NewPublishedUpdate()
{ {
return new ReleaseUpdate return new ReleaseUpdate
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = "Update", Title = "Update",
Summary = "Something changed.", Summary = "Something changed.",
Category = ReleaseUpdateCategory.Improvement, TitleFr = "Mise a jour",
Importance = ReleaseUpdateImportance.Normal, SummaryFr = "Quelque chose a change.",
Audience = audience,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = 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: - Developer-only release communication back office:
- `/app/developer/updates` - `/app/developer/updates`
- `/app/developer/updates/:id` - `/app/developer/updates/:id`
- `/app/developer/release-commits` - `/app/developer/release-notes`
Feature-owned frontend code belongs under: Feature-owned frontend code belongs under:
@@ -235,9 +235,8 @@ GET /api/developer/release-updates/{id}
PUT /api/developer/release-updates/{id} PUT /api/developer/release-updates/{id}
POST /api/developer/release-updates/{id}/publish POST /api/developer/release-updates/{id}/publish
POST /api/developer/release-updates/{id}/archive POST /api/developer/release-updates/{id}/archive
POST /api/developer/release-updates/{id}/send-email
GET /api/developer/release-commits 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}/link
POST /api/developer/release-commits/{sha}/unlink POST /api/developer/release-commits/{sha}/unlink
POST /api/developer/release-commits/{sha}/internal-only 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 internal-only
- mark a commit ignored - mark a commit ignored
- Add developer-only frontend screens: - Add developer-only frontend screens:
- `/app/developer/release-commits` - `/app/developer/release-notes`
- linked commits on `/app/developer/updates/:id` - linked commits on `/app/developer/updates/:id`
- Add repository-backed import from configured HTTPS repository settings. - 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. - 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"> <div class="shell-sidebar-wrap">
<app-sidebar :is-expanded="isSidebarExpanded" /> <app-sidebar :is-expanded="isSidebarExpanded" />
<button <v-btn
class="sidebar-boundary-toggle" class="sidebar-boundary-toggle"
type="button" variant="text"
:ripple="false"
@click="isSidebarExpanded = !isSidebarExpanded" @click="isSidebarExpanded = !isSidebarExpanded"
> >
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" /> <v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
</button> </v-btn>
</div> </div>
</template> </template>
@@ -69,8 +70,8 @@
background: background:
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%), 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%), radial-gradient(circle at top right, rgba(52, 211, 153, 0.16), transparent 24%),
linear-gradient(180deg, #fffaf2 0%, #f6efe2 100%); linear-gradient(180deg, var(--app-color-on-primary) 0%, #f6efe2 100%);
color: #172033; color: var(--app-color-on-surface);
} }
.shell-main { .shell-main {
@@ -86,16 +87,17 @@
} }
.sidebar-boundary-toggle { .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); background: rgba(255, 250, 242, 0.98);
border-color: rgba(23, 32, 51, 0.12); border-color: var(--app-border-subtle);
color: #44516a; 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 { .sidebar-boundary-toggle:hover {
background: #172033; background: var(--app-color-on-surface);
color: #fffaf2; color: var(--app-color-on-primary);
} }
.sidebar-boundary-toggle :deep(.v-icon) { .sidebar-boundary-toggle :deep(.v-icon) {

View File

@@ -132,6 +132,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/api/developer/release-updates/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -164,22 +180,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/developer/release-commits": {
parameters: { parameters: {
query?: never; query?: never;
@@ -260,7 +260,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/developer/release-updates/{id}/send-email": { "/api/developer/release-commits/refresh": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -269,7 +269,7 @@ export interface paths {
}; };
get?: never; get?: never;
put?: never; put?: never;
post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"]; post: operations["SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -292,6 +292,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/developer/release-commits/{sha}/unlink": {
parameters: { parameters: {
query?: never; query?: never;
@@ -580,6 +596,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/users/confirm-email-change": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1471,15 +1503,12 @@ export interface components {
/** Format: guid */ /** Format: guid */
id?: string; id?: string;
title?: string; title?: string;
summary?: string; description?: string;
body?: string | null; titleEn?: string;
category?: string; descriptionEn?: string;
importance?: string; titleFr?: string;
audience?: string; descriptionFr?: string;
status?: string; status?: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
/** Format: date-time */ /** Format: date-time */
createdAt?: string; createdAt?: string;
/** Format: date-time */ /** Format: date-time */
@@ -1488,25 +1517,17 @@ export interface components {
publishedAt?: string | null; publishedAt?: string | null;
/** Format: date-time */ /** Format: date-time */
archivedAt?: string | null; archivedAt?: string | null;
/** Format: guid */
manualEmailSentByUserId?: string | null;
/** Format: date-time */
manualEmailSentAt?: string | null;
manualEmailAudience?: string | null;
/** Format: int32 */
manualEmailRecipientCount?: number | null;
isRead?: boolean; isRead?: boolean;
}; };
SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: { SocializeApiModulesReleaseCommunicationsHandlersCreateDeveloperReleaseUpdateRequest: {
title: string; titleEn: string;
summary: string; descriptionEn: string;
body?: string | null; titleFr: string;
category: string; descriptionFr: string;
importance: string; };
audience: string; SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto: {
deploymentLabel?: string | null; /** Format: int32 */
buildVersion?: string | null; sentCount?: number;
commitRange?: string | null;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: { SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */ /** Format: int32 */
@@ -1515,15 +1536,6 @@ export interface components {
importantUnreadCount?: number; importantUnreadCount?: number;
updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][]; updates?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDto"][];
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitImportResultDto: {
/** Format: int32 */
importedCount?: number;
/** Format: int32 */
updatedCount?: number;
/** Format: int32 */
skippedCount?: number;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
};
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: { SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto: {
sha?: string; sha?: string;
shortSha?: string; shortSha?: string;
@@ -1545,52 +1557,32 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt?: string; updatedAt?: string;
}; };
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: { SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto: {
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: {
/** Format: int32 */ /** Format: int32 */
recipientCount?: number; createdCount?: number;
/** Format: date-time */ /** Format: int32 */
sentAt?: string; updatedCount?: number;
testMode?: boolean; /** Format: int32 */
}; skippedCount?: number;
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: { commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][];
testMode?: boolean;
confirmResend?: boolean;
}; };
SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: { SocializeApiModulesReleaseCommunicationsHandlersLinkDeveloperReleaseCommitRequest: {
/** Format: guid */ /** Format: guid */
releaseUpdateId?: string; releaseUpdateId?: string;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitBulkLinkResultDto: {
/** Format: int32 */
linkedCount?: number;
};
SocializeApiModulesReleaseCommunicationsHandlersLinkFirstReleaseCommitsRequest: {
/** Format: guid */
releaseUpdateId?: string;
};
SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: { SocializeApiModulesReleaseCommunicationsHandlersUpdateDeveloperReleaseUpdateRequest: {
title: string; titleEn: string;
summary: string; descriptionEn: string;
body?: string | null; titleFr: string;
category: string; descriptionFr: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
}; };
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: { SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
/** Format: guid */ /** Format: guid */
@@ -1727,6 +1719,9 @@ export interface components {
/** Format: binary */ /** Format: binary */
file: string; file: string;
}; };
SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest: {
preferredLanguage?: string;
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: { SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
message?: string; message?: string;
}; };
@@ -1752,6 +1747,7 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
birthDate?: string | null; birthDate?: string | null;
address?: string | null; address?: string | null;
preferredLanguage?: string;
}; };
SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & { SystemIOStream: components["schemas"]["SystemMarshalByRefObject"] & {
canTimeout?: boolean; 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: { SocializeApiModulesReleaseCommunicationsHandlersGetDeveloperReleaseUpdateHandler: {
parameters: { parameters: {
query?: never; 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: { SocializeApiModulesReleaseCommunicationsHandlersListDeveloperReleaseCommitsHandler: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3058,20 +3050,14 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: { SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path: { path?: never;
id: string;
};
cookie?: never; cookie?: never;
}; };
requestBody: { requestBody?: never;
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
};
};
responses: { responses: {
/** @description Success */ /** @description Success */
200: { 200: {
@@ -3079,7 +3065,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"]; "application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto"];
}; };
}; };
/** @description Unauthorized */ /** @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: { SocializeApiModulesReleaseCommunicationsHandlersUnlinkDeveloperReleaseCommitHandler: {
parameters: { parameters: {
query?: never; 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: { SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: {
parameters: { parameters: {
query: { query: {

View File

@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
@theme inline { @theme inline {
@@ -23,139 +24,110 @@ body,
background: #f4f6f3; 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, input::placeholder,
textarea::placeholder { textarea::placeholder {
color: #68778a; 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 { @layer components {
.btn { .app-sidebar .sidebar-control {
@apply min-w-24 w-full; @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 p-4; background: transparent;
@apply flex flex-nowrap gap-4 items-center justify-center; color: #44516a;
@apply rounded-lg;
@apply capitalize text-base font-sans font-medium;
@apply px-10;
@apply cursor-pointer;
} }
button.primary { .app-sidebar .sidebar-control:hover {
@apply min-w-24 w-full; background: var(--app-control-hover);
@apply p-4; color: var(--app-color-on-surface);
@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;
} }
button.secondary { .app-sidebar .sidebar-control-active {
@apply min-w-24 w-full; background: linear-gradient(135deg, rgba(255, 138, 61, 0.14), rgba(239, 68, 68, 0.1));
@apply p-4; box-shadow: inset 0 0 0 1px rgba(255, 138, 61, 0.2);
@apply flex flex-nowrap gap-4 items-center justify-center; color: var(--app-color-on-surface);
@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;
} }
div.dialog { .app-sidebar .sidebar-icon-button {
@apply max-h-[90vh]; @apply flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[1rem] transition-colors no-underline;
@apply place-self-center; background: transparent;
color: var(--app-text-muted);
} }
div.card { .app-sidebar .sidebar-icon-button:hover {
@apply w-full max-w-[1024px]; background: var(--app-control-hover);
@apply rounded-xl p-4; color: var(--app-color-on-surface);
@apply flex flex-col gap-4;
@apply bg-hSurface text-hOnSurface;
} }
/* Specific styling for dialog cards */ .app-sidebar .sidebar-menu-surface {
div.card.dialog { @apply z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
@apply bg-hSurface text-hOnSurface; background: var(--app-surface-raised);
@apply rounded-xl; border-color: var(--app-border-subtle);
@apply shadow-lg; box-shadow: var(--app-shadow-popover);
} }
div.card-title { .app-sidebar .sidebar-menu-option {
@apply font-sans font-bold text-2xl; @apply flex items-center gap-3 rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
@apply p-2; color: var(--app-color-on-surface);
@apply text-hOnSurface;
} }
div.card-content { .app-sidebar .sidebar-menu-option:hover {
@apply flex flex-col gap-4; background: var(--app-control-hover);
@apply p-2;
@apply text-hOnSurface;
@apply overflow-y-auto max-h-[60vh];
} }
div.card-actions { .app-sidebar .sidebar-menu-option .v-icon {
@apply p-2; @apply text-base;
@apply flex flex-row gap-4 justify-end; color: var(--app-text-muted);
} }
div.card-actions > * { .app-sidebar .sidebar-menu-option-danger {
@apply w-fit; color: var(--app-danger-muted);
@apply sm:min-w-40 min-w-0; }
.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 { .avatar {
@apply inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-black uppercase; @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%); 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 { .avatar img {

View File

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

View File

@@ -18,16 +18,16 @@
:callback="googleCallback" :callback="googleCallback"
popup-type="TOKEN" popup-type="TOKEN"
> >
<button class="secondary"> <v-btn variant="text" :ripple="false" class="secondary">
<v-icon <v-icon
:icon="mdiGoogle" :icon="mdiGoogle"
class="mr-2" class="mr-2"
/> />
{{ t('continueWithGoogle') }} {{ t('continueWithGoogle') }}
</button> </v-btn>
</google-login> </google-login>
<button <v-btn variant="text" :ripple="false"
class="secondary" class="secondary"
type="button" type="button"
@click="handleFacebookLogin" @click="handleFacebookLogin"
@@ -37,7 +37,7 @@
class="mr-2" class="mr-2"
/> />
{{ t('continueWithFacebook') }} {{ t('continueWithFacebook') }}
</button> </v-btn>
</div> </div>
<div class="my-4 flex items-center"> <div class="my-4 flex items-center">
@@ -225,13 +225,13 @@
.login-card { .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; @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; box-shadow: none;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.login-card { .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 { .content-card {
@apply rounded-[1.5rem] border; @apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08); border-color: var(--app-border-subtle);
} }
.hero { .hero {
@@ -146,7 +146,7 @@
.breadcrumb-row { .breadcrumb-row {
@apply flex items-center gap-2 text-sm; @apply flex items-center gap-2 text-sm;
color: #0f766e; color: var(--app-color-on-tertiary);
} }
.breadcrumb, .breadcrumb,
@@ -157,18 +157,18 @@
.status-row small, .status-row small,
.status-row em { .status-row em {
@apply text-sm leading-6 not-italic; @apply text-sm leading-6 not-italic;
color: #526178; color: var(--app-text-muted);
} }
.breadcrumb { .breadcrumb {
@apply font-bold uppercase tracking-[0.16em]; @apply font-bold uppercase tracking-[0.16em];
color: #0f766e; color: var(--app-color-on-tertiary);
} }
.hero h1, .hero h1,
.section-header strong, .section-header strong,
.content-card strong { .content-card strong {
color: #172033; color: var(--app-color-on-surface);
} }
.hero h1 { .hero h1 {
@@ -182,8 +182,8 @@
.meta-chip, .meta-chip,
.version-chip { .version-chip {
@apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em]; @apply rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
background: rgba(23, 32, 51, 0.08); background: var(--app-border-subtle);
color: #172033; color: var(--app-color-on-surface);
} }
.section-header { .section-header {
@@ -196,8 +196,8 @@
.scope-button { .scope-button {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition; @apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold no-underline transition;
background: #172033; background: var(--app-color-on-surface);
color: #fffaf2; color: var(--app-color-on-primary);
} }
.scope-button:hover { .scope-button:hover {
@@ -223,11 +223,11 @@
.page-message { .page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium; @apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84); background: rgba(255, 255, 255, 0.84);
border-color: rgba(23, 32, 51, 0.08); border-color: var(--app-border-subtle);
color: #526178; color: var(--app-text-muted);
} }
.page-message.error { .page-message.error {
color: #b91c1c; color: var(--app-danger-muted);
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -253,7 +253,7 @@
<div class="eyebrow">{{ t('organizationSettings.eyebrow') }}</div> <div class="eyebrow">{{ t('organizationSettings.eyebrow') }}</div>
<p>{{ t('organizationSettings.description') }}</p> <p>{{ t('organizationSettings.description') }}</p>
<div class="organization-title-line"> <div class="organization-title-line">
<button <v-btn variant="text" :ripple="false"
v-if="organization" v-if="organization"
class="organization-logo-button" class="organization-logo-button"
type="button" type="button"
@@ -267,7 +267,7 @@
:src="organization.logoUrl" :src="organization.logoUrl"
size="lg" size="lg"
/> />
</button> </v-btn>
<v-form <v-form
v-if="organization && isEditingName" v-if="organization && isEditingName"
class="title-edit-form" class="title-edit-form"
@@ -282,7 +282,7 @@
maxlength="256" maxlength="256"
variant="outlined" variant="outlined"
/> />
<button <v-btn variant="text" :ripple="false"
class="icon-action" class="icon-action"
type="submit" type="submit"
:disabled="organizationStore.isSaving" :disabled="organizationStore.isSaving"
@@ -290,8 +290,8 @@
:title="t('organizationSettings.saveName')" :title="t('organizationSettings.saveName')"
> >
<v-icon :icon="mdiCheck" /> <v-icon :icon="mdiCheck" />
</button> </v-btn>
<button <v-btn variant="text" :ripple="false"
class="icon-action secondary" class="icon-action secondary"
type="button" type="button"
:disabled="organizationStore.isSaving" :disabled="organizationStore.isSaving"
@@ -300,14 +300,14 @@
@click="cancelEditingName" @click="cancelEditingName"
> >
<v-icon :icon="mdiClose" /> <v-icon :icon="mdiClose" />
</button> </v-btn>
</v-form> </v-form>
<div <div
v-else v-else
class="title-row" class="title-row"
> >
<h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1> <h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1>
<button <v-btn variant="text" :ripple="false"
v-if="organization && canManageSettings" v-if="organization && canManageSettings"
class="icon-action secondary" class="icon-action secondary"
type="button" type="button"
@@ -316,7 +316,7 @@
@click="startEditingName" @click="startEditingName"
> >
<v-icon :icon="mdiPencilOutline" /> <v-icon :icon="mdiPencilOutline" />
</button> </v-btn>
</div> </div>
</div> </div>
<div class="hero-status"> <div class="hero-status">
@@ -358,7 +358,7 @@
class="settings-tabs" class="settings-tabs"
aria-label="Organization settings sections" aria-label="Organization settings sections"
> >
<button <v-btn variant="text" :ripple="false"
v-for="section in visibleSections" v-for="section in visibleSections"
:key="section.key" :key="section.key"
class="settings-tab" class="settings-tab"
@@ -368,7 +368,7 @@
> >
<v-icon :icon="section.icon" /> <v-icon :icon="section.icon" />
<span>{{ t(`organizationSettings.sections.${section.key}.title`) }}</span> <span>{{ t(`organizationSettings.sections.${section.key}.title`) }}</span>
</button> </v-btn>
</nav> </nav>
<div <div
@@ -419,13 +419,13 @@
hide-details hide-details
/> />
<div class="form-actions"> <div class="form-actions">
<button <v-btn variant="text" :ripple="false"
class="primary-action" class="primary-action"
type="submit" type="submit"
:disabled="organizationStore.isAddingMember" :disabled="organizationStore.isAddingMember"
> >
{{ organizationStore.isAddingMember ? t('organizationSettings.addingMember') : t('organizationSettings.addMember') }} {{ organizationStore.isAddingMember ? t('organizationSettings.addingMember') : t('organizationSettings.addMember') }}
</button> </v-btn>
</div> </div>
</v-form> </v-form>
<div <div
@@ -561,7 +561,7 @@
.settings-hero h1, .settings-hero h1,
.title-edit-form input { .title-edit-form input {
@apply min-w-0 text-3xl font-black md:text-4xl; @apply min-w-0 text-3xl font-black md:text-4xl;
color: #172033; color: var(--app-color-on-surface);
} }
.settings-hero p, .settings-hero p,
@@ -570,7 +570,7 @@
.placeholder-panel span, .placeholder-panel span,
.empty-state { .empty-state {
@apply text-sm leading-6; @apply text-sm leading-6;
color: #526178; color: var(--app-text-muted);
} }
.organization-title-line { .organization-title-line {
@@ -579,11 +579,11 @@
.organization-logo-button { .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; @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) { .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); box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
} }
@@ -601,24 +601,24 @@
} }
.title-edit-form input:focus { .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); box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
} }
.icon-action { .icon-action {
@apply inline-flex size-9 flex-shrink-0 items-center justify-center rounded-[0.5rem] transition-colors; @apply inline-flex size-9 flex-shrink-0 items-center justify-center rounded-[0.5rem] transition-colors;
background: #172033; background: var(--app-color-on-surface);
color: #fffaf2; color: var(--app-color-on-primary);
} }
.icon-action.secondary { .icon-action.secondary {
background: rgba(23, 32, 51, 0.06); background: var(--app-control-hover);
color: #172033; color: var(--app-color-on-surface);
} }
.icon-action:hover:not(:disabled) { .icon-action:hover:not(:disabled) {
background: #0f766e; background: var(--app-color-on-tertiary);
color: #fffaf2; color: var(--app-color-on-primary);
} }
.icon-action:disabled { .icon-action:disabled {
@@ -635,22 +635,22 @@
.settings-tabs { .settings-tabs {
@apply flex flex-wrap gap-2 border-b pb-3; @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 { .settings-tab {
@apply inline-flex h-10 items-center gap-2 rounded-[0.75rem] px-3 text-sm font-semibold transition-colors; @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 { .settings-tab:hover {
background: rgba(23, 32, 51, 0.06); background: var(--app-control-hover);
color: #172033; color: var(--app-color-on-surface);
} }
.settings-tab-active { .settings-tab-active {
background: #172033; background: var(--app-color-on-surface);
color: #fffaf2; color: var(--app-color-on-primary);
} }
.settings-tab :deep(.v-icon) { .settings-tab :deep(.v-icon) {
@@ -667,13 +667,13 @@
.section-heading h2 { .section-heading h2 {
@apply text-2xl font-black; @apply text-2xl font-black;
color: #172033; color: var(--app-color-on-surface);
} }
.content-card { .content-card {
@apply flex flex-col gap-4 rounded-[0.75rem] border p-5; @apply flex flex-col gap-4 rounded-[0.75rem] border p-5;
background: rgba(255, 255, 255, 0.94); background: var(--app-surface-glass);
border-color: rgba(23, 32, 51, 0.08); border-color: var(--app-border-subtle);
} }
.table-list { .table-list {
@@ -682,7 +682,7 @@
.table-row { .table-row {
@apply flex items-center justify-between gap-4 rounded-[0.75rem] px-4 py-3; @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 { .table-row {
@@ -694,7 +694,7 @@
} }
.table-row-button:hover { .table-row-button:hover {
background: rgba(23, 32, 51, 0.08); background: var(--app-border-subtle);
} }
.table-row div { .table-row div {
@@ -704,7 +704,7 @@
.table-row strong, .table-row strong,
.placeholder-panel strong { .placeholder-panel strong {
@apply font-semibold; @apply font-semibold;
color: #172033; color: var(--app-color-on-surface);
} }
.table-row small { .table-row small {
@@ -715,18 +715,18 @@
.placeholder-panel, .placeholder-panel,
.empty-state { .empty-state {
@apply rounded-[0.75rem] px-4 py-4; @apply rounded-[0.75rem] px-4 py-4;
background: rgba(23, 32, 51, 0.04); background: var(--app-control-subtle);
} }
.settings-form { .settings-form {
@apply flex flex-col gap-4 rounded-[0.75rem] p-4; @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-plan span,
.usage-row-heading span { .usage-row-heading span {
@apply text-sm; @apply text-sm;
color: #526178; color: var(--app-text-muted);
} }
.field-error { .field-error {
@@ -734,7 +734,7 @@
} }
.field-success { .field-success {
color: #0f766e !important; color: var(--app-color-on-tertiary) !important;
} }
.invite-form { .invite-form {
@@ -743,7 +743,7 @@
.settings-form label { .settings-form label {
@apply flex min-w-0 flex-col gap-2 text-sm font-semibold; @apply flex min-w-0 flex-col gap-2 text-sm font-semibold;
color: #172033; color: var(--app-color-on-surface);
} }
.settings-form input, .settings-form input,
@@ -751,12 +751,12 @@
@apply h-11 w-full rounded-[0.5rem] border px-3 text-sm outline-none transition-colors; @apply h-11 w-full rounded-[0.5rem] border px-3 text-sm outline-none transition-colors;
background: #ffffff; background: #ffffff;
border-color: rgba(23, 32, 51, 0.14); border-color: rgba(23, 32, 51, 0.14);
color: #172033; color: var(--app-color-on-surface);
} }
.settings-form input:focus, .settings-form input:focus,
.settings-form select: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); box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
} }
@@ -766,12 +766,12 @@
.primary-action { .primary-action {
@apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors; @apply inline-flex h-11 items-center justify-center rounded-[0.5rem] px-4 text-sm font-bold transition-colors;
background: #172033; background: var(--app-color-on-surface);
color: #fffaf2; color: var(--app-color-on-primary);
} }
.primary-action:hover:not(:disabled) { .primary-action:hover:not(:disabled) {
background: #0f766e; background: var(--app-color-on-tertiary);
} }
.primary-action:disabled { .primary-action:disabled {
@@ -789,7 +789,7 @@
.settings-alert.success { .settings-alert.success {
background: rgba(15, 118, 110, 0.12); background: rgba(15, 118, 110, 0.12);
color: #0f766e; color: var(--app-color-on-tertiary);
} }
.placeholder-panel { .placeholder-panel {
@@ -802,13 +802,13 @@
.tier-form { .tier-form {
@apply grid gap-3 rounded-[0.75rem] p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end; @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-plan,
.usage-row { .usage-row {
@apply rounded-[0.75rem] p-4; @apply rounded-[0.75rem] p-4;
background: rgba(23, 32, 51, 0.04); background: var(--app-control-subtle);
} }
.usage-plan { .usage-plan {
@@ -825,12 +825,12 @@
.usage-meter { .usage-meter {
@apply h-2 overflow-hidden rounded-full; @apply h-2 overflow-hidden rounded-full;
background: rgba(23, 32, 51, 0.1); background: var(--app-control-active);
} }
.usage-meter span { .usage-meter span {
@apply block h-full rounded-full; @apply block h-full rounded-full;
background: #0f766e; background: var(--app-color-on-tertiary);
} }
</style> </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'; import { useClient } from '@/plugins/api.js';
const DEFAULT_COMMIT_FILTERS = Object.freeze({ const DEFAULT_COMMIT_FILTERS = Object.freeze({
status: '', inclusion: 'notIncluded',
updateId: '',
author: '',
search: '',
}); });
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', () => { export const useReleaseCommunicationsStore = defineStore('release-communications', () => {
const client = useClient(); const client = useClient();
const updates = ref([]); const updates = ref([]);
@@ -21,11 +13,12 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
const developerUpdates = ref([]); const developerUpdates = ref([]);
const selectedUpdate = ref(null); const selectedUpdate = ref(null);
const commits = ref([]); const commits = ref([]);
const selectedCommitShas = ref([]);
const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS }); const commitFilters = ref({ ...DEFAULT_COMMIT_FILTERS });
const isLoading = ref(false); const isLoading = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const isSendingEmail = ref(false); const isRefreshingCommits = ref(false);
const isImporting = ref(false); const isForcingDigestEmails = ref(false);
const error = ref(null); const error = ref(null);
const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0); const unreadCount = computed(() => unreadSummary.value?.unreadCount ?? 0);
@@ -35,40 +28,15 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
); );
const filteredCommits = computed(() => { const filteredCommits = computed(() => {
const query = commitFilters.value.search.trim().toLowerCase();
const author = commitFilters.value.author.trim().toLowerCase();
return commits.value.filter(commit => { return commits.value.filter(commit => {
if (commitFilters.value.status && commit.communicationStatus !== commitFilters.value.status) { if (commitFilters.value.inclusion === 'included' && !commit.releaseUpdateId) {
return false; return false;
} }
if (commitFilters.value.updateId && commit.releaseUpdateId !== commitFilters.value.updateId) { if (commitFilters.value.inclusion === 'notIncluded' && commit.releaseUpdateId) {
return false; 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; return true;
}); });
}); });
@@ -159,28 +127,29 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
return response.data; 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() { async function loadCommits() {
const response = await client.get('/api/developer/release-commits'); const response = await client.get('/api/developer/release-commits');
commits.value = response.data ?? []; commits.value = response.data ?? [];
} }
async function importCommits(payload) { async function refreshCommits() {
isImporting.value = true; isRefreshingCommits.value = true;
try { try {
const response = await client.post('/api/developer/release-commits/import', payload); const response = await client.post('/api/developer/release-commits/refresh');
await loadCommits(); await loadCommits();
return response.data; return response.data;
} finally { } 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()]); 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) { async function unlinkCommit(sha) {
await client.post(`/api/developer/release-commits/${sha}/unlink`); await client.post(`/api/developer/release-commits/${sha}/unlink`);
await loadCommits(); await loadCommits();
@@ -213,12 +188,23 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
commitFilters.value = { ...DEFAULT_COMMIT_FILTERS }; 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 { return {
updates, updates,
unreadSummary, unreadSummary,
developerUpdates, developerUpdates,
selectedUpdate, selectedUpdate,
commits, commits,
selectedCommitShas,
commitFilters, commitFilters,
filteredCommits, filteredCommits,
unreadCount, unreadCount,
@@ -226,8 +212,8 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
unreviewedCommitCount, unreviewedCommitCount,
isLoading, isLoading,
isSaving, isSaving,
isSendingEmail, isRefreshingCommits,
isImporting, isForcingDigestEmails,
error, error,
loadUserUpdates, loadUserUpdates,
loadUnreadSummary, loadUnreadSummary,
@@ -238,14 +224,17 @@ export const useReleaseCommunicationsStore = defineStore('release-communications
saveDeveloperUpdate, saveDeveloperUpdate,
publishUpdate, publishUpdate,
archiveUpdate, archiveUpdate,
sendUpdateEmail,
loadCommits, loadCommits,
importCommits, refreshCommits,
forceDigestEmails,
linkCommit, linkCommit,
linkCommitsToUpdate, linkCommitsToUpdate,
linkFirstReleaseCommits,
unlinkCommit, unlinkCommit,
markCommitInternalOnly, markCommitInternalOnly,
ignoreCommit, ignoreCommit,
resetCommitFilters, 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 { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { formatReleaseDescription } from '@/features/release-communications/formatReleaseDescription.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js'; import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
const { t } = useI18n(); const { locale, t } = useI18n();
const route = useRoute(); const route = useRoute();
const store = useReleaseCommunicationsStore(); const store = useReleaseCommunicationsStore();
@@ -12,31 +13,46 @@
onMounted(async () => { onMounted(async () => {
await store.loadUserUpdates(); await store.loadUserUpdates();
if (highlightedId.value) { if (store.updates.some(update => !update.isRead)) {
await store.markRead(highlightedId.value); await store.markAllRead();
} }
}); });
function formatDate(value) { 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> </script>
<template> <template>
<section class="updates-page"> <section class="updates-page">
<header class="updates-header"> <header class="page-header">
<div> <div>
<div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div> <div class="eyebrow">{{ t('releaseCommunications.user.eyebrow') }}</div>
<h1>{{ t('releaseCommunications.user.title') }}</h1> <h1>{{ t('releaseCommunications.user.title') }}</h1>
<p>{{ t('releaseCommunications.user.description') }}</p> <p>{{ t('releaseCommunications.user.description') }}</p>
</div> </div>
<v-btn
variant="outlined"
:disabled="!store.unreadCount"
@click="store.markAllRead"
>
{{ t('releaseCommunications.user.markAllRead') }}
</v-btn>
</header> </header>
<div <div
@@ -55,21 +71,25 @@
:key="update.id" :key="update.id"
class="update-entry" class="update-entry"
:class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }" :class="{ 'update-entry-unread': !update.isRead, 'update-entry-highlight': update.id === highlightedId }"
@click="!update.isRead && store.markRead(update.id)"
> >
<div class="update-meta"> <h2>{{ updateTitle(update) }}</h2>
<span>{{ update.category }}</span> <div class="release-description">
<span>{{ update.importance }}</span> <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> <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> </article>
<div <div
@@ -83,81 +103,83 @@
</template> </template>
<style scoped> <style scoped>
@reference "@/assets/main.css";
.updates-page { .updates-page {
display: grid; @apply mx-auto flex w-full max-w-6xl flex-col gap-5 px-5 py-8 md:px-8;
gap: 20px;
padding: 24px;
} }
.updates-header { .page-header {
display: flex; @apply flex flex-col justify-between gap-4 md:flex-row md:items-start;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
} }
.eyebrow, .eyebrow {
.update-meta { @apply text-xs font-bold uppercase tracking-[0.22em];
color: rgb(var(--v-theme-primary)); color: var(--app-color-on-tertiary);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
} }
.updates-header h1 { .page-header h1 {
margin: 4px 0; @apply mt-2 text-3xl font-black md:text-4xl;
font-size: 1.75rem; color: var(--app-color-on-surface);
} }
.updates-header p { .page-header p {
margin: 0; @apply mt-2 max-w-3xl text-sm leading-6;
color: #64748b; color: var(--app-text-muted);
} }
.updates-list { .updates-list {
display: grid; display: grid;
gap: 12px;
} }
.update-entry { .update-entry {
border: 1px solid #d8dee8; display: grid;
border-radius: 8px; gap: 8px;
background: #fff; border-bottom: 1px solid #d8dee8;
padding: 16px; padding: 18px 0;
} }
.update-entry-unread { .update-entry:first-child {
border-color: rgb(var(--v-theme-primary)); padding-top: 0;
box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary)); }
cursor: pointer;
.update-entry:last-of-type {
border-bottom: 0;
} }
.update-entry-highlight { .update-entry-highlight {
outline: 2px solid rgb(var(--v-theme-primary)); box-shadow: inset 3px 0 0 rgb(var(--v-theme-primary));
} padding-left: 12px;
.update-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
color: #64748b;
} }
.update-entry h2 { .update-entry h2 {
margin: 0 0 6px;
font-size: 1.1rem;
}
.update-entry p {
margin: 0; margin: 0;
color: #334155; font-size: 1.1rem;
font-weight: 800;
color: var(--app-color-on-surface);
} }
.update-body { .release-description {
margin-top: 12px; display: grid;
color: #475569; gap: 8px;
white-space: pre-line; 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 { .page-message {

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,14 @@ import {defineStore} from 'pinia'
import {useAuthStore} from "@/features/auth/stores/authStore.js"; import {useAuthStore} from "@/features/auth/stores/authStore.js";
import {useClient} from "@/plugins/api.js"; import {useClient} from "@/plugins/api.js";
import {useSessionStorage} from "@vueuse/core"; import {useSessionStorage} from "@vueuse/core";
import {useLanguageStore} from "@/stores/languageStore.js";
export const useUserProfileStore = defineStore( export const useUserProfileStore = defineStore(
'user-profile', 'user-profile',
() => { () => {
const authStore = useAuthStore() const authStore = useAuthStore()
const languageStore = useLanguageStore()
const isUpdating = ref(false) const isUpdating = ref(false)
const isUploadingPortrait = ref(false) const isUploadingPortrait = ref(false)
const isLoadingCalendarFeed = ref(false) const isLoadingCalendarFeed = ref(false)
@@ -72,6 +74,7 @@ export const useUserProfileStore = defineStore(
const client = useClient() const client = useClient()
const userResponse = await client.get("/api/users/profile"); const userResponse = await client.get("/api/users/profile");
value.value = userResponse.data value.value = userResponse.data
languageStore.setLocale(userResponse.data?.preferredLanguage ?? 'en')
} catch (fetchError) { } catch (fetchError) {
console.error(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) { async function changeAddress(address) {
try { try {
const client = useClient() const client = useClient()
@@ -278,6 +303,7 @@ export const useUserProfileStore = defineStore(
changeBirthday, changeBirthday,
changePhone, changePhone,
changeEmail, changeEmail,
changePreferredLanguage,
changeAddress, changeAddress,
changePortrait, changePortrait,
fetchCalendarExportFeed, fetchCalendarExportFeed,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,20 +3,26 @@
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useReleaseCommunicationsStore } from '@/features/release-communications/stores/releaseCommunicationsStore.js';
import WorkspaceSelector from './WorkspaceSelector.vue'; import WorkspaceSelector from './WorkspaceSelector.vue';
import { import {
mdiCalendar, mdiCalendar,
mdiChevronDown, mdiChevronDown,
mdiCogOutline, mdiCogOutline,
mdiEmailOutline,
mdiEyeOffOutline,
mdiFlagVariantOutline,
mdiFormatListBulleted, mdiFormatListBulleted,
mdiLogin, mdiLogin,
mdiPlus, mdiPlus,
mdiRefresh,
mdiTable, mdiTable,
} from '@mdi/js'; } from '@mdi/js';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const authStore = useAuthStore(); const authStore = useAuthStore();
const releaseCommunicationsStore = useReleaseCommunicationsStore();
const isContentViewMenuOpen = ref(false); const isContentViewMenuOpen = ref(false);
const contentViewActions = computed(() => { const contentViewActions = computed(() => {
@@ -74,6 +80,17 @@
contentViewActions.value.find(action => action.active) ?? contentViewActions.value[0] 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(() => { const appBarActions = computed(() => {
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated) {
return []; return [];
@@ -104,6 +121,74 @@
icon: mdiPlus, icon: mdiPlus,
route: { name: 'channels', query: { create: 'true' } }, 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 'workspace-settings':
case 'settings-user-information': case 'settings-user-information':
case 'settings-workspaces': case 'settings-workspaces':
@@ -131,21 +216,25 @@
<div class="side-menu-items side-menu-right"> <div class="side-menu-items side-menu-right">
<template v-if="!authStore.isAuthenticated"> <template v-if="!authStore.isAuthenticated">
<router-link to="/login"> <v-btn
<button class="menu-item-action"> to="/login"
class="menu-item-action"
variant="text"
:ripple="false"
>
<v-icon :icon="mdiLogin" /> <v-icon :icon="mdiLogin" />
<span class="label">{{ t('nav.signIn') }}</span> <span class="label">{{ t('nav.signIn') }}</span>
</button> </v-btn>
</router-link>
</template> </template>
<div <div
v-if="contentViewActions.length" v-if="contentViewActions.length"
class="view-selector" class="view-selector"
> >
<button <v-btn
class="menu-item-action view-selector-button" class="menu-item-action view-selector-button"
type="button" variant="text"
:ripple="false"
@click="isContentViewMenuOpen = !isContentViewMenuOpen" @click="isContentViewMenuOpen = !isContentViewMenuOpen"
> >
<v-icon :icon="activeContentViewAction.icon" /> <v-icon :icon="activeContentViewAction.icon" />
@@ -154,42 +243,73 @@
class="selector-chevron" class="selector-chevron"
:icon="mdiChevronDown" :icon="mdiChevronDown"
/> />
</button> </v-btn>
<div <div
v-if="isContentViewMenuOpen" v-if="isContentViewMenuOpen"
class="view-selector-menu" class="view-selector-menu"
> >
<router-link <v-btn
v-for="action in contentViewActions" v-for="action in contentViewActions"
:key="action.key" :key="action.key"
:to="action.route" :to="action.route"
class="menu-action-link" class="view-selector-option menu-action-link"
@click="isContentViewMenuOpen = false"
>
<button
class="view-selector-option"
:class="{ 'view-selector-option-active': action.active }" :class="{ 'view-selector-option-active': action.active }"
type="button" variant="text"
:ripple="false"
@click="isContentViewMenuOpen = false"
> >
<v-icon :icon="action.icon" /> <v-icon :icon="action.icon" />
<span>{{ action.label }}</span> <span>{{ action.label }}</span>
</button> </v-btn>
</router-link>
</div> </div>
</div> </div>
<router-link <template
v-for="action in appBarActions" v-for="action in appBarActions"
:key="action.key" :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" /> <v-icon :icon="action.icon" />
<span class="label">{{ action.label }}</span> <span class="label">{{ action.label }}</span>
</button> </v-btn>
</router-link> <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>
</div> </div>
</nav> </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; @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); background: rgba(255, 250, 242, 0.82);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(23, 32, 51, 0.08); border-bottom: 1px solid var(--app-border-subtle);
isolation: isolate; isolation: isolate;
} }
@@ -236,8 +356,8 @@
.view-selector-menu { .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; @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; background: var(--app-surface-raised);
border-color: rgba(23, 32, 51, 0.1); border-color: var(--app-border-subtle);
} }
.label { .label {
@@ -245,29 +365,46 @@
} }
.menu-item-action { .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); background: rgba(255, 255, 255, 0.8);
color: #172033; color: var(--app-color-on-surface);
border: 1px solid rgba(23, 32, 51, 0.06); border: 1px solid var(--app-border-muted);
letter-spacing: 0;
} }
.menu-item-action:hover { .menu-item-action:hover {
background: #172033; background: var(--app-color-primary);
color: #fffaf2; 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 { .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; @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: #172033; color: var(--app-color-on-surface);
letter-spacing: 0;
} }
.view-selector-option:hover, .view-selector-option:hover,
.view-selector-option-active { .view-selector-option-active {
background: #172033; background: var(--app-color-primary);
color: #fffaf2; 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; @apply text-xl;
} }

View File

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

View File

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

View File

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

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