diff --git a/api/src/main/java/edu/cornell/mannlib/vedit/controller/OperationController.java b/api/src/main/java/edu/cornell/mannlib/vedit/controller/OperationController.java index 99b14e68f8..8066407a4d 100644 --- a/api/src/main/java/edu/cornell/mannlib/vedit/controller/OperationController.java +++ b/api/src/main/java/edu/cornell/mannlib/vedit/controller/OperationController.java @@ -39,7 +39,9 @@ import edu.cornell.mannlib.vitro.webapp.auth.checks.UserOnThread; import edu.cornell.mannlib.vitro.webapp.auth.policy.EntityPolicyController; import edu.cornell.mannlib.vitro.webapp.auth.policy.PolicyLoader; +import edu.cornell.mannlib.vitro.webapp.beans.ApplicationBean; import edu.cornell.mannlib.vitro.webapp.beans.PermissionSet; +import edu.cornell.mannlib.vitro.webapp.controller.freemarker.SiteBrandingController; import edu.cornell.mannlib.vitro.webapp.modelaccess.ModelAccess; import org.apache.commons.lang3.EnumUtils; import org.apache.commons.lang3.StringUtils; @@ -155,6 +157,10 @@ public void doPost (HttpServletRequest request, HttpServletResponse response) { notifyChangeListeners(epo, action); + if (newObj instanceof ApplicationBean) { + SiteBrandingController.updateThemeBrandingCache(((ApplicationBean) newObj).getThemeDir()); + } + /* send the user somewhere */ switch (action) { case "insert": diff --git a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/edit/ApplicationBeanRetryController.java b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/edit/ApplicationBeanRetryController.java index 4dea400aff..efeab6809d 100644 --- a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/edit/ApplicationBeanRetryController.java +++ b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/edit/ApplicationBeanRetryController.java @@ -11,6 +11,8 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import edu.cornell.mannlib.vitro.webapp.controller.freemarker.SiteBrandingLogoController; +import edu.cornell.mannlib.vitro.webapp.controller.freemarker.UrlBuilder; import edu.cornell.mannlib.vitro.webapp.controller.freemarker.SiteStyleController; import edu.cornell.mannlib.vitro.webapp.utils.JSPPageHandler; import org.apache.commons.logging.Log; @@ -87,10 +89,17 @@ public void doPost (HttpServletRequest req, HttpServletResponse response) { FormUtils.populateFormFromBean(applicationForEditing, epo.getAction(), foo); request.setAttribute("formJsp","/templates/edit/specific/applicationBean_retry.jsp"); - request.setAttribute("scripts","/js/siteAdmin/cssUploadUtils.js"); + request.setAttribute("scripts","/templates/edit/specific/applicationBeanRetry_scripts.jsp"); request.setAttribute("title","Site Information"); request.setAttribute("_action",action); request.setAttribute("unqualifiedClassName","ApplicationBean"); + request.setAttribute("i18n", I18n.bundle(request)); + + request.setAttribute("actionLogoUploadUrl", SiteBrandingLogoController.getLogoUploadUrlString()); + request.setAttribute("logoUrl", SiteBrandingLogoController.getLogoUrlCache()); + request.setAttribute("logoSmallUrl", SiteBrandingLogoController.getMobileLogoUrlCache()); + + request.setAttribute("updateLogoUrl", UrlBuilder.getUrl("site-branding-logo")); request.setAttribute("customCssPath", SiteStyleController.getCustomCssUrlCache()); request.setAttribute("actionRemove", SiteStyleController.getRemovePathString()); diff --git a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/FreemarkerHttpServlet.java b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/FreemarkerHttpServlet.java index 5a7ffa788c..510e240dac 100644 --- a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/FreemarkerHttpServlet.java +++ b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/FreemarkerHttpServlet.java @@ -453,6 +453,8 @@ protected Map getPageTemplateValues(VitroRequest vreq) { map.put("siteName", vreq.getAppBean().getApplicationName()); + map.put("brandingColors", SiteBrandingController.getThemeBrandingCache(vreq.getAppBean().getThemeDir())); + map.put("urls", buildRequestUrls(vreq)); map.put("menu", getDisplayModelMenu(vreq)); @@ -480,6 +482,10 @@ protected Map getPageTemplateValues(VitroRequest vreq) { map.put("headScripts", new Tags().wrap()); map.put("metaTags", new Tags().wrap()); + + map.put("logoUrl", SiteBrandingLogoController.getLogoUrlCache()); + map.put("logoSmallUrl", SiteBrandingLogoController.getMobileLogoUrlCache()); + return map; } diff --git a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/ImageUploadHelper.java b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/ImageUploadHelper.java index 6d0222adfa..02184c9ec7 100644 --- a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/ImageUploadHelper.java +++ b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/ImageUploadHelper.java @@ -115,17 +115,17 @@ private static Map createNonStandardMimeTypesMap() { * if there is no file, if it is empty, or if it is not an image * file. */ - FileItem validateImageFromRequest(VitroRequest vreq) + FileItem validateImageFromRequest(VitroRequest vreq, String fieldName) throws UserMistakeException { Map> map = vreq.getFiles(); if (map == null) { throw new IllegalStateException(ERROR_CODE_BAD_MULTIPART_REQUEST); } - List list = map.get(PARAMETER_UPLOADED_FILE); + List list = map.get(fieldName); if ((list == null) || list.isEmpty()) { throw new UserMistakeException(ERROR_CODE_FORM_FIELD_MISSING, - PARAMETER_UPLOADED_FILE); + fieldName); } FileItem file = list.get(0); @@ -144,6 +144,10 @@ FileItem validateImageFromRequest(VitroRequest vreq) return file; } + FileItem validateImageFromRequest(VitroRequest vreq) throws UserMistakeException { + return validateImageFromRequest(vreq, PARAMETER_UPLOADED_FILE); + } + /** * The user has uploaded a new main image, but we're not ready to assign it * to them. @@ -151,7 +155,7 @@ FileItem validateImageFromRequest(VitroRequest vreq) * Put it into the file storage system, and attach it as a temp file on the * session until we need it. */ - FileInfo storeNewImage(FileItem fileItem, VitroRequest vreq) { + FileInfo storeNewImage(FileItem fileItem, VitroRequest vreq, boolean storeTemp) { InputStream inputStream = null; try { inputStream = fileItem.getInputStream(); @@ -160,8 +164,10 @@ FileInfo storeNewImage(FileItem fileItem, VitroRequest vreq) { FileInfo fileInfo = uploadedFileHelper.createFile(filename, mimeType, inputStream); - TempFileHolder.attach(vreq.getSession(), ATTRIBUTE_TEMP_FILE, - fileInfo); + if (storeTemp) { + TempFileHolder.attach(vreq.getSession(), ATTRIBUTE_TEMP_FILE, + fileInfo); + } return fileInfo; } catch (IOException e) { @@ -178,6 +184,10 @@ FileInfo storeNewImage(FileItem fileItem, VitroRequest vreq) { } } + FileInfo storeNewImage(FileItem fileItem, VitroRequest vreq) { + return this.storeNewImage(fileItem, vreq, true); + } + /** * Find out how big this image is. * diff --git a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/SiteBrandingController.java b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/SiteBrandingController.java new file mode 100644 index 0000000000..bc1268f305 --- /dev/null +++ b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/SiteBrandingController.java @@ -0,0 +1,208 @@ +/* $This file is distributed under the terms of the license in LICENSE$ */ + +package edu.cornell.mannlib.vitro.webapp.controller.freemarker; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletResponse; + +import com.fasterxml.jackson.databind.JsonNode; +import edu.cornell.mannlib.vitro.webapp.auth.permissions.SimplePermission; +import edu.cornell.mannlib.vitro.webapp.auth.requestedAction.AuthorizationRequest; +import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest; +import edu.cornell.mannlib.vitro.webapp.controller.ajax.VitroAjaxController; +import edu.cornell.mannlib.vitro.webapp.controller.edit.ReorderController; +import edu.cornell.mannlib.vitro.webapp.dao.VitroVocabulary; +import edu.cornell.mannlib.vitro.webapp.dao.WebappDaoFactory; +import edu.cornell.mannlib.vitro.webapp.modelaccess.ContextModelAccess; +import edu.cornell.mannlib.vitro.webapp.modelaccess.ModelAccess; +import edu.cornell.mannlib.vitro.webapp.modelaccess.ModelNames; +import edu.cornell.mannlib.vitro.webapp.utils.json.JacksonUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.jena.atlas.json.JsonObject; +import org.apache.jena.ontology.OntModel; +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.rdf.model.Statement; +import org.apache.jena.rdf.model.StmtIterator; +import org.apache.jena.rdf.model.impl.StatementImpl; + +/** + * Handle adding, replacing or deleting the custom css file. + */ +@WebServlet(name = "SiteBrandingController", urlPatterns = {"/siteBranding"}) +public class SiteBrandingController extends VitroAjaxController { + + private static final long serialVersionUID = 1L; + private static final Log log = LogFactory.getLog(ReorderController.class); + + public static boolean themeBrandingLoaded = false; + public static Map themeBranding = null; + + private static Map getBrandingColors(String theme) { + ContextModelAccess cma = ModelAccess.getInstance(); + OntModel displayModel = cma.getOntModel(ModelNames.DISPLAY); + + + Resource s = ResourceFactory.createResource(VitroVocabulary.vitroURI + theme); + Property themeColorsProp = ResourceFactory.createProperty(VitroVocabulary.PORTAL_THEMECOLORS); + StmtIterator iter = displayModel.listStatements(s, themeColorsProp, (RDFNode) null); + + if (!iter.hasNext()) { + return new HashMap<>(); + } + + Statement stmt = iter.nextStatement(); + RDFNode object = stmt.getObject(); + + String colorsConfigJson = object.asLiteral().getString(); + Map brandingColors = new HashMap<>(); + try { + JsonNode node = JacksonUtils.parseJson(colorsConfigJson); + if (node != null && node.isObject()) { + node.fields().forEachRemaining(entry -> { + JsonNode val = entry.getValue(); + brandingColors.put(entry.getKey(), val.isNull() ? null : val.asText()); + }); + } + } catch (Exception e) { + log.error("Failed to parse colorsConfigJson for branding colors", e); + } + return brandingColors; + + } + + public static String getCurrentTheme(VitroRequest vreq) { + WebappDaoFactory wadf = ModelAccess.on(vreq).getWebappDaoFactory(); + return wadf.getApplicationDao().getApplicationBean().getThemeDir(); + } + + public static void updateThemeBrandingCache(String theme) { + themeBranding = getBrandingColors(theme); + themeBrandingLoaded = true; + } + + public static Map getThemeBrandingCache(String theme) { + if (!themeBrandingLoaded) { + updateThemeBrandingCache(theme); + } + return themeBranding; + } + + @Override + protected AuthorizationRequest requiredActions(VitroRequest vreq) { + return SimplePermission.EDIT_SITE_INFORMATION.ACTION; + } + + @Override + protected void doRequest(VitroRequest vreq, HttpServletResponse response) throws IOException { + String method = vreq.getMethod(); + + switch (method.toUpperCase()) { + case "GET": + listBrandingColors(vreq, response); + break; + + case "POST": + handlePostRequest(vreq, response); + break; + + default: + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, + "Only GET and POST methods are supported"); + } + } + + private void handlePostRequest(VitroRequest vreq, HttpServletResponse response) throws IOException { + String action = vreq.getParameter("action"); + + switch (action != null ? action.toLowerCase() : "") { + case "update": + updateBrandingColor(vreq, response); + break; + + case "remove-all": + removeAllBrandingColors(vreq, response); + response.setStatus(HttpServletResponse.SC_OK); + break; + + default: + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid action parameter"); + } + + String currentTheme = vreq.getParameter("theme"); + if (currentTheme == null) { + currentTheme = getCurrentTheme(vreq); + } + updateThemeBrandingCache(currentTheme); + } + + private void listBrandingColors(VitroRequest vreq, HttpServletResponse response) { + String currentTheme = vreq.getParameter("theme"); + if (currentTheme == null) { + currentTheme = getCurrentTheme(vreq); + } + + Map brandingColors = getBrandingColors(currentTheme); + + JsonObject jsonResponse = new JsonObject(); + brandingColors.forEach(jsonResponse::put); + + response.setContentType("application/json"); + try { + response.getWriter().write(jsonResponse.toString()); + } catch (IOException e) { + log.error("Error writing response", e); + } + } + + private String getRequestTheme(VitroRequest vreq) { + String currentTheme = vreq.getParameter("theme"); + if (currentTheme == null) { + currentTheme = getCurrentTheme(vreq); + } + return currentTheme; + } + + private OntModel getDisplayModel() { + ContextModelAccess cma = ModelAccess.getInstance(); + return cma.getOntModel(ModelNames.DISPLAY); + } + + private void updateBrandingColor(VitroRequest vreq, HttpServletResponse response) { + String currentTheme = getRequestTheme(vreq); + OntModel displayModel = getDisplayModel(); + Resource themeResource = ResourceFactory.createResource(VitroVocabulary.vitroURI + currentTheme); + Property themeColorsProp = ResourceFactory.createProperty(VitroVocabulary.PORTAL_THEMECOLORS); + displayModel.removeAll(themeResource, themeColorsProp, null); + + String colorsConfigJson = vreq.getParameter("colors"); + + if (colorsConfigJson == null || colorsConfigJson.trim().isEmpty() || colorsConfigJson.trim().equals("{}") || + colorsConfigJson.trim().equals("null")) { + listBrandingColors(vreq, response); + return; + } + + Statement statement = + new StatementImpl(themeResource, themeColorsProp, ResourceFactory.createTypedLiteral(colorsConfigJson)); + displayModel.add(statement); + listBrandingColors(vreq, response); + } + + private void removeAllBrandingColors(VitroRequest vreq, HttpServletResponse response) { + String currentTheme = getRequestTheme(vreq); + OntModel displayModel = getDisplayModel(); + Resource themeResource = ResourceFactory.createResource(VitroVocabulary.vitroURI + currentTheme); + Property themeColorsProp = ResourceFactory.createProperty(VitroVocabulary.PORTAL_THEMECOLORS); + displayModel.removeAll(themeResource, themeColorsProp, null); + listBrandingColors(vreq, response); + } + +} \ No newline at end of file diff --git a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/SiteBrandingLogoController.java b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/SiteBrandingLogoController.java new file mode 100644 index 0000000000..86770d53c9 --- /dev/null +++ b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/controller/freemarker/SiteBrandingLogoController.java @@ -0,0 +1,244 @@ +/* $This file is distributed under the terms of the license in LICENSE$ */ + +package edu.cornell.mannlib.vitro.webapp.controller.freemarker; + +import java.io.IOException; +import java.util.Objects; + +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import edu.cornell.mannlib.vitro.webapp.application.ApplicationUtils; +import edu.cornell.mannlib.vitro.webapp.auth.permissions.SimplePermission; +import edu.cornell.mannlib.vitro.webapp.auth.requestedAction.AuthorizationRequest; +import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest; +import edu.cornell.mannlib.vitro.webapp.controller.freemarker.ImageUploadController.UserMistakeException; +import edu.cornell.mannlib.vitro.webapp.controller.freemarker.UrlBuilder.ParamMap; +import edu.cornell.mannlib.vitro.webapp.dao.VitroVocabulary; +import edu.cornell.mannlib.vitro.webapp.filestorage.model.FileInfo; +import edu.cornell.mannlib.vitro.webapp.modelaccess.ContextModelAccess; +import edu.cornell.mannlib.vitro.webapp.modelaccess.ModelAccess; +import edu.cornell.mannlib.vitro.webapp.modelaccess.ModelNames; +import edu.cornell.mannlib.vitro.webapp.modules.fileStorage.FileStorage; +import org.apache.commons.fileupload.FileItem; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.jena.ontology.OntModel; +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.rdf.model.Statement; +import org.apache.jena.rdf.model.StmtIterator; +import org.apache.jena.rdf.model.impl.StatementImpl; + + +/** + * Handle adding, replacing or deleting the custom css file. + */ +@WebServlet(name = "SiteBrandingLogoController", urlPatterns = {"/site-branding-logo"}) +public class SiteBrandingLogoController extends FreemarkerHttpServlet { + private static final long serialVersionUID = 1L; + private static final Log log = LogFactory + .getLog(SiteBrandingLogoController.class); + + /** + * Limit file size to 6 megabytes. + */ + public static final int MAXIMUM_FILE_SIZE = 6 * 1024 * 1024; + + /** + * The form field of the uploaded file; use as a key to the FileItem map. + */ + + public static final String URL_HERE = UrlBuilder.getUrl("/site-branding-logo"); + private static final String PARAMETER_ACTION = "action"; + + public static final String ACTION_UPLOAD = "upload"; + public static final String ACTION_REMOVE = "remove"; + + public static final String LOGO_PARAMETER_ACTION = "portalLogoAction"; + public static final String MOBILE_LOGO_PARAMETER_ACTION = "mobilePortalLogoAction"; + + public static boolean logoUrlLoaded = false; + public static String logoUrl = null; + public static String mobileLogoUrl = null; + + + private FileStorage fileStorage; + + /** + * When initialized, get a reference to the File Storage system. Without + * that, we can do nothing. + */ + @Override + public void init() throws ServletException { + super.init(); + fileStorage = ApplicationUtils.instance().getFileStorage(); + } + + /** + * How large css file will we accept? + */ + @Override + public long maximumMultipartFileSize() { + return MAXIMUM_FILE_SIZE; + } + + /** + * What will we do if there is a problem parsing the request? + */ + @Override + public boolean stashFileSizeException() { + return true; + } + + /** + * The required action depends on what we are trying to do. + */ + @Override + protected AuthorizationRequest requiredActions(VitroRequest vreq) { + return SimplePermission.EDIT_SITE_INFORMATION.ACTION; + } + + /** + * Handle the different actions. If not specified, the default action is to + * show the intro screen. + * + */ + @Override + public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + VitroRequest vreq = new VitroRequest(request); + + if (!isAuthorizedToDisplayPage(request, response, + requiredActions(vreq))) { + return; + } + + String action = vreq.getParameter(PARAMETER_ACTION); + + if (Objects.equals(vreq.getMethod(), "POST")) { + + if (action.equals("upload")) { + uploadLogoFiles(vreq); + } + } + + printDefaultPage(request, response, ""); + } + + private void uploadLogoFiles(VitroRequest vreq) { + ImageUploadHelper helper = new ImageUploadHelper(fileStorage, + vreq.getUnfilteredWebappDaoFactory(), getServletContext()); + + + String desktopLogoAction = vreq.getParameter(LOGO_PARAMETER_ACTION); + String mobileLogoAction = vreq.getParameter(MOBILE_LOGO_PARAMETER_ACTION); + + try { + if (desktopLogoAction.equals("update")) { + FileItem desktopLogoFileItem = helper.validateImageFromRequest(vreq, "portalLogo"); + FileInfo desktopLogoFileInfo = helper.storeNewImage(desktopLogoFileItem, vreq, false); + String desktopLogoUrl = UrlBuilder.getUrl(desktopLogoFileInfo.getBytestreamAliasUrl()); + updateDesktopLogo(desktopLogoUrl); + } else if (desktopLogoAction.equals("reset")) { + updateDesktopLogo(""); + } + + if (mobileLogoAction.equals("update")) { + FileItem mobileLogoFileItem = helper.validateImageFromRequest(vreq, "mobilePortalLogo"); + FileInfo mobileLogoFileInfo = helper.storeNewImage(mobileLogoFileItem, vreq, false); + String mobileLogoUrl = UrlBuilder.getUrl(mobileLogoFileInfo.getBytestreamAliasUrl()); + updateMobileLogo(mobileLogoUrl); + } else if (mobileLogoAction.equals("reset")) { + updateMobileLogo(""); + } + updateLogoUrlCache(); + + } catch (UserMistakeException e) { + log.error("Error validating image from request: " + e.getMessage()); + // Handle the exception appropriately, e.g., return an error response or rethrow it + } + + } + + private void printDefaultPage(HttpServletRequest request, HttpServletResponse response, String info) { + try { + response.setContentType("text/html"); + response.getWriter().println("

Site Style

logoUrl: "
+                + (logoUrl != null ? logoUrl : "none") + "
mobileLogoUrl: "
+                + (mobileLogoUrl != null ? mobileLogoUrl : "none") + "
Info: "
+                + (info != null ? info : "") + "
"); + } catch (IOException e) { + log.error("Error writing sample data to response", e); + } + } + + private void updateLogoPath(String propertyUri, String value) { + ContextModelAccess cma = ModelAccess.getInstance(); + OntModel displayModel = cma.getOntModel(ModelNames.DISPLAY); + + Resource portalResource = ResourceFactory.createResource(VitroVocabulary.PROPERTY_CUSTOMSTYLE); + Property property = ResourceFactory.createProperty(propertyUri); + + displayModel.removeAll(portalResource, property, null); + if (!value.isEmpty() && !value.equals("null")) { + Statement statement = + new StatementImpl(portalResource, property, ResourceFactory.createTypedLiteral(value)); + displayModel.add(statement); + } + } + + private void updateDesktopLogo(String cssFilePath) { + updateLogoPath(VitroVocabulary.PORTAL_LOGOURL, cssFilePath); + } + + private void updateMobileLogo(String cssFilePath) { + updateLogoPath(VitroVocabulary.PORTAL_LOGOSMALLURL, cssFilePath); + } + + public static String getLogoUploadUrlString() { + return UrlBuilder.getPath(URL_HERE, new ParamMap(PARAMETER_ACTION, ACTION_UPLOAD)); + } + + private static String getLogo(String propertyUrl) { + ContextModelAccess cma = ModelAccess.getInstance(); + OntModel displayModel = cma.getOntModel(ModelNames.DISPLAY); + + Resource s = ResourceFactory.createResource(VitroVocabulary.PROPERTY_CUSTOMSTYLE); + Property customCssPathProperty = ResourceFactory.createProperty(propertyUrl); + StmtIterator iter = displayModel.listStatements(s, customCssPathProperty, (RDFNode) null); + + if (iter.hasNext()) { + Statement stmt = iter.nextStatement(); + RDFNode object = stmt.getObject(); + + if (object.isLiteral()) { + return object.asLiteral().getString(); + } + } + return null; + } + + private static void updateLogoUrlCache() { + logoUrl = getLogo(VitroVocabulary.PORTAL_LOGOURL); + mobileLogoUrl = getLogo(VitroVocabulary.PORTAL_LOGOSMALLURL); + logoUrlLoaded = true; + } + + public static String getLogoUrlCache() { + if (!logoUrlLoaded) { + updateLogoUrlCache(); + } + return logoUrl; + } + + public static String getMobileLogoUrlCache() { + if (!logoUrlLoaded) { + updateLogoUrlCache(); + } + return mobileLogoUrl; + } +} diff --git a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/dao/VitroVocabulary.java b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/dao/VitroVocabulary.java index fc6d1fe6ec..a779e6c3d8 100644 --- a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/dao/VitroVocabulary.java +++ b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/dao/VitroVocabulary.java @@ -116,6 +116,7 @@ public class VitroVocabulary { public static final String PORTAL = vitroURI+"Portal"; public static final String PORTAL_THEMEDIR = vitroURI+"themeDir"; + public static final String PORTAL_THEMECOLORS = vitroURI+"themeColors"; public static final String PORTAL_CONTACTMAIL = vitroURI+"contactMail"; public static final String PORTAL_CORRECTIONMAIL = vitroURI+"correctionMail"; public static final String PORTAL_ABOUTTEXT = vitroURI+"aboutText"; @@ -127,6 +128,9 @@ public class VitroVocabulary { public static final String PORTAL_CUSTOMCSSVERSION = vitroURI+"customCssVersion"; + public static final String PORTAL_LOGOURL = vitroURI+"logoUrl"; + public static final String PORTAL_LOGOSMALLURL = vitroURI+"logoSmallUrl"; + // reusing displayRank property above public static final String PORTAL_URLPREFIX = vitroURI + "urlPrefix"; diff --git a/home/src/main/resources/rdf/i18n/en_US/interface-i18n/firsttime/vitro_UiLabel.ttl b/home/src/main/resources/rdf/i18n/en_US/interface-i18n/firsttime/vitro_UiLabel.ttl index 9f652cb83e..1a555772ae 100644 --- a/home/src/main/resources/rdf/i18n/en_US/interface-i18n/firsttime/vitro_UiLabel.ttl +++ b/home/src/main/resources/rdf/i18n/en_US/interface-i18n/firsttime/vitro_UiLabel.ttl @@ -6046,6 +6046,14 @@ uil-data:save_changes.Vitro uil:hasKey "save_changes" ; uil:hasPackage "Vitro-languages" . +uil-data:back.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Back"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "back" ; + uil:hasPackage "Vitro-languages" . + uil-data:cant_activate_while_logged_in.Vitro rdf:type owl:NamedIndividual ; rdf:type uil:UILabel ; @@ -6931,6 +6939,118 @@ uil-data:there_are_no_results_to_display.Vitro uil:hasKey "there_are_no_results_to_display" ; uil:hasPackage "Vitro-languages" . +uil-data:branding_colors_submit_alert.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Color scheme changes submitted."@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_submit_alert" . + +uil-data:branding_colors_cancel_alert.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Color scheme changes canceled."@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_cancel_alert" . + +uil-data:branding_colors_reset_alert.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Color scheme reset successfully."@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_reset_alert" . + +uil-data:branding_colors_error_fetch_config.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Failed to load default theme palette. Please check if 'theme-config.json' file exists and is accessible."@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_error_fetch_config" . + +uil-data:branding_colors_error_format_config.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "The theme configuration file is not in the expected format. Please verify its structure."@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_error_format_config" . + +uil-data:branding_colors_error_unexpected_config.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "An unexpected error occurred while loading the theme configuration. Please verify its structure."@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_error_unexpected_config" . + +uil-data:branding_colors_open_after_save.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "The editor will open once you save your changes."@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_open_after_save" . + +uil-data:branding_colors_modified.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Theme colors are modified"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_colors_modified" . + +uil-data:change_branding_colors_link.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Change theme branding colors"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "change_branding_colors_link" . + +uil-data:change_logo_link.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Change logo"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "change_logo_link" . + +uil-data:branding_logo_title.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Logo Configuration"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "branding_logo_title" . + +uil-data:desktop_logo.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Desktop Logo"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "desktop_logo" . + +uil-data:desktop_logo_preview.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Desktop Logo Preview"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "desktop_logo_preview" . + +uil-data:mobile_logo.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Mobile Logo"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "mobile_logo" . + +uil-data:mobile_logo_preview.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Mobile Logo Preview"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "mobile_logo_preview" . + +uil-data:reset.Vitro + rdf:type owl:NamedIndividual ; + rdf:type uil:UILabel ; + rdfs:label "Reset"@en-US ; + uil:hasApp "Vitro" ; + uil:hasKey "reset" . + uil-data:remove_custom_css.Vitro rdf:type owl:NamedIndividual ; rdf:type uil:UILabel ; diff --git a/webapp/src/main/webapp/css/account/account.css b/webapp/src/main/webapp/css/account/account.css index fa6f230e2b..d4f39d6e1e 100644 --- a/webapp/src/main/webapp/css/account/account.css +++ b/webapp/src/main/webapp/css/account/account.css @@ -274,7 +274,7 @@ section.proxy-profile h4 { float: right; margin-right: 1em; margin-bottom: 1em; - background-color: #317e95; + background-color: var(--secondary-color, #317e95); } #add-relation input.pos-submit:hover { background-color: #87b4c1; diff --git a/webapp/src/main/webapp/css/browseClassGroups.css b/webapp/src/main/webapp/css/browseClassGroups.css index 172625d639..bffd81e406 100644 --- a/webapp/src/main/webapp/css/browseClassGroups.css +++ b/webapp/src/main/webapp/css/browseClassGroups.css @@ -48,7 +48,7 @@ ul#browse-classgroups a { display: block; padding-left: 15px; width: 200px; - color: #5e6363; + color: var(--text-color, #5E6363); text-decoration: none; padding-top: 8px; padding-bottom: 8px; @@ -99,7 +99,7 @@ ul#classes-in-classgroup a { display: block; padding-left: 15px; height: 35px; - color: #5e6363; + color: var(--text-color, #5E6363); text-decoration: none; } ul#classes-in-classgroup .count-individuals { diff --git a/webapp/src/main/webapp/css/browseIndex.css b/webapp/src/main/webapp/css/browseIndex.css index 0a13c48ddf..a05ee515a3 100644 --- a/webapp/src/main/webapp/css/browseIndex.css +++ b/webapp/src/main/webapp/css/browseIndex.css @@ -45,7 +45,7 @@ } .individualList .display-title { font-size: .825em; - color: #5e6363; + color: var(--text-color, #5E6363); border-left: 1px solid #A6B1B0; padding-left: .7em; padding-right: .3em; diff --git a/webapp/src/main/webapp/css/individual/individual-property-groups.css b/webapp/src/main/webapp/css/individual/individual-property-groups.css index cc4b7cf1b2..5ff73049be 100644 --- a/webapp/src/main/webapp/css/individual/individual-property-groups.css +++ b/webapp/src/main/webapp/css/individual/individual-property-groups.css @@ -41,8 +41,8 @@ ul.propertyTabsList li:first-child { } li.nonSelectedGroupTab { float:left; - border: 1px solid #DFE6E5; - background-color:#E4ECF3; + border: 1px solid var(--secondary-color-lighter, #dfe6e5); + background-color: var(--secondary-color-extralight, #E4ECF3); padding: 6px 8px 6px 8px; cursor:pointer; border-top-right-radius: 4px; @@ -54,7 +54,7 @@ li.nonSelectedGroupTab { } li.selectedGroupTab { float:left; - border: 1px solid #DFE6E5; + border: 1px solid var(--secondary-color-lighter, #dfe6e5); border-bottom-color:#fff; background-color:#FFF; padding: 6px 8px 6px 8px; @@ -66,7 +66,7 @@ li.selectedGroupTab { -webkit-border-top-left-radius: 4px; } li.groupTabSpacer { - float:left;border-bottom: 1px solid #DFE6E5;background-color:#fff;width:3px;height:37px + float:left;border-bottom: 1px solid var(--secondary-color-lighter, #dfe6e5);background-color:#fff;width:3px;height:37px } div.additionalItems { margin-top: 0 !important; diff --git a/webapp/src/main/webapp/css/individual/individual.css b/webapp/src/main/webapp/css/individual/individual.css index 2651a6e871..9937131374 100644 --- a/webapp/src/main/webapp/css/individual/individual.css +++ b/webapp/src/main/webapp/css/individual/individual.css @@ -164,10 +164,10 @@ article.property { width: 93%; margin: 0 auto; margin-bottom: 20px; - border: 1px solid #dfe6e5; + border: 1px solid var(--secondary-color-lighter, #dfe6e5); } article.property h3 { - border-bottom: 1px solid #dfe6e5; + border-bottom: 1px solid var(--secondary-color-lighter, #dfe6e5); padding: 10px 20px 10px 20px; } article.property ul.property-list li.subclass h3 { diff --git a/webapp/src/main/webapp/css/menupage/menupage.css b/webapp/src/main/webapp/css/menupage/menupage.css index 51afcc95fa..b53ef387e2 100644 --- a/webapp/src/main/webapp/css/menupage/menupage.css +++ b/webapp/src/main/webapp/css/menupage/menupage.css @@ -94,7 +94,7 @@ ul#find-filters a { width: 150px; padding: 20px 12px 10px 12px; font-size: 1.25em; - color: #2485ae; + color: var(--primary-color, #2485ae); font-weight: normal; background: url(../images/arrow.gif) 140px 27px no-repeat; } diff --git a/webapp/src/main/webapp/css/search-results.css b/webapp/src/main/webapp/css/search-results.css index ddabeae021..74f509ca4f 100644 --- a/webapp/src/main/webapp/css/search-results.css +++ b/webapp/src/main/webapp/css/search-results.css @@ -2,7 +2,7 @@ display: inline-block; width: max-content; margin-right: 10px; - border-right: #dfe6e5 solid 1px; + border-right: var(--secondary-color-lighter, #dfe6e5) solid 1px; padding-right: 10px; } @@ -18,7 +18,7 @@ .listElementContainer { margin: 0px 10px 10px 10px; - border-bottom: #dfe6e5 solid 1px; + border-bottom: var(--secondary-color-lighter, #dfe6e5) solid 1px; padding-bottom: 10px; display:flex; } diff --git a/webapp/src/main/webapp/css/search.css b/webapp/src/main/webapp/css/search.css index 249bcddece..b25e57b4a1 100644 --- a/webapp/src/main/webapp/css/search.css +++ b/webapp/src/main/webapp/css/search.css @@ -65,7 +65,7 @@ ul.searchTips li { /* Search results */ .display-title { font-size: .825em; - color: #5e6363; + color: var(--text-color, #5E6363); border-left: 1px solid #A6B1B0; padding-left: .7em; padding-right: .3em; diff --git a/webapp/src/main/webapp/css/vitro.css b/webapp/src/main/webapp/css/vitro.css index bb5b18368b..d79238fbfe 100644 --- a/webapp/src/main/webapp/css/vitro.css +++ b/webapp/src/main/webapp/css/vitro.css @@ -23,6 +23,9 @@ .disabledSubmit { cursor:default ! important; } +.relative { + position: relative; +} /* <------ USER FEEDBACK*/ #error-alert { color: #900; @@ -522,6 +525,156 @@ table#table-listing td a { word-wrap: break-word; } +/* Logo upload */ +.logo-section { + margin-bottom: 2rem; +} + +.logo-label { + font-weight: 600; + display: block; + margin-bottom: 0.5rem; + color: #555; +} + +.logo-preview { + width: 100px; + height: 100px; + object-fit: contain; + background-color: #f2f2f2; + border: 1px dashed #ccc; + border-radius: 8px; + margin: 0.5rem 0; + display: block; +} + +.logo-preview img { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 8px; + display: none; +} + +.logo-controls { + display: flex; + align-items: center; + gap: 1rem; +} + +.logo-controls button { + background: #e0e0e0; + border: none; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; +} + +.logo-controls button:hover { + background: #ccc; +} + +/* If input selector have attribute [user-changed="false"] then .chain gets this style */ +input.shade-borderless[type="color"][user-changed="false"] + .chain { + content: ""; + color: #888; + position: absolute; + right: -3px; + width: 13px; + top: 1px; + height: 22px; + background-image: url('../images/link-chain.png'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + user-select: none; +} + +/* Branding Colors Editor */ +.branding-editor-body { + margin-bottom: 180px; +} + +footer#fixed-footer.branding-fixed-footer { + position: fixed; + bottom: 0; + width: 100%; + background: rgb(243 243 240); + border-top: 1px solid #ccc; + color: #fff; + text-align: center; + padding: 10px; + height: 180px; + max-height: 160px; + overflow-y: auto; + z-index: 10000; + display: flex; + justify-content: center; + gap: 20px; +} + +#color-inputs.branding-color-inputs { + display: flex; + justify-content: center; + column-gap: 50px; + flex-wrap: wrap; + padding: 0 10px; +} + +.branding-color-col { + align-items: center; + margin: 5px; + display: flex; + flex-direction: column; + align-items: center; +} + +.branding-color-label { + color: #000; + margin-bottom: 10px; + margin-top: 0; + font-weight: 600; +} + +.branding-color-group { + display: flex; + flex-direction: column; + align-items: center; + position: relative; +} + +input.branding-color-input.shade-borderless { + border: 0; +} + +.branding-button-container { + display: flex; + flex-direction: column; + gap: 10px; + justify-content: center; + margin-top: 10px; +} + +.branding-btn { + padding: 5px 10px; + color: #fff; + border: none; + cursor: pointer; +} + +.branding-btn.btn-danger { + background: #f44336; +} + +.branding-btn.btn-success { + background: #4CAF50; +} + +.branding-reset-btn { + margin-top: 10px; + display: none; +} + a.button.blue { padding: 7px 22px 8px; font-family: sans-serif; diff --git a/webapp/src/main/webapp/images/link-chain.png b/webapp/src/main/webapp/images/link-chain.png new file mode 100644 index 0000000000..b87c5efc02 Binary files /dev/null and b/webapp/src/main/webapp/images/link-chain.png differ diff --git a/webapp/src/main/webapp/js/brandingColors.js b/webapp/src/main/webapp/js/brandingColors.js new file mode 100644 index 0000000000..5214bd77ec --- /dev/null +++ b/webapp/src/main/webapp/js/brandingColors.js @@ -0,0 +1,293 @@ +/* $This file is distributed under the terms of the license in LICENSE$ */ +$.extend(this, globalI18nStrings); + +let brandingColors = null; + +function initBrandingColors() { + showColorSchemeEditor(); + + async function showColorSchemeEditor() { + await loadThemeConfig(); + if (!brandingColors) { + return; + } + + renderEditor(); + initColors(); + loadDateFromLocalStorage(); + } + + function getSchemaData() { + return JSON.parse(localStorage.getItem('colorSchemeEditor')); + } + + function saveDateToLocalStorage() { + let data = getSchemaData(); + brandingColors.palette.forEach(colorPaletteGroup => { + colorPaletteGroup.colors.forEach(color => { + const colorInput = $('#' + color.name + '-color'); + + if (colorInput.attr('default-color') === 'true') { + data.updatedColors[color.cssVariable] = undefined; + } else { + data.updatedColors[color.cssVariable] = colorInput.val(); + } + }); + }); + localStorage.setItem('colorSchemeEditor', JSON.stringify(data)); + } + + function loadDateFromLocalStorage() { + let data = getSchemaData(); + brandingColors.palette.forEach(colorPaletteGroup => { + colorPaletteGroup.colors.forEach(color => { + if (data.updatedColors[color.cssVariable]) { + const colorInput = $('#' + color.name + '-color'); + colorInput.val(data.updatedColors[color.cssVariable]); + colorInput.attr('default-color', false); + colorInput.attr('user-changed', true); + handleColorInput(colorPaletteGroup, color, data.updatedColors[color.cssVariable], false, true); + } + }); + }); + } + + function toggleResetButton(inputId, show) { + const resetLink = document.getElementById(inputId); + resetLink.style.display = show ? 'block' : 'none'; + } + + function adjustHexColor(hex, percent) { + if (hex.startsWith("#")) hex = hex.slice(1); + if (hex.length !== 6) throw new Error("Invalid hex color format."); + + const adjust = (value) => Math.max(0, Math.min(255, Math.round(value + (percent / 100) * (percent > 0 ? (255 - value) : value)))); + const [r, g, b] = [0, 2, 4].map(offset => adjust(parseInt(hex.substring(offset, offset + 2), 16))); + + return `#${[r, g, b].map(value => value.toString(16).padStart(2, "0")).join("")}`; + } + + function resetColor(color) { + let palette = color.value; + + const colorInput = $('#' + color.name + '-color'); + colorInput.val(palette); + colorInput.attr('default-color', true); + colorInput.attr('user-changed', false); + + updateCSSVariable(color.cssVariable, null); + } + + function handleResetButton(colorPaletteGroup) { + colorPaletteGroup.colors.forEach(color => { + resetColor(color); + }); + + toggleResetButton(colorPaletteGroup.groupName + "-reset", false); + saveDateToLocalStorage(); + } + + function handleColorInput(group, color, value, updateShades = true, userChanged = true) { + const colorInput = $('#' + color.name + '-color') + updateCSSVariable(color.cssVariable, value); + toggleResetButton(group.groupName + '-reset', true); + colorInput.attr('default-color', false); + colorInput.attr('user-changed', userChanged); + + if (updateShades) { + const paletteGroup = brandingColors.palette.find(x => x.groupName == group.groupName) + const dependentColors = paletteGroup.colors.filter(x => x.shade?.base == color.name) + dependentColors.forEach(dependentColor => { + + const shadeColorInput = $('#' + dependentColor.name + '-color'); + if (shadeColorInput.attr('user-changed') === 'true') { + return; + } + const adjustedValue = applyShadeTransformation(value, dependentColor.shade); + shadeColorInput.val(adjustedValue); + handleColorInput(group, dependentColor, adjustedValue, false, false); + }); + } + } + + function applyShadeTransformation(color, shadeConfig) { + if (!shadeConfig) return color; + + const amount = shadeConfig.amount || 0; + const type = shadeConfig.type; + + if (type === 'lighten') { + return adjustHexColor(color, amount); + } else if (type === 'darken') { + return adjustHexColor(color, -amount); + } + + return color; + } + + function updateCSSVariable(cssVar, value) { + document.documentElement.style.setProperty(cssVar, value ? value : 'unset'); + + } + + async function loadThemeConfig() { + let data = getSchemaData(); + brandingColors = data.brandingColors; + } + + function initColors() { + brandingColors.palette.forEach(colorPaletteGroup => { + colorPaletteGroup.colors.forEach(color => { + resetColor(color); + }); + }); + } + + function renderEditor() { + + $('body').addClass('branding-editor-body'); + var $footer = $('