Add AWS S3 integration to push strings

This commit is contained in:
Simon Prévost 2024-03-14 21:58:45 -04:00
parent 388c07a227
commit 857176fdbf
37 changed files with 912 additions and 202 deletions

View File

@ -0,0 +1,53 @@
defmodule Accent.IntegrationManager.Execute.AWSS3 do
@moduledoc false
alias Accent.Hook
def upload_translations(integration, user, params) do
{uploads, version_tag} = Accent.IntegrationManager.Execute.UploadDocuments.all(integration, params)
base_url = "https://s3-#{integration.data.aws_s3_region}.amazonaws.com/#{integration.data.aws_s3_bucket}"
url =
Path.join([
base_url,
integration.data.aws_s3_path_prefix
])
uri = URI.parse(url)
document_urls =
for upload <- uploads do
{url, document_name} = Accent.IntegrationManager.Execute.UploadDocuments.url(upload, uri, version_tag)
headers =
:aws_signature.sign_v4(
integration.data.aws_s3_access_key_id,
integration.data.aws_s3_secret_access_key,
integration.data.aws_s3_region,
"s3",
:calendar.universal_time(),
"put",
url,
[{"host", uri.authority}],
upload.render,
uri_encode_path: false
)
{:ok, %{status_code: 200}} = HTTPoison.put(url, {:file, upload.file}, headers)
%{name: document_name, url: url}
end
Hook.outbound(%Hook.Context{
event: "integration_execute_azure_storage_container",
project_id: integration.project_id,
user_id: user.id,
payload: %{
version_tag: version_tag,
document_urls: document_urls
}
})
:ok
end
end

View File

