Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion app/helpers/custom_styles_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,24 @@ def custom_logo_upload(custom_style, mode:, field:)

{
field:,
mode:,
label: t("admin.custom_styles.branding.modes.#{mode}.name"),
present:,
source: present ? custom_logo_url(custom_style, attachment) : nil,
img_class: field.to_s.start_with?("logo_mobile") ? "custom-logo-mobile-preview" : "custom-logo-preview",
accept: "image/*",
delete_path: custom_style_logo_delete_path(field:),
instructions: t("admin.custom_styles.branding.modes.#{mode}.description")
instructions: t("admin.custom_styles.branding.modes.#{mode}.description"),
preview_data: logo_mode_data_attributes(mode)
}
end

# Mirrors ApplicationHelper#user_theme_data_attributes/ThemeUtils#applyThemeToBody: with these
# two attributes on an element, CSS custom properties mapped in _primer_color_mapping.erb
# (e.g. --header-bg-color) resolve exactly as they would under that color mode, so a logo
# preview can show its background the way the real header would render it.
def logo_mode_data_attributes(mode)
color_mode = mode == :dark ? :dark : :light
{ "color-mode": color_mode, "#{color_mode}-theme": mode }
end
end
46 changes: 32 additions & 14 deletions app/views/custom_styles/_branding.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,35 @@ See COPYRIGHT and LICENSE files for more details.

++#%>

<%= render(Primer::Beta::Subhead.new(spacious: true)) do |subhead| %>
<% subhead.with_heading(tag: :h3, size: :medium) { t("admin.custom_styles.branding.desktop.title") } %>
<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %>
<% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.desktop.title") } %>
<% subhead.with_description { t("admin.custom_styles.branding.desktop.description") } %>
<% end %>

<% custom_logo_uploads(@custom_style).each do |image| %>
<%= render partial: "custom_styles/uploads/image", locals: { image: } %>
<%= flex_layout(flex_wrap: :wrap, classes: "gap-3") do |flex| %>
Comment thread
HDinger marked this conversation as resolved.
<% custom_logo_uploads(@custom_style).each do |image| %>
<% flex.with_column(flex: 1, classes: "custom-style--growing-logo-form") do %>
<%= render partial: "custom_styles/uploads/image", locals: { image: } %>
<% end %>
<% end %>
<% end %>

<%= render(Primer::Beta::Subhead.new(spacious: true)) do |subhead| %>
<% subhead.with_heading(tag: :h3, size: :medium) { t("admin.custom_styles.branding.mobile.title") } %>
<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %>
<% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.mobile.title") } %>
<% subhead.with_description { t("admin.custom_styles.branding.mobile.description") } %>
<% end %>

<% custom_logo_uploads(@custom_style, mobile: true).each do |image| %>
<%= render partial: "custom_styles/uploads/image", locals: { image: } %>
<%= flex_layout(flex_wrap: :wrap, classes: "gap-3") do |flex| %>
Comment thread
HDinger marked this conversation as resolved.
<% custom_logo_uploads(@custom_style, mobile: true).each do |image| %>
<% flex.with_column(flex: 1, classes: "custom-style--growing-logo-form") do %>
<%= render partial: "custom_styles/uploads/image", locals: { image: } %>
<% end %>
<% end %>
<% end %>

<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %>
<% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.favicon.title") } %>
<% subhead.with_description { t("admin.custom_styles.branding.favicon.title_description") } %>
<% end %>

<%
Expand All @@ -55,15 +68,20 @@ See COPYRIGHT and LICENSE files for more details.
%>
<%= render partial: "custom_styles/uploads/image", locals: { image: {
field: :favicon,
label: I18n.t(:label_custom_favicon),
label: t("admin.custom_styles.branding.favicon.field"),
instructions: t("admin.custom_styles.branding.favicon.field_description"),
present: favicon_present,
source: favicon_source,
img_class: "custom-favicon-preview",
accept: "image/*",
delete_path: custom_style_favicon_delete_path,
instructions: I18n.t("text_custom_favicon_instructions")
delete_path: custom_style_favicon_delete_path
} } %>

<%= render(Primer::Beta::Subhead.new(spacious: false)) do |subhead| %>
<% subhead.with_heading(tag: :h3, size: :medium, mt: 4) { t("admin.custom_styles.branding.touch_icon.title") } %>
<% subhead.with_description { t("admin.custom_styles.branding.touch_icon.title_description") } %>
<% end %>

<%
touch_icon_present = @custom_style.persisted? && @custom_style.touch_icon.present?
touch_icon_source = if touch_icon_present
Expand All @@ -75,11 +93,11 @@ See COPYRIGHT and LICENSE files for more details.
%>
<%= render partial: "custom_styles/uploads/image", locals: { image: {
field: :touch_icon,
label: I18n.t(:label_custom_touch_icon),
label: t("admin.custom_styles.branding.touch_icon.field"),
instructions: t("admin.custom_styles.branding.touch_icon.field_description"),
present: touch_icon_present,
source: touch_icon_source,
img_class: "custom-touch-icon-preview",
accept: "image/*",
delete_path: custom_style_touch_icon_delete_path,
instructions: I18n.t("text_custom_touch_icon_instructions")
delete_path: custom_style_touch_icon_delete_path
} } %>
102 changes: 53 additions & 49 deletions app/views/custom_styles/_pdf_export_styles.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -29,41 +29,43 @@ See COPYRIGHT and LICENSE files for more details.
<p><%= I18n.t("label_custom_export_images_instructions") %></p>

<%=
render(Primer::Beta::Button.new(
target: "_blank",
data: { turbo: false },
tag: :a,
href: export_demo_pdf_download_custom_style_path
)) do |_c|
render(
Primer::Beta::Button.new(
target: "_blank",
data: { turbo: false },
tag: :a,
href: export_demo_pdf_download_custom_style_path
)
) do |_c|
t("export.demo.button_text")
end
%>

<%= render partial: "custom_styles/uploads/image", locals: { image:{
field: :export_logo,
label: I18n.t(:label_custom_export_logo),
present: @custom_style.id && @custom_style.export_logo.present?,
source: @custom_style.id && @custom_style.export_logo.present? ?
custom_style_export_logo_path(digest: @custom_style.digest, filename: @custom_style.export_logo_identifier) :
nil,
img_class: "custom-export-logo-preview",
accept: "image/png, image/jpg",
delete_path: custom_style_export_logo_delete_path,
instructions: I18n.t("text_custom_export_logo_instructions")
} } %>
<%= render partial: "custom_styles/uploads/image", locals: { image: {
field: :export_logo,
label: I18n.t(:label_custom_export_logo),
present: @custom_style.id && @custom_style.export_logo.present?,
source: if @custom_style.id && @custom_style.export_logo.present?
custom_style_export_logo_path(digest: @custom_style.digest, filename: @custom_style.export_logo_identifier)
end,
img_class: "custom-export-logo-preview",
accept: "image/png, image/jpeg",
delete_path: custom_style_export_logo_delete_path,
instructions: I18n.t("text_custom_export_logo_instructions")
} } %>

<%= render partial: "custom_styles/uploads/image", locals: { image:{
field: :export_cover,
label: I18n.t(:label_custom_export_cover),
present: @custom_style.id && @custom_style.export_cover.present?,
source: @custom_style.id && @custom_style.export_cover.present? ?
custom_style_export_cover_path(digest: @custom_style.digest, filename: @custom_style.export_cover_identifier) :
nil,
img_class: "custom-export-cover-preview",
accept: "image/png, image/jpg",
delete_path: custom_style_export_cover_delete_path,
instructions: I18n.t("text_custom_export_cover_instructions")
} } %>
<%= render partial: "custom_styles/uploads/image", locals: { image: {
field: :export_cover,
label: I18n.t(:label_custom_export_cover),
present: @custom_style.id && @custom_style.export_cover.present?,
source: if @custom_style.id && @custom_style.export_cover.present?
custom_style_export_cover_path(digest: @custom_style.digest, filename: @custom_style.export_cover_identifier)
end,
img_class: "custom-export-cover-preview",
accept: "image/png, image/jpeg",
delete_path: custom_style_export_cover_delete_path,
instructions: I18n.t("text_custom_export_cover_instructions")
} } %>

