mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 22:59:39 +02:00
Feature: Implement Mobile Responsiveness (#2092)
* WIP * WIP * WIP * WIP * WIP * WIP * WIP * format * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * WIP * fix conflict * fix conflict * chore: run rubocop * fix test * update PWA logo * fix tests * chore: lint * fix test * Refactor: Remove duplicate data attribute in activity partial and add chat form rendering in chats index --------- Co-authored-by: Josh Pigford <josh@joshpigford.com>
This commit is contained in:
parent
6a21f26d2d
commit
65e1bc6edd
91 changed files with 1333 additions and 527 deletions
74
app/javascript/controllers/file_upload_controller.js
Normal file
74
app/javascript/controllers/file_upload_controller.js
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "fileName", "uploadArea", "uploadText"]
|
||||
|
||||
connect() {
|
||||
if (this.hasInputTarget) {
|
||||
this.inputTarget.addEventListener("change", this.fileSelected.bind(this))
|
||||
}
|
||||
|
||||
// Find the form element
|
||||
this.form = this.element.closest("form")
|
||||
if (this.form) {
|
||||
this.form.addEventListener("turbo:submit-start", this.formSubmitting.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.hasInputTarget) {
|
||||
this.inputTarget.removeEventListener("change", this.fileSelected.bind(this))
|
||||
}
|
||||
|
||||
if (this.form) {
|
||||
this.form.removeEventListener("turbo:submit-start", this.formSubmitting.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
triggerFileInput() {
|
||||
if (this.hasInputTarget) {
|
||||
this.inputTarget.click()
|
||||
}
|
||||
}
|
||||
|
||||
fileSelected() {
|
||||
if (this.hasInputTarget && this.inputTarget.files.length > 0) {
|
||||
const fileName = this.inputTarget.files[0].name
|
||||
|
||||
if (this.hasFileNameTarget) {
|
||||
// Find the paragraph element inside the fileName target
|
||||
const fileNameText = this.fileNameTarget.querySelector('p')
|
||||
if (fileNameText) {
|
||||
fileNameText.textContent = fileName
|
||||
}
|
||||
|
||||
this.fileNameTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
if (this.hasUploadTextTarget) {
|
||||
this.uploadTextTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
formSubmitting() {
|
||||
if (this.hasFileNameTarget && this.hasInputTarget && this.inputTarget.files.length > 0) {
|
||||
const fileNameText = this.fileNameTarget.querySelector('p')
|
||||
if (fileNameText) {
|
||||
fileNameText.textContent = `Uploading ${this.inputTarget.files[0].name}...`
|
||||
}
|
||||
|
||||
// Change the icon to a loader
|
||||
const iconContainer = this.fileNameTarget.querySelector('.lucide-file-text')
|
||||
if (iconContainer) {
|
||||
iconContainer.classList.add('animate-pulse')
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasUploadAreaTarget) {
|
||||
this.uploadAreaTarget.classList.add("opacity-70")
|
||||
}
|
||||
}
|
||||
}
|
149
app/javascript/controllers/mobile_cell_interaction_controller.js
Normal file
149
app/javascript/controllers/mobile_cell_interaction_controller.js
Normal file
|
@ -0,0 +1,149 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="mobile-cell-interaction"
|
||||
export default class extends Controller {
|
||||
static targets = ["field", "highlight", "errorTooltip", "errorIcon"];
|
||||
static values = { error: String };
|
||||
|
||||
touchTimeout = null;
|
||||
activeTooltip = null;
|
||||
documentClickHandler = null;
|
||||
|
||||
connect() {
|
||||
this.documentClickHandler = this.handleDocumentClick.bind(this);
|
||||
document.addEventListener('click', this.documentClickHandler);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.documentClickHandler) {
|
||||
document.removeEventListener('click', this.documentClickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
handleDocumentClick(event) {
|
||||
if (event.target.closest('[data-mobile-cell-interaction-target="errorTooltip"]') ||
|
||||
event.target.closest('[data-mobile-cell-interaction-target="errorIcon"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideAllErrorTooltips();
|
||||
}
|
||||
|
||||
highlightCell(event) {
|
||||
const field = event.target;
|
||||
const highlight = this.findHighlightForField(field);
|
||||
if (highlight) {
|
||||
highlight.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
unhighlightCell(event) {
|
||||
const field = event.target;
|
||||
const highlight = this.findHighlightForField(field);
|
||||
if (highlight) {
|
||||
highlight.style.opacity = '0';
|
||||
}
|
||||
|
||||
this.hideAllErrorTooltips();
|
||||
}
|
||||
|
||||
handleCellTouch(event) {
|
||||
if (this.touchTimeout) {
|
||||
clearTimeout(this.touchTimeout);
|
||||
}
|
||||
|
||||
const field = event.target;
|
||||
|
||||
const highlight = this.findHighlightForField(field);
|
||||
if (highlight) {
|
||||
highlight.style.opacity = '1';
|
||||
|
||||
this.touchTimeout = window.setTimeout(() => {
|
||||
if (document.activeElement !== field) {
|
||||
highlight.style.opacity = '0';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (this.hasErrorValue && this.errorValue) {
|
||||
this.showErrorTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
toggleErrorMessage(event) {
|
||||
const errorIcon = event.currentTarget;
|
||||
const cellContainer = errorIcon.closest('div');
|
||||
const field = cellContainer.querySelector('input');
|
||||
|
||||
if (field) {
|
||||
field.focus();
|
||||
}
|
||||
|
||||
const tooltip = this.errorTooltipTarget;
|
||||
|
||||
this.hideAllTooltipsExcept(tooltip);
|
||||
|
||||
if (tooltip.classList.contains('hidden')) {
|
||||
tooltip.classList.remove('hidden');
|
||||
this.activeTooltip = tooltip;
|
||||
|
||||
setTimeout(() => {
|
||||
if (tooltip === this.activeTooltip) {
|
||||
tooltip.classList.add('hidden');
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
}, 3000);
|
||||
} else {
|
||||
tooltip.classList.add('hidden');
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
showErrorTooltip() {
|
||||
if (this.hasErrorTooltipTarget) {
|
||||
const tooltip = this.errorTooltipTarget;
|
||||
tooltip.classList.remove('hidden');
|
||||
this.activeTooltip = tooltip;
|
||||
|
||||
setTimeout(() => {
|
||||
if (tooltip === this.activeTooltip) {
|
||||
tooltip.classList.add('hidden');
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
hideAllErrorTooltips() {
|
||||
document.querySelectorAll('[data-mobile-cell-interaction-target="errorTooltip"]').forEach(tooltip => {
|
||||
tooltip.classList.add('hidden');
|
||||
});
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
|
||||
hideAllTooltipsExcept(tooltipToKeep) {
|
||||
document.querySelectorAll('[data-mobile-cell-interaction-target="errorTooltip"]').forEach(tooltip => {
|
||||
if (tooltip !== tooltipToKeep) {
|
||||
tooltip.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectCell(event) {
|
||||
const errorIcon = event.currentTarget;
|
||||
const cellContainer = errorIcon.closest('div');
|
||||
const field = cellContainer.querySelector('input');
|
||||
|
||||
if (field) {
|
||||
field.focus();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
findHighlightForField(field) {
|
||||
const container = field.closest('div');
|
||||
return container ? container.querySelector('[data-mobile-cell-interaction-target="highlight"]') : null;
|
||||
}
|
||||
}
|
63
app/javascript/controllers/password_validator_controller.js
Normal file
63
app/javascript/controllers/password_validator_controller.js
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="password-validator"
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "requirementType", "blockLine"];
|
||||
|
||||
connect() {
|
||||
this.validate();
|
||||
}
|
||||
|
||||
validate() {
|
||||
const password = this.inputTarget.value;
|
||||
let requirementsMet = 0;
|
||||
|
||||
// Check each requirement and count how many are met
|
||||
const lengthValid = password.length >= 8;
|
||||
const caseValid = /[A-Z]/.test(password) && /[a-z]/.test(password);
|
||||
const numberValid = /\d/.test(password);
|
||||
const specialValid = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
// Update individual requirement text
|
||||
this.validateRequirementText("length", lengthValid);
|
||||
this.validateRequirementText("case", caseValid);
|
||||
this.validateRequirementText("number", numberValid);
|
||||
this.validateRequirementText("special", specialValid);
|
||||
|
||||
// Count total requirements met
|
||||
if (lengthValid) requirementsMet++;
|
||||
if (caseValid) requirementsMet++;
|
||||
if (numberValid) requirementsMet++;
|
||||
if (specialValid) requirementsMet++;
|
||||
|
||||
// Update block lines sequentially
|
||||
this.updateBlockLines(requirementsMet);
|
||||
}
|
||||
|
||||
validateRequirementText(type, isValid) {
|
||||
this.requirementTypeTargets.forEach((target) => {
|
||||
if (target.dataset.requirementType === type) {
|
||||
if (isValid) {
|
||||
target.classList.remove("text-secondary");
|
||||
target.classList.add("text-green-600");
|
||||
} else {
|
||||
target.classList.remove("text-green-600");
|
||||
target.classList.add("text-secondary");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateBlockLines(requirementsMet) {
|
||||
// Update block lines sequentially based on total requirements met
|
||||
this.blockLineTargets.forEach((line, index) => {
|
||||
if (index < requirementsMet) {
|
||||
line.classList.remove("bg-gray-200");
|
||||
line.classList.add("bg-green-600");
|
||||
} else {
|
||||
line.classList.remove("bg-green-600");
|
||||
line.classList.add("bg-gray-200");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
19
app/javascript/controllers/password_visibility_controller.js
Normal file
19
app/javascript/controllers/password_visibility_controller.js
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="password-visibility"
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "showIcon", "hideIcon"];
|
||||
|
||||
connect() {
|
||||
this.hideIconTarget.classList.add("hidden");
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const input = this.inputTarget;
|
||||
const type = input.type === "password" ? "text" : "password";
|
||||
input.type = type;
|
||||
|
||||
this.showIconTarget.classList.toggle("hidden");
|
||||
this.hideIconTarget.classList.toggle("hidden");
|
||||
}
|
||||
}
|
39
app/javascript/controllers/preserve_scroll_controller.js
Normal file
39
app/javascript/controllers/preserve_scroll_controller.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i
|
||||
modified to add support for horizontal scrolling
|
||||
*/
|
||||
if (!window.scrollPositions) {
|
||||
window.scrollPositions = {};
|
||||
}
|
||||
|
||||
function preserveScroll() {
|
||||
document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
|
||||
scrollPositions[element.id] = {
|
||||
top: element.scrollTop,
|
||||
left: element.scrollLeft
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function restoreScroll(event) {
|
||||
document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
|
||||
if (scrollPositions[element.id]) {
|
||||
element.scrollTop = scrollPositions[element.id].top;
|
||||
element.scrollLeft = scrollPositions[element.id].left;
|
||||
}
|
||||
});
|
||||
|
||||
if (!event.detail.newBody) return;
|
||||
// event.detail.newBody is the body element to be swapped in.
|
||||
// https://turbo.hotwired.dev/reference/events
|
||||
event.detail.newBody.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
|
||||
if (scrollPositions[element.id]) {
|
||||
element.scrollTop = scrollPositions[element.id].top;
|
||||
element.scrollLeft = scrollPositions[element.id].left;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("turbo:before-cache", preserveScroll);
|
||||
window.addEventListener("turbo:before-render", restoreScroll);
|
||||
window.addEventListener("turbo:render", restoreScroll);
|
|
@ -8,6 +8,9 @@ export default class extends Controller {
|
|||
"deleteProfileImage",
|
||||
"input",
|
||||
"clearBtn",
|
||||
"uploadText",
|
||||
"changeText",
|
||||
"cameraIcon"
|
||||
];
|
||||
|
||||
clearFileInput() {
|
||||
|
@ -17,6 +20,12 @@ export default class extends Controller {
|
|||
this.attachedImageTarget.classList.add("hidden");
|
||||
this.previewImageTarget.classList.add("hidden");
|
||||
this.deleteProfileImageTarget.value = "1";
|
||||
this.uploadTextTarget.classList.remove("hidden");
|
||||
this.changeTextTarget.classList.add("hidden");
|
||||
this.changeTextTarget.setAttribute("aria-hidden", "true");
|
||||
this.uploadTextTarget.setAttribute("aria-hidden", "false");
|
||||
this.cameraIconTarget.classList.remove("!hidden");
|
||||
|
||||
}
|
||||
|
||||
showFileInputPreview(event) {
|
||||
|
@ -28,7 +37,11 @@ export default class extends Controller {
|
|||
this.previewImageTarget.classList.remove("hidden");
|
||||
this.clearBtnTarget.classList.remove("hidden");
|
||||
this.deleteProfileImageTarget.value = "0";
|
||||
|
||||
this.uploadTextTarget.classList.add("hidden");
|
||||
this.changeTextTarget.classList.remove("hidden");
|
||||
this.changeTextTarget.setAttribute("aria-hidden", "false");
|
||||
this.uploadTextTarget.setAttribute("aria-hidden", "true");
|
||||
this.cameraIconTarget.classList.add("!hidden");
|
||||
this.previewImageTarget.querySelector("img").src =
|
||||
URL.createObjectURL(file);
|
||||
}
|
||||
|
|
|
@ -50,7 +50,8 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
systemPrefersDark() {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
return false
|
||||
// return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
}
|
||||
|
||||
handleSystemThemeChange = (event) => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue