Compare commits
5 Commits
feat/googl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ca6ab7117 | |||
| e81c9f42c9 | |||
| c527011646 | |||
| 0b7edb1b7f | |||
| dcfdce1ec6 |
@@ -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,
|
||||||
|
|||||||
2591
backend/src/Socialize.Api/Migrations/20260508030349_SimplifyReleaseUpdates.Designer.cs
generated
Normal file
2591
backend/src/Socialize.Api/Migrations/20260508030349_SimplifyReleaseUpdates.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2601
backend/src/Socialize.Api/Migrations/20260508031114_AddFrenchReleaseUpdateFields.Designer.cs
generated
Normal file
2601
backend/src/Socialize.Api/Migrations/20260508031114_AddFrenchReleaseUpdateFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2592
backend/src/Socialize.Api/Migrations/20260508034156_RemoveManualReleaseUpdateEmail.Designer.cs
generated
Normal file
2592
backend/src/Socialize.Api/Migrations/20260508034156_RemoveManualReleaseUpdateEmail.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2592
backend/src/Socialize.Api/Migrations/20260508034902_ExpandReleaseUpdateDescriptions.Designer.cs
generated
Normal file
2592
backend/src/Socialize.Api/Migrations/20260508034902_ExpandReleaseUpdateDescriptions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2597
backend/src/Socialize.Api/Migrations/20260508122220_AddUserPreferredLanguage.Designer.cs
generated
Normal file
2597
backend/src/Socialize.Api/Migrations/20260508122220_AddUserPreferredLanguage.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -34,23 +34,20 @@ 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> clientScopeIds = User.GetClientScopeIds();
|
||||||
|
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||||
|
|
||||||
|
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
|
||||||
|
|
||||||
|
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
}
|
||||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
|
||||||
|
|
||||||
query = query.Where(campaign => workspaceScopeIds.Contains(campaign.WorkspaceId));
|
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0)
|
||||||
|
{
|
||||||
if (clientScopeIds.Count > 0)
|
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
|
||||||
{
|
|
||||||
query = query.Where(campaign => clientScopeIds.Contains(campaign.ClientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (campaignScopeIds.Count > 0)
|
|
||||||
{
|
|
||||||
query = query.Where(campaign => campaignScopeIds.Contains(campaign.Id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.ClientId.HasValue)
|
if (request.ClientId.HasValue)
|
||||||
|
|||||||
@@ -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);
|
||||||
{
|
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
|
||||||
query = query.Where(channel => workspaceScopeIds.Contains(channel.WorkspaceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.WorkspaceId.HasValue)
|
if (request.WorkspaceId.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,18 +33,14 @@ 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> clientScopeIds = User.GetClientScopeIds();
|
||||||
|
|
||||||
|
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
||||||
|
|
||||||
|
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
query = query.Where(client => clientScopeIds.Contains(client.Id));
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
|
||||||
|
|
||||||
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
|
||||||
|
|
||||||
if (clientScopeIds.Count > 0)
|
|
||||||
{
|
|
||||||
query = query.Where(client => clientScopeIds.Contains(client.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.WorkspaceId.HasValue)
|
if (request.WorkspaceId.HasValue)
|
||||||
|
|||||||
@@ -37,23 +37,20 @@ 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> clientScopeIds = User.GetClientScopeIds();
|
||||||
|
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||||
|
|
||||||
|
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
|
||||||
|
|
||||||
|
if (!AccessScopeService.IsManager(User) && clientScopeIds.Count > 0)
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
}
|
||||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
|
||||||
|
|
||||||
query = query.Where(item => workspaceScopeIds.Contains(item.WorkspaceId));
|
if (!AccessScopeService.IsManager(User) && campaignScopeIds.Count > 0)
|
||||||
|
{
|
||||||
if (clientScopeIds.Count > 0)
|
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
|
||||||
{
|
|
||||||
query = query.Where(item => clientScopeIds.Contains(item.ClientId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (campaignScopeIds.Count > 0)
|
|
||||||
{
|
|
||||||
query = query.Where(item => campaignScopeIds.Contains(item.CampaignId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.WorkspaceId.HasValue)
|
if (request.WorkspaceId.HasValue)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
{
|
query = query.Where(notificationEvent =>
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
|
||||||
query = query.Where(notificationEvent =>
|
notificationEvent.RecipientUserId == currentUserId);
|
||||||
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
|
|
||||||
notificationEvent.RecipientUserId == currentUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
query = query.Where(notificationEvent =>
|
query = query.Where(notificationEvent =>
|
||||||
notificationEvent.RecipientUserId == null ||
|
notificationEvent.RecipientUserId == null ||
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
|
|
||||||
internal enum ReleaseUpdateAudience
|
|
||||||
{
|
|
||||||
Everyone,
|
|
||||||
OrganizationOwners,
|
|
||||||
Developers,
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
|
|
||||||
internal enum ReleaseUpdateCategory
|
|
||||||
{
|
|
||||||
Feature,
|
|
||||||
Improvement,
|
|
||||||
Fix,
|
|
||||||
BreakingChange,
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Socialize.Api.Modules.ReleaseCommunications.Data;
|
|
||||||
|
|
||||||
internal enum ReleaseUpdateImportance
|
|
||||||
{
|
|
||||||
Normal,
|
|
||||||
Important,
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
27
docs/TASKS/frontend/002-style-system-baseline.md
Normal file
27
docs/TASKS/frontend/002-style-system-baseline.md
Normal 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.
|
||||||
31
docs/TASKS/frontend/003-vuetify-button-migration.md
Normal file
31
docs/TASKS/frontend/003-vuetify-button-migration.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
340
frontend/src/api/schema.d.ts
vendored
340
frontend/src/api/schema.d.ts
vendored
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
frontend/src/assets/styles.css
Normal file
2
frontend/src/assets/styles.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "vuetify/styles";
|
||||||
|
@import "./main.css";
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
|
||||||
@@ -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
|
||||||
<time>{{ formatDate(update.publishedAt) }}</time>
|
v-for="(block, index) in updateDescriptionBlocks(update)"
|
||||||
</div>
|
:key="index"
|
||||||
<h2>{{ update.title }}</h2>
|
>
|
||||||
<p>{{ update.summary }}</p>
|
<p v-if="block.type === 'paragraph'">{{ block.text }}</p>
|
||||||
<div
|
<ul v-else>
|
||||||
v-if="update.body"
|
<li
|
||||||
class="update-body"
|
v-for="item in block.items"
|
||||||
>
|
:key="item"
|
||||||
{{ update.body }}
|
>
|
||||||
|
{{ item }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<time>{{ formatDate(update.publishedAt) }}</time>
|
||||||
</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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
<v-icon :icon="mdiLogin" />
|
class="menu-item-action"
|
||||||
<span class="label">{{ t('nav.signIn') }}</span>
|
variant="text"
|
||||||
</button>
|
:ripple="false"
|
||||||
</router-link>
|
>
|
||||||
|
<v-icon :icon="mdiLogin" />
|
||||||
|
<span class="label">{{ t('nav.signIn') }}</span>
|
||||||
|
</v-btn>
|
||||||
</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"
|
||||||
|
:class="{ 'view-selector-option-active': action.active }"
|
||||||
|
variant="text"
|
||||||
|
:ripple="false"
|
||||||
@click="isContentViewMenuOpen = false"
|
@click="isContentViewMenuOpen = false"
|
||||||
>
|
>
|
||||||
<button
|
<v-icon :icon="action.icon" />
|
||||||
class="view-selector-option"
|
<span>{{ action.label }}</span>
|
||||||
:class="{ 'view-selector-option-active': action.active }"
|
</v-btn>
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<v-icon :icon="action.icon" />
|
|
||||||
<span>{{ action.label }}</span>
|
|
||||||
</button>
|
|
||||||
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user