<%= form_tag update_custom_style_export_cover_text_color_path, html: { multipart: true, class: "form -vertical" } do %>
<section class="form--section">
Expand All @@ -79,7 +81,7 @@ See COPYRIGHT and LICENSE files for more details.
Color.new(
hexcode: design_color_hex || "#000"
),
data: { target: "#" + design_color_name }
data: { target: "##{design_color_name}" }
) %>
</div>
<span class="form--text-field-container">
Expand All @@ -96,12 +98,14 @@ See COPYRIGHT and LICENSE files for more details.
</div>
</div>
<%=
render(Primer::Beta::Button.new(
type: :submit,
data: {
"test-selector": "text-color-change"
}
)) do |button|
render(
Primer::Beta::Button.new(
type: :submit,
data: {
"test-selector": "text-color-change"
}
)
) do |_button|
I18n.t(:button_save)
end
%>
Expand All @@ -110,16 +114,16 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>

<% if OpenProject::FeatureDecisions.minutes_styling_meeting_pdf_active? %>
<%= render partial: "custom_styles/uploads/image", locals: { image:{
field: :export_footer,
label: I18n.t(:label_custom_export_footer),
present: @custom_style.id && @custom_style.export_footer.present?,
source: @custom_style.id && @custom_style.export_footer.present? ?
custom_style_export_footer_path(digest: @custom_style.digest, filename: @custom_style.export_footer_identifier) :
nil,
img_class: "custom-export-footer-preview",
accept: "image/png, image/jpg",
delete_path: custom_style_export_footer_delete_path,
instructions: I18n.t("text_custom_export_footer_instructions")
} } %>
<%= render partial: "custom_styles/uploads/image", locals: { image: {
field: :export_footer,
label: I18n.t(:label_custom_export_footer),
present: @custom_style.id && @custom_style.export_footer.present?,
source: if @custom_style.id && @custom_style.export_footer.present?
custom_style_export_footer_path(digest: @custom_style.digest, filename: @custom_style.export_footer_identifier)
end,
img_class: "custom-export-footer-preview",
accept: "image/png, image/jpeg",
delete_path: custom_style_export_footer_delete_path,
instructions: I18n.t("text_custom_export_footer_instructions")
} } %>
<% end %>
73 changes: 29 additions & 44 deletions app/views/custom_styles/uploads/_image.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,47 +26,32 @@ See COPYRIGHT and LICENSE files for more details.

++#%>