@ -1,82 +1,24 @@
defmodule Accent.IntegrationManager.Execute.AzureStorageContainer do
@moduledoc false
alias Accent.Document
alias Accent.Hook
alias Accent.Repo
alias Accent.Revision
alias Accent.Scopes.Document, as: DocumentScope
alias Accent.Scopes.Revision, as: RevisionScope
alias Accent.Scopes.Translation, as: TranslationScope
alias Accent.Scopes.Version, as: VersionScope
alias Accent.Translation
alias Accent.Version
def upload_translations(integration, user, params) do
project = Repo.one!(Ecto.assoc(integration, :project))
version = fetch_version(project, params)
documents = fetch_documents(project)
revisions = fetch_revisions(project)
master_revision =
Repo.preload(Repo.one!(RevisionScope.master(Ecto.assoc(project, :revisions))), :language)
uploads =
Enum.flat_map(documents, fn document ->
Enum.flat_map(revisions, fn revision ->
translations = fetch_translations(document, revision, version)
if Enum.any?(translations) do
render_options = %{
translations: translations,
master_language: Revision.language(master_revision),
language: Revision.language(revision),
document: document
}
%{render: render} = Accent.TranslationsRenderer.render_translations(render_options)
[
%{
document: %{document | render: render},
language: Accent.Revision.language(revision)
}
]
else
[]
end
end)
end)
{uploads, version_tag} = Accent.IntegrationManager.Execute.UploadDocuments.all(integration, params)
uri = URI.parse(integration.data.azure_storage_container_sas)
version_tag = (version && version.tag) || "latest"
document_urls =
for upload <- uploads do
file = Path.join([System.tmp_dir(), Accent.Utils.SecureRandom.urlsafe_base64(16)])
:ok = File.write(file, upload.document.render)
extension = Accent.DocumentFormat.extension_by_format(upload.document.format)
document_name = upload.document.path <> "." <> extension
path =
Path.join([
uri.path || "/",
version_tag,
upload.language.slug,
document_name
])
url = URI.to_string(%{uri | path: path})
HTTPoison.put(url, {:file, file}, [{"x-ms-blob-type", "BlockBlob"}])
{url, document_name} = Accent.IntegrationManager.Execute.UploadDocuments.url(upload, uri, version_tag)
HTTPoison.put(url, {:file, upload.file}, [{"x-ms-blob-type", "BlockBlob"}])
%{name: document_name, url: url}
end
Hook.outbound(%Hook.Context{
event: "integration_execute_azure_storage_container",
project_id: project.id,
project_id: integration.project_id,
user_id: user.id,
payload: %{
version_tag: version_tag,
@ -86,37 +28,4 @@ defmodule Accent.IntegrationManager.Execute.AzureStorageContainer do
:ok
end
defp fetch_version(project, %{target_version: :specific, tag: tag}) do
Version
|> VersionScope.from_project(project.id)
|> VersionScope.from_tag(tag)
|> Repo.one()
end
defp fetch_version(_, _) do
nil
end
defp fetch_documents(project) do
Document
|> DocumentScope.from_project(project.id)
|> Repo.all()
end
defp fetch_revisions(project) do
Revision
|> RevisionScope.from_project(project.id)
|> Repo.all()
|> Repo.preload(:language)
end
defp fetch_translations(document, revision, version) do
Translation
|> TranslationScope.active()
|> TranslationScope.from_document(document.id)
|> TranslationScope.from_revision(revision.id)
|> TranslationScope.from_version(version && version.id)
|> Repo.all()
end
end

View File

@ -0,0 +1,106 @@
defmodule Accent.IntegrationManager.Execute.UploadDocuments do
@moduledoc false
alias Accent.Document
alias Accent.Repo
alias Accent.Revision
alias Accent.Scopes.Document, as: DocumentScope
alias Accent.Scopes.Revision, as: RevisionScope
alias Accent.Scopes.Translation, as: TranslationScope
alias Accent.Scopes.Version, as: VersionScope
alias Accent.Translation
alias Accent.Version
def url(upload, uri, version_tag) do
extension = Accent.DocumentFormat.extension_by_format(upload.document.format)
document_name = upload.document.path <> "." <> extension
path =
Path.join([
uri.path || "/",
version_tag,
upload.language.slug,
document_name
])
{URI.to_string(%{uri | path: path}), document_name}
end
def all(integration, params) do
project = Repo.one!(Ecto.assoc(integration, :project))
version = fetch_version(project, params)
documents = fetch_documents(project)
revisions = fetch_revisions(project)
master_revision =
Repo.preload(Repo.one!(RevisionScope.master(Ecto.assoc(project, :revisions))), :language)
version_tag = (version && version.tag) || "latest"
uploads =
Enum.flat_map(documents, fn document ->
Enum.flat_map(revisions, fn revision ->
translations = fetch_translations(document, revision, version)
if Enum.any?(translations) do
render_options = %{
translations: translations,
master_language: Revision.language(master_revision),
language: Revision.language(revision),
document: document
}
%{render: render} = Accent.TranslationsRenderer.render_translations(render_options)
file = Path.join([System.tmp_dir(), Accent.Utils.SecureRandom.urlsafe_base64(16)])
:ok = File.write(file, render)
[
%{
file: file,
render: render,
document: document,
language: Accent.Revision.language(revision)
}
]
else
[]
end
end)
end)
{uploads, version_tag}
end
defp fetch_version(project, %{target_version: :specific, tag: tag}) do
Version
|> VersionScope.from_project(project.id)
|> VersionScope.from_tag(tag)
|> Repo.one()
end
defp fetch_version(_, _) do
nil
end
defp fetch_documents(project) do
Document
|> DocumentScope.from_project(project.id)
|> Repo.all()
end
defp fetch_revisions(project) do
Revision
|> RevisionScope.from_project(project.id)
|> Repo.all()
|> Repo.preload(:language)
end
defp fetch_translations(document, revision, version) do
Translation
|> TranslationScope.active()
|> TranslationScope.from_document(document.id)
|> TranslationScope.from_revision(revision.id)
|> TranslationScope.from_version(version && version.id)
|> Repo.all()
end
end

View File

@ -45,7 +45,7 @@ defmodule Accent.IntegrationManager do
defp changeset(model, params) do
model
|> cast(params, [:project_id, :user_id, :service, :events])
|> validate_inclusion(:service, ~w(slack github discord azure_storage_container))
|> validate_inclusion(:service, ~w(slack github discord azure_storage_container aws_s3))
|> cast_embed(:data, with: changeset_data(params[:service] || model.service))
|> foreign_key_constraint(:project_id)
|> validate_required([:service, :data])
@ -61,6 +61,16 @@ defmodule Accent.IntegrationManager do
:ok
end
defp execute_integration(%{service: "aws_s3"} = integration, user, params) do
Accent.IntegrationManager.Execute.AWSS3.upload_translations(
integration,
user,
params[:aws_s3]
)
:ok
end
defp execute_integration(_integration, _user, _params) do
:noop
end
@ -89,6 +99,26 @@ defmodule Accent.IntegrationManager do
end
end
defp changeset_data("aws_s3") do
fn model, params ->
model
|> cast(params, [
:aws_s3_bucket,
:aws_s3_path_prefix,
:aws_s3_region,
:aws_s3_access_key_id,
:aws_s3_secret_access_key
])
|> validate_required([
:aws_s3_bucket,
:aws_s3_path_prefix,
:aws_s3_region,
:aws_s3_access_key_id,
:aws_s3_secret_access_key
])
end
end
defp changeset_data(_) do
fn model, params -> cast(model, params, []) end
end

View File

@ -10,6 +10,11 @@ defmodule Accent.Integration do
embeds_one(:data, IntegrationData, on_replace: :update) do
field(:url)
field(:azure_storage_container_sas)
field(:aws_s3_bucket)
field(:aws_s3_path_prefix)
field(:aws_s3_region)
field(:aws_s3_access_key_id)
field(:aws_s3_secret_access_key)
end
belongs_to(:project, Accent.Project)

View File

@ -12,15 +12,30 @@ defmodule Accent.GraphQL.Mutations.Integration do
value(:latest)
end
enum :project_integration_execute_aws_s3_target_version do
value(:specific)
value(:latest)
end
input_object :project_integration_execute_azure_storage_container_input do
field(:target_version, :project_integration_execute_azure_storage_container_target_version)
field(:tag, :string)
end
input_object :project_integration_execute_aws_s3_input do
field(:target_version, :project_integration_execute_aws_s3_target_version)
field(:tag, :string)
end
input_object :project_integration_data_input do
field(:id, :id)
field(:url, :string)
field(:azure_storage_container_sas, :string)
field(:aws_s3_region, :string)
field(:aws_s3_bucket, :string)
field(:aws_s3_path_prefix, :string)
field(:aws_s3_access_key_id, :string)
field(:aws_s3_secret_access_key, :string)
end
payload_object(:project_integration_payload, :project_integration)
@ -39,6 +54,7 @@ defmodule Accent.GraphQL.Mutations.Integration do
field :execute_project_integration, :project_integration_payload do
arg(:id, non_null(:id))
arg(:azure_storage_container, :project_integration_execute_azure_storage_container_input)
arg(:aws_s3, :project_integration_execute_aws_s3_input)
resolve(integration_authorize(:execute_project_integration, &IntegrationResolver.execute/3))
middleware(&build_payload/2)

View File

@ -6,6 +6,10 @@ defmodule Accent.GraphQL.Resolvers.Integration do
alias Accent.IntegrationManager
alias Accent.Plugs.GraphQLContext
alias Accent.Project
alias Accent.Repo
alias Accent.Scopes.Integration, as: IntegrationScope
require Ecto.Query
@typep integration_operation :: Accent.GraphQL.Response.t()
@ -38,4 +42,13 @@ defmodule Accent.GraphQL.Resolvers.Integration do
|> IntegrationManager.delete()
|> build()
end
@spec list_project(Project.t(), map(), GraphQLContext.t()) :: {:ok, [Integration.t()]}
def list_project(project, _args, _) do
Integration
|> IntegrationScope.from_project(project.id)
|> Ecto.Query.order_by(desc: :inserted_at)
|> Repo.all()
|> then(&{:ok, &1})
end
end

View File

@ -7,6 +7,7 @@ defmodule Accent.GraphQL.Types.Integration do
value(:discord, as: "discord")
value(:github, as: "github")
value(:azure_storage_container, as: "azure_storage_container")
value(:aws_s3, as: "aws_s3")
end
enum :project_integration_event do
@ -16,6 +17,7 @@ defmodule Accent.GraphQL.Types.Integration do
value(:create_collaborator, as: "create_collaborator")
value(:create_comment, as: "create_comment")
value(:integration_execute_azure_storage_container, as: "integration_execute_azure_storage_container")
value(:integration_execute_aws_s3, as: "integration_execute_aws_s3")
end
interface :project_integration do
@ -27,6 +29,7 @@ defmodule Accent.GraphQL.Types.Integration do
%{service: "slack"}, _ -> :project_integration_slack
%{service: "github"}, _ -> :project_integration_github
%{service: "azure_storage_container"}, _ -> :project_integration_azure_storage_container
%{service: "aws_s3"}, _ -> :project_integration_aws_s3
end)
end
@ -61,7 +64,16 @@ defmodule Accent.GraphQL.Types.Integration do
field(:id, non_null(:id))
field(:service, non_null(:project_integration_service))
field(:last_executed_at, :datetime)
field(:data, non_null(:project_integration_azure_data))
field(:data, non_null(:project_integration_azure_storage_container_data))
interfaces([:project_integration])
end
object :project_integration_aws_s3 do
field(:id, non_null(:id))
field(:service, non_null(:project_integration_service))
field(:last_executed_at, :datetime)
field(:data, non_null(:project_integration_aws_s3_data))
interfaces([:project_integration])
end
@ -75,7 +87,7 @@ defmodule Accent.GraphQL.Types.Integration do
field(:id, non_null(:id))
end
object :project_integration_azure_data do
object :project_integration_azure_storage_container_data do
field(:id, non_null(:id))
field(:sas_base_url, non_null(:string),
@ -86,4 +98,12 @@ defmodule Accent.GraphQL.Types.Integration do
end
)
end
object :project_integration_aws_s3_data do
field(:id, non_null(:id))
field(:bucket, non_null(:string), resolve: fn data, _, _ -> {:ok, data.aws_s3_bucket} end)
field(:path_prefix, non_null(:string), resolve: fn data, _, _ -> {:ok, data.aws_s3_path_prefix} end)
field(:region, non_null(:string), resolve: fn data, _, _ -> {:ok, data.aws_s3_region} end)
field(:access_key_id, non_null(:string), resolve: fn data, _, _ -> {:ok, data.aws_s3_access_key_id} end)
end
end

View File

@ -125,7 +125,10 @@ defmodule Accent.GraphQL.Types.Project do
end
field(:language, :language, resolve: dataloader(Accent.Language))
field(:integrations, list_of(:project_integration), resolve: dataloader(Accent.Integration))
field :integrations, list_of(non_null(:project_integration)) do
resolve(project_authorize(:index_project_integrations, &Accent.GraphQL.Resolvers.Integration.list_project/3))
end
field :document, :document do
arg(:id, non_null(:id))

View File

@ -82,6 +82,7 @@ defmodule Accent.Mixfile do
{:jason, "~> 1.2", override: true},
{:erlsom, "~> 1.5"},
{:xml_builder, "~> 2.0"},
{:aws_signature, "~> 0.3"},
# Auth
{:ueberauth, "~> 0.10"},

View File

@ -2,6 +2,7 @@
"absinthe": {:hex, :absinthe, "1.7.6", "0b897365f98d068cfcb4533c0200a8e58825a4aeeae6ec33633ebed6de11773b", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7626951ca5eec627da960615b51009f3a774765406ff02722b1d818f17e5778"},
"absinthe_error_payload": {:hex, :absinthe_error_payload, "1.1.4", "502ff239148c8deaac028ddb600d6502d5be68d24fece0c93f4c3cf7e74c1a4d", [:make, :mix], [{:absinthe, "~> 1.3", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "9e262ef2fd4a2c644075e0cdde2573b1f713c0676ab905c8640eaa8a882b2aca"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
"aws_signature": {:hex, :aws_signature, "0.3.2", "adf33bc4af00b2089b7708bf20e3246f09c639a905a619b3689f0a0a22c3ef8f", [:rebar3], [], "hexpm", "b0daf61feb4250a8ab0adea60db3e336af732ff71dd3fb22e45ae3dcbd071e44"},
"bamboo": {:hex, :bamboo, "2.3.0", "d2392a2cabe91edf488553d3c70638b532e8db7b76b84b0a39e3dfe492ffd6fc", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dd0037e68e108fd04d0e8773921512c940e35d981e097b5793543e3b2f9cd3f6"},
"bamboo_phoenix": {:hex, :bamboo_phoenix, "1.0.0", "f3cc591ffb163ed0bf935d256f1f4645cd870cf436545601215745fb9cc9953f", [:mix], [{:bamboo, ">= 2.0.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6db88fbb26019c84a47994bb2bd879c0887c29ce6c559bc6385fd54eb8b37dee"},
"bamboo_smtp": {:hex, :bamboo_smtp, "4.2.2", "e9f57a2300df9cb496c48751bd7668a86a2b89aa2e79ccaa34e0c46a5f64c3ae", [:mix], [{:bamboo, "~> 2.2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 1.2.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm", "28cac2ec8adaae02aed663bf68163992891a3b44cfd7ada0bebe3e09bed7207f"},

View File

@ -77,7 +77,8 @@ defmodule AccentTest.GraphQL.Resolvers.Integration do
assert integration.errors == [
service:
{"is invalid", [validation: :inclusion, enum: ["slack", "github", "discord", "azure_storage_container"]]}
{"is invalid",
[validation: :inclusion, enum: ["slack", "github", "discord", "azure_storage_container", "aws_s3"]]}
]
assert Repo.all(Integration) == []

View File

@ -0,0 +1,65 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
interface Args {
errors: any;
project: any;
onChangeBucket: (value: string) => void;
onChangePathPrefix: (value: string) => void;
onChangeRegion: (value: string) => void;
onChangeAccessKeyId: (value: string) => void;
onChangeSecretAccessKey: (value: string) => void;
}
export default class AwsS3 extends Component<Args> {
get policyContent() {
const bucket = this.args.bucket || '-';
const pathPrefix = this.args.pathPrefix || '/';
return `{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::${bucket}${pathPrefix}*"
}
]
}`;
}
@action
changeBucket(event: Event) {
const target = event.target as HTMLInputElement;
this.args.onChangeBucket(target.value);
}
@action
changePathPrefix(event: Event) {
const target = event.target as HTMLInputElement;
this.args.onChangePathPrefix(target.value);
}
@action
changeRegion(event: Event) {
const target = event.target as HTMLInputElement;
this.args.onChangeRegion(target.value);
}
@action
changeAccessKeyId(event: Event) {
const target = event.target as HTMLInputElement;
this.args.onChangeAccessKeyId(target.value);
}
@action
changeSecretAccessKey(event: Event) {
const target = event.target as HTMLInputElement;
this.args.onChangeSecretAccessKey(target.value);
}
}

View File

@ -7,6 +7,7 @@ import {tracked} from '@glimmer/tracking';
const LOGOS = {
AZURE_STORAGE_CONTAINER: 'assets/services/azure.svg',
AWS_S3: 'assets/services/aws-s3.svg',
DISCORD: 'assets/services/discord.svg',
GITHUB: 'assets/services/github.svg',
SLACK: 'assets/services/slack.svg'
@ -27,6 +28,11 @@ interface Args {
data: {
url: string;
azureStorageContainerSas: string;
awsS3Bucket: string;
awsS3PathPrefix: string;
awsS3Region: string;
awsS3AccessKeyId: string;
awsS3SecretAccessKey: string;
};
}) => Promise<{errors: any}>;
onCancel: () => void;
@ -62,7 +68,22 @@ export default class IntegrationsForm extends Component<Args> {
@tracked
azureStorageContainerSasBaseUrl: string;
services = ['AZURE_STORAGE_CONTAINER', 'SLACK', 'DISCORD'];
@tracked
awsS3Bucket: string;
@tracked
awsS3PathPrefix: string;
@tracked
awsS3Region: string;
@tracked
awsS3AccessKeyId: string;
@tracked
awsS3SecretAccessKey: string;
services = ['AWS_S3', 'AZURE_STORAGE_CONTAINER', 'SLACK', 'DISCORD'];
@not('url')
emptyUrl: boolean;
@ -109,6 +130,10 @@ export default class IntegrationsForm extends Component<Args> {
this.url = this.integration.data.url;
this.events = this.integration.events;
this.azureStorageContainerSasBaseUrl = this.integration.data.sasBaseUrl;
this.awsS3Bucket = this.integration.data.bucket;
this.awsS3PathPrefix = this.integration.data.pathPrefix;
this.awsS3Region = this.integration.data.region;
this.awsS3AccessKeyId = this.integration.data.accessKeyId;
}
@action
@ -132,8 +157,33 @@ export default class IntegrationsForm extends Component<Args> {
}
@action
setAzureStorageContainerSas(sas: string) {
this.azureStorageContainerSas = sas;
setAzureStorageContainerSas(value: string) {
this.azureStorageContainerSas = value;
}
@action
setAwsS3Bucket(value: string) {
this.awsS3Bucket = value;
}
@action
setAwsS3PathPrefix(value: string) {
this.awsS3PathPrefix = value;
}
@action
setAwsS3Region(value: string) {
this.awsS3Region = value;
}
@action
setAwsS3AccessKeyId(value: string) {
this.awsS3AccessKeyId = value;
}
@action
setAwsS3SecretAccessKey(value: string) {
this.awsS3SecretAccessKey = value;
}
@action
@ -146,7 +196,12 @@ export default class IntegrationsForm extends Component<Args> {
integration: this.integration.newRecord ? null : this.integration,
data: {
url: this.url,
azureStorageContainerSas: this.azureStorageContainerSas
azureStorageContainerSas: this.azureStorageContainerSas,
awsS3Bucket: this.awsS3Bucket,
awsS3PathPrefix: this.awsS3PathPrefix,
awsS3Region: this.awsS3Region,
awsS3AccessKeyId: this.awsS3AccessKeyId,
awsS3SecretAccessKey: this.awsS3SecretAccessKey
}
});

View File

@ -4,12 +4,13 @@ import {tracked} from '@glimmer/tracking';
const LOGOS = {
AZURE_STORAGE_CONTAINER: 'assets/services/azure.svg',
AWS_S3: 'assets/services/aws-s3.svg',
DISCORD: 'assets/services/discord.svg',
GITHUB: 'assets/services/github.svg',
SLACK: 'assets/services/slack.svg'
};
const EXECUTABLE_SERVICES = ['AZURE_STORAGE_CONTAINER'];
const EXECUTABLE_SERVICES = ['AZURE_STORAGE_CONTAINER', 'AWS_S3'];
interface Args {
project: any;

View File

@ -0,0 +1,110 @@
import {inject as service} from '@ember/service';
import {action} from '@ember/object';
import {readOnly} from '@ember/object/computed';
import {tracked} from '@glimmer/tracking';
import Component from '@glimmer/component';
import IntlService from 'ember-intl/services/intl';
import FlashMessages from 'ember-cli-flash/services/flash-messages';
import ApolloMutate from 'accent-webapp/services/apollo-mutate';
import executeIntegration from 'accent-webapp/queries/execute-integration';
const FLASH_MESSAGE_CREATE_SUCCESS =
'pods.versions.new.flash_messages.create_success';
const FLASH_MESSAGE_CREATE_ERROR =
'pods.versions.new.flash_messages.create_error';
interface Args {
close: () => void;
integration: {
id: string;
};
}
export default class IntegrationExecuteAwsS3 extends Component<Args> {
@service('intl')
intl: IntlService;
@service('apollo-mutate')
apolloMutate: ApolloMutate;
@service('flash-messages')
flashMessages: FlashMessages;
@readOnly('model.projectModel.project')
project: any;
@tracked
error = false;
allTargetVersions = [
{
value: 'LATEST',
label:
'components.project_settings.integrations.execute.aws_s3.target_version.options.latest'
},
{
value: 'SPECIFIC',
label:
'components.project_settings.integrations.execute.aws_s3.target_version.options.specific'
}
];
@tracked
targetVersion = this.allTargetVersions[0].value;
@tracked
tag: string | null = null;
@tracked
isSubmitting = false;
@action
setTargetVersion(targetVersion: string) {
this.tag = null;
this.targetVersion = targetVersion;
}
@action
setTag(event: Event) {
const target = event.target as HTMLInputElement;
this.tag = target.value;
}
@action
autofocus(input: HTMLInputElement) {
input.focus();
}
@action
async submit() {
const confirmMessage = this.intl.t(
'components.project_settings.integrations.execute.aws_s3.submit_confirm'
);
/* eslint-disable-next-line no-alert */
if (!window.confirm(confirmMessage)) {
return;
}
const response = await this.apolloMutate.mutate({
mutation: executeIntegration,
refetchQueries: ['ProjectServiceIntegrations'],
variables: {
integrationId: this.args.integration.id,
azureStorageContainer: {
tag: this.tag,
targetVersion: this.targetVersion
}
}
});
if (response.errors) {
this.flashMessages.error(this.intl.t(FLASH_MESSAGE_CREATE_ERROR));
} else {
this.args.close();
this.flashMessages.success(this.intl.t(FLASH_MESSAGE_CREATE_SUCCESS));
}
return response;
}
}

View File

@ -487,6 +487,7 @@
"developer_text": "Can make file operations: Sync, add translations and preview those operations in the UI.",
"owner_text": "With the same roles as the admin, the owners are people who the project belongs to.",
"reviewer_text": "Can do every tasks except those listed in the above roles. Review, update strings, comments, etc.",
"translator_text": "Translate strings and make updates on documents. The translator is not included in the review process.",
"title": "Collaborators"
},
"collaborators_item": {
@ -509,9 +510,18 @@
"last_executed_at": "Last executed at:",
"data": {
"azure_storage_container_sas": "SAS URL",
"aws_s3_bucket": "Bucket name",
"aws_s3_path_prefix": "Path prefix",
"aws_s3_region": "Region",
"aws_s3_access_key_id": "Access Key ID",
"aws_s3_secret_access_key": "Secret Access Key",
"url": "URL"
},
"empty_description": {
"aws_s3": {
"title": "AWS S3",
"text": "Upload your files in Amazon Web Services S3 to serve them directly in your application"
},
"azure_storage_container": {
"title": "Azure Storage Container",
"text": "Upload your files in Azure Storage Container to serve them directly in your application"
@ -525,11 +535,6 @@
"text": "Post your projects notifications in Slack"
}
},
"token_how": "How?",
"github_webhook_url_how": "How?",
"github_webhook_accent_cli_1": "You need to have a valid <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/mirego/accent/tree/master/cli\">accent-cli</a> setup for the hook to work.",
"github_webhook_accent_cli_2": "The <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/mirego/accent/blob/master/cli/examples/react/accent.json\">accent.json</a> file at the root of your project will be used.",
"github_webhook_url": "Make sure to add the webhook in your projects settings",
"webhook_url_how": "How to obtain a webhook URL?",
"events": {
"title": "Which events would you like to trigger this webhook?",
@ -541,6 +546,22 @@
}
},
"execute": {
"aws_s3": {
"title": "Upload files to AWS S3",
"cancel_button": "Cancel",
"error": "Error while pushing to your bucket. Check your credentials and try again.",
"push_button": "Upload",
"bucket": "Bucket",
"path_prefix": "Path prefix",
"submit_confirm": "Uploading files to AWS S3 can override existing files if you upload for the latest or an already uploaded version tag.",
"target_version": {
"label": "Target Version",
"options": {
"latest": "Latest Version",
"specific": "Specific Version"
}
}
},
"azure_storage_container": {
"title": "Upload files to Azure Storage Container",
"cancel_button": "Cancel",
@ -1020,7 +1041,8 @@
"integration_services": {
"DISCORD": "Discord",
"SLACK": "Slack",
"AZURE_STORAGE_CONTAINER": "Azure Storage Container"
"AZURE_STORAGE_CONTAINER": "Azure Storage Container",
"AWS_S3": "AWS S3"
},
"search_input_placeholder_text": "Search for a string…"
},

View File

@ -503,6 +503,7 @@
"developer_text": "Peut effectuer des opérations sur les fichiers : synchroniser, ajouter des traductions et prévisualiser ces opérations dans linterface utilisateur.",
"owner_text": "Avec les mêmes rôles que ladministrateur, les propriétaires sont les personnes à qui appartient le projet.",
"reviewer_text": "Peut effectuer toutes les tâches sauf celles énumérées dans les rôles ci-dessus. Réviser, mettre à jour les chaînes, les commentaires, etc.",
"translator_text": "Traduire des chaînes et effectuez des mises à jour sur les fichiers. Le traducteur nest pas inclus dans le processus de révision.",
"title": "Collaborateurs"
},
"collaborators_item": {
@ -525,9 +526,18 @@
"delete": "Supprimer",
"data": {
"azure_storage_container_sas": "SAS URL",
"aws_s3_bucket": "Nom du bucket",
"aws_s3_path_prefix": "Préfix",
"aws_s3_region": "Région",
"aws_s3_access_key_id": "Access Key ID",
"aws_s3_secret_access_key": "Secret Access Key",
"url": "URL"
},
"empty_description": {
"aws_s3": {
"title": "AWS S3",
"text": "Téléchargez vos fichiers dans Amazon Web Service S3 pour les servir directement dans votre application"
},
"azure_storage_container": {
"title": "Azure Storage Container",
"text": "Téléchargez vos fichiers dans Azure Storage Container pour les servir directement dans votre application"
@ -541,11 +551,6 @@
"text": "Publiez les notifications de votre projet dans Slack"
}
},
"token_how": "Comment?",
"github_webhook_url_how": "Comment?",
"github_webhook_accent_cli_1": "Vous devez avoir une configuration <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/mirego/accent/tree/master/cli\">accent-cli</a> valide pour le crochet au travail.",
"github_webhook_accent_cli_2": "Le <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/mirego/accent/blob/master/cli/examples/react/accent.json\">accent.json</a > fichier à la racine de votre projet sera utilisé.",
"github_webhook_url": "Assurez-vous dajouter le webhook dans les paramètres de votre projet",
"webhook_url_how": "Comment obtenir une URL de webhook ?",
"events": {
"title": "Quels événements souhaitez-vous déclencher ce webhook ?",
@ -557,6 +562,22 @@
}
},
"execute": {
"aws_s3": {
"title": "Publier sur AWS S3",
"cancel_button": "Annuler",
"error": "Une erreur sest produite lors de la publication sur Azure. Veuillez vérifier vos informations didentification et réessayer.",
"push_button": "Publier",
"bucket": "Nom du bucket",
"path_prefix": "Préfix",
"submit_confirm": "La publication de fichiers vers AWS S3 peut remplacer les fichiers existants si vous publiez la dernière version ou une version déjà publiée.",
"target_version": {
"label": "Version Cible",
"options": {
"latest": "Dernière version",
"specific": "Version spécifique"
}
}
},
"azure_storage_container": {
"title": "Publier sur Azure Storage Container",
"cancel_button": "Annuler",
@ -1020,7 +1041,8 @@
"integration_services": {
"DISCORD": "Discord",
"SLACK": "Slack",
"AZURE_STORAGE_CONTAINER": "Azure Storage Container"
"AZURE_STORAGE_CONTAINER": "Azure Storage Container",
"AWS_S3": "AWS S3"
},
"search_input_placeholder_text": "Rechercher une chaîne…"
},

View File

@ -4,10 +4,12 @@ export default gql`
mutation IntegrationExecute(
$integrationId: ID!
$azureStorageContainer: ProjectIntegrationExecuteAzureStorageContainerInput
$awsS3: ProjectIntegrationExecuteAwsS3Input
) {
executeProjectIntegration(
id: $integrationId
azureStorageContainer: $azureStorageContainer
awsS3: $awsS3
) {
projectIntegration: result {
id

View File

@ -27,6 +27,17 @@ export default gql`
}
}
... on ProjectIntegrationAwsS3 {
lastExecutedAt
data {
id
bucket
pathPrefix
region
accessKeyId
}
}
... on ProjectIntegrationAzureStorageContainer {
lastExecutedAt
data {

View File

@ -24,13 +24,13 @@
}
.prompt-button-quick-access[data-rtl] {
transform: translateX(36px);
transform: translateX(40px);
}
.prompt-button-quick-access {
opacity: 1;
transform: translateX(-36px);
padding: 0 10px;
transform: translateX(-40px);
padding: 0 5px;
pointer-events: all;
}
}
@ -46,6 +46,7 @@
background: var(--input-background);
opacity: 0;
pointer-events: none;
border-radius: var(--border-radius);
right: 0;
display: flex;
gap: 0;

View File

@ -40,16 +40,16 @@
}
.project {
padding: 20px 0 20px 16px;
padding: 20px 10px 20px 16px;
margin-left: 0;
position: relative;
display: flex;
align-items: flex-start;
font-size: 18px;
align-items: center;
font-size: 15px;
font-weight: 700;
color: var(--color-black);
text-decoration: none;
line-height: 1;
line-height: 1.2;
}
.project-logo {
@ -59,13 +59,11 @@
top: 2px;
font-size: 22px;
margin-right: 10px;
line-height: 1.1;
line-height: 1.2;
svg {
position: relative;
top: -2px;
width: 25px;
height: 25px;
width: 22px;
height: 22px;
circle {
fill: var(--logo-background);

View File

@ -28,8 +28,15 @@
border-left: 1px solid var(--background-light-highlight);
}
.rolesList-icon {
width: 13px;
height: 13px;
}
.rolesList-title {
display: block;
display: flex;
align-items: center;
gap: 5px;
padding-bottom: 5px;
margin: 15px 0 0;
color: var(--color-primary);

View File

@ -23,7 +23,7 @@
.empty-description {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 30px;
margin-top: 20px;
}
@ -73,7 +73,7 @@
color: var(--color-black);
}
@media (max-width: 940px) {
@media (max-width: 1140px) {
.empty-description {
grid-template-columns: 1fr 1fr;
gap: 15px;

View File

@ -0,0 +1,32 @@
.container {
display: flex;
align-items: flex-start;
gap: 15px;
}
.form {
width: 100%;
max-width: 400px;
}
.policy {
width: 100%;
}
.policy-title {
display: block;
font-weight: bold;
font-size: 13px;
margin-bottom: 6px;
}
.policy-render {
background: var(--content-background);
border: 1px solid var(--background-light-highlight);
border-radius: var(--border-radius);
}
@media (max-width: 600px) {
.container {
flex-wrap: wrap;
}
}

View File

@ -1,60 +0,0 @@
.instructions {
border-top: 1px solid var(--background-light-highlight);
padding-top: 10px;
margin: 20px 0 10px;
}
.instructions-text {
margin-top: 7px;
font-style: italic;
color: #555;
a {
font-family: var(--font-monospace);
color: var(--color-primary);
text-decoration: none;
&:focus,
&:hover {
text-decoration: underline;
opacity: 0.8;
}
}
}
.data-control {
margin-bottom: 15px;
}
.data-title {
display: block;
margin-bottom: 5px;
font-size: 13px;
font-weight: bold;
}
.data-title-help {
margin-left: 4px;
font-size: 11px;
font-weight: normal;
text-decoration: none;
color: var(--color-primary);
&:focus,
&:hover {
text-decoration: underline;
}
}
.textInput {
@extend %textInput;
flex-grow: 1;
flex-shrink: 0;
padding: 8px 10px;
margin-right: 10px;
background: #fafafa;
max-width: 500px;
width: 100%;
font-family: var(--font-monospace);
font-size: 11px;
}

View File

@ -1,5 +1,5 @@
.data-control {
margin-bottom: 15px;
margin-bottom: 10px;
}
.data-title {

View File

@ -0,0 +1,103 @@
.aws-push-form {
padding: 20px;
background: var(--content-background);
}
.title {
margin-bottom: 20px;
text-align: center;
line-height: 1.2;
font-size: 27px;
font-weight: 300;
color: var(--color-primary);
}
.info {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 16px;
border-bottom: 1px solid var(--background-light-highlight);
font-size: 13px;
div {
display: flex;
flex-direction: column;
gap: 2px;
}
span {
font-size: 12px;
font-family: var(--font-monospace);
opacity: 0.6;
}
}
.text {
font-size: 13px;
margin-bottom: 20px;
color: #555;
}
.textInput {
@extend %textInput;
flex-grow: 1;
flex-shrink: 1;
padding: 10px;
min-width: 250px;
width: 100%;
font-size: 12px;
font-family: var(--font-primary);
}
.errors {
margin-bottom: 15px;
padding-bottom: 5px;
}
.error {
margin-bottom: 5px;
color: var(--color-error);
font-size: 13px;
font-weight: bold;
}
.formActions {
display: flex;
justify-content: flex-end;
padding-top: 20px;
}
.data-control {
.radio {
display: inline-flex;
align-items: center;
margin-right: 10px;
border: 1px solid var(--background-light-highlight);
padding: 4px 6px;
border-radius: var(--border-radius);
background: var(--input-background);
cursor: pointer;
font-size: 12px;
transition: 0.2s ease-in-out;
transition-property: background;
&:hover,
&:focus {
background: var(--background-light);
}
input {
margin-right: 5px;
cursor: pointer;
}
}
}
.data-title {
display: block;
margin-bottom: 5px;
font-size: 13px;
font-weight: bold;
}

View File

@ -48,27 +48,24 @@
</div>
{{#if @translation.isConflicted}}
{{#if (get @permissions 'update_translation')}}
<AsyncButton @loading={{this.isUpdateLoading}} tabindex='-1' class='button button--borderLess button--iconOnly button--grey' @onClick={{fn this.updateConflict}}>
{{inline-svg '/assets/pencil.svg' class='button-icon'}}
</AsyncButton>
{{/if}}
{{#if (get @permissions 'correct_translation')}}
<AsyncButton @loading={{this.isCorrectLoading}} class='button button--iconOnly button--borderLess button--green' @onClick={{fn this.correctConflict}}>
{{inline-svg '/assets/check.svg' class='button-icon'}}
</AsyncButton>
{{/if}}
{{else}}
{{#if (get @permissions 'update_translation')}}
{{else if (get @permissions 'update_translation')}}
<AsyncButton @loading={{this.isUpdateLoading}} tabindex='-1' class='button button--borderLess button--iconOnly button--grey' @onClick={{fn this.updateConflict}}>
{{inline-svg '/assets/pencil.svg' class='button-icon'}}
</AsyncButton>
{{/if}}
{{else}}
{{#if (get @permissions 'uncorrect_translation')}}
<AsyncButton @loading={{this.isUncorrectLoading}} class='button button--borderLess button--iconOnly button--red' @onClick={{fn this.uncorrectConflict}}>
{{inline-svg '/assets/revert.svg' class='button-icon'}}
</AsyncButton>
{{else if (get @permissions 'update_translation')}}
<AsyncButton @loading={{this.isUpdateLoading}} tabindex='-1' class='button button--borderLess button--iconOnly button--grey' @onClick={{fn this.updateConflict}}>
{{inline-svg '/assets/pencil.svg' class='button-icon'}}
</AsyncButton>
{{/if}}
{{/if}}
</div>

View File

@ -17,6 +17,7 @@
<div local-class='columns-item rolesList'>
<span local-class='rolesList-title'>
{{inline-svg 'assets/tool.svg' local-class='rolesList-icon'}}
{{t 'general.roles.OWNER'}}
</span>
<p local-class='rolesList-text'>
@ -24,6 +25,7 @@
</p>
<span local-class='rolesList-title'>
{{inline-svg 'assets/users.svg' local-class='rolesList-icon'}}
{{t 'general.roles.ADMIN'}}
</span>
<p local-class='rolesList-text'>
@ -31,6 +33,7 @@
</p>
<span local-class='rolesList-title'>
{{inline-svg 'assets/code.svg' local-class='rolesList-icon'}}
{{t 'general.roles.DEVELOPER'}}
</span>
<p local-class='rolesList-text'>
@ -38,11 +41,20 @@
</p>
<span local-class='rolesList-title'>
{{inline-svg 'assets/check.svg' local-class='rolesList-icon'}}
{{t 'general.roles.REVIEWER'}}
</span>
<p local-class='rolesList-text'>
{{t 'components.project_settings.collaborators.reviewer_text'}}
</p>
<span local-class='rolesList-title'>
{{inline-svg 'assets/machine-translations.svg' local-class='rolesList-icon'}}
{{t 'general.roles.TRANSLATOR'}}
</span>
<p local-class='rolesList-text'>
{{t 'components.project_settings.collaborators.translator_text'}}
</p>
</div>
</div>
</div>

View File

@ -38,6 +38,12 @@
<p>{{t 'components.project_settings.integrations.empty_description.azure_storage_container.text'}}</p>
</button>
<button local-class='empty-description-item' {{on 'click' (fn this.toggleCreateForm 'AWS_S3')}}>
{{inline-svg 'assets/services/aws-s3.svg' local-class='empty-description-icon'}}
<strong>{{t 'components.project_settings.integrations.empty_description.aws_s3.title'}}</strong>
<p>{{t 'components.project_settings.integrations.empty_description.aws_s3.text'}}</p>
</button>
<button local-class='empty-description-item' {{on 'click' (fn this.toggleCreateForm 'SLACK')}}>
{{inline-svg 'assets/services/slack.svg' local-class='empty-description-icon'}}
<strong>{{t 'components.project_settings.integrations.empty_description.slack.title'}}</strong>

View File

@ -24,10 +24,19 @@
this.dataFormComponent
errors=this.errors
url=this.url
bucket=this.awsS3Bucket
pathPrefix=this.awsS3PathPrefix
region=this.awsS3Region
accessKeyId=this.awsS3AccessKeyId
project=@project
events=this.events
onChangeUrl=(fn this.setUrl)
onChangeSas=(fn this.setAzureStorageContainerSas)
onChangeBucket=(fn this.setAwsS3Bucket)
onChangePathPrefix=(fn this.setAwsS3PathPrefix)
onChangeRegion=(fn this.setAwsS3Region)
onChangeAccessKeyId=(fn this.setAwsS3AccessKeyId)
onChangeSecretAccessKey=(fn this.setAwsS3SecretAccessKey)
onChangeEventsChecked=(fn this.setEventsChecked)
}}
</div>

View File

@ -0,0 +1,49 @@
<div local-class='container'>
<div local-class='form'>
<ProjectSettings::Integrations::Form::DataControlText
@error={{field-error @errors 'data.awsS3Bucket'}}
@label={{t 'components.project_settings.integrations.data.aws_s3_bucket'}}
@value={{@bucket}}
@onChange={{this.changeBucket}}
/>
<ProjectSettings::Integrations::Form::DataControlText
@error={{field-error @errors 'data.awsS3PathPrefix'}}
@label={{t 'components.project_settings.integrations.data.aws_s3_path_prefix'}}
@value={{@pathPrefix}}
@placeholder='/'
@onChange={{this.changePathPrefix}}
/>
<ProjectSettings::Integrations::Form::DataControlText
@error={{field-error @errors 'data.awsS3Region'}}
@label={{t 'components.project_settings.integrations.data.aws_s3_region'}}
@value={{@region}}
@placeholder='us-east-1'
@onChange={{this.changeRegion}}
/>
<ProjectSettings::Integrations::Form::DataControlText
@error={{field-error @errors 'data.awsS3AccessKeyId'}}
@label={{t 'components.project_settings.integrations.data.aws_s3_access_key_id'}}
@value={{@accessKeyId}}
@onChange={{this.changeAccessKeyId}}
/>
<ProjectSettings::Integrations::Form::DataControlText
@error={{field-error @errors 'data.awsS3SecretAccessKey'}}
@label={{t 'components.project_settings.integrations.data.aws_s3_secret_access_key'}}
@onChange={{this.changeSecretAccessKey}}
/>
</div>
<div local-class='policy'>
<label local-class='policy-title'>
Minimum policy
</label>
<div local-class='policy-render'>
<HighlightRender @content={{this.policyContent}} />
</div>
</div>
</div>

View File

@ -18,6 +18,9 @@
<span local-class='details-preview'>
{{@integration.data.url}}
{{@integration.data.sasBaseUrl}}
{{@integration.data.accessKeyId}}
{{@integration.data.bucket}}
{{@integration.data.pathPrefix}}
{{#if @integration.lastExecutedAt}}
<span local-class='details-last-executed-at'>{{t 'components.project_settings.integrations.last_executed_at'}} {{time-ago-in-words @integration.lastExecutedAt}}</span>

View File

@ -0,0 +1,50 @@
<div local-class='aws-push-form'>
<h1 local-class='title'>
{{t 'components.project_settings.integrations.execute.aws_s3.title'}}
</h1>
<div local-class='info'>
<div>
<strong>{{t 'components.project_settings.integrations.execute.aws_s3.bucket'}}</strong>
<span>{{@integration.data.bucket}}</span>
</div>
<div>
<strong>{{t 'components.project_settings.integrations.execute.aws_s3.path_prefix'}}</strong>
<span>{{@integration.data.pathPrefix}}</span>
</div>
</div>
{{#if this.error}}
<div local-class='errors'>
<div local-class='error'>
{{t 'components.project_settings.integrations.execute.aws_s3.error'}}
</div>
</div>
{{/if}}
<div local-class='formItem'>
<div local-class='data-control'>
<h3 local-class='data-title'>
{{t 'components.project_settings.integrations.execute.aws_s3.target_version.label'}}
</h3>
{{#each this.allTargetVersions as |target|}}
<label local-class='radio'>
<input type='radio' checked={{eq this.targetVersion target.value}} name='target_version' {{on 'change' (fn this.setTargetVersion target.value)}} required />
{{t target.label}}
</label>
{{/each}}
{{#if (eq this.targetVersion 'SPECIFIC')}}
<ProjectSettings::Integrations::Form::DataControlText @placeholder='1.0.0' @value={{this.tag}} @onChange={{this.setTag}} />
{{/if}}
</div>
</div>
<div local-class='formActions'>
<AsyncButton {{did-insert (fn this.autofocus)}} class='button button--filled' @loading={{this.isSubmitting}} @onClick={{this.submit}}>
{{inline-svg '/assets/arrow-up-right.svg' class='button-icon'}}
{{t 'components.project_settings.integrations.execute.aws_s3.push_button'}}
</AsyncButton>
</div>
</div>

View File

@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 512">
<defs>
<style>
.cls-1 {
fill: #e25444;
}
.cls-1,
.cls-2,
.cls-3 {
fill-rule: evenodd;
}
.cls-2 {
fill: #7b1d13;
}
.cls-3 {
fill: #58150d;
}
</style>
</defs>
<path d="m378 99-83 158 83 158 34-19V118Z" class="cls-1" />
<path d="m378 99-166 19-84.5 139L212 396l166 19z" class="cls-2" />
<path d="m43 99-27 12v292l27 12 169-158Z" class="cls-3" />
<path
d="m42.637 98.667 169.587 47.111v226.666L42.637 415.111z"
class="cls-1"
/>
<path
d="m212.313 170.667-72.008-11.556 72.008-81.778 71.83 81.778Z"
class="cls-3"
/>
<path
d="m284.143 159.111-71.919 11.733-71.919-11.733V77.333M212.313 342.222l-72.008 13.334 72.008 70.222 71.83-70.222Z"
class="cls-3"
/>
<path
d="m212 16-72 38v105l72.224-20.333ZM212.224 196.444l-71.919 7.823v104.838l71.919 8.228zM212.224 373.333 140.305 355.3v103.063L212.224 496z"
class="cls-2"
/>
<path
d="m284.143 355.3-71.919 18.038V496l71.919-37.637zM212.224 196.444l71.919 7.823v104.838l-71.919 8.228zM212 16l72 38v105l-72-20z"
class="cls-1"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
class="feather feather-tool"
viewBox="0 0 24 24"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>

After

Width:  |  Height:  |  Size: 386 B