1 Commits

Author SHA1 Message Date
0fbb30bb4f feat: add google drive dam foundation 2026-05-08 11:36:30 -04:00
137 changed files with 4319 additions and 14454 deletions

View File

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

View File

@@ -15,6 +15,7 @@ using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data; using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services; using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Socialize.Api.Infrastructure.TestData; namespace Socialize.Api.Infrastructure.TestData;
@@ -261,6 +262,10 @@ internal static class TestDataSeedExtensions
} }
organization.Name = "Northstar Agency"; organization.Name = "Northstar Agency";
organization.IsGoogleDriveDamEnabled = true;
organization.GoogleDriveRootFolderId = "dev-socialize-dam-root";
organization.GoogleDriveRootFolderName = "Socialize DAM";
organization.GoogleDriveRootFolderUrl = "https://drive.google.com/drive/folders/dev-socialize-dam-root";
organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId; organization.MembershipTierId = OrganizationMembershipTierSeed.AgencyId;
organization.OwnerUserId = managerUserId; organization.OwnerUserId = managerUserId;
@@ -465,6 +470,7 @@ internal static class TestDataSeedExtensions
asset.DisplayName = "Spring launch cut"; asset.DisplayName = "Spring launch cut";
asset.GoogleDriveFileId = "dev-socialize-demo"; asset.GoogleDriveFileId = "dev-socialize-demo";
asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view"; asset.GoogleDriveLink = "https://drive.google.com/file/d/dev-socialize-demo/view";
asset.GoogleDriveWorkspaceFolderPath = "Socialize DAM/luma-coffee";
asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo"; asset.PreviewUrl = "https://drive.google.com/thumbnail?id=dev-socialize-demo";
asset.CurrentRevisionNumber = 2; asset.CurrentRevisionNumber = 2;
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
@@ -587,6 +593,7 @@ internal static class TestDataSeedExtensions
{ {
Id = id, Id = id,
Name = string.Empty, Name = string.Empty,
Slug = string.Empty,
TimeZone = string.Empty, TimeZone = string.Empty,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
}; };
@@ -594,6 +601,12 @@ internal static class TestDataSeedExtensions
} }
workspace.Name = name; workspace.Name = name;
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
organizationId,
string.IsNullOrWhiteSpace(workspace.Slug) ? name : workspace.Slug,
workspace.Id,
cancellationToken);
workspace.OrganizationId = organizationId; workspace.OrganizationId = organizationId;
workspace.OwnerUserId = ownerUserId; workspace.OwnerUserId = ownerUserId;
workspace.TimeZone = timeZone; workspace.TimeZone = timeZone;

View File

@@ -1,118 +0,0 @@
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");
}
}
}

View File

@@ -1,49 +0,0 @@
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");
}
}
}

View File

@@ -1,53 +0,0 @@
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);
}
}
}

View File

@@ -1,62 +0,0 @@
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);
}
}
}

View File

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

View File

@@ -12,8 +12,8 @@ using Socialize.Api.Data;
namespace Socialize.Api.Migrations namespace Socialize.Api.Migrations
{ {
[DbContext(typeof(AppDbContext))] [DbContext(typeof(AppDbContext))]
[Migration("20260508031114_AddFrenchReleaseUpdateFields")] [Migration("20260508152102_AddGoogleDriveDamFoundation")]
partial class AddFrenchReleaseUpdateFields partial class AddGoogleDriveDamFoundation
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -377,6 +377,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("GoogleDriveWorkspaceFolderPath")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PreviewUrl") b.Property<string>("PreviewUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1661,6 +1665,23 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("GoogleDriveRootFolderId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsGoogleDriveDamEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl") b.Property<string>("LogoUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1983,6 +2004,28 @@ 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")
@@ -1991,6 +2034,19 @@ 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") b.Property<int?>("ManualEmailRecipientCount")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -2013,26 +2069,18 @@ namespace Socialize.Api.Migrations
.HasMaxLength(512) .HasMaxLength(512)
.HasColumnType("character varying(512)"); .HasColumnType("character varying(512)");
b.Property<string>("SummaryFr")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
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");
@@ -2143,6 +2191,11 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -2154,6 +2207,9 @@ namespace Socialize.Api.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId", "Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });

View File

@@ -0,0 +1,139 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
internal partial class AddGoogleDriveDamFoundation : Migration
{
private static readonly string[] WorkspaceOrganizationSlugIndexColumns =
[
"OrganizationId",
"Slug",
];
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<string>(
name: "Slug",
table: "Workspaces",
type: "character varying(96)",
maxLength: 96,
nullable: false,
defaultValue: "");
migrationBuilder.Sql(
"""
WITH normalized AS (
SELECT
"Id",
"OrganizationId",
COALESCE(
NULLIF(
trim(both '-' from lower(regexp_replace(trim("Name"), '[^a-zA-Z0-9]+', '-', 'g'))),
''
),
'workspace'
) AS "BaseSlug"
FROM "Workspaces"
),
numbered AS (
SELECT
"Id",
"BaseSlug",
row_number() OVER (PARTITION BY "OrganizationId", "BaseSlug" ORDER BY "CreatedAt", "Id") AS "SlugIndex"
FROM normalized
)
UPDATE "Workspaces"
SET "Slug" = left(
CASE
WHEN numbered."SlugIndex" = 1 THEN numbered."BaseSlug"
ELSE numbered."BaseSlug" || '-' || numbered."SlugIndex"
END,
96
)
FROM numbered
WHERE "Workspaces"."Id" = numbered."Id";
""");
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderId",
table: "Organizations",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderName",
table: "Organizations",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveRootFolderUrl",
table: "Organizations",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsGoogleDriveDamEnabled",
table: "Organizations",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "GoogleDriveWorkspaceFolderPath",
table: "Assets",
type: "character varying(512)",
maxLength: 512,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OrganizationId_Slug",
table: "Workspaces",
columns: WorkspaceOrganizationSlugIndexColumns,
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropIndex(
name: "IX_Workspaces_OrganizationId_Slug",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "Slug",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderId",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderName",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveRootFolderUrl",
table: "Organizations");
migrationBuilder.DropColumn(
name: "IsGoogleDriveDamEnabled",
table: "Organizations");
migrationBuilder.DropColumn(
name: "GoogleDriveWorkspaceFolderPath",
table: "Assets");
}
}
}

View File

@@ -374,6 +374,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
b.Property<string>("GoogleDriveWorkspaceFolderPath")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("PreviewUrl") b.Property<string>("PreviewUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1556,11 +1560,6 @@ 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)");
@@ -1663,6 +1662,23 @@ namespace Socialize.Api.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("GoogleDriveRootFolderId")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("GoogleDriveRootFolderUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsGoogleDriveDamEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("LogoUrl") b.Property<string>("LogoUrl")
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("character varying(2048)"); .HasColumnType("character varying(2048)");
@@ -1985,6 +2001,28 @@ 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")
@@ -1993,6 +2031,28 @@ 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");
@@ -2003,29 +2063,21 @@ namespace Socialize.Api.Migrations
b.Property<string>("Summary") b.Property<string>("Summary")
.IsRequired() .IsRequired()
.HasMaxLength(4000) .HasMaxLength(512)
.HasColumnType("character varying(4000)"); .HasColumnType("character varying(512)");
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");
@@ -2136,6 +2188,11 @@ namespace Socialize.Api.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(96)
.HasColumnType("character varying(96)");
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
@@ -2147,6 +2204,9 @@ namespace Socialize.Api.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.HasIndex("OrganizationId", "Slug")
.IsUnique();
b.ToTable("Workspaces", (string)null); b.ToTable("Workspaces", (string)null);
}); });

View File

@@ -10,6 +10,7 @@ internal class Asset
public required string DisplayName { get; set; } public required string DisplayName { get; set; }
public string? GoogleDriveFileId { get; set; } public string? GoogleDriveFileId { get; set; }
public string? GoogleDriveLink { get; set; } public string? GoogleDriveLink { get; set; }
public string? GoogleDriveWorkspaceFolderPath { get; set; }
public string? PreviewUrl { get; set; } public string? PreviewUrl { get; set; }
public int CurrentRevisionNumber { get; set; } public int CurrentRevisionNumber { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }

View File

@@ -17,6 +17,7 @@ internal static class AssetModelConfiguration
asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired(); asset.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256); asset.Property(x => x.GoogleDriveFileId).HasMaxLength(256);
asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048); asset.Property(x => x.GoogleDriveLink).HasMaxLength(2048);
asset.Property(x => x.GoogleDriveWorkspaceFolderPath).HasMaxLength(512);
asset.Property(x => x.PreviewUrl).HasMaxLength(2048); asset.Property(x => x.PreviewUrl).HasMaxLength(2048);
asset.Property(x => x.CreatedAt) asset.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()

View File