<%= form_for @custom_style, url: custom_style_path, html: { multipart: true, class: "form -vertical" } do |f| %>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= image[:label] %></legend>
<p class="form--field-instructions"><%= image[:instructions] %></p>
<% if image[:present] %>
<%= tag("img", src: image[:source], class: image[:img_class]) %>
<% end %>
<div class="grid-block">
<div class="form--field -required">
<div class="attachment_field form--field-container -vertical -shrink">
<div class="form--file-container">
<%= f.file_field image[:field], required: true, class: "attachment_choose_file", accept: image[:accept], size: "15" %>
</div>
</div>
</div>
</div>
<%= render(
Primer::Beta::Button.new(
type: :submit
)
) do |button| %>
<% button.with_leading_visual_icon(icon: image[:present] ? :upload : :plus) %>
<%= t(image[:present] ? :button_replace : :button_upload) %>
<% end %>
<% if image[:present] %>
<%= render(
Primer::Beta::Button.new(
tag: :a,
scheme: :danger,
href: image[:delete_path],
data: {
turbo_method: :delete,
test_selector: "delete-custom-style-image-#{image[:field]}"
}
)
) do |button| %>
<% button.with_leading_visual_icon(icon: :trash) %>
<%= t(:button_delete) %>
<% end %>
<% end %>
</fieldset>
</section>
<% end %>
<%=
primer_form_with(
model: @custom_style,
url: custom_style_path,
multipart: true,
data: { controller: "auto-submit" }
) do |f|
render_inline_form(f) do |form|
form.file_field(
name: image[:field],
label: image[:label],
description: image[:instructions],
accept: image[:accept],
preview_url: (image[:source] if image[:present]),
preview_class: image[:img_class],
preview_data: image[:preview_data] || {},
required: true,
auto_submit: true,
delete_path: (image[:delete_path] if image[:present]),
delete_button_arguments: {
data: {
turbo_method: :delete,
test_selector: "delete-custom-style-image-#{image[:field]}"
}
}
)
end
end
%>
10 changes: 10 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,11 @@ en:
desktop:
description: "By default the OpenProject logos are used, but if you upload new logos the theme will automatically be changed to Custom theme. For better results, we recommend a transparent logo."
title: "Custom desktop logos"
favicon:
field: "Favicon"
field_description: "The image should be 32x32px in PNG format on transparent background."
title: "Custom favicon"
title_description: "This is the tiny icon that appears next to the page title in the browser. By default the OpenProject favicon is used, but if you upload a new favicon the theme will automatically be changed to Custom theme."
mobile:
description: "By default the OpenProject logos are used for the mobile browser version, the native mobile app will not be updated by this change. For better results, we recommend a transparent logo."
title: "Custom mobile logos"
Expand All @@ -1505,6 +1510,11 @@ en:
light_high_contrast:
description: "The app header will be grey. We recommend a black logo on transparent background."
name: "High contrast mode logo"
touch_icon:
field: "Touch icon"
field_description: "The image should be 180x180px in PNG format and colour background to avoid contrast problems."
title: "Custom touch icon"
title_description: "This is the icon that appears when bookmark in the tablets or mobiles home. By default the OpenProject touch icon is used, but if you upload a new touch icon the theme will automatically be changed to Custom theme."
color_groups:
base_colors: "Base colors"
main_menu: "Main lateral menu"
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/app/shared/helpers/dom-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
toggleEnabled,
enableElement,
disableElement,
setInputFiles,
} from './dom-helpers';

describe('dom-helpers', () => {
Expand Down Expand Up @@ -536,4 +537,29 @@ describe('dom-helpers', () => {
});
});
});

describe('setInputFiles', () => {
it('puts the given file on the input', () => {
const input = document.createElement('input');
input.type = 'file';
const file = new File(['content'], 'contract.pdf', { type: 'application/pdf' });

setInputFiles(input, file);

expect(input.files).toHaveLength(1);
expect(input.files?.[0]).toBe(file);
});

it('replaces a previously set file rather than appending to it', () => {
const input = document.createElement('input');
input.type = 'file';
setInputFiles(input, new File(['first'], 'first.pdf', { type: 'application/pdf' }));

const second = new File(['second'], 'second.pdf', { type: 'application/pdf' });
setInputFiles(input, second);

expect(input.files).toHaveLength(1);
expect(input.files?.[0]).toBe(second);
});
});
});
11 changes: 11 additions & 0 deletions frontend/src/app/shared/helpers/dom-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,17 @@ export function isHTMLInputElement(element:EventTarget|Element|null):element is
return element !== null && element instanceof HTMLInputElement;
}

/**
* Sets a single file on an `<input type="file">`. The `files` property is a read-only
* `FileList`, so a `DataTransfer` is the only way to assign to it programmatically — e.g. after
* a drag-and-drop, or to replace a chosen file with a processed version of itself.
*/
export function setInputFiles(input:HTMLInputElement, file:File):void {
const transfer = new DataTransfer();
transfer.items.add(file);
input.files = transfer.files;
}

/**
* Reads a CSS custom property off the document body, so that a value defined by the stylesheet
* and by the theme in force can be used where only a plain string will do -- a canvas fill, say.
Expand Down
Loading
Loading