@@ -6,6 +6,8 @@ using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts; using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data; using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts; using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
using System.Text.Json; using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers; namespace Socialize.Api.Modules.Assets.Handlers;
@@ -67,6 +69,26 @@ internal class CreateGoogleDriveAssetHandler(
return; return;
} }
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
string? workspaceFolderPath = organization.IsGoogleDriveDamEnabled
? $"{organization.GoogleDriveRootFolderName}/{workspace.Slug}"
: null;
Asset asset = new() Asset asset = new()
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -77,6 +99,7 @@ internal class CreateGoogleDriveAssetHandler(
DisplayName = request.DisplayName.Trim(), DisplayName = request.DisplayName.Trim(),
GoogleDriveFileId = request.GoogleDriveFileId.Trim(), GoogleDriveFileId = request.GoogleDriveFileId.Trim(),
GoogleDriveLink = request.GoogleDriveLink.Trim(), GoogleDriveLink = request.GoogleDriveLink.Trim(),
GoogleDriveWorkspaceFolderPath = workspaceFolderPath,
PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(), PreviewUrl = string.IsNullOrWhiteSpace(request.PreviewUrl) ? null : request.PreviewUrl.Trim(),
CurrentRevisionNumber = 1, CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
@@ -111,6 +134,7 @@ internal class CreateGoogleDriveAssetHandler(
assetType = asset.AssetType, assetType = asset.AssetType,
sourceType = asset.SourceType, sourceType = asset.SourceType,
googleDriveFileId = asset.GoogleDriveFileId, googleDriveFileId = asset.GoogleDriveFileId,
googleDriveWorkspaceFolderPath = asset.GoogleDriveWorkspaceFolderPath,
currentRevisionNumber = asset.CurrentRevisionNumber, currentRevisionNumber = asset.CurrentRevisionNumber,
})), })),
ct); ct);
@@ -137,6 +161,7 @@ internal class CreateGoogleDriveAssetHandler(
asset.DisplayName, asset.DisplayName,
asset.GoogleDriveFileId, asset.GoogleDriveFileId,
asset.GoogleDriveLink, asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl, asset.PreviewUrl,
asset.CurrentRevisionNumber, asset.CurrentRevisionNumber,
asset.CreatedAt, asset.CreatedAt,

View File

@@ -26,6 +26,7 @@ internal record AssetDto(
string DisplayName, string DisplayName,
string? GoogleDriveFileId, string? GoogleDriveFileId,
string? GoogleDriveLink, string? GoogleDriveLink,
string? GoogleDriveWorkspaceFolderPath,
string? PreviewUrl, string? PreviewUrl,
int CurrentRevisionNumber, int CurrentRevisionNumber,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
@@ -70,6 +71,7 @@ internal class GetAssetsHandler(
asset.DisplayName, asset.DisplayName,
asset.GoogleDriveFileId, asset.GoogleDriveFileId,
asset.GoogleDriveLink, asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl, asset.PreviewUrl,
asset.CurrentRevisionNumber, asset.CurrentRevisionNumber,
asset.CreatedAt, asset.CreatedAt,

View File

@@ -0,0 +1,123 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Assets.Handlers;
internal record WorkspaceDamBackingStoreDto(
string Type,
bool IsConfigured,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl);
internal record WorkspaceDamFolderDto(
string Name,
string Path);
internal record WorkspaceDamDto(
Guid WorkspaceId,
Guid OrganizationId,
string WorkspaceName,
string WorkspaceSlug,
WorkspaceDamBackingStoreDto BackingStore,
WorkspaceDamFolderDto? Folder,
IReadOnlyCollection<AssetDto> Assets);
internal class GetWorkspaceDamHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: EndpointWithoutRequest<WorkspaceDamDto>
{
public override void Configure()
{
Get("/api/workspaces/{workspaceId:guid}/dam");
Options(o => o.WithTags("Assets"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid workspaceId = Route<Guid>("workspaceId");
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
if (!accessibleWorkspaceIds.Contains(workspace.Id))
{
await SendForbiddenAsync(ct);
return;
}
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == workspace.OrganizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
WorkspaceDamBackingStoreDto backingStore = new(
organization.IsGoogleDriveDamEnabled ? "GoogleDrive" : "Unconfigured",
organization.IsGoogleDriveDamEnabled,
organization.GoogleDriveRootFolderId,
organization.GoogleDriveRootFolderName,
organization.GoogleDriveRootFolderUrl);
WorkspaceDamFolderDto? folder = organization.IsGoogleDriveDamEnabled
? new WorkspaceDamFolderDto(
workspace.Slug,
$"{organization.GoogleDriveRootFolderName}/{workspace.Slug}")
: null;
List<AssetDto> assets = await dbContext.Assets
.Where(asset => asset.WorkspaceId == workspace.Id)
.OrderBy(asset => asset.DisplayName)
.Select(asset => new AssetDto(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.GoogleDriveWorkspaceFolderPath,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
dbContext.AssetRevisions
.Where(revision => revision.AssetId == asset.Id)
.OrderByDescending(revision => revision.RevisionNumber)
.Select(revision => new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt))
.ToList()))
.ToListAsync(ct);
await SendOkAsync(
new WorkspaceDamDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
backingStore,
folder,
assets),
ct);
}
}

View File

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

View File

@@ -23,8 +23,11 @@ internal class GetChannelsHandler(
{ {
IQueryable<Channel> query = dbContext.Channels.AsQueryable(); IQueryable<Channel> query = dbContext.Channels.AsQueryable();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct); if (!AccessScopeService.IsManager(User))
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)
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,10 @@ internal class Organization
public Guid Id { get; init; } public Guid Id { get; init; }
public required string Name { get; set; } public required string Name { get; set; }
public string? LogoUrl { get; set; } public string? LogoUrl { get; set; }
public bool IsGoogleDriveDamEnabled { get; set; }
public string? GoogleDriveRootFolderId { get; set; }
public string? GoogleDriveRootFolderName { get; set; }
public string? GoogleDriveRootFolderUrl { get; set; }
public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId; public Guid MembershipTierId { get; set; } = OrganizationMembershipTierSeed.FreeId;
public Guid OwnerUserId { get; set; } public Guid OwnerUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }

View File

@@ -12,6 +12,10 @@ internal static class OrganizationModelConfiguration
organization.HasKey(x => x.Id); organization.HasKey(x => x.Id);
organization.Property(x => x.Name).HasMaxLength(256).IsRequired(); organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
organization.Property(x => x.LogoUrl).HasMaxLength(2048); organization.Property(x => x.LogoUrl).HasMaxLength(2048);
organization.Property(x => x.IsGoogleDriveDamEnabled).HasDefaultValue(false);
organization.Property(x => x.GoogleDriveRootFolderId).HasMaxLength(256);
organization.Property(x => x.GoogleDriveRootFolderName).HasMaxLength(256);
organization.Property(x => x.GoogleDriveRootFolderUrl).HasMaxLength(2048);
organization.Property(x => x.MembershipTierId) organization.Property(x => x.MembershipTierId)
.HasDefaultValue(OrganizationMembershipTierSeed.FreeId); .HasDefaultValue(OrganizationMembershipTierSeed.FreeId);
organization.Property(x => x.CreatedAt) organization.Property(x => x.CreatedAt)

View File

@@ -16,6 +16,7 @@ internal record OrganizationDto(
Guid Id, Guid Id,
string Name, string Name,
string? LogoUrl, string? LogoUrl,
OrganizationGoogleDriveDamConfigurationDto GoogleDriveDam,
OrganizationMembershipTierDto? MembershipTier, OrganizationMembershipTierDto? MembershipTier,
Guid OwnerUserId, Guid OwnerUserId,
IReadOnlyCollection<string> CurrentUserPermissions, IReadOnlyCollection<string> CurrentUserPermissions,
@@ -38,6 +39,7 @@ internal record OrganizationDto(
organization.Id, organization.Id,
organization.Name, organization.Name,
organization.LogoUrl, organization.LogoUrl,
OrganizationGoogleDriveDamConfigurationDto.FromOrganization(organization),
membershipTier, membershipTier,
organization.OwnerUserId, organization.OwnerUserId,
currentUserPermissions, currentUserPermissions,
@@ -49,6 +51,22 @@ internal record OrganizationDto(
} }
} }
internal record OrganizationGoogleDriveDamConfigurationDto(
bool IsEnabled,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl)
{
public static OrganizationGoogleDriveDamConfigurationDto FromOrganization(Organization organization)
{
return new OrganizationGoogleDriveDamConfigurationDto(
organization.IsGoogleDriveDamEnabled,
organization.GoogleDriveRootFolderId,
organization.GoogleDriveRootFolderName,
organization.GoogleDriveRootFolderUrl);
}
}
internal record OrganizationMembershipTierDto( internal record OrganizationMembershipTierDto(
Guid Id, Guid Id,
string Key, string Key,

View File

@@ -0,0 +1,87 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations.Handlers;
internal record UpdateGoogleDriveDamConfigurationRequest(
bool IsEnabled,
string? RootFolderId,
string? RootFolderName,
string? RootFolderUrl);
internal class UpdateGoogleDriveDamConfigurationRequestValidator
: Validator<UpdateGoogleDriveDamConfigurationRequest>
{
public UpdateGoogleDriveDamConfigurationRequestValidator()
{
When(x => x.IsEnabled, () =>
{
RuleFor(x => x.RootFolderId).NotEmpty().MaximumLength(256);
RuleFor(x => x.RootFolderName).NotEmpty().MaximumLength(256);
RuleFor(x => x.RootFolderUrl).NotEmpty().MaximumLength(2048);
});
RuleFor(x => x.RootFolderId).MaximumLength(256);
RuleFor(x => x.RootFolderName).MaximumLength(256);
RuleFor(x => x.RootFolderUrl).MaximumLength(2048);
}
}
internal class UpdateGoogleDriveDamConfigurationHandler(
AppDbContext dbContext,
OrganizationAccessService organizationAccessService)
: Endpoint<UpdateGoogleDriveDamConfigurationRequest, OrganizationGoogleDriveDamConfigurationDto>
{
public override void Configure()
{
Put("/api/organizations/{organizationId:guid}/google-drive-dam");
Options(o => o.WithTags("Organizations"));
}
public override async Task HandleAsync(UpdateGoogleDriveDamConfigurationRequest request, CancellationToken ct)
{
Guid organizationId = Route<Guid>("organizationId");
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await organizationAccessService.HasOrganizationPermissionAsync(
User,
organizationId,
OrganizationPermissions.ManageConnectors,
ct))
{
await SendForbiddenAsync(ct);
return;
}
organization.IsGoogleDriveDamEnabled = request.IsEnabled;
organization.GoogleDriveRootFolderId = NormalizeOptional(request.RootFolderId);
organization.GoogleDriveRootFolderName = NormalizeOptional(request.RootFolderName);
organization.GoogleDriveRootFolderUrl = NormalizeOptional(request.RootFolderUrl);
if (!organization.IsGoogleDriveDamEnabled)
{
organization.GoogleDriveRootFolderId = null;
organization.GoogleDriveRootFolderName = null;
organization.GoogleDriveRootFolderUrl = null;
}
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(OrganizationGoogleDriveDamConfigurationDto.FromOrganization(organization), ct);
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -2,16 +2,29 @@ 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
@@ -34,6 +47,13 @@ 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);
@@ -48,6 +68,11 @@ 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(
@@ -64,6 +89,11 @@ 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(
@@ -87,6 +117,11 @@ 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(
@@ -115,6 +150,11 @@ internal sealed class OrganizationAccessService(
string permission, string permission,
CancellationToken ct) CancellationToken ct)
{ {
if (IsGlobalManager(user))
{
return true;
}
Guid? organizationId = await dbContext.Workspaces Guid? organizationId = await dbContext.Workspaces
.Where(workspace => workspace.Id == workspaceId) .Where(workspace => workspace.Id == workspaceId)
.Select(workspace => (Guid?)workspace.OrganizationId) .Select(workspace => (Guid?)workspace.OrganizationId)

View File

@@ -5,16 +5,23 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Contracts;
internal record ReleaseUpdateDto( internal record ReleaseUpdateDto(
Guid Id, Guid Id,
string Title, string Title,
string Description, string Summary,
string TitleEn, string? Body,
string DescriptionEn, string Category,
string TitleFr, string Importance,
string DescriptionFr, string Audience,
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(
@@ -33,21 +40,22 @@ internal record ReleaseCommitDto(
DateTimeOffset ImportedAt, DateTimeOffset ImportedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
internal record ReleaseCommitRefreshResultDto( internal record ReleaseCommitImportResultDto(
int CreatedCount, int ImportedCount,
int UpdatedCount, int UpdatedCount,
int SkippedCount, int SkippedCount,
IReadOnlyCollection<ReleaseCommitDto> Commits); IReadOnlyCollection<ReleaseCommitDto> Commits);
internal record ReleaseCommitBulkLinkResultDto(int LinkedCount); internal record ReleaseUpdateEmailSendResultDto(
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)
@@ -56,15 +64,22 @@ internal static class ReleaseUpdateDtoMapper
update.Id, update.Id,
update.Title, update.Title,
update.Summary, update.Summary,
update.Title, update.Body,
update.Summary, ToDisplayString(update.Category),
update.TitleFr, update.Importance.ToString(),
update.SummaryFr, update.Audience.ToString(),
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);
} }
@@ -87,4 +102,8 @@ internal static class ReleaseUpdateDtoMapper
commit.UpdatedAt); commit.UpdatedAt);
} }
private static string ToDisplayString(ReleaseUpdateCategory category)
{
return category == ReleaseUpdateCategory.BreakingChange ? "Breaking Change" : category.ToString();
}
} }

View File

@@ -11,12 +11,19 @@ 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(4000).IsRequired(); releaseUpdate.Property(x => x.Summary).HasMaxLength(512).IsRequired();
releaseUpdate.Property(x => x.TitleFr).HasMaxLength(160).IsRequired(); releaseUpdate.Property(x => x.Body).HasMaxLength(8000);
releaseUpdate.Property(x => x.SummaryFr).HasMaxLength(4000).IsRequired(); releaseUpdate.Property(x => x.Category).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Importance).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Audience).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired(); releaseUpdate.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
releaseUpdate.Property(x => x.DeploymentLabel).HasMaxLength(128);
releaseUpdate.Property(x => x.BuildVersion).HasMaxLength(128);
releaseUpdate.Property(x => x.CommitRange).HasMaxLength(256);
releaseUpdate.Property(x => x.ManualEmailAudience).HasMaxLength(64);
releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP"); releaseUpdate.Property(x => x.CreatedAt).ValueGeneratedOnAdd().HasDefaultValueSql("CURRENT_TIMESTAMP");
releaseUpdate.HasIndex(x => x.Status); releaseUpdate.HasIndex(x => x.Status);
releaseUpdate.HasIndex(x => x.Audience);
releaseUpdate.HasIndex(x => x.PublishedAt); releaseUpdate.HasIndex(x => x.PublishedAt);
releaseUpdate.HasIndex(x => x.CreatedByUserId); releaseUpdate.HasIndex(x => x.CreatedByUserId);
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,24 +4,35 @@ 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 TitleEn, string Title,
string DescriptionEn, string Summary,
string TitleFr, string? Body,
string DescriptionFr); string Category,
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.TitleEn).NotEmpty().MaximumLength(160); RuleFor(x => x.Title).NotEmpty().MaximumLength(160);
RuleFor(x => x.DescriptionEn).NotEmpty().MaximumLength(4000); RuleFor(x => x.Summary).NotEmpty().MaximumLength(512);
RuleFor(x => x.TitleFr).NotEmpty().MaximumLength(160); RuleFor(x => x.Body).MaximumLength(8000);
RuleFor(x => x.DescriptionFr).NotEmpty().MaximumLength(4000); RuleFor(x => x.Category).NotEmpty().MaximumLength(32);
RuleFor(x => x.Importance).NotEmpty().MaximumLength(32);
RuleFor(x => x.Audience).NotEmpty().MaximumLength(32);
RuleFor(x => x.DeploymentLabel).MaximumLength(128);
RuleFor(x => x.BuildVersion).MaximumLength(128);
RuleFor(x => x.CommitRange).MaximumLength(256);
} }
} }
@@ -37,15 +48,26 @@ 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.TitleEn.Trim(), Title = request.Title.Trim(),
Summary = request.DescriptionEn.Trim(), Summary = request.Summary.Trim(),
TitleFr = request.TitleFr.Trim(), Body = NormalizeOptional(request.Body),
SummaryFr = request.DescriptionFr.Trim(), Category = category,
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,
@@ -56,4 +78,38 @@ internal class CreateDeveloperReleaseUpdateHandler(AppDbContext dbContext)
await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct); await SendAsync(update.ToDto(false), StatusCodes.Status201Created, ct);
} }
private bool TryParseRequest(
CreateDeveloperReleaseUpdateRequest request,
out ReleaseUpdateCategory category,
out ReleaseUpdateImportance importance,
out ReleaseUpdateAudience audience)
{
bool isValid = true;
if (!ReleaseUpdateRules.TryParseCategory(request.Category, out category))
{
AddError(x => x.Category, "The selected release update category is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseImportance(request.Importance, out importance))
{
AddError(x => x.Importance, "The selected release update importance is not valid.");
isValid = false;
}
if (!ReleaseUpdateRules.TryParseAudience(request.Audience, out audience))
{
AddError(x => x.Audience, "The selected release update audience is not valid.");
isValid = false;
}
return isValid;
}
private static string? NormalizeOptional(string? value)
{
string? normalized = value?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
} }

View File

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

View File

@@ -20,9 +20,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.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))
@@ -33,7 +35,7 @@ internal class GetUnreadReleaseUpdatesHandler(AppDbContext dbContext)
await SendOkAsync( await SendOkAsync(
new ReleaseUpdateUnreadSummaryDto( new ReleaseUpdateUnreadSummaryDto(
unreadUpdates.Count, unreadUpdates.Count,
0, unreadUpdates.Count(update => update.Importance == ReleaseUpdateImportance.Important),
unreadUpdates.Select(update => update.ToDto(false)).ToArray()), unreadUpdates.Select(update => update.ToDto(false)).ToArray()),
ct); ct);
} }

View File

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

View File

@@ -20,9 +20,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.OrderByDescending(update => update.PublishedAt) .OrderByDescending(update => update.PublishedAt)
.ThenByDescending(update => update.CreatedAt) .ThenByDescending(update => update.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -19,9 +19,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.Select(update => update.Id) .Select(update => update.Id)
.ToListAsync(ct); .ToListAsync(ct);

View File

@@ -20,9 +20,11 @@ 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
.VisibleToUsers() .VisibleTo(audienceContext)
.AnyAsync(update => update.Id == id, ct); .AnyAsync(update => update.Id == id, ct);
if (!canReadUpdate) if (!canReadUpdate)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,41 +5,46 @@ 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 ReleaseCommitRepositoryRefreshResult( internal sealed record ReleaseCommitRepositoryImportResult(
IReadOnlyCollection<ReleaseCommit> Commits, IReadOnlyCollection<ReleaseCommit> Commits,
string? ErrorMessage) string? ErrorMessage)
{ {
public bool IsSuccess => ErrorMessage is null; public bool IsSuccess => ErrorMessage is null;
public static ReleaseCommitRepositoryRefreshResult Success(IReadOnlyCollection<ReleaseCommit> commits) public static ReleaseCommitRepositoryImportResult Success(IReadOnlyCollection<ReleaseCommit> commits)
{ {
return new ReleaseCommitRepositoryRefreshResult(commits, null); return new ReleaseCommitRepositoryImportResult(commits, null);
} }
public static ReleaseCommitRepositoryRefreshResult Failure(string errorMessage) public static ReleaseCommitRepositoryImportResult Failure(string errorMessage)
{ {
return new ReleaseCommitRepositoryRefreshResult([], errorMessage); return new ReleaseCommitRepositoryImportResult([], errorMessage);
} }
} }
internal sealed class ReleaseCommitRepositoryRefreshService( internal sealed class ReleaseCommitRepositoryImportService(
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<ReleaseCommitRepositoryRefreshResult> FetchCommitsAsync( public async Task<ReleaseCommitRepositoryImportResult> 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 ReleaseCommitRepositoryRefreshResult.Failure(targetError ?? "Repository configuration is not valid."); return ReleaseCommitRepositoryImportResult.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))
@@ -47,11 +52,11 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
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), ct); using HttpResponseMessage response = await httpClient.GetAsync(BuildRequestUri(target, request, limit), ct);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return ReleaseCommitRepositoryRefreshResult.Failure( return ReleaseCommitRepositoryImportResult.Failure(
$"Repository commit refresh failed with HTTP {(int)response.StatusCode} ({response.ReasonPhrase})."); $"Repository commit import 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);
@@ -68,31 +73,56 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
} }
else else
{ {
return ReleaseCommitRepositoryRefreshResult.Failure("Repository API response did not include a commit list."); return ReleaseCommitRepositoryImportResult.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, now); ReleaseCommit? commit = ToReleaseCommit(commitElement, request, now);
if (commit is not null) if (commit is not null)
{ {
commits.Add(commit); commits.Add(commit);
} }
} }
return ReleaseCommitRepositoryRefreshResult.Success(commits); return ReleaseCommitRepositoryImportResult.Success(commits);
} }
private static Uri BuildRequestUri(RepositoryApiTarget target) private static Uri BuildRequestUri(
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"] = DefaultLimit.ToString(CultureInfo.InvariantCulture), ["limit"] = limit.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)}"));
@@ -110,7 +140,7 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
if (string.IsNullOrWhiteSpace(repositoryUrl)) if (string.IsNullOrWhiteSpace(repositoryUrl))
{ {
errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository refresh can be used."; errorMessage = "ReleaseCommunications:Repository:RepositoryUrl is required before repository import can be used.";
return false; return false;
} }
@@ -146,6 +176,7 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
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");
@@ -180,8 +211,8 @@ internal sealed class ReleaseCommitRepositoryRefreshService(
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 = null, SourceBranch = NormalizeOptional(request.SourceBranch),
DeploymentLabel = null, DeploymentLabel = NormalizeOptional(request.DeploymentLabel),
ExternalUrl = NormalizeOptional(GetString(commitElement, "html_url") ?? GetString(commitElement, "url")), ExternalUrl = NormalizeOptional(GetString(commitElement, "html_url") ?? GetString(commitElement, "url")),
CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed, CommunicationStatus = ReleaseCommitCommunicationStatus.Unreviewed,
ImportedAt = now, ImportedAt = now,

View File

@@ -40,8 +40,7 @@ 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),
force: false, stoppingToken);
ct: stoppingToken);
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information)) if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
{ {
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount); logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);

View File

@@ -1,11 +1,15 @@
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;
@@ -16,22 +20,63 @@ 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 GetReleaseNoteRecipientsAsync(ct); List<User> ownerUsers = await GetAudienceRecipientsAsync(ReleaseUpdateAudience.OrganizationOwners, 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) ||
(!force && !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))) !ReleaseUpdateEmailRules.IsInactive(user.LastAuthenticatedAt, inactiveBefore))
{ {
continue; continue;
} }
@@ -41,13 +86,19 @@ 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 (!force && !ReleaseUpdateEmailRules.CanSendDigest(lastDigestSentAt, lastSentBefore)) if (!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
.VisibleToUsers() .VisibleTo(audienceContext)
.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))
@@ -62,8 +113,8 @@ internal class ReleaseUpdateEmailService(
await emailSender.SendEmailAsync( await emailSender.SendEmailAsync(
user.Email, user.Email,
GetDigestSubject(user.PreferredLanguage), "What's new in Socialize",
BuildDigestEmail(unreadUpdates, user.PreferredLanguage)); BuildDigestEmail(unreadUpdates));
dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt dbContext.ReleaseUpdateEmailDigestReceipts.Add(new ReleaseUpdateEmailDigestReceipt
{ {
@@ -79,47 +130,69 @@ internal class ReleaseUpdateEmailService(
return sentCount; return sentCount;
} }
private async Task<List<User>> GetReleaseNoteRecipientsAsync(CancellationToken ct) private async Task<IReadOnlyCollection<User>> GetTestRecipientsAsync(Guid senderUserId, CancellationToken ct)
{ {
return await userManager.Users User? sender = await userManager.Users.SingleOrDefaultAsync(user => user.Id == senderUserId, ct);
.Where(user => user.EmailConfirmed && user.Email != null) return sender is null ? [] : [sender];
.OrderBy(user => user.Email)
.ToListAsync(ct);
} }
private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates, string? preferredLanguage) private async Task<List<User>> GetAudienceRecipientsAsync(ReleaseUpdateAudience audience, CancellationToken ct)
{ {
bool useFrench = IsFrench(preferredLanguage); IQueryable<User> query = userManager.Users.Where(user => user.EmailConfirmed && user.Email != null);
string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
string listItems = string.Join(
Environment.NewLine,
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"; if (audience == ReleaseUpdateAudience.Developers)
string linkText = useFrench ? "Ouvrir les nouveautes" : "Open What's New"; {
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 $""" return $"""
<h1>{HtmlEncode(heading)}</h1> <h1>{HtmlEncode(update.Title)}</h1>
<ul>{listItems}</ul> <p><strong>{HtmlEncode(update.Category.ToString())}</strong></p>
<p><a href="{HtmlEncode(updateUrl)}">{HtmlEncode(linkText)}</a></p> <p>{HtmlEncode(update.Summary)}</p>
{FormatBody(update.Body)}
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
"""; """;
} }
private static string GetDigestSubject(string? preferredLanguage) private string BuildDigestEmail(IReadOnlyCollection<ReleaseUpdate> updates)
{ {
return IsFrench(preferredLanguage) string updateUrl = $"{websiteOptions.Value.FrontendBaseUrl.TrimEnd('/')}/app/updates";
? "Nouveautes dans Socialize" string listItems = string.Join(
: "What's new in Socialize"; Environment.NewLine,
updates.Select(update => $"<li><strong>{HtmlEncode(update.Title)}</strong><br>{HtmlEncode(update.Summary)}</li>"));
return $"""
<h1>What's new in Socialize</h1>
<ul>{listItems}</ul>
<p><a href="{HtmlEncode(updateUrl)}">Open What's New</a></p>
""";
} }
private static bool IsFrench(string? preferredLanguage) private static string FormatBody(string? body)
{ {
return string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase); return string.IsNullOrWhiteSpace(body)
? string.Empty
: $"<p>{HtmlEncode(body).Replace(Environment.NewLine, "<br>", StringComparison.Ordinal)}</p>";
} }
private static string HtmlEncode(string? value) private static string HtmlEncode(string? value)

View File

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

View File

@@ -1,11 +1,46 @@
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 IQueryable<ReleaseUpdate> VisibleToUsers(this IQueryable<ReleaseUpdate> query) public static async Task<ReleaseUpdateAudienceContext> GetAudienceContextAsync(
AppDbContext dbContext,
ClaimsPrincipal user,
Guid userId,
CancellationToken ct)
{ {
return query.Where(update => update.Status == ReleaseUpdateStatus.Published); bool isDeveloper = user.IsInRole(KnownRoles.Developer);
bool isOrganizationOwner = await dbContext.Organizations.AnyAsync(
organization => organization.OwnerUserId == userId,
ct)
|| await dbContext.OrganizationMemberships.AnyAsync(
membership =>
membership.UserId == userId &&
membership.Role == OrganizationRoles.Owner,
ct);
return new ReleaseUpdateAudienceContext(isDeveloper, isOrganizationOwner);
}
public static IQueryable<ReleaseUpdate> VisibleTo(
this IQueryable<ReleaseUpdate> query,
ReleaseUpdateAudienceContext context)
{
return query.Where(update =>
update.Status == ReleaseUpdateStatus.Published &&
(update.Audience == ReleaseUpdateAudience.Everyone ||
(update.Audience == ReleaseUpdateAudience.OrganizationOwners && context.IsOrganizationOwner) ||
(update.Audience == ReleaseUpdateAudience.Developers && context.IsDeveloper)));
} }
} }
internal record ReleaseUpdateAudienceContext(
bool IsDeveloper,
bool IsOrganizationOwner);

View File

@@ -4,6 +4,7 @@ internal class Workspace
{ {
public Guid Id { get; init; } public Guid Id { get; init; }
public required string Name { get; set; } public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; } public string? LogoUrl { get; set; }
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public Guid OwnerUserId { get; set; } public Guid OwnerUserId { get; set; }

View File

@@ -12,6 +12,7 @@ internal static class WorkspaceModelConfiguration
workspace.ToTable("Workspaces"); workspace.ToTable("Workspaces");
workspace.HasKey(x => x.Id); workspace.HasKey(x => x.Id);
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired(); workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
workspace.Property(x => x.Slug).HasMaxLength(96).IsRequired();
workspace.Property(x => x.LogoUrl).HasMaxLength(2048); workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired(); workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required"); workspace.Property(x => x.ApprovalMode).HasMaxLength(32).IsRequired().HasDefaultValue("Required");
@@ -22,6 +23,7 @@ internal static class WorkspaceModelConfiguration
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP"); .HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.OrganizationId); workspace.HasIndex(x => x.OrganizationId);
workspace.HasIndex(x => new { x.OrganizationId, x.Slug }).IsUnique();
workspace.HasIndex(x => x.OwnerUserId); workspace.HasIndex(x => x.OwnerUserId);
workspace.HasOne<Organization>() workspace.HasOne<Organization>()
.WithMany() .WithMany()

View File

@@ -3,12 +3,14 @@ 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.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
namespace Socialize.Api.Modules.Workspaces.Handlers; namespace Socialize.Api.Modules.Workspaces.Handlers;
internal record CreateWorkspaceRequest( internal record CreateWorkspaceRequest(
Guid OrganizationId, Guid OrganizationId,
string Name, string Name,
string? Slug,
string TimeZone); string TimeZone);
internal class CreateWorkspaceRequestValidator internal class CreateWorkspaceRequestValidator
@@ -18,6 +20,7 @@ internal class CreateWorkspaceRequestValidator
{ {
RuleFor(x => x.OrganizationId).NotEmpty(); RuleFor(x => x.OrganizationId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug).MaximumLength(96);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128); RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
} }
} }
@@ -51,6 +54,12 @@ internal class CreateWorkspaceHandler(
} }
string normalizedName = request.Name.Trim(); string normalizedName = request.Name.Trim();
string normalizedSlug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
request.OrganizationId,
string.IsNullOrWhiteSpace(request.Slug) ? normalizedName : request.Slug,
null,
ct);
string normalizedTimeZone = request.TimeZone.Trim(); string normalizedTimeZone = request.TimeZone.Trim();
Workspace workspace = new() Workspace workspace = new()
@@ -58,6 +67,7 @@ internal class CreateWorkspaceHandler(
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
OrganizationId = request.OrganizationId, OrganizationId = request.OrganizationId,
Name = normalizedName, Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(), OwnerUserId = User.GetUserId(),
TimeZone = normalizedTimeZone, TimeZone = normalizedTimeZone,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,

View File

@@ -21,6 +21,7 @@ internal record WorkspaceDto(
Guid Id, Guid Id,
Guid OrganizationId, Guid OrganizationId,
string Name, string Name,
string Slug,
string? LogoUrl, string? LogoUrl,
string TimeZone, string TimeZone,
string ApprovalMode, string ApprovalMode,
@@ -38,6 +39,7 @@ internal record WorkspaceDto(
workspace.Id, workspace.Id,
workspace.OrganizationId, workspace.OrganizationId,
workspace.Name, workspace.Name,
workspace.Slug,
workspace.LogoUrl, workspace.LogoUrl,
workspace.TimeZone, workspace.TimeZone,
workspace.ApprovalMode, workspace.ApprovalMode,

View File

@@ -5,6 +5,7 @@ using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data; using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services; using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Workspaces.Data; using Socialize.Api.Modules.Workspaces.Data;
using Socialize.Api.Modules.Workspaces.Services;
namespace Socialize.Api.Modules.Workspaces.Handlers; namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -17,6 +18,7 @@ internal record UpdateApprovalStepConfigurationRequest(
internal record UpdateWorkspaceRequest( internal record UpdateWorkspaceRequest(
string Name, string Name,
string? Slug,
string TimeZone, string TimeZone,
string? ApprovalMode, string? ApprovalMode,
bool? SchedulePostsAutomaticallyOnApproval, bool? SchedulePostsAutomaticallyOnApproval,
@@ -32,6 +34,7 @@ internal class UpdateWorkspaceRequestValidator
public UpdateWorkspaceRequestValidator() public UpdateWorkspaceRequestValidator()
{ {
RuleFor(x => x.Name).NotEmpty().MaximumLength(256); RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug).MaximumLength(96);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128); RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
RuleFor(x => x.ApprovalMode) RuleFor(x => x.ApprovalMode)
.Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim())) .Must(mode => string.IsNullOrWhiteSpace(mode) || AllowedApprovalModes.Contains(mode.Trim()))
@@ -106,6 +109,12 @@ internal class UpdateWorkspaceHandler(
} }
workspace.Name = request.Name.Trim(); workspace.Name = request.Name.Trim();
workspace.Slug = await WorkspaceSlugGenerator.CreateUniqueAsync(
dbContext,
workspace.OrganizationId,
string.IsNullOrWhiteSpace(request.Slug) ? workspace.Name : request.Slug,
workspace.Id,
ct);
workspace.TimeZone = request.TimeZone.Trim(); workspace.TimeZone = request.TimeZone.Trim();
workspace.ApprovalMode = nextApprovalMode; workspace.ApprovalMode = nextApprovalMode;
workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval; workspace.SchedulePostsAutomaticallyOnApproval = request.SchedulePostsAutomaticallyOnApproval ?? workspace.SchedulePostsAutomaticallyOnApproval;

View File

@@ -0,0 +1,64 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
namespace Socialize.Api.Modules.Workspaces.Services;
internal static class WorkspaceSlugGenerator
{
public static string Normalize(string value)
{
#pragma warning disable CA1308 // Workspace slugs are intentionally lowercase external folder names.
string trimmed = value.Trim().ToLowerInvariant();
#pragma warning restore CA1308
var builder = new StringBuilder(trimmed.Length);
bool previousWasSeparator = false;
foreach (char character in trimmed)
{
if (char.IsLetterOrDigit(character))
{
builder.Append(character);
previousWasSeparator = false;
continue;
}
if (previousWasSeparator)
{
continue;
}
builder.Append('-');
previousWasSeparator = true;
}
string slug = builder.ToString().Trim('-');
return string.IsNullOrWhiteSpace(slug) ? "workspace" : slug[..Math.Min(slug.Length, 96)];
}
public static async Task<string> CreateUniqueAsync(
AppDbContext dbContext,
Guid organizationId,
string source,
Guid? excludingWorkspaceId,
CancellationToken ct)
{
string baseSlug = Normalize(source);
string candidate = baseSlug;
int suffix = 2;
while (await dbContext.Workspaces.AnyAsync(
workspace => workspace.OrganizationId == organizationId &&
workspace.Slug == candidate &&
(!excludingWorkspaceId.HasValue || workspace.Id != excludingWorkspaceId.Value),
ct))
{
string suffixText = $"-{suffix}";
int maxBaseLength = 96 - suffixText.Length;
candidate = $"{baseSlug[..Math.Min(baseSlug.Length, maxBaseLength)]}{suffixText}";
suffix++;
}
return candidate;
}
}

View File

@@ -6,16 +6,64 @@ 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_maps_summary_to_description() public void ToDto_formats_breaking_change_category_for_display()
{ {
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.",
TitleFr = "Changement API", Category = ReleaseUpdateCategory.BreakingChange,
SummaryFr = "Une API du flux de travail a change.", Importance = ReleaseUpdateImportance.Important,
Audience = ReleaseUpdateAudience.Developers,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
@@ -25,22 +73,18 @@ public class ReleaseUpdateRulesTests
ReleaseUpdateDto dto = update.ToDto(isRead: true); ReleaseUpdateDto dto = update.ToDto(isRead: true);
Assert.Equal("A workflow API changed.", dto.Description); Assert.Equal("Breaking Change", dto.Category);
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 VisibleToUsers_returns_published_updates() public void VisibleTo_returns_everyone_updates_for_any_authenticated_user()
{ {
ReleaseUpdate update = NewPublishedUpdate(); ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
List<ReleaseUpdate> visibleUpdates = new[] { update } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleToUsers() .VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false))
.ToList(); .ToList();
Assert.Same(update, Assert.Single(visibleUpdates)); Assert.Same(update, Assert.Single(visibleUpdates));
@@ -49,17 +93,37 @@ public class ReleaseUpdateRulesTests
[Fact] [Fact]
public void VisibleTo_rejects_unpublished_updates() public void VisibleTo_rejects_unpublished_updates()
{ {
ReleaseUpdate update = NewPublishedUpdate(); ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
update.Status = ReleaseUpdateStatus.Draft; update.Status = ReleaseUpdateStatus.Draft;
List<ReleaseUpdate> visibleUpdates = new[] { update } List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable() .AsQueryable()
.VisibleToUsers() .VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true))
.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()
{ {
@@ -108,15 +172,16 @@ public class ReleaseUpdateRulesTests
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore)); Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
} }
private static ReleaseUpdate NewPublishedUpdate() private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience)
{ {
return new ReleaseUpdate return new ReleaseUpdate
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
Title = "Update", Title = "Update",
Summary = "Something changed.", Summary = "Something changed.",
TitleFr = "Mise a jour", Category = ReleaseUpdateCategory.Improvement,
SummaryFr = "Quelque chose a change.", Importance = ReleaseUpdateImportance.Normal,
Audience = audience,
Status = ReleaseUpdateStatus.Published, Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,

View File

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

View File

@@ -0,0 +1,56 @@
# Digital Asset Management
## Status
Draft
## Goal
Provide a workspace media library backed by the organization's configured Google Drive.
The DAM is not a standalone storage system in v1. When an organization has Google Drive configured, Google Drive is the backing store for media files and Socialize stores metadata, workflow relationships, revisions, and audit history.
## Backing Store
Google Drive configuration belongs to the organization.
An organization Google Drive configuration includes:
- enabled state
- root folder id
- root folder name
- root folder URL
Workspace media is organized inside the organization Drive root by workspace slug:
```txt
<organization-drive-root>/<workspace-slug>/
```
Each workspace must have a stable slug. Slugs are unique within the owning organization and should not change casually because they map to an external DAM folder.
## Workspace DAM
The workspace DAM view should expose:
- whether Google Drive is configured for the owning organization
- the workspace slug
- the resolved workspace DAM folder name and path
- linked media assets for the workspace
- asset revisions and source references
## Business Rules
- Organization connector settings are managed by users with `ManageConnectors`.
- Workspace slugs are required and unique within an organization.
- Asset metadata remains workspace-scoped.
- Content item assets can point at Google Drive files in the workspace DAM folder.
- Socialize must not claim to upload or sync files until the Google Drive API integration exists.
## Out Of Scope For First Slice
- Google OAuth consent and refresh-token storage.
- Creating folders in Google Drive through the Drive API.
- Uploading files to Google Drive.
- Background Drive synchronization.
- Moving existing files between Drive folders.

View File

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

View File

@@ -0,0 +1,45 @@
# Task: Add Google Drive backed DAM foundation
## Feature
`docs/FEATURES/digital-asset-management.md`
## Goal
Make the DAM model aware of organization-level Google Drive backing storage and workspace slug folders.
## Scope
- Add organization Google Drive configuration metadata.
- Add a required workspace `Slug`, unique within the owning organization.
- Generate a slug from workspace name during workspace creation.
- Allow workspace managers to update a workspace slug.
- Add DAM metadata to workspace asset responses.
- Add a workspace DAM endpoint that returns backing-store configuration, workspace folder information, and workspace assets.
- Keep actual Google Drive API folder creation, uploads, and sync out of scope.
## Likely Files
- `backend/src/Socialize.Api/Modules/Organizations/`
- `backend/src/Socialize.Api/Modules/Workspaces/`
- `backend/src/Socialize.Api/Modules/Assets/`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `frontend/src/features/content/views/MediaLibraryView.vue`
- `frontend/src/features/content/stores/`
## Validation
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend && npm run build
```
## Acceptance Criteria
- [x] Organization responses include Google Drive DAM configuration metadata.
- [x] Organization connector managers can save Google Drive DAM configuration.
- [x] Workspace responses include a stable slug.
- [x] New workspaces receive a unique slug based on the workspace name.
- [x] Workspace DAM data resolves to `<drive-root>/<workspace-slug>`.
- [x] Existing manually linked Google Drive content assets remain supported.

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,13 @@
<div class="shell-sidebar-wrap"> <div class="shell-sidebar-wrap">
<app-sidebar :is-expanded="isSidebarExpanded" /> <app-sidebar :is-expanded="isSidebarExpanded" />
<v-btn <button
class="sidebar-boundary-toggle" class="sidebar-boundary-toggle"
variant="text" type="button"
:ripple="false"
@click="isSidebarExpanded = !isSidebarExpanded" @click="isSidebarExpanded = !isSidebarExpanded"
> >
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" /> <v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
</v-btn> </button>
</div> </div>
</template> </template>
@@ -70,8 +69,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, var(--app-color-on-primary) 0%, #f6efe2 100%); linear-gradient(180deg, #fffaf2 0%, #f6efe2 100%);
color: var(--app-color-on-surface); color: #172033;
} }
.shell-main { .shell-main {
@@ -87,17 +86,16 @@
} }
.sidebar-boundary-toggle { .sidebar-boundary-toggle {
@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; @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;
background: rgba(255, 250, 242, 0.98); background: rgba(255, 250, 242, 0.98);
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
color: #44516a; color: #44516a;
box-shadow: 0 12px 28px var(--app-border-subtle); box-shadow: 0 12px 28px rgba(23, 32, 51, 0.12);
letter-spacing: 0;
} }
.sidebar-boundary-toggle:hover { .sidebar-boundary-toggle:hover {
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.sidebar-boundary-toggle :deep(.v-icon) { .sidebar-boundary-toggle :deep(.v-icon) {

View File

@@ -132,22 +132,6 @@ 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;
@@ -180,6 +164,22 @@ 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-commits/refresh": { "/api/developer/release-updates/{id}/send-email": {
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["SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler"]; post: operations["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler"];
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -292,22 +292,6 @@ 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;
@@ -596,22 +580,6 @@ 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;
@@ -1503,12 +1471,15 @@ export interface components {
/** Format: guid */ /** Format: guid */
id?: string; id?: string;
title?: string; title?: string;
description?: string; summary?: string;
titleEn?: string; body?: string | null;
descriptionEn?: string; category?: string;
titleFr?: string; importance?: string;
descriptionFr?: string; audience?: 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 */
@@ -1517,17 +1488,25 @@ 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: {
titleEn: string; title: string;
descriptionEn: string; summary: string;
titleFr: string; body?: string | null;
descriptionFr: string; category: string;
}; importance: string;
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateDigestSendResultDto: { audience: string;
/** Format: int32 */ deploymentLabel?: string | null;
sentCount?: number; buildVersion?: string | null;
commitRange?: string | null;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: { SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateUnreadSummaryDto: {
/** Format: int32 */ /** Format: int32 */
@@ -1536,6 +1515,15 @@ 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;
@@ -1557,32 +1545,52 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt?: string; updatedAt?: string;
}; };
SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto: { SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitsRequest: {
sinceSha?: string | null;
untilSha?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto"][] | null;
};
SocializeApiModulesReleaseCommunicationsHandlersImportDeveloperReleaseCommitDto: {
sha?: string;
shortSha?: string | null;
subject?: string;
authorName?: string | null;
authorEmail?: string | null;
/** Format: date-time */
authoredAt?: string | null;
/** Format: date-time */
committedAt?: string | null;
sourceBranch?: string | null;
deploymentLabel?: string | null;
externalUrl?: string | null;
};
SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto: {
/** Format: int32 */ /** Format: int32 */
createdCount?: number; recipientCount?: number;
/** Format: int32 */ /** Format: date-time */
updatedCount?: number; sentAt?: string;
/** Format: int32 */ testMode?: boolean;
skippedCount?: number; };
commits?: components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitDto"][]; SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest: {
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: {
titleEn: string; title: string;
descriptionEn: string; summary: string;
titleFr: string; body?: string | null;
descriptionFr: string; category: string;
importance: string;
audience: string;
deploymentLabel?: string | null;
buildVersion?: string | null;
commitRange?: string | null;
}; };
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: { SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
/** Format: guid */ /** Format: guid */
@@ -1719,9 +1727,6 @@ export interface components {
/** Format: binary */ /** Format: binary */
file: string; file: string;
}; };
SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest: {
preferredLanguage?: string;
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: { SocializeApiModulesIdentityHandlersConfirmEmailChangeResponse: {
message?: string; message?: string;
}; };
@@ -1747,7 +1752,6 @@ 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;
@@ -2755,40 +2759,6 @@ 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;
@@ -2901,6 +2871,44 @@ 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;
@@ -3050,14 +3058,20 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesReleaseCommunicationsHandlersRefreshDeveloperReleaseCommitsHandler: { SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailHandler: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path: {
id: string;
};
cookie?: never; cookie?: never;
}; };
requestBody?: never; requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsHandlersSendDeveloperReleaseUpdateEmailRequest"];
};
};
responses: { responses: {
/** @description Success */ /** @description Success */
200: { 200: {
@@ -3065,7 +3079,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseCommitRefreshResultDto"]; "application/json": components["schemas"]["SocializeApiModulesReleaseCommunicationsContractsReleaseUpdateEmailSendResultDto"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -3124,46 +3138,6 @@ 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;
@@ -3863,44 +3837,6 @@ export interface operations {
}; };
}; };
}; };
SocializeApiModulesIdentityHandlersChangePreferredLanguageHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesIdentityHandlersChangePreferredLanguageRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: { SocializeApiModulesIdentityHandlersConfirmEmailChangeHandler: {
parameters: { parameters: {
query: { query: {

View File

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

View File

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

View File

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

View File

@@ -148,13 +148,13 @@
<div class="cropper-eyebrow">Image editor</div> <div class="cropper-eyebrow">Image editor</div>
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
</div> </div>
<v-btn variant="text" :ripple="false" <button
class="plain-button" class="plain-button"
:disabled="isSaving" :disabled="isSaving"
@click="closeDialog" @click="closeDialog"
> >
Close Close
</v-btn> </button>
</div> </div>
<div class="cropper-actions"> <div class="cropper-actions">
@@ -178,42 +178,42 @@
variant="outlined" variant="outlined"
hide-details hide-details
/> />
<v-btn variant="text" :ripple="false" <button
class="action-button secondary" class="action-button secondary"
:disabled="isSaving" :disabled="isSaving"
@click="loadImageFromUrl" @click="loadImageFromUrl"
> >
{{ loadLabel }} {{ loadLabel }}
</v-btn> </button>
</div> </div>
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
class="action-button secondary" class="action-button secondary"
:disabled="!isReady || isSaving" :disabled="!isReady || isSaving"
@click="rotate(-90)" @click="rotate(-90)"
> >
Rotate left Rotate left
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
class="action-button secondary" class="action-button secondary"
:disabled="!isReady || isSaving" :disabled="!isReady || isSaving"
@click="rotate(90)" @click="rotate(90)"
> >
Rotate right Rotate right
</v-btn> </button>
</div> </div>
<div <div
@@ -242,14 +242,14 @@
</div> </div>
<div class="footer-actions"> <div class="footer-actions">
<v-btn variant="text" :ripple="false" <button
class="action-button secondary" class="action-button secondary"
:disabled="isSaving" :disabled="isSaving"
@click="closeDialog" @click="closeDialog"
> >
Cancel Cancel
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</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: var(--app-surface-raised); background: rgba(255, 255, 255, 0.98);
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.cropper-header h2 { .cropper-header h2 {
@apply mt-2 text-2xl font-black; @apply mt-2 text-2xl font-black;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
color: var(--app-color-on-surface); color: #172033;
} }
.footer-actions { .footer-actions {
@@ -315,22 +315,22 @@
} }
.action-button { .action-button {
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.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: var(--app-color-on-surface); color: #172033;
border: 1px solid var(--app-border-subtle); border: 1px solid rgba(23, 32, 51, 0.12);
} }
.cropper-stage { .cropper-stage {
@apply overflow-hidden rounded-[1.5rem] border; @apply overflow-hidden rounded-[1.5rem] border;
height: 28rem; height: 28rem;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: var(--app-color-on-primary); background: #fffaf2;
} }
.empty-state, .empty-state,
@@ -339,14 +339,14 @@
} }
.empty-state { .empty-state {
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-text-muted); color: #526178;
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: var(--app-danger-muted); color: #b91c1c;
background: rgba(254, 226, 226, 0.75); background: rgba(254, 226, 226, 0.75);
} }

View File

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

View File

@@ -18,16 +18,16 @@
:callback="googleCallback" :callback="googleCallback"
popup-type="TOKEN" popup-type="TOKEN"
> >
<v-btn variant="text" :ripple="false" class="secondary"> <button class="secondary">
<v-icon <v-icon
:icon="mdiGoogle" :icon="mdiGoogle"
class="mr-2" class="mr-2"
/> />
{{ t('continueWithGoogle') }} {{ t('continueWithGoogle') }}
</v-btn> </button>
</google-login> </google-login>
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
box-shadow: none; box-shadow: none;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.login-card { .login-card {
box-shadow: 0 18px 40px var(--app-border-subtle); box-shadow: 0 18px 40px rgba(23, 32, 51, 0.08);
} }
} }

View File

@@ -137,7 +137,7 @@
.content-card { .content-card {
@apply rounded-[1.5rem] border; @apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-text-muted); color: #526178;
} }
.breadcrumb { .breadcrumb {
@apply font-bold uppercase tracking-[0.16em]; @apply font-bold uppercase tracking-[0.16em];
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.hero h1, .hero h1,
.section-header strong, .section-header strong,
.content-card strong { .content-card strong {
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); background: rgba(23, 32, 51, 0.08);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-text-muted); color: #526178;
} }
.page-message.error { .page-message.error {
color: var(--app-danger-muted); color: #b91c1c;
} }
</style> </style>

View File

@@ -178,14 +178,14 @@
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<v-btn variant="text" :ripple="false" <button
class="secondary" class="secondary"
:disabled="campaignsStore.isCreating" :disabled="campaignsStore.isCreating"
@click="isCreateFormVisible = false" @click="isCreateFormVisible = false"
> >
{{ t('common.cancel') }} {{ t('common.cancel') }}
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</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: var(--app-color-on-tertiary); color: #0f766e;
} }
.header h1 { .header h1 {
@apply mt-2 text-4xl font-black; @apply mt-2 text-4xl font-black;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.primary, .primary,
@@ -273,20 +273,20 @@
} }
.primary { .primary {
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.secondary { .secondary {
background: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.create-panel { .create-panel {
@@ -299,7 +299,7 @@
.panel-header strong, .panel-header strong,
.campaign-row strong { .campaign-row strong {
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: var(--app-color-on-surface); color: #172033;
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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-text-muted); color: #526178;
} }
.page-message.error { .page-message.error {
color: var(--app-danger-muted); color: #b91c1c;
} }
</style> </style>

View File

@@ -134,7 +134,7 @@
</div> </div>
<div class="network-tabs"> <div class="network-tabs">
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</div> </div>
<div <div
@@ -173,21 +173,21 @@
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<v-btn variant="text" :ripple="false" <button
class="secondary" class="secondary"
type="button" type="button"
@click="isCreateFormVisible = false" @click="isCreateFormVisible = false"
> >
{{ t('common.cancel') }} {{ t('common.cancel') }}
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</div> </div>
</div> </div>
@@ -241,7 +241,7 @@
</article> </article>
</div> </div>
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</section> </section>
</template> </template>
@@ -261,7 +261,7 @@
.header h1 { .header h1 {
@apply text-4xl font-black; @apply text-4xl font-black;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.empty-state { .empty-state {
@@ -317,13 +317,13 @@
} }
.primary { .primary {
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.secondary { .secondary {
background: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
color: var(--app-color-on-surface); color: #172033;
} }
.panel-header { .panel-header {
@@ -334,12 +334,12 @@
.field, .field,
.channel-header strong, .channel-header strong,
.channel-metrics strong { .channel-metrics strong {
color: var(--app-color-on-surface); color: #172033;
} }
.panel-header span { .panel-header span {
@apply text-sm font-semibold; @apply text-sm font-semibold;
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-primary); background: #fffaf2;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.page-message.error { .page-message.error {
color: var(--app-danger-muted); color: #b91c1c;
} }
</style> </style>

View File

@@ -220,13 +220,13 @@
<div class="section details-section"> <div class="section details-section">
<div class="section-header"> <div class="section-header">
<strong>Client details</strong> <strong>Client details</strong>
<v-btn variant="text" :ripple="false" <button
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' }}
</v-btn> </button>
</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">
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
</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">
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.breadcrumb { .breadcrumb {
@apply font-bold uppercase tracking-[0.18em]; @apply font-bold uppercase tracking-[0.18em];
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.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: var(--app-color-on-surface); color: #172033;
border: 1px solid var(--app-border-subtle); border: 1px solid rgba(23, 32, 51, 0.12);
} }
.scope-button-secondary:hover { .scope-button-secondary:hover {
background: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
} }
.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: var(--app-color-on-primary); background: #fffaf2;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
background: white; background: white;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-primary); background: #fffaf2;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.image-picker-copy { .image-picker-copy {
@@ -647,12 +647,12 @@
} }
.image-picker-copy strong { .image-picker-copy strong {
color: var(--app-color-on-surface); color: #172033;
} }
.image-picker-copy small { .image-picker-copy small {
@apply text-sm leading-6; @apply text-sm leading-6;
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-text-muted); color: #526178;
} }
.page-message.error { .page-message.error {
color: var(--app-danger-muted); color: #b91c1c;
} }
</style> </style>

View File

@@ -75,13 +75,13 @@
</div> </div>
<div class="action-row"> <div class="action-row">
<v-btn variant="text" :ripple="false" <button
v-if="authStore.isManager" v-if="authStore.isManager"
class="create-button" class="create-button"
@click="openCreateForm" @click="openCreateForm"
> >
{{ t('clients.newClient') }} {{ t('clients.newClient') }}
</v-btn> </button>
</div> </div>
<div <div
@@ -151,14 +151,14 @@
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<v-btn variant="text" :ripple="false" <button
class="secondary" class="secondary"
:disabled="clientsStore.isCreating" :disabled="clientsStore.isCreating"
@click="isCreateFormVisible = false" @click="isCreateFormVisible = false"
> >
{{ t('common.cancel') }} {{ t('common.cancel') }}
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</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: var(--app-color-on-tertiary); color: #0f766e;
} }
.header h1 { .header h1 {
@apply mt-2 text-4xl font-black; @apply mt-2 text-4xl font-black;
color: var(--app-color-on-surface); color: #172033;
} }
.header p { .header p {
@apply mt-2 text-sm leading-6; @apply mt-2 text-sm leading-6;
color: var(--app-text-muted); color: #526178;
} }
.header { .header {
@@ -261,7 +261,7 @@
.create-button, .create-button,
.primary { .primary {
background: var(--app-color-on-surface); background: #172033;
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 var(--app-border-subtle); border: 1px solid rgba(23, 32, 51, 0.12);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.panel-header span { .panel-header span {
@apply text-sm font-semibold; @apply text-sm font-semibold;
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
background: white; background: white;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-text-muted); color: #526178;
} }
.page-message.error { .page-message.error {
color: var(--app-danger-muted); color: #b91c1c;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.client-card small { .client-card small {

View File

@@ -24,7 +24,7 @@
<template> <template>
<div class="color-palette"> <div class="color-palette">
<v-btn variant="text" :ripple="false" <button
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 var(--app-control-active); border: 1px solid rgba(23, 32, 51, 0.1);
border-radius: 0.75rem; border-radius: 0.75rem;
background: #ffffff; background: #ffffff;
border-color: var(--app-control-active); border-color: rgba(23, 32, 51, 0.1);
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 var(--app-border-subtle); box-shadow: 0 0 0 1px rgba(23, 32, 51, 0.12);
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 var(--app-color-on-surface); box-shadow: 0 0 0 2px #172033;
} }
</style> </style>

View File

@@ -255,7 +255,7 @@
class="approval-step" class="approval-step"
:class="`is-${step.status}`" :class="`is-${step.status}`"
> >
<v-btn variant="text" :ripple="false" <button
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 }}
</v-btn> </button>
<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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-surface); background: #fffdf8;
border-color: rgba(23, 32, 51, 0.16); border-color: rgba(23, 32, 51, 0.16);
color: var(--app-text-muted); color: #526178;
} }
button.step-circle:not(:disabled) { button.step-circle:not(:disabled) {
@@ -397,37 +397,37 @@
} }
.step-circle.is-muted { .step-circle.is-muted {
background: var(--app-control-subtle); background: rgba(23, 32, 51, 0.04);
} }
.approval-step.is-approved .step-circle { .approval-step.is-approved .step-circle {
background: var(--app-color-on-tertiary); background: #0f766e;
border-color: var(--app-color-on-tertiary); border-color: #0f766e;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.approval-step.is-scheduled .step-circle { .approval-step.is-scheduled .step-circle {
background: #b45309; background: #b45309;
border-color: #b45309; border-color: #b45309;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.approval-step.is-published .step-circle { .approval-step.is-published .step-circle {
background: #7c3aed; background: #7c3aed;
border-color: #7c3aed; border-color: #7c3aed;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.approval-step.is-current .step-circle { .approval-step.is-current .step-circle {
background: var(--app-color-on-surface); background: #172033;
border-color: var(--app-color-on-surface); border-color: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.decision-row div { .decision-row div {

View File

@@ -114,13 +114,13 @@
<span>Replying to</span> <span>Replying to</span>
<strong>{{ replyTarget.authorDisplayName }}</strong> <strong>{{ replyTarget.authorDisplayName }}</strong>
</div> </div>
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
</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>
<v-btn variant="text" :ripple="false" <button
type="button" type="button"
title="Remove selected media" title="Remove selected media"
@click="clearMediaFile" @click="clearMediaFile"
> >
<v-icon :icon="mdiClose" /> <v-icon :icon="mdiClose" />
</v-btn> </button>
</div> </div>
<div <div
v-if="form.showMentionPicker" v-if="form.showMentionPicker"
class="mention-picker" class="mention-picker"
> >
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
<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"
/> />
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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' }}
</v-btn> </button>
</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: var(--app-color-surface); background: #fffdf8;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
} }
.comment-composer.reply { .comment-composer.reply {
@@ -259,28 +259,28 @@
} }
.reply-context span { .reply-context span {
color: var(--app-text-muted); color: #526178;
} }
.reply-context strong { .reply-context strong {
@apply truncate; @apply truncate;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-surface); color: #172033;
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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.selected-media-file span { .selected-media-file span {
@apply min-w-0 truncate font-semibold; @apply min-w-0 truncate font-semibold;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-danger-muted); color: #b91c1c;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.mention-option:hover { .mention-option:hover {
background: rgba(15, 118, 110, 0.1); background: rgba(15, 118, 110, 0.1);
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.empty-note { .empty-note {
@apply text-sm leading-6; @apply text-sm leading-6;
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.internal-toggle { .internal-toggle {
@@ -355,20 +355,20 @@
.icon-tool-button { .icon-tool-button {
@apply w-10; @apply w-10;
background: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.post-button { .post-button {
@apply px-4; @apply px-4;
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.post-button:disabled { .post-button:disabled {

View File

@@ -85,28 +85,28 @@
</div> </div>
<div class="comment-actions"> <div class="comment-actions">
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
class="comment-action-button" class="comment-action-button"
type="button" type="button"
title="Resolve" title="Resolve"
> >
<v-icon :icon="mdiCheckCircleOutline" /> <v-icon :icon="mdiCheckCircleOutline" />
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
<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">
<v-btn variant="text" :ripple="false" <button
class="comment-menu-item" class="comment-menu-item"
type="button" type="button"
> >
<v-icon :icon="mdiPencilOutline" /> <v-icon :icon="mdiPencilOutline" />
Edit Edit
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
class="comment-menu-item danger" class="comment-menu-item danger"
type="button" type="button"
> >
<v-icon :icon="mdiDeleteOutline" /> <v-icon :icon="mdiDeleteOutline" />
Delete Delete
</v-btn> </button>
</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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-surface); background: #fffdf8;
border-color: rgba(15, 118, 110, 0.24); border-color: rgba(15, 118, 110, 0.24);
box-shadow: 0 16px 34px var(--app-border-subtle); box-shadow: 0 16px 34px rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-control-active); border-color: rgba(23, 32, 51, 0.1);
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.comment-menu-item.danger { .comment-menu-item.danger {
color: var(--app-danger-muted); color: #b91c1c;
} }
.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: var(--app-danger-muted); color: #b91c1c;
} }
.comment-body { .comment-body {
@apply whitespace-pre-line text-sm leading-6; @apply whitespace-pre-line text-sm leading-6;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-control-active); border-color: rgba(23, 32, 51, 0.1);
background: #ffffff; background: #ffffff;
} }
@@ -372,7 +372,7 @@
.reply-meta strong { .reply-meta strong {
@apply truncate text-sm; @apply truncate text-sm;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
</style> </style>

View File

@@ -0,0 +1,41 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
export const useMediaLibraryStore = defineStore('media-library', () => {
const client = useClient();
const dam = ref(null);
const isLoading = ref(false);
const error = ref(null);
async function fetchWorkspaceDam(workspaceId) {
if (!workspaceId) {
dam.value = null;
return null;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get(`/api/workspaces/${workspaceId}/dam`);
dam.value = response.data ?? null;
return dam.value;
} catch (fetchError) {
console.error('Failed to load workspace DAM:', fetchError);
dam.value = null;
error.value = 'Failed to load the media library.';
return null;
} finally {
isLoading.value = false;
}
}
return {
dam,
isLoading,
error,
fetchWorkspaceDam,
};
});

View File

@@ -583,7 +583,7 @@
} }
function calendarEventColor(event) { function calendarEventColor(event) {
return calendarEventSource(event)?.color ?? 'var(--app-color-on-tertiary)'; return calendarEventSource(event)?.color ?? '#0f766e';
} }
function formatDateTime(value) { function formatDateTime(value) {
@@ -704,14 +704,14 @@
<template> <template>
<section class="editor-shell"> <section class="editor-shell">
<v-btn variant="text" :ripple="false" <button
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
</v-btn> </button>
<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>
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</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">
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</div> </div>
<div <div
v-if="selectedDateCalendarEvents.length" v-if="selectedDateCalendarEvents.length"
class="date-context-panel" class="date-context-panel"
> >
<v-btn variant="text" :ripple="false" <button
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 }}
</v-btn> </button>
</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>
<v-btn variant="text" :ripple="false" <button
class="secondary-button" class="secondary-button"
type="button" type="button"
@click="addPlacement()" @click="addPlacement()"
> >
Add channel Add channel
</v-btn> </button>
</div> </div>
<div <div
v-if="groupedChannels.length" v-if="groupedChannels.length"
class="channel-suggestions" class="channel-suggestions"
> >
<v-btn variant="text" :ripple="false" <button
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 }}
</v-btn> </button>
</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>
<v-btn variant="text" :ripple="false" <button
class="link-button" class="link-button"
type="button" type="button"
@click="removePlacement(placement.id)" @click="removePlacement(placement.id)"
> >
Remove Remove
</v-btn> </button>
</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>
<v-btn variant="text" :ripple="false" <button
class="secondary-button" class="secondary-button"
type="button" type="button"
@click="addMedia(placement)" @click="addMedia(placement)"
> >
Add media Add media
</v-btn> </button>
</div> </div>
<div <div
@@ -1063,13 +1063,13 @@
/> />
</div> </div>
<v-btn variant="text" :ripple="false" <button
class="link-button" class="link-button"
type="button" type="button"
@click="removeMedia(placement, media.id)" @click="removeMedia(placement, media.id)"
> >
Remove media Remove media
</v-btn> </button>
</div> </div>
</div> </div>
@@ -1108,7 +1108,7 @@
<template v-else> <template v-else>
<div class="tab-strip"> <div class="tab-strip">
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
</div> </div>
<template v-if="activeProductionTab === 'comments'"> <template v-if="activeProductionTab === 'comments'">
@@ -1200,13 +1200,13 @@
variant="outlined" variant="outlined"
hide-details hide-details
/> />
<v-btn variant="text" :ripple="false" <button
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' }}
</v-btn> </button>
</div> </div>
<div class="timeline-list"> <div class="timeline-list">
@@ -1262,13 +1262,13 @@
variant="outlined" variant="outlined"
hide-details hide-details
/> />
<v-btn variant="text" :ripple="false" <button
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' }}
</v-btn> </button>
</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>
<v-btn variant="text" :ripple="false" <button
class="link-button" class="link-button"
type="button" type="button"
@click="closeCalendarEvent" @click="closeCalendarEvent"
> >
{{ t('close') }} {{ t('close') }}
</v-btn> </button>
</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">
<v-btn variant="text" :ripple="false" <button
class="secondary-button" class="secondary-button"
type="button" type="button"
@click="navigateToCalendarDay(activeCalendarEvent)" @click="navigateToCalendarDay(activeCalendarEvent)"
> >
{{ t('contentItems.dateContext.viewDay') }} {{ t('contentItems.dateContext.viewDay') }}
</v-btn> </button>
</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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-text-muted); color: #526178;
} }
.page-message.error { .page-message.error {
color: var(--app-danger-muted); color: #b91c1c;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.eyebrow { .eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em]; @apply text-xs font-bold uppercase tracking-[0.24em];
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.editor-header h1 { .editor-header h1 {
@apply mt-2 text-4xl font-black; @apply mt-2 text-4xl font-black;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); background: rgba(23, 32, 51, 0.08);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
color: var(--app-color-on-surface); color: #172033;
} }
.back-button:hover { .back-button:hover {
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.primary-button { .primary-button {
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.secondary-button { .secondary-button {
background: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.panel-stack, .panel-stack,
@@ -1495,16 +1495,16 @@
.field span { .field span {
@apply text-sm font-semibold; @apply text-sm font-semibold;
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-surface); background: #fffdf8;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
color: var(--app-color-on-surface); color: #172033;
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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); background: rgba(23, 32, 51, 0.08);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.date-context-day.marked strong, .date-context-day.marked strong,
.date-context-day.active strong { .date-context-day.active strong {
background: var(--app-color-on-tertiary); background: #0f766e;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.date-context-day.active { .date-context-day.active {
background: var(--app-color-on-surface); background: #172033;
border-color: var(--app-color-on-surface); border-color: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.calendar-context-pill:hover { .calendar-context-pill:hover {
background: var(--calendar-color); background: var(--calendar-color);
color: var(--app-color-on-primary); color: #fffaf2;
} }
.date-context-empty { .date-context-empty {
@apply text-sm leading-6; @apply text-sm leading-6;
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-surface); background: #fffdf8;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
} }
.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: var(--app-color-on-surface); color: #172033;
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: var(--app-control-subtle); background: rgba(23, 32, 51, 0.04);
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-primary); background: #fffaf2;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-control-subtle); background: rgba(23, 32, 51, 0.04);
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
color: var(--app-color-on-surface); color: #172033;
} }
.tab-button.active { .tab-button.active {
background: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.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: var(--app-color-on-primary); background: #fffaf2;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-text-muted); color: #526178;
} }
.asset-link { .asset-link {
@apply w-fit text-sm font-semibold; @apply w-fit text-sm font-semibold;
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.asset-revision-row span { .asset-revision-row span {
@apply mr-2 text-sm font-bold; @apply mr-2 text-sm font-bold;
color: var(--app-color-on-surface); color: #172033;
} }
.compact-form { .compact-form {
@@ -1717,7 +1717,7 @@
.link-button { .link-button {
@apply text-sm font-semibold; @apply text-sm font-semibold;
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-surface); background: #fffdf8;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
} }
.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: var(--app-text-muted); color: #526178;
} }
.calendar-event-header strong { .calendar-event-header strong {
@apply text-xl font-black; @apply text-xl font-black;
color: var(--app-color-on-surface); color: #172033;
} }
.calendar-event-actions { .calendar-event-actions {

View File

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

View File

@@ -1,25 +1,35 @@
<script setup> <script setup>
import { computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useMediaLibraryStore } from '@/features/content/stores/mediaLibraryStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { import {
mdiCheckCircleOutline, mdiCheckCircleOutline,
mdiCloudSyncOutline, mdiCloudSyncOutline,
mdiFolderGoogleDrive, mdiFolderGoogleDrive,
mdiImageMultipleOutline,
mdiVideoOutline,
} from '@mdi/js'; } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
const mediaLibraryStore = useMediaLibraryStore();
const workspaceStore = useWorkspaceStore();
const mediaTypes = [ const activeWorkspaceId = computed(() => workspaceStore.activeWorkspaceId);
{ label: t('mediaLibrary.mediaTypes.images'), icon: mdiImageMultipleOutline }, const dam = computed(() => mediaLibraryStore.dam);
{ label: t('mediaLibrary.mediaTypes.videos'), icon: mdiVideoOutline }, const assets = computed(() => dam.value?.assets ?? []);
]; const folderPath = computed(() => dam.value?.folder?.path ?? t('mediaLibrary.notConfigured'));
const workflowSteps = [ const workflowSteps = [
t('mediaLibrary.workflow.connectDrive'), t('mediaLibrary.workflow.connectDrive'),
t('mediaLibrary.workflow.syncAssets'), t('mediaLibrary.workflow.syncAssets'),
t('mediaLibrary.workflow.organizeLibrary'), t('mediaLibrary.workflow.organizeLibrary'),
]; ];
async function loadDam() {
await mediaLibraryStore.fetchWorkspaceDam(activeWorkspaceId.value);
}
onMounted(loadDam);
watch(activeWorkspaceId, loadDam);
</script> </script>
<template> <template>
@@ -35,26 +45,40 @@
<div class="hero-card-icon"> <div class="hero-card-icon">
<v-icon :icon="mdiFolderGoogleDrive" /> <v-icon :icon="mdiFolderGoogleDrive" />
</div> </div>
<strong>{{ t('mediaLibrary.syncCard.title') }}</strong> <strong>{{ dam?.backingStore?.isConfigured ? dam.backingStore.rootFolderName : t('mediaLibrary.syncCard.title') }}</strong>
<span>{{ t('mediaLibrary.syncCard.description') }}</span> <span>{{ dam?.backingStore?.isConfigured ? folderPath : t('mediaLibrary.syncCard.description') }}</span>
</div> </div>
</div> </div>
<div
v-if="mediaLibraryStore.isLoading"
class="page-message"
>
{{ t('mediaLibrary.loading') }}
</div>
<div
v-else-if="mediaLibraryStore.error"
class="page-message error"
>
{{ mediaLibraryStore.error }}
</div>
<div class="content-grid"> <div class="content-grid">
<article class="panel"> <article class="panel">
<div class="panel-header"> <div class="panel-header">
<strong>{{ t('mediaLibrary.mediaTypesTitle') }}</strong> <strong>{{ t('mediaLibrary.damRootTitle') }}</strong>
<span>{{ t('mediaLibrary.mediaTypesDescription') }}</span> <span>{{ dam?.backingStore?.isConfigured ? dam.backingStore.rootFolderUrl : t('mediaLibrary.notConfiguredDescription') }}</span>
</div> </div>
<div class="media-type-list"> <div class="media-type-list">
<div <div class="media-type-item">
v-for="type in mediaTypes" <v-icon :icon="mdiFolderGoogleDrive" />
:key="type.label" <span>{{ folderPath }}</span>
class="media-type-item" </div>
> <div class="media-type-item">
<v-icon :icon="type.icon" /> <v-icon :icon="mdiCheckCircleOutline" />
<span>{{ type.label }}</span> <span>{{ t('mediaLibrary.workspaceSlug', { slug: dam?.workspaceSlug ?? '-' }) }}</span>
</div> </div>
</div> </div>
</article> </article>
@@ -87,8 +111,40 @@
<v-icon :icon="mdiCloudSyncOutline" /> <v-icon :icon="mdiCloudSyncOutline" />
<span>{{ t('mediaLibrary.statusLabel') }}</span> <span>{{ t('mediaLibrary.statusLabel') }}</span>
</div> </div>
<strong>{{ t('mediaLibrary.pendingTitle') }}</strong> <strong>{{ dam?.backingStore?.isConfigured ? t('mediaLibrary.configuredTitle') : t('mediaLibrary.pendingTitle') }}</strong>
<p>{{ t('mediaLibrary.pendingDescription') }}</p> <p>{{ dam?.backingStore?.isConfigured ? t('mediaLibrary.configuredDescription') : t('mediaLibrary.pendingDescription') }}</p>
</div>
</article>
<article class="panel">
<div class="panel-header">
<strong>{{ t('mediaLibrary.assetsTitle') }}</strong>
<span>{{ t('mediaLibrary.assetsDescription') }}</span>
</div>
<div
v-if="!assets.length"
class="empty-state"
>
{{ t('mediaLibrary.emptyAssets') }}
</div>
<div
v-for="asset in assets"
:key="asset.id"
class="asset-row"
>
<div>
<strong>{{ asset.displayName }}</strong>
<span>{{ asset.assetType }} · {{ asset.googleDriveWorkspaceFolderPath || folderPath }}</span>
</div>
<a
v-if="asset.googleDriveLink"
:href="asset.googleDriveLink"
target="_blank"
rel="noreferrer"
>
{{ t('mediaLibrary.openDrive') }}
</a>
</div> </div>
</article> </article>
</section> </section>
@@ -109,7 +165,7 @@
.panel, .panel,
.status-panel { .status-panel {
@apply rounded-[1.75rem] border; @apply rounded-[1.75rem] border;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
} }
@@ -117,17 +173,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, var(--app-surface-raised), rgba(240, 249, 255, 0.92)); linear-gradient(135deg, rgba(255, 255, 255, 0.98), 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: var(--app-color-on-tertiary); color: #0f766e;
} }
.hero-copy h1 { .hero-copy h1 {
@apply mt-3 text-4xl font-black; @apply mt-3 text-4xl font-black;
color: var(--app-color-on-surface); color: #172033;
} }
.hero-copy p, .hero-copy p,
@@ -138,7 +194,7 @@
.status-copy p, .status-copy p,
.status-label span { .status-label span {
@apply text-sm leading-6; @apply text-sm leading-6;
color: var(--app-text-muted); color: #526178;
} }
.hero-card { .hero-card {
@@ -158,13 +214,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: var(--app-color-on-tertiary); color: #0f766e;
} }
.hero-card strong, .hero-card strong,
.panel-header strong, .panel-header strong,
.status-copy strong { .status-copy strong {
color: var(--app-color-on-surface); color: #172033;
} }
.hero-card strong { .hero-card strong {
@@ -189,23 +245,41 @@
} }
.media-type-list, .media-type-list,
.workflow-list { .workflow-list,
.asset-row-list {
@apply flex flex-col gap-3; @apply flex flex-col gap-3;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.9); background: rgba(248, 250, 252, 0.9);
} }
.workflow-icon { .workflow-icon {
color: var(--app-color-on-tertiary); color: #0f766e;
}
.asset-row {
@apply flex items-center justify-between gap-4 rounded-[1.1rem] border px-4 py-3;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.9);
}
.asset-row div {
@apply flex min-w-0 flex-col gap-1;
}
.asset-row span,
.asset-row a,
.empty-state {
@apply text-sm;
color: #526178;
} }
.status-panel { .status-panel {
background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), var(--app-surface-raised)); background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
} }
.status-copy { .status-copy {
@@ -214,7 +288,7 @@
.status-label { .status-label {
@apply text-xs font-bold uppercase tracking-[0.2em]; @apply text-xs font-bold uppercase tracking-[0.2em];
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.status-copy strong { .status-copy strong {

View File

@@ -14,7 +14,7 @@
class="feedback-entry" class="feedback-entry"
data-feedback-ui="true" data-feedback-ui="true"
> >
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
<FeedbackSubmissionDialog v-model="isDialogOpen" /> <FeedbackSubmissionDialog v-model="isDialogOpen" />
</div> </div>

View File

@@ -136,7 +136,7 @@
await nextTick(); await nextTick();
const target = document.querySelector('.shell-container') ?? document.body; const target = document.querySelector('.shell-container') ?? document.body;
const canvas = await html2canvas(target, { const canvas = await html2canvas(target, {
backgroundColor: 'var(--app-color-on-primary)', backgroundColor: '#fffaf2',
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>
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
</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">
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
<span class="feedback-tool-divider"></span> <span class="feedback-tool-divider"></span>
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
</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: var(--app-color-on-primary); background: #fffaf2;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
color: var(--app-color-on-surface); color: #172033;
} }
.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 var(--app-border-subtle); border-bottom: 1px solid rgba(23, 32, 51, 0.08);
} }
.feedback-dialog-footer { .feedback-dialog-footer {
@apply justify-end; @apply justify-end;
border-bottom: 0; border-bottom: 0;
border-top: 1px solid var(--app-border-subtle); border-top: 1px solid rgba(23, 32, 51, 0.08);
} }
.feedback-dialog-header p { .feedback-dialog-header p {
@apply text-xs font-black uppercase; @apply text-xs font-black uppercase;
color: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
color: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-surface); background: #172033;
color: var(--app-color-on-primary); color: #fffaf2;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
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: var(--app-text-muted); color: #526178;
} }
.feedback-empty-preview i { .feedback-empty-preview i {

View File

@@ -184,14 +184,14 @@
<template> <template>
<section class="feedback-detail-page"> <section class="feedback-detail-page">
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
<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>
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</div> </div>
<div <div
@@ -329,13 +329,13 @@
variant="outlined" variant="outlined"
hide-details hide-details
/> />
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</v-form> </v-form>
</section> </section>
</main> </main>
@@ -384,14 +384,14 @@
</template> </template>
</v-combobox> </v-combobox>
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</section> </section>
<section class="panel"> <section class="panel">
@@ -459,20 +459,20 @@
.back-button, .back-button,
.small-button { .small-button {
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
color: var(--app-color-on-surface); color: #172033;
} }
.back-button:hover, .back-button:hover,
.small-button:hover { .small-button:hover {
background: var(--app-color-on-surface); background: #172033;
color: white; color: white;
} }
.primary-button { .primary-button {
border-color: var(--app-color-on-tertiary); border-color: #0f766e;
background: var(--app-color-on-tertiary); background: #0f766e;
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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.detail-grid { .detail-grid {
@@ -519,7 +519,7 @@
.panel { .panel {
@apply rounded-lg border p-5; @apply rounded-lg border p-5;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.description { .description {
@@ -545,7 +545,7 @@
.screenshot-frame { .screenshot-frame {
@apply overflow-hidden rounded-lg border; @apply overflow-hidden rounded-lg border;
border-color: var(--app-control-active); border-color: rgba(23, 32, 51, 0.1);
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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.9); background: rgba(248, 250, 252, 0.9);
color: var(--app-text-muted); color: #526178;
} }
.page-message-error { .page-message-error {
border-color: rgba(220, 38, 38, 0.24); border-color: rgba(220, 38, 38, 0.24);
color: var(--app-danger-muted); color: #b91c1c;
} }
.timeline { .timeline {
@@ -572,7 +572,7 @@
.timeline-item { .timeline-item {
@apply rounded-lg border p-4; @apply rounded-lg border p-4;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
</style> </style>

View File

@@ -70,14 +70,14 @@
<p>{{ t('feedback.review.description') }}</p> <p>{{ t('feedback.review.description') }}</p>
</div> </div>
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
</header> </header>
<section class="metric-grid"> <section class="metric-grid">
@@ -186,14 +186,14 @@
hide-details hide-details
/> />
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
</section> </section>
<div <div
@@ -214,7 +214,7 @@
v-else v-else
class="report-table" class="report-table"
> >
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
<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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
color: var(--app-color-on-surface); color: #172033;
} }
.icon-button:hover, .icon-button:hover,
.filter-reset:hover { .filter-reset:hover {
background: var(--app-color-on-surface); background: #172033;
color: white; color: white;
} }
@@ -320,7 +320,7 @@
.metric { .metric {
@apply rounded-lg border p-4; @apply rounded-lg border p-4;
border-color: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.status-dot { .status-dot {
@@ -402,7 +402,7 @@
} }
.status-planned { .status-planned {
background: var(--app-color-on-tertiary); background: #0f766e;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
background: rgba(255, 255, 255, 0.86); background: rgba(255, 255, 255, 0.86);
color: var(--app-text-muted); color: #526178;
} }
.page-message-error { .page-message-error {
border-color: rgba(220, 38, 38, 0.24); border-color: rgba(220, 38, 38, 0.24);
color: var(--app-danger-muted); color: #b91c1c;
} }
</style> </style>

View File

@@ -100,14 +100,14 @@
<template> <template>
<section class="feedback-detail-page"> <section class="feedback-detail-page">
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
<div <div
v-if="feedbackStore.isDetailLoading" v-if="feedbackStore.isDetailLoading"
@@ -134,7 +134,7 @@
</div> </div>
</div> </div>
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</header> </header>
<div class="detail-grid"> <div class="detail-grid">
@@ -230,14 +230,14 @@
hide-details hide-details
/> />
<v-btn variant="text" :ripple="false" <button
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') }}
</v-btn> </button>
</section> </section>
</aside> </aside>
</div> </div>
@@ -258,19 +258,19 @@
} }
.back-button { .back-button {
color: var(--app-color-on-tertiary); color: #0f766e;
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: var(--app-danger-muted); color: #b91c1c;
background: white; background: white;
} }
.primary-button { .primary-button {
@apply mt-3 justify-center; @apply mt-3 justify-center;
background: var(--app-color-on-surface); background: #172033;
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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.description { .description {
@apply whitespace-pre-wrap text-sm leading-6; @apply whitespace-pre-wrap text-sm leading-6;
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.page-message-error { .page-message-error {
border-color: rgba(220, 38, 38, 0.24); border-color: rgba(220, 38, 38, 0.24);
color: var(--app-danger-muted); color: #b91c1c;
} }
</style> </style>

View File

@@ -43,14 +43,14 @@
<p>{{ t('feedback.mine.description') }}</p> <p>{{ t('feedback.mine.description') }}</p>
</div> </div>
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
</header> </header>
<section class="metric-grid"> <section class="metric-grid">
@@ -98,14 +98,14 @@
hide-details hide-details
/> />
<v-btn variant="text" :ripple="false" <button
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" />
</v-btn> </button>
</section> </section>
<div <div
@@ -126,7 +126,7 @@
v-else v-else
class="report-list" class="report-list"
> >
<v-btn variant="text" :ripple="false" <button
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>
</v-btn> </button>
<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: var(--app-color-on-tertiary); color: #0f766e;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-text-muted); color: #526178;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.08);
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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-border-subtle); border-color: rgba(23, 32, 51, 0.12);
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
color: var(--app-color-on-surface); color: #172033;
} }
.icon-button:hover { .icon-button:hover {
background: var(--app-color-on-surface); background: #172033;
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: var(--app-color-on-tertiary); background: #0f766e;
} }
.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: var(--app-color-on-surface); color: #172033;
} }
.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: var(--app-color-on-tertiary); color: #0f766e;
} }
.report-description { .report-description {
@apply line-clamp-2 text-sm leading-6; @apply line-clamp-2 text-sm leading-6;
color: var(--app-text-muted); color: #526178;
} }
.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: var(--app-control-hover); background: rgba(23, 32, 51, 0.06);
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: var(--app-text-muted); color: #526178;
} }
.page-message-error { .page-message-error {
border-color: rgba(220, 38, 38, 0.24); border-color: rgba(220, 38, 38, 0.24);
color: var(--app-danger-muted); color: #b91c1c;
} }
</style> </style>

View File

@@ -27,6 +27,7 @@ export const useOrganizationStore = defineStore('organization', () => {
const isLoadingMembershipTiers = ref(false); const isLoadingMembershipTiers = ref(false);
const isSaving = ref(false); const isSaving = ref(false);
const isUpdatingMembershipTier = ref(false); const isUpdatingMembershipTier = ref(false);
const isSavingGoogleDriveDam = ref(false);
const isAddingMember = ref(false); const isAddingMember = ref(false);
const isUploadingLogo = ref(false); const isUploadingLogo = ref(false);
const error = ref(null); const error = ref(null);
@@ -260,6 +261,45 @@ export const useOrganizationStore = defineStore('organization', () => {
} }
} }
async function updateGoogleDriveDam(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to update organization connectors.');
}
isSavingGoogleDriveDam.value = true;
error.value = null;
try {
const response = await client.put(`/api/organizations/${organizationId}/google-drive-dam`, payload);
const googleDriveDam = response.data;
const currentDetails = detailsById.value[organizationId];
if (currentDetails) {
detailsById.value = {
...detailsById.value,
[organizationId]: {
...currentDetails,
googleDriveDam,
},
};
}
organizations.value = organizations.value.map(organization =>
organization.id === organizationId
? { ...organization, googleDriveDam }
: organization
);
return googleDriveDam;
} catch (updateError) {
console.error('Failed to update Google Drive DAM configuration:', updateError);
error.value = 'Failed to update Google Drive DAM configuration.';
throw updateError;
} finally {
isSavingGoogleDriveDam.value = false;
}
}
async function addMember(organizationId, payload) { async function addMember(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) { if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to add an organization member.'); throw new Error('You must be authenticated to add an organization member.');
@@ -371,6 +411,7 @@ export const useOrganizationStore = defineStore('organization', () => {
isLoadingMembershipTiers, isLoadingMembershipTiers,
isSaving, isSaving,
isUpdatingMembershipTier, isUpdatingMembershipTier,
isSavingGoogleDriveDam,
isAddingMember, isAddingMember,
isUploadingLogo, isUploadingLogo,
error, error,
@@ -383,6 +424,7 @@ export const useOrganizationStore = defineStore('organization', () => {
createOrganization, createOrganization,
updateOrganization, updateOrganization,
updateMembershipTier, updateMembershipTier,
updateGoogleDriveDam,
addMember, addMember,
uploadLogo, uploadLogo,
}; };

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