mirror of
https://github.com/mue/mue.git
synced 2026-07-16 13:34:03 +02:00
Dev (#1134)
* feat: add professional three-branch release workflow automation (#1129) - Add version-bump workflow for semantic versioning across all files - Add beta-release workflow for automated pre-release testing - Add production-release workflow with manual approval gates - Add hotfix-release workflow for emergency patches - Create comprehensive CONTRIBUTING.md with workflow guide - Create detailed RELEASE_PROCESS.md for maintainers - Add PR template with release checklists - Update CODEOWNERS to protect workflow files - Update README with contribution links - Remove /docs from .gitignore to allow documentation This implements a dev beta main branching strategy with: - Automated version management across 6 files - Changelog generation from conventional commits - GitHub Releases with build artifacts - Environment-based approvals for production - Back-merge support for hotfixes * feat: new default quotes experience, improve added page * Fix/beta workflow version check (#1131) * fix(workflows): prevent beta release for non-beta versions * fix(workflows): address copilot PR review feedback - Support iterative beta versions (7.6.0-beta.1 -> 7.6.0-beta.2) - Remove tag trigger from beta workflow to prevent premature releases - Fix tag format in docs/summaries to include 'v' prefix - Clarify deployment approval wording * feat: replace mui with new style * feat: improve time formatting in Clock component with padded digits * fix: change Checkbox component from label to div for better semantics * fix: change Switch component from label to div for better semantics * feat: add smooth animation to reset functionality in Slider component * feat: enhance accessibility and styling for form components including Checkbox, Dropdown, Radio, Slider, and Text * feat: enhance WeatherOptions component with improved layout and auto location reset functionality * feat: update Slider and Dropdown components with improved layout and z-index adjustments * feat: add reset functionality to Dropdown component with toast notification * feat: update Dropdown component styles for improved layout and structure * feat: update languageSettings component with increased padding for better spacing * feat: bump version to 7.6.0 across all manifests and documentation --------- Signed-off-by: Alex Sparkes <alexsparkes@gmail.com> Co-authored-by: David Ralph <me@davidcralph.co.uk>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import variables from 'config/variables';
|
||||
|
||||
import { useState, memo } from 'react';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
import { MdAddLink, MdClose } from 'react-icons/md';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { Button } from 'components/Elements';
|
||||
@@ -26,22 +25,24 @@ function AddModal({ urlError, iconError, addLink, closeModal, edit, editData, ed
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="quicklinkModalTextbox">
|
||||
<TextareaAutosize
|
||||
maxRows={1}
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
placeholder={variables.getMessage('widgets.quicklinks.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
|
||||
style={{ gridColumn: 'span 2' }}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
maxRows={10}
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
placeholder={variables.getMessage('widgets.quicklinks.url')}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
maxRows={10}
|
||||
maxLines={1}
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
placeholder={variables.getMessage('widgets.quicklinks.icon')}
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
|
||||
|
||||
@@ -206,7 +206,7 @@ h5 {
|
||||
}
|
||||
|
||||
.languageSettings {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 50px;
|
||||
|
||||
.MuiFormGroup-root {
|
||||
gap: 5px;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 280px));
|
||||
grid-gap: 1.5rem;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 30px;
|
||||
@@ -62,6 +62,20 @@
|
||||
width: 60px !important;
|
||||
border-radius: 12px;
|
||||
transition: 0.5s;
|
||||
|
||||
&.item-icon-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-details {
|
||||
@@ -113,6 +127,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.item-uninstall-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(220, 50, 50, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.item-installed-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
@@ -135,9 +171,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
.item-sideload-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(100, 100, 100, 0.9);
|
||||
cursor: help;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .item-installed-badge {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.item-sideloaded {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { Checkbox as CheckboxUI, FormControlLabel } from '@mui/material';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Checkbox.scss';
|
||||
|
||||
const Checkbox = memo((props) => {
|
||||
const [checked, setChecked] = useState(localStorage.getItem(props.name) === 'true');
|
||||
|
||||
@@ -18,7 +20,7 @@ const Checkbox = memo((props) => {
|
||||
|
||||
variables.stats.postEvent(
|
||||
'setting',
|
||||
`${props.name} ${checked ? 'enabled' : 'disabled'}`,
|
||||
`${props.name} ${value ? 'enabled' : 'disabled'}`,
|
||||
);
|
||||
|
||||
if (props.element) {
|
||||
@@ -31,20 +33,30 @@ const Checkbox = memo((props) => {
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [checked, props]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if ((e.key === ' ' || e.key === 'Enter') && !props.disabled) {
|
||||
e.preventDefault();
|
||||
handleChange();
|
||||
}
|
||||
}, [handleChange, props.disabled]);
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<CheckboxUI
|
||||
name={props.name}
|
||||
color="primary"
|
||||
className="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={props.disabled || false}
|
||||
/>
|
||||
}
|
||||
label={props.text}
|
||||
/>
|
||||
<div className={`checkbox-wrapper ${props.disabled ? 'disabled' : ''}`}>
|
||||
<span className="checkbox-label">{props.text}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={props.disabled || false}
|
||||
className="checkbox-input"
|
||||
aria-label={props.text}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className={`checkbox-box ${checked ? 'checked' : ''}`}>
|
||||
{checked && <MdCheck />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
118
src/components/Form/Settings/Checkbox/Checkbox.scss
Normal file
118
src/components/Form/Settings/Checkbox/Checkbox.scss
Normal file
@@ -0,0 +1,118 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(checkScale) {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) .checkbox-label {
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
flex: 1;
|
||||
transition: color 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
border: 2px solid t($modal-sidebarActive);
|
||||
background: t($modal-sidebar);
|
||||
color: t($color);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: t($color);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
transform: scale(0.95);
|
||||
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.checked {
|
||||
@include themed {
|
||||
background: t($link);
|
||||
border-color: t($link);
|
||||
}
|
||||
|
||||
svg {
|
||||
@include animation(checkScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
&:focus-visible + .checkbox-box {
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 3px t($link);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useState, memo } from 'react';
|
||||
import { useState, memo, useRef, useEffect } from 'react';
|
||||
import { MdExpandMore, MdClose } from 'react-icons/md';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import './ChipSelect.scss';
|
||||
|
||||
function ChipSelect({ label, options, onChange }) {
|
||||
let start = (localStorage.getItem('apiCategories') || '').split(',');
|
||||
@@ -14,47 +9,88 @@ function ChipSelect({ label, options, onChange }) {
|
||||
start = [];
|
||||
}
|
||||
|
||||
const [optionsSelected, setoptionsSelected] = useState(start);
|
||||
const [optionsSelected, setOptionsSelected] = useState(start);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
setoptionsSelected(typeof value === 'string' ? value.split(',') : value);
|
||||
localStorage.setItem('apiCategories', value);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleToggle = (optionName) => {
|
||||
let newSelected;
|
||||
if (optionsSelected.includes(optionName)) {
|
||||
newSelected = optionsSelected.filter((item) => item !== optionName);
|
||||
} else {
|
||||
newSelected = [...optionsSelected, optionName];
|
||||
}
|
||||
|
||||
setOptionsSelected(newSelected);
|
||||
localStorage.setItem('apiCategories', newSelected.join(','));
|
||||
|
||||
// Call parent onChange if provided
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
onChange(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveChip = (e, optionName) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(optionName);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<InputLabel id="chipSelect-label">{label}</InputLabel>
|
||||
<Select
|
||||
labelId="chipSelect-label"
|
||||
id="chipSelect"
|
||||
multiple
|
||||
value={optionsSelected}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-multiple-chip" label={label} />}
|
||||
renderValue={(optionsSelected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{optionsSelected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.name} value={option.name}>
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}{' '}
|
||||
{option.count && `(${option.count})`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="chipSelect" ref={containerRef}>
|
||||
{label && <label className="chipSelect-label">{label}</label>}
|
||||
<div className="chipSelect-control" onClick={() => setIsOpen(!isOpen)}>
|
||||
<div className="chipSelect-value">
|
||||
{optionsSelected.length === 0 ? (
|
||||
<span className="chipSelect-placeholder">Select options...</span>
|
||||
) : (
|
||||
<div className="chipSelect-chips">
|
||||
{optionsSelected.map((value) => (
|
||||
<span key={value} className="chipSelect-chip">
|
||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
<button
|
||||
type="button"
|
||||
className="chipSelect-chip-remove"
|
||||
onClick={(e) => handleRemoveChip(e, value)}
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MdExpandMore className={`chipSelect-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="chipSelect-dropdown">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.name}
|
||||
className={`chipSelect-option ${optionsSelected.includes(option.name) ? 'selected' : ''}`}
|
||||
onClick={() => handleToggle(option.name)}
|
||||
>
|
||||
<span className="chipSelect-option-checkbox">
|
||||
{optionsSelected.includes(option.name) && '✓'}
|
||||
</span>
|
||||
<span className="chipSelect-option-label">
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}
|
||||
{option.count && ` (${option.count})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
178
src/components/Form/Settings/ChipSelect/ChipSelect.scss
Normal file
178
src/components/Form/Settings/ChipSelect/ChipSelect.scss
Normal file
@@ -0,0 +1,178 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.chipSelect {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
margin-top: 10px;
|
||||
|
||||
.chipSelect-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover {
|
||||
border-color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chipSelect-placeholder {
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chipSelect-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
text-transform: capitalize;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
border-radius: calc(t($borderRadius) / 2);
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
.chipSelect-chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: t($modal-sidebar);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
|
||||
@include themed {
|
||||
border: 2px solid t($modal-sidebarActive);
|
||||
border-radius: 4px;
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected .chipSelect-option-checkbox {
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
border-color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option-label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,165 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback, useRef } from 'react';
|
||||
import { InputLabel, MenuItem, FormControl, Select } from '@mui/material';
|
||||
import { memo, useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { MdExpandMore, MdCheck, MdRefresh } from 'react-icons/md';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Dropdown.scss';
|
||||
|
||||
const Dropdown = memo((props) => {
|
||||
const [value, setValue] = useState(
|
||||
localStorage.getItem(props.name) || props.items[0].value,
|
||||
localStorage.getItem(props.name) || props.items[0]?.value,
|
||||
);
|
||||
const dropdown = useRef();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const containerRef = useRef(null);
|
||||
const optionsRef = useRef([]);
|
||||
|
||||
const onChange = useCallback((e) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (newValue === variables.getMessage('modals.main.loading')) {
|
||||
return;
|
||||
}
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
setValue(newValue);
|
||||
|
||||
if (!props.noSetting) {
|
||||
localStorage.setItem(props.name, newValue);
|
||||
localStorage.setItem(props.name2, props.value2);
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [value, props]);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newValue) => {
|
||||
if (newValue === variables.getMessage('modals.main.loading')) {
|
||||
return;
|
||||
}
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
setValue(newValue);
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
|
||||
if (!props.noSetting) {
|
||||
localStorage.setItem(props.name, newValue);
|
||||
localStorage.setItem(props.name2, props.value2);
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
},
|
||||
[value, props],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev));
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen, props.items, props.disabled],
|
||||
);
|
||||
|
||||
const handleOptionKeyDown = useCallback(
|
||||
(e, item) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(item.value);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const resetItem = useCallback(() => {
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
onChange(defaultValue);
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [onChange, props.default, props.items]);
|
||||
|
||||
const id = 'dropdown' + props.name;
|
||||
const label = props.label || '';
|
||||
const selectedItem = props.items.find((item) => item?.value === value);
|
||||
|
||||
return (
|
||||
<FormControl fullWidth className={id}>
|
||||
<InputLabel id={id}>{label}</InputLabel>
|
||||
<Select
|
||||
labelId={id}
|
||||
id={props.name}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
ref={dropdown}
|
||||
key={id}
|
||||
<div className={`dropdown ${id} ${props.disabled ? 'disabled' : ''}`} ref={containerRef}>
|
||||
{label && (
|
||||
<div className="dropdown-header">
|
||||
<label className="dropdown-label">{label}</label>
|
||||
<span className="dropdown-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-control"
|
||||
onClick={() => !props.disabled && setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label || props.name}
|
||||
tabIndex={props.disabled ? -1 : 0}
|
||||
>
|
||||
{props.items.map((item) =>
|
||||
item !== null ? (
|
||||
<MenuItem key={id + item.value} value={item.value}>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
) : null,
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<span className="dropdown-value">{selectedItem?.text || value}</span>
|
||||
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="dropdown-menu" role="listbox">
|
||||
{props.items.map((item, index) =>
|
||||
item !== null ? (
|
||||
<div
|
||||
key={id + item.value}
|
||||
ref={(el) => (optionsRef.current[index] = el)}
|
||||
className={`dropdown-option ${value === item.value ? 'selected' : ''} ${index === focusedIndex ? 'focused' : ''}`}
|
||||
onClick={() => onChange(item.value)}
|
||||
onKeyDown={(e) => handleOptionKeyDown(e, item)}
|
||||
role="option"
|
||||
aria-selected={value === item.value}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="dropdown-option-text">{item.text}</span>
|
||||
{value === item.value && <MdCheck className="dropdown-option-check" />}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
218
src/components/Form/Settings/Dropdown/Dropdown.scss
Normal file
218
src/components/Form/Settings/Dropdown/Dropdown.scss
Normal file
@@ -0,0 +1,218 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(dropdownSlideIn) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
margin-top: 10px;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover {
|
||||
border-color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@include themed {
|
||||
border-color: t($link);
|
||||
box-shadow: 0 0 0 3px t($link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-value {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
z-index: 9999;
|
||||
@include animation(dropdownSlideIn 0.2s ease-out);
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
outline: none;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: t($modal-sidebar);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
background: t($modal-sidebarActive);
|
||||
border-left: 2px solid t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-option-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-option-check {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@include themed {
|
||||
background: t($link);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +1,88 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'contexts/TranslationContext';
|
||||
import {
|
||||
Radio as RadioUI,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@mui/material';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Radio.scss';
|
||||
|
||||
const Radio = memo((props) => {
|
||||
const { changeLanguage } = useTranslation();
|
||||
const [value, setValue] = useState(localStorage.getItem(props.name));
|
||||
|
||||
const handleChange = useCallback(async (e) => {
|
||||
const newValue = e.target.value;
|
||||
const handleChange = useCallback(
|
||||
async (newValue) => {
|
||||
if (newValue === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === 'loading') {
|
||||
return;
|
||||
}
|
||||
if (props.name === 'language') {
|
||||
changeLanguage(newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (props.name === 'language') {
|
||||
// Use context to change language directly - no EventBus needed
|
||||
changeLanguage(newValue);
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
return;
|
||||
}
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [value, props, changeLanguage]);
|
||||
EventBus.emit('refresh', props.category);
|
||||
},
|
||||
[value, props, changeLanguage],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel
|
||||
className={props.smallTitle ? 'radio-title-small' : 'radio-title'}
|
||||
component="legend"
|
||||
>
|
||||
{props.title}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
aria-label={props.name}
|
||||
name={props.name}
|
||||
onChange={handleChange}
|
||||
value={value}
|
||||
>
|
||||
<div className="radio-group">
|
||||
{props.title && (
|
||||
<legend className={props.smallTitle ? 'radio-title-small' : 'radio-title'}>
|
||||
{props.title}
|
||||
</legend>
|
||||
)}
|
||||
<div className="radio-options" role="radiogroup" aria-label={props.name}>
|
||||
{props.options.map((option) => (
|
||||
<FormControlLabel
|
||||
value={option.value}
|
||||
control={<RadioUI />}
|
||||
label={option.name}
|
||||
<label
|
||||
key={option.value}
|
||||
/>
|
||||
className={`radio-option ${value === option.value ? 'selected' : ''} ${option.disabled || props.disabled ? 'disabled' : ''}`}
|
||||
>
|
||||
<span className="radio-label">{option.name}</span>
|
||||
<input
|
||||
type="radio"
|
||||
name={props.name}
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={() => handleChange(option.value)}
|
||||
disabled={option.disabled || props.disabled || false}
|
||||
className="radio-input"
|
||||
aria-label={option.name}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div className="radio-circle">
|
||||
{value === option.value && <div className="radio-dot" />}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
159
src/components/Form/Settings/Radio/Radio.scss
Normal file
159
src/components/Form/Settings/Radio/Radio.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(radioDotScale) {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
|
||||
.radio-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.17rem;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-title-small {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 16px 20px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: t($modal-secondaryColour);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.selected .radio-circle {
|
||||
@include themed {
|
||||
border-color: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-circle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: 20px;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
border: 2px solid t($modal-sidebarActive);
|
||||
background: t($modal-secondaryColour);
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.radio-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
@include animation(radioDotScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
|
||||
|
||||
@include themed {
|
||||
background: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
&:focus-visible + .radio-circle {
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 3px t($link);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/components/Form/Settings/SearchInput/SearchInput.jsx
Normal file
23
src/components/Form/Settings/SearchInput/SearchInput.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { memo } from 'react';
|
||||
import { MdSearch } from 'react-icons/md';
|
||||
|
||||
import './SearchInput.scss';
|
||||
|
||||
const SearchInput = memo(({ value, onChange, placeholder, fullWidth }) => {
|
||||
return (
|
||||
<div className={`search-input-container${fullWidth ? ' full-width' : ''}`}>
|
||||
<MdSearch className="search-input-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="search-input-field"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SearchInput.displayName = 'SearchInput';
|
||||
|
||||
export { SearchInput as default, SearchInput };
|
||||
48
src/components/Form/Settings/SearchInput/SearchInput.scss
Normal file
48
src/components/Form/Settings/SearchInput/SearchInput.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 250px;
|
||||
|
||||
&.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
font-size: 20px;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-field {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px 0 44px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: 24px;
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/SearchInput/index.jsx
Normal file
1
src/components/Form/Settings/SearchInput/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SearchInput';
|
||||
@@ -1,23 +1,20 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { memo, useState, useCallback, useRef } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Slider } from '@mui/material';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Slider.scss';
|
||||
|
||||
const SliderComponent = memo((props) => {
|
||||
const [value, setValue] = useState(localStorage.getItem(props.name) || props.default);
|
||||
const animationRef = useRef(null);
|
||||
|
||||
const handleChange = useCallback((e, text) => {
|
||||
let newValue = e.target.value;
|
||||
newValue = Number(newValue);
|
||||
|
||||
if (text) {
|
||||
if (newValue === '') {
|
||||
setValue(0);
|
||||
return;
|
||||
}
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
let newValue = e.target.value;
|
||||
newValue = Number(newValue);
|
||||
|
||||
if (newValue > props.max) {
|
||||
newValue = props.max;
|
||||
@@ -26,52 +23,104 @@ const SliderComponent = memo((props) => {
|
||||
if (newValue < props.min) {
|
||||
newValue = props.min;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [props]);
|
||||
EventBus.emit('refresh', props.category);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const resetItem = useCallback(() => {
|
||||
handleChange({
|
||||
target: {
|
||||
value: props.default || '',
|
||||
},
|
||||
});
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
|
||||
const startValue = Number(value);
|
||||
const endValue = Number(props.default || 0);
|
||||
const duration = 300; // milliseconds
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const currentValue = startValue + (endValue - startValue) * easeOutCubic;
|
||||
const roundedValue = Math.round(currentValue / (Number(props.step) || 1)) * (Number(props.step) || 1);
|
||||
|
||||
localStorage.setItem(props.name, roundedValue);
|
||||
setValue(roundedValue);
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Ensure we end exactly at the target value
|
||||
localStorage.setItem(props.name, endValue);
|
||||
setValue(endValue);
|
||||
EventBus.emit('refresh', props.category);
|
||||
}
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [handleChange, props.default]);
|
||||
}, [value, props]);
|
||||
|
||||
const percentage =
|
||||
((Number(value) - Number(props.min)) / (Number(props.max) - Number(props.min))) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={'sliderTitle'}>
|
||||
{props.title}
|
||||
<span>{Number(value)}</span>
|
||||
<span className="link" onClick={resetItem}>
|
||||
<div className="slider-container">
|
||||
<div className="slider-header">
|
||||
<span className="slider-value">{Number(value)}</span>
|
||||
<span className="slider-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</span>
|
||||
<Slider
|
||||
value={Number(value)}
|
||||
onChange={handleChange}
|
||||
valueLabelDisplay="auto"
|
||||
default={Number(props.default)}
|
||||
min={Number(props.min)}
|
||||
max={Number(props.max)}
|
||||
step={Number(props.step) || 1}
|
||||
getAriaValueText={(value) => `${value}`}
|
||||
marks={props.marks || []}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
<div className="slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
className="slider-input"
|
||||
value={Number(value)}
|
||||
onChange={handleChange}
|
||||
min={Number(props.min)}
|
||||
max={Number(props.max)}
|
||||
step={Number(props.step) || 1}
|
||||
style={{ '--slider-percentage': `${percentage}%` }}
|
||||
aria-label={props.title}
|
||||
aria-valuemin={Number(props.min)}
|
||||
aria-valuemax={Number(props.max)}
|
||||
aria-valuenow={Number(value)}
|
||||
disabled={props.disabled || false}
|
||||
/>
|
||||
{props.marks && props.marks.length > 0 && (
|
||||
<div className="slider-marks">
|
||||
{props.marks.map((mark) => (
|
||||
<span
|
||||
key={mark.value}
|
||||
className="slider-mark"
|
||||
style={{
|
||||
left: `${((mark.value - Number(props.min)) / (Number(props.max) - Number(props.min))) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{mark.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
174
src/components/Form/Settings/Slider/Slider.scss
Normal file
174
src/components/Form/Settings/Slider/Slider.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.slider-container {
|
||||
width: 300px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.slider-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
|
||||
@include themed {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
t($link) 0%,
|
||||
t($link) var(--slider-percentage),
|
||||
t($modal-sidebarActive) var(--slider-percentage),
|
||||
t($modal-sidebarActive) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@include themed {
|
||||
background: t($color);
|
||||
border: 2px solid t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
border: none;
|
||||
|
||||
@include themed {
|
||||
background: t($color);
|
||||
border: 2px solid t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
@include themed {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
|
||||
}
|
||||
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
@include themed {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
|
||||
}
|
||||
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
&::-webkit-slider-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
cursor: not-allowed;
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
cursor: not-allowed;
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-marks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
margin-top: 8px;
|
||||
|
||||
.slider-mark {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { Switch as SwitchUI, FormControlLabel } from '@mui/material';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Switch.scss';
|
||||
|
||||
const Switch = memo((props) => {
|
||||
const [checked, setChecked] = useState(localStorage.getItem(props.name) === 'true');
|
||||
|
||||
@@ -32,18 +33,20 @@ const Switch = memo((props) => {
|
||||
}, [checked, props]);
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<SwitchUI
|
||||
name={props.name}
|
||||
color="primary"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
}
|
||||
label={props.header ? '' : props.text}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
<div className="switch-wrapper">
|
||||
{!props.header && <span className="switch-label">{props.text}</span>}
|
||||
<div className={`switch-track ${checked ? 'checked' : ''}`} onClick={handleChange}>
|
||||
<div className="switch-thumb" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
className="switch-input"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
63
src/components/Form/Settings/Switch/Switch.scss
Normal file
63
src/components/Form/Settings/Switch/Switch.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.switch-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
|
||||
.switch-label {
|
||||
flex: 1;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
@include themed {
|
||||
background: t($link);
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@include themed {
|
||||
background: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.switch-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,95 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { TextField } from '@mui/material';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Text.scss';
|
||||
|
||||
const Text = memo((props) => {
|
||||
const [value, setValue] = useState(localStorage.getItem(props.name) || '');
|
||||
const { name, upperCaseFirst, element, category, onChange, title, textarea, customcss, placeholder } = props;
|
||||
const defaultValue = props.default;
|
||||
const [value, setValue] = useState(localStorage.getItem(name) || '');
|
||||
|
||||
const handleChange = useCallback((e) => {
|
||||
let { value } = e.target;
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
let newValue = e.target.value;
|
||||
|
||||
// Alex wanted font to work with montserrat and Montserrat, so I made it work
|
||||
if (props.upperCaseFirst === true) {
|
||||
value = value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
localStorage.setItem(props.name, value);
|
||||
setValue(value);
|
||||
|
||||
// Call parent onChange if provided
|
||||
if (props.onChange) {
|
||||
props.onChange(value);
|
||||
}
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
if (upperCaseFirst === true) {
|
||||
newValue = newValue.charAt(0).toUpperCase() + newValue.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [props.name, props.upperCaseFirst, props.element, props.category, props.onChange]);
|
||||
localStorage.setItem(name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
if (!document.querySelector(element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', category);
|
||||
},
|
||||
[name, upperCaseFirst, element, category, onChange],
|
||||
);
|
||||
|
||||
const resetItem = useCallback(() => {
|
||||
handleChange({
|
||||
target: {
|
||||
value: props.default || '',
|
||||
value: defaultValue || '',
|
||||
},
|
||||
});
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [handleChange, props.default]);
|
||||
}, [handleChange, defaultValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.textarea === true ? (
|
||||
<TextField
|
||||
label={props.title}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
varient="outlined"
|
||||
className={props.customcss ? 'customcss' : ''}
|
||||
multiline
|
||||
spellCheck={false}
|
||||
minRows={4}
|
||||
maxRows={10}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<div className="text-field-container">
|
||||
{textarea === true ? (
|
||||
<div className={`text-field ${customcss ? 'customcss' : ''}`}>
|
||||
{title && (
|
||||
<div className="text-field-header">
|
||||
<label className="text-field-label">{title}</label>
|
||||
<span className="text-field-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
spellCheck={false}
|
||||
rows={4}
|
||||
className="text-field-textarea"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
label={props.title}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
varient="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
placeholder={props.placeholder || ''}
|
||||
/>
|
||||
<div className="text-field">
|
||||
{title && (
|
||||
<div className="text-field-header">
|
||||
<label className="text-field-label">{title}</label>
|
||||
<span className="text-field-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder || ''}
|
||||
className="text-field-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="link" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
110
src/components/Form/Settings/Text/Text.scss
Normal file
110
src/components/Form/Settings/Text/Text.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.text-field-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.text-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.text-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text-field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.text-field-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-field-input {
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-field-textarea {
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.customcss .text-field-textarea {
|
||||
font-family: Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console',
|
||||
'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono',
|
||||
'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace !important;
|
||||
}
|
||||
}
|
||||
57
src/components/Form/Settings/Textarea/Textarea.jsx
Normal file
57
src/components/Form/Settings/Textarea/Textarea.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { memo, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
import './Textarea.scss';
|
||||
|
||||
const Textarea = memo(({ value, onChange, placeholder, minRows = 1, maxRows, className, style, readOnly }) => {
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Calculate line height
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const lineHeight = parseInt(computedStyle.lineHeight) || 24;
|
||||
const paddingTop = parseInt(computedStyle.paddingTop) || 0;
|
||||
const paddingBottom = parseInt(computedStyle.paddingBottom) || 0;
|
||||
|
||||
// Calculate min and max heights
|
||||
const minHeight = (minRows * lineHeight) + paddingTop + paddingBottom;
|
||||
const maxHeight = maxRows ? (maxRows * lineHeight) + paddingTop + paddingBottom : Infinity;
|
||||
|
||||
// Set the height based on content, clamped between min and max
|
||||
const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, [minRows, maxRows]);
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [value, adjustHeight]);
|
||||
|
||||
// Adjust on mount and window resize
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [adjustHeight]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`textarea-autosize${className ? ` ${className}` : ''}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
style={style}
|
||||
readOnly={readOnly}
|
||||
rows={minRows}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea as default, Textarea };
|
||||
34
src/components/Form/Settings/Textarea/Textarea.scss
Normal file
34
src/components/Form/Settings/Textarea/Textarea.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.textarea-autosize {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
transition: 0.2s ease;
|
||||
font-family: inherit;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
&[readonly] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/Textarea/index.jsx
Normal file
1
src/components/Form/Settings/Textarea/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Textarea';
|
||||
@@ -3,6 +3,8 @@ export * from './ChipSelect';
|
||||
export * from './Dropdown';
|
||||
export * from './FileUpload';
|
||||
export * from './Radio';
|
||||
export * from './SearchInput';
|
||||
export * from './Slider';
|
||||
export * from './Switch';
|
||||
export * from './Text';
|
||||
export * from './Textarea';
|
||||
|
||||
@@ -25,4 +25,4 @@ export const EMAIL = 'hello@muetab.com';
|
||||
export const TWITTER_HANDLE = 'getmue';
|
||||
export const DISCORD_SERVER = 'zv8C9F8';
|
||||
|
||||
export const VERSION = '7.5.0';
|
||||
export const VERSION = '7.6.0';
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Section,
|
||||
} from 'components/Layout/Settings';
|
||||
import { Checkbox, Switch, Text } from 'components/Form/Settings';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
import { Button } from 'components/Elements';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -192,15 +191,15 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
|
||||
<span className="subtitle">
|
||||
{variables.getMessage(`${GREETING_SECTION}.event_name`)}
|
||||
</span>
|
||||
<TextareaAutosize
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={event.name}
|
||||
placeholder={variables.getMessage(`${GREETING_SECTION}.event_name`)}
|
||||
onChange={(e) => {
|
||||
const updatedEvent = { ...event, name: e.target.value };
|
||||
updateEvent(index, updatedEvent);
|
||||
}}
|
||||
varient="outlined"
|
||||
style={{ padding: '0' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import variables from 'config/variables';
|
||||
import React, { memo, useState, useMemo } from 'react';
|
||||
import { MdAutoFixHigh, MdOutlineArrowForward, MdOutlineOpenInNew, MdCheckCircle } from 'react-icons/md';
|
||||
import {
|
||||
MdAutoFixHigh,
|
||||
MdOutlineArrowForward,
|
||||
MdOutlineOpenInNew,
|
||||
MdCheckCircle,
|
||||
MdOutlineUploadFile,
|
||||
MdClose,
|
||||
} from 'react-icons/md';
|
||||
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
|
||||
|
||||
import { Button } from 'components/Elements';
|
||||
import { Button, Tooltip } from 'components/Elements';
|
||||
import Dropdown from '../../../../components/Form/Settings/Dropdown/Dropdown';
|
||||
|
||||
function filterItems(item, filter, categoryFilter) {
|
||||
@@ -28,7 +35,29 @@ function filterItems(item, filter, categoryFilter) {
|
||||
return textMatch && item.type === categoryMap[categoryFilter];
|
||||
}
|
||||
|
||||
function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInstalled }) {
|
||||
function getInitials(name) {
|
||||
if (!name) return '??';
|
||||
const words = name.split(' ');
|
||||
if (words.length === 1) {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function getTypeTranslationKey(type) {
|
||||
const typeMap = {
|
||||
photos: 'photo_packs',
|
||||
quotes: 'quote_packs',
|
||||
settings: 'preset_settings',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
}
|
||||
|
||||
function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInstalled, isAdded, onUninstall }) {
|
||||
item._onCollection = onCollection;
|
||||
|
||||
// Convert hex color to RGB for gradient with opacity
|
||||
@@ -73,28 +102,62 @@ function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInsta
|
||||
};
|
||||
};
|
||||
|
||||
const isSideloaded = item.sideload === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="item"
|
||||
onClick={() => toggleFunction(item)}
|
||||
className={`item ${isSideloaded ? 'item-sideloaded' : ''}`}
|
||||
onClick={isSideloaded ? undefined : () => toggleFunction(item)}
|
||||
key={item.name}
|
||||
style={getGradientStyle()}
|
||||
>
|
||||
{isInstalled && item.colour && (
|
||||
{isAdded && onUninstall && (
|
||||
<Tooltip
|
||||
title={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
|
||||
style={{ position: 'absolute', top: '12px', right: '12px', zIndex: 3 }}
|
||||
>
|
||||
<button
|
||||
className="item-uninstall-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUninstall(item.type, item.name);
|
||||
}}
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isSideloaded && (
|
||||
<Tooltip
|
||||
title={variables.getMessage('modals.main.addons.sideload.title')}
|
||||
style={{ position: 'absolute', top: '12px', right: '48px', zIndex: 2 }}
|
||||
>
|
||||
<div className="item-sideload-badge">
|
||||
<MdOutlineUploadFile />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isInstalled && item.colour && !isSideloaded && (
|
||||
<div className="item-installed-badge" style={getBadgeStyle()}>
|
||||
<MdCheckCircle />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
className="item-icon"
|
||||
alt="icon"
|
||||
draggable={false}
|
||||
src={item.icon_url}
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = placeholderIcon;
|
||||
}}
|
||||
/>
|
||||
{item.icon_url ? (
|
||||
<img
|
||||
className="item-icon"
|
||||
alt="icon"
|
||||
draggable={false}
|
||||
src={item.icon_url}
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = placeholderIcon;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="item-icon item-icon-text">
|
||||
{getInitials(item.display_name || item.name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-details">
|
||||
<span className="card-title">{item.display_name || item.name}</span>
|
||||
{!isCurator ? (
|
||||
@@ -106,17 +169,14 @@ function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInsta
|
||||
)}
|
||||
|
||||
<div className="card-chips">
|
||||
{type === 'all' && !onCollection ? (
|
||||
{item.type && (
|
||||
<span className="card-type">
|
||||
{variables.getMessage('modals.main.marketplace.' + item.type)}
|
||||
{variables.getMessage('modals.main.marketplace.' + getTypeTranslationKey(item.type))}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* {item.in_collections && item.in_collections.length > 0 && !onCollection ? (
|
||||
<span className="card-collection">
|
||||
{item.in_collections[0]}
|
||||
</span>
|
||||
) : null} */}
|
||||
)}
|
||||
{item.in_collections && item.in_collections.length > 0 && !onCollection && (
|
||||
<span className="card-collection">{item.in_collections[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,6 +196,8 @@ function Items({
|
||||
showCreateYourOwn,
|
||||
filterOptions = false,
|
||||
onSortChange,
|
||||
isAdded = false,
|
||||
onUninstall,
|
||||
}) {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [sortType, setSortType] = useState(localStorage.getItem('sortMarketplace') || 'a-z');
|
||||
@@ -239,6 +301,8 @@ function Items({
|
||||
type={type}
|
||||
onCollection={onCollection}
|
||||
isInstalled={installedNames.has(item.name)}
|
||||
isAdded={isAdded}
|
||||
onUninstall={onUninstall}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -84,27 +84,24 @@ const Added = memo(() => {
|
||||
|
||||
const sortAddons = useCallback((value, sendEvent) => {
|
||||
const installedItems = JSON.parse(localStorage.getItem('installed'));
|
||||
|
||||
|
||||
switch (value) {
|
||||
case 'newest':
|
||||
installedItems.reverse();
|
||||
break;
|
||||
case 'oldest':
|
||||
break;
|
||||
case 'a-z':
|
||||
installedItems.sort((a, b) => {
|
||||
if (a.display_name < b.display_name) {
|
||||
return -1;
|
||||
}
|
||||
if (a.display_name > b.display_name) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
const nameA = (a.display_name || a.name || '').toLowerCase();
|
||||
const nameB = (b.display_name || b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
break;
|
||||
case 'z-a':
|
||||
installedItems.sort();
|
||||
installedItems.reverse();
|
||||
case 'recently-updated':
|
||||
installedItems.sort((a, b) => {
|
||||
const dateA = a.updated_at ? new Date(a.updated_at) : new Date(0);
|
||||
const dateB = b.updated_at ? new Date(b.updated_at) : new Date(0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -154,6 +151,12 @@ const Added = memo(() => {
|
||||
setInstalled([]);
|
||||
}, [installed]);
|
||||
|
||||
const handleUninstall = useCallback((type, name) => {
|
||||
uninstall(type, name);
|
||||
toast(variables.getMessage('toasts.uninstalled'));
|
||||
setInstalled(JSON.parse(localStorage.getItem('installed')));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sortAddons(localStorage.getItem('sortAddons'), false);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -243,9 +246,8 @@ const Added = memo(() => {
|
||||
onChange={(value) => sortAddons(value)}
|
||||
items={[
|
||||
{ value: 'newest', text: variables.getMessage('modals.main.addons.sort.newest') },
|
||||
{ value: 'oldest', text: variables.getMessage('modals.main.addons.sort.oldest') },
|
||||
{ value: 'a-z', text: variables.getMessage('modals.main.addons.sort.a_z') },
|
||||
{ value: 'z-a', text: variables.getMessage('modals.main.addons.sort.z_a') },
|
||||
{ value: 'recently-updated', text: 'Recently Updated' },
|
||||
]}
|
||||
/>
|
||||
<Items
|
||||
@@ -254,6 +256,7 @@ const Added = memo(() => {
|
||||
filter=""
|
||||
toggleFunction={(input) => toggle('item', input)}
|
||||
showCreateYourOwn={false}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@ import variables from 'config/variables';
|
||||
import { useState } from 'react';
|
||||
import { MdCancel, MdAdd, MdOutlineTextsms } from 'react-icons/md';
|
||||
import { toast } from 'react-toastify';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
|
||||
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
|
||||
import { Textarea } from 'components/Form/Settings';
|
||||
import { Button } from 'components/Elements';
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
@@ -82,14 +82,13 @@ const MessageOptions = () => {
|
||||
<span className="subtitle">
|
||||
{variables.getMessage(`${MESSAGE_SECTION}.title`)}
|
||||
</span>
|
||||
<TextareaAutosize
|
||||
<Textarea
|
||||
value={messages[index]}
|
||||
placeholder={variables.getMessage(
|
||||
'modals.main.settings.sections.message.content',
|
||||
)}
|
||||
onChange={(e) => message(e, true, index)}
|
||||
varient="outlined"
|
||||
style={{ padding: '0' }}
|
||||
minRows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,45 @@ import Preview from '../../helpers/preview/Preview';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
import { parseDeepLink, shouldAutoOpenModal, updateHash } from 'utils/deepLinking';
|
||||
import { install } from 'utils/marketplace';
|
||||
|
||||
import Welcome from 'features/welcome/Welcome';
|
||||
|
||||
const DEFAULT_PACK_ID = '0c8a5bdebd13';
|
||||
|
||||
const isDefaultPackInstalled = () => {
|
||||
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
|
||||
return installed.some((item) => item.id === DEFAULT_PACK_ID);
|
||||
};
|
||||
|
||||
const isDefaultPackUninstalled = () => {
|
||||
const uninstalledPacks = JSON.parse(localStorage.getItem('uninstalledPacks') || '[]');
|
||||
return uninstalledPacks.includes(DEFAULT_PACK_ID);
|
||||
};
|
||||
|
||||
const tryInstallDefaultPack = async () => {
|
||||
// Don't install if offline mode, already installed, or explicitly uninstalled
|
||||
if (
|
||||
localStorage.getItem('offlineMode') === 'true' ||
|
||||
isDefaultPackInstalled() ||
|
||||
isDefaultPackUninstalled()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${variables.constants.API_URL}/marketplace/item/${DEFAULT_PACK_ID}`,
|
||||
);
|
||||
const { data } = await response.json();
|
||||
install(data.type, data, false, true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to install default pack:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const Modals = () => {
|
||||
const [mainModal, setMainModal] = useState(false);
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
@@ -60,6 +96,15 @@ const Modals = () => {
|
||||
localStorage.setItem('showReminder', false);
|
||||
}
|
||||
|
||||
// Try to install default pack if it wasn't installed during welcome (e.g., no internet)
|
||||
if (localStorage.getItem('showWelcome') !== 'true') {
|
||||
tryInstallDefaultPack().then((installed) => {
|
||||
if (installed) {
|
||||
EventBus.emit('refresh', 'quote');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for EventBus modal open requests
|
||||
const handleModalOpen = (data) => {
|
||||
if (data === 'openMainModal') {
|
||||
@@ -76,9 +121,12 @@ const Modals = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeWelcome = () => {
|
||||
const closeWelcome = async () => {
|
||||
localStorage.setItem('showWelcome', false);
|
||||
setWelcomeModal(false);
|
||||
|
||||
await tryInstallDefaultPack();
|
||||
|
||||
EventBus.emit('refresh', 'widgetsWelcomeDone');
|
||||
EventBus.emit('refresh', 'widgets');
|
||||
EventBus.emit('refresh', 'backgroundwelcome');
|
||||
|
||||
@@ -2,7 +2,6 @@ import variables from 'config/variables';
|
||||
import { useState, memo } from 'react';
|
||||
import { Checkbox, Slider } from 'components/Form/Settings';
|
||||
import { Button } from 'components/Elements';
|
||||
import { TextField } from '@mui/material';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
@@ -39,22 +38,26 @@ function ExperimentalOptions() {
|
||||
element=".other"
|
||||
/>
|
||||
<p style={{ textAlign: 'left', width: '100%' }}>Send Event</p>
|
||||
<TextField
|
||||
label={'Type'}
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
spellCheck={false}
|
||||
varient="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label={'Name'}
|
||||
value={eventName}
|
||||
onChange={(e) => setEventName(e.target.value)}
|
||||
spellCheck={false}
|
||||
varient="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<div className="text-field">
|
||||
<label className="text-field-label">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={eventType || ''}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-field">
|
||||
<label className="text-field-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={eventName || ''}
|
||||
onChange={(e) => setEventName(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => EventBus.emit(eventType, eventName)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useT, useTranslation } from 'contexts/TranslationContext';
|
||||
|
||||
import { MdOutlineOpenInNew, MdSearch, MdComputer } from 'react-icons/md';
|
||||
import { TextField, InputAdornment } from '@mui/material';
|
||||
import { MdOutlineOpenInNew, MdComputer } from 'react-icons/md';
|
||||
|
||||
import { Radio, Checkbox } from 'components/Form/Settings';
|
||||
import { Radio, Checkbox, SearchInput } from 'components/Form/Settings';
|
||||
|
||||
import languages from '@/i18n/languages.json';
|
||||
import translationPercentages from '@/i18n/translationPercentages.json';
|
||||
@@ -123,35 +122,10 @@ const LanguageOptions = () => {
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
<SearchInput
|
||||
placeholder={t('modals.main.settings.sections.language.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<MdSearch style={{ color: '#888' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
width: '250px',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '24px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
'& fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{currentLangOption && (
|
||||
<div style={{ color: '#888', whiteSpace: 'nowrap' }}>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useT } from 'contexts';
|
||||
|
||||
import { MdContentCopy, MdAssignment, MdPushPin, MdDownload } from 'react-icons/md';
|
||||
import { useFloating, shift } from '@floating-ui/react-dom';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { Textarea } from 'components/Form/Settings';
|
||||
|
||||
import { saveFile } from 'utils/saveFile';
|
||||
import EventBus from 'utils/eventbus';
|
||||
@@ -112,12 +112,11 @@ const Notes = ({ notesRef, floatRef, position, xPosition, yPosition }) => {
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TextareaAutosize
|
||||
<Textarea
|
||||
placeholder={t('widgets.navbar.notes.placeholder')}
|
||||
value={notes}
|
||||
onChange={handleSetNotes}
|
||||
minRows={5}
|
||||
maxLength={10000}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
MdPlaylistAdd,
|
||||
MdOutlineDragIndicator,
|
||||
MdPlaylistRemove,
|
||||
MdCheck,
|
||||
} from 'react-icons/md';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import { Textarea } from 'components/Form/Settings';
|
||||
import { shift, useFloating } from '@floating-ui/react-dom';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -210,15 +209,18 @@ function Todo({ todoRef, floatRef, position, xPosition, yPosition }) {
|
||||
<SortableItem key={index} id={index}>
|
||||
{({ attributes, listeners }) => (
|
||||
<div className={'todoRow' + (todoItem.done ? ' done' : '')}>
|
||||
<Checkbox
|
||||
checked={todoItem.done}
|
||||
<div
|
||||
className={'todo-checkbox' + (todoItem.done ? ' checked' : '')}
|
||||
onClick={() => updateTodo('done', index)}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
>
|
||||
{todoItem.done && <MdCheck />}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={t('widgets.navbar.notes.placeholder')}
|
||||
value={todoItem.value}
|
||||
onChange={(data) => updateTodo('set', index, data)}
|
||||
readOnly={todoItem.done}
|
||||
minRows={1}
|
||||
/>
|
||||
<Tooltip
|
||||
title={t(
|
||||
|
||||
@@ -83,7 +83,13 @@ export function useQuoteLoader(updateQuote) {
|
||||
|
||||
const getQuote = useCallback(async () => {
|
||||
const offline = localStorage.getItem('offlineMode') === 'true';
|
||||
const type = localStorage.getItem('quoteType') || 'api';
|
||||
let type = localStorage.getItem('quoteType') || 'quote_pack';
|
||||
|
||||
// Migrate deprecated 'api' type to 'quote_pack'
|
||||
if (type === 'api') {
|
||||
type = 'quote_pack';
|
||||
localStorage.setItem('quoteType', 'quote_pack');
|
||||
}
|
||||
|
||||
// Check for favourite quote first
|
||||
const favouriteQuote = localStorage.getItem('favouriteQuote');
|
||||
@@ -128,7 +134,8 @@ export function useQuoteLoader(updateQuote) {
|
||||
});
|
||||
}
|
||||
|
||||
case 'quote_pack': {
|
||||
case 'quote_pack':
|
||||
default: {
|
||||
if (offline) return doOffline();
|
||||
|
||||
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
|
||||
@@ -138,56 +145,31 @@ export function useQuoteLoader(updateQuote) {
|
||||
...quote,
|
||||
fallbackauthorimg: item.icon_url,
|
||||
packName: item.display_name || item.name,
|
||||
noAuthorImg: item.noAuthorImg || quote.noAuthorImg,
|
||||
})));
|
||||
|
||||
if (quotePack.length === 0) return doOffline();
|
||||
|
||||
const data = quotePack[Math.floor(Math.random() * quotePack.length)];
|
||||
const hasAuthor = data.author && data.author.trim() !== '';
|
||||
const displayAuthor = hasAuthor ? data.author : data.packName;
|
||||
|
||||
// Try to get author image from Wikipedia unless pack disables it
|
||||
let authorimgdata = { authorimg: data.fallbackauthorimg, authorimglicense: null };
|
||||
if (hasAuthor && !data.noAuthorImg) {
|
||||
const wikiImg = await getAuthorImg(data.author);
|
||||
if (wikiImg.authorimg) {
|
||||
authorimgdata = wikiImg;
|
||||
}
|
||||
}
|
||||
|
||||
return updateQuote({
|
||||
quote: `"${data.quote}"`,
|
||||
author: hasAuthor ? data.author : data.packName,
|
||||
author: displayAuthor,
|
||||
authorlink: hasAuthor ? getAuthorLink(data.author) : null,
|
||||
authorimg: data.fallbackauthorimg,
|
||||
...authorimgdata,
|
||||
});
|
||||
}
|
||||
|
||||
case 'api': {
|
||||
if (offline) return doOffline();
|
||||
|
||||
const fetchAPIQuote = async () => {
|
||||
const response = await fetch(
|
||||
`${variables.constants.API_URL}/quotes/random`
|
||||
).then(res => res.json());
|
||||
|
||||
if (response.statusCode === 429) return null;
|
||||
|
||||
const authorimgdata = await getAuthorImg(response.author);
|
||||
return {
|
||||
quote: `"${response.quote.replace(/\s+$/g, '')}"`,
|
||||
author: response.author,
|
||||
authorlink: getAuthorLink(response.author),
|
||||
...authorimgdata,
|
||||
authorOccupation: response.author_occupation,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem('nextQuote')) || await fetchAPIQuote();
|
||||
localStorage.setItem('nextQuote', null);
|
||||
|
||||
if (data) {
|
||||
updateQuote(data);
|
||||
localStorage.setItem('currentQuote', JSON.stringify(data));
|
||||
localStorage.setItem('nextQuote', JSON.stringify(await fetchAPIQuote()));
|
||||
} else {
|
||||
doOffline();
|
||||
}
|
||||
} catch {
|
||||
doOffline();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [updateQuote, getAuthorLink, getAuthorImg, doOffline]);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import variables from 'config/variables';
|
||||
import React, { useState } from 'react';
|
||||
import { MdCancel, MdAdd, MdSource, MdOutlineFormatQuote } from 'react-icons/md';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
|
||||
import {
|
||||
Header,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
Section,
|
||||
PreferencesWrapper,
|
||||
} from 'components/Layout/Settings';
|
||||
import { Checkbox, Dropdown } from 'components/Form/Settings';
|
||||
import { Checkbox, Dropdown, Textarea } from 'components/Form/Settings';
|
||||
import { Button } from 'components/Elements';
|
||||
|
||||
const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) => {
|
||||
@@ -23,7 +22,15 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
|
||||
return data;
|
||||
};
|
||||
|
||||
const [quoteType, setQuoteType] = useState(localStorage.getItem('quoteType') || 'api');
|
||||
const [quoteType, setQuoteType] = useState(() => {
|
||||
let type = localStorage.getItem('quoteType') || 'quote_pack';
|
||||
// Migrate deprecated 'api' type to 'quote_pack'
|
||||
if (type === 'api') {
|
||||
type = 'quote_pack';
|
||||
localStorage.setItem('quoteType', 'quote_pack');
|
||||
}
|
||||
return type;
|
||||
});
|
||||
const [customQuote, setCustomQuote] = useState(getCustom());
|
||||
|
||||
const handleCustomQuote = (e, text, index, type) => {
|
||||
@@ -93,10 +100,6 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
|
||||
value: 'quote_pack',
|
||||
text: variables.getMessage('modals.main.marketplace.title'),
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
text: variables.getMessage('modals.main.settings.sections.background.type.api'),
|
||||
},
|
||||
{ value: 'custom', text: variables.getMessage(`${QUOTE_SECTION}.custom`) },
|
||||
]}
|
||||
/>
|
||||
@@ -162,23 +165,23 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
|
||||
<MdOutlineFormatQuote />
|
||||
</div>
|
||||
<div className="messageText">
|
||||
<TextareaAutosize
|
||||
<Textarea
|
||||
value={customQuote[index].quote}
|
||||
placeholder={variables.getMessage(
|
||||
'modals.main.settings.sections.quote.title',
|
||||
)}
|
||||
onChange={(e) => handleCustomQuote(e, true, index, 'quote')}
|
||||
varient="outlined"
|
||||
style={{ fontSize: '22px', fontWeight: 'bold' }}
|
||||
minRows={1}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
<Textarea
|
||||
value={customQuote[index].author}
|
||||
placeholder={variables.getMessage(
|
||||
'modals.main.settings.sections.quote.author',
|
||||
)}
|
||||
className="subtitle"
|
||||
onChange={(e) => handleCustomQuote(e, true, index, 'author')}
|
||||
varient="outlined"
|
||||
minRows={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -8,6 +8,13 @@ import EventBus from 'utils/eventbus';
|
||||
|
||||
import './clock.scss';
|
||||
|
||||
// Helper function to format padded time values while preserving padding
|
||||
const formatPaddedDigits = (value) => {
|
||||
const str = String(value);
|
||||
// Format each digit individually to preserve padding with locale numerals
|
||||
return str.split('').map(digit => formatDigits(digit)).join('');
|
||||
};
|
||||
|
||||
const Clock = () => {
|
||||
const [timeType] = useState(localStorage.getItem('timeType'));
|
||||
const [time, setTime] = useState('');
|
||||
@@ -51,23 +58,22 @@ const Clock = () => {
|
||||
|
||||
if (localStorage.getItem('seconds') === 'true') {
|
||||
const secs = ('00' + now.getSeconds()).slice(-2);
|
||||
sec = `:${formatDigits(secs)}`;
|
||||
setFinalSeconds(formatDigits(secs));
|
||||
sec = `:${formatPaddedDigits(secs)}`;
|
||||
setFinalSeconds(formatPaddedDigits(secs));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('timeformat') === 'twentyfourhour') {
|
||||
if (zero === 'false') {
|
||||
const hours = now.getHours();
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
} else {
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
if (zero === 'true') {
|
||||
const hours = ('00' + now.getHours()).slice(-2);
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
|
||||
time = `${formatPaddedDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatPaddedDigits(hours));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
} else {
|
||||
const hours = now.getHours();
|
||||
time = `${formatDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
}
|
||||
|
||||
setTime(time);
|
||||
@@ -82,17 +88,16 @@ const Clock = () => {
|
||||
hours = 12;
|
||||
}
|
||||
|
||||
if (zero === 'false') {
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
} else {
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
if (zero === 'true') {
|
||||
const paddedHours = ('00' + hours).slice(-2);
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(paddedHours)}:${formatDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(paddedHours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
time = `${formatPaddedDigits(paddedHours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatPaddedDigits(paddedHours));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
} else {
|
||||
time = `${formatDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
}
|
||||
|
||||
setTime(time);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MdAutoAwesome } from 'react-icons/md';
|
||||
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
|
||||
import { useLocalStorageState } from 'utils/useLocalStorageState';
|
||||
import { Radio, Dropdown, Checkbox } from 'components/Form/Settings';
|
||||
import { TextField } from '@mui/material';
|
||||
import variables from 'config/variables';
|
||||
|
||||
const useWeatherSettings = () => {
|
||||
@@ -82,18 +81,26 @@ const WeatherOptions = () => {
|
||||
<Row>
|
||||
<Content title={variables.getMessage(`${WEATHER_SECTION}.location`)} />
|
||||
<Action>
|
||||
<TextField
|
||||
label={variables.getMessage(`${WEATHER_SECTION}.location`)}
|
||||
value={location}
|
||||
onChange={changeLocation}
|
||||
placeholder="London"
|
||||
variant="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<span className="link" onClick={getAutoLocation}>
|
||||
<MdAutoAwesome />
|
||||
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
|
||||
</span>
|
||||
<div className="text-field-container">
|
||||
<div className="text-field">
|
||||
<div className="text-field-header">
|
||||
<label className="text-field-label">
|
||||
{variables.getMessage(`${WEATHER_SECTION}.location`)}
|
||||
</label>
|
||||
<span className="text-field-reset" onClick={getAutoLocation}>
|
||||
<MdAutoAwesome />
|
||||
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={location}
|
||||
onChange={changeLocation}
|
||||
placeholder="London"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Action>
|
||||
</Row>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { MdOutlineOpenInNew, MdSearch } from 'react-icons/md';
|
||||
import { TextField, InputAdornment } from '@mui/material';
|
||||
import { MdOutlineOpenInNew } from 'react-icons/md';
|
||||
import languages from '@/i18n/languages.json';
|
||||
import translationPercentages from '@/i18n/translationPercentages.json';
|
||||
import { useT, useTranslation } from 'contexts/TranslationContext';
|
||||
import variables from 'config/variables';
|
||||
|
||||
import { Radio } from 'components/Form/Settings';
|
||||
import { Radio, SearchInput } from 'components/Form/Settings';
|
||||
import { Header, Content } from '../Layout';
|
||||
|
||||
function ChooseLanguage() {
|
||||
@@ -107,37 +106,14 @@ function ChooseLanguage() {
|
||||
{t('modals.main.settings.sections.language.use_system')} ({systemLanguage.name})
|
||||
</button>
|
||||
)}
|
||||
<TextField
|
||||
placeholder={t('modals.main.settings.sections.language.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<MdSearch style={{ color: '#888' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
marginBottom: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '24px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
'& fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<SearchInput
|
||||
placeholder={t('modals.main.settings.sections.language.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<div className="languageSettings">
|
||||
<Radio name="language" options={filteredLanguages} category="welcomeLanguage" />
|
||||
</div>
|
||||
|
||||
@@ -599,10 +599,9 @@
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort",
|
||||
"newest": "Installed (Newest)",
|
||||
"oldest": "Installed (Oldest)",
|
||||
"a_z": "Alphabetical (A-Z)",
|
||||
"z_a": "Alphabetical (Z-A)"
|
||||
"newest": "Recently Added",
|
||||
"a_z": "Name (A-Z)",
|
||||
"recently_updated": "Recently Updated"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create",
|
||||
|
||||
@@ -1,41 +1,98 @@
|
||||
@use 'variables' as *;
|
||||
@use 'mixins' as *;
|
||||
|
||||
.Toastify__toast-body {
|
||||
font-size: 16px !important;
|
||||
padding: 15px 20px !important;
|
||||
}
|
||||
|
||||
.Toastify__toast {
|
||||
border-radius: 12px !important;
|
||||
height: auto !important;
|
||||
width: auto !important;
|
||||
min-width: auto !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.Toastify__close-button {
|
||||
align-self: center !important;
|
||||
color: var(--modal-text) !important;
|
||||
}
|
||||
|
||||
/* .Toastify__progress-bar--default {
|
||||
height: 3px !important;
|
||||
background: var(--modal-text) !important;
|
||||
color: var(--modal-text) !important;
|
||||
} */
|
||||
|
||||
// Toast container
|
||||
.Toastify__toast-container {
|
||||
width: auto !important;
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.Toastify__toast--default {
|
||||
@extend %basic;
|
||||
// Main toast styling with glassmorphism effect
|
||||
.Toastify__toast {
|
||||
@include themed() {
|
||||
background: t($background) !important;
|
||||
color: t($color) !important;
|
||||
box-shadow: t($boxShadow), 0 8px 32px rgb(0 0 0 / 20%) !important;
|
||||
}
|
||||
|
||||
border-radius: $borderRadius !important;
|
||||
-webkit-backdrop-filter: blur(15px) saturate(180%) !important;
|
||||
backdrop-filter: blur(15px) saturate(180%) !important;
|
||||
padding: 16px 20px !important;
|
||||
min-height: 64px !important;
|
||||
width: auto !important;
|
||||
min-width: 300px !important;
|
||||
max-width: 500px !important;
|
||||
font-family: inherit !important;
|
||||
box-sizing: border-box !important;
|
||||
transition: all 0.3s ease !important;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px) !important;
|
||||
box-shadow: 0 0 0 1px #484848, 0 12px 40px rgb(0 0 0 / 30%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.Toastify__toast--info {
|
||||
@extend %basic;
|
||||
// Toast body
|
||||
.Toastify__toast-body {
|
||||
padding: 0 !important;
|
||||
font-size: 15px !important;
|
||||
line-height: 1.5 !important;
|
||||
margin: 0 !important;
|
||||
|
||||
@include themed() {
|
||||
color: t($color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.Toastify__progress-bar--info {
|
||||
background-color: gold !important;
|
||||
// Close button
|
||||
.Toastify__close-button {
|
||||
@include themed() {
|
||||
color: t($color) !important;
|
||||
opacity: 0.6 !important;
|
||||
}
|
||||
|
||||
align-self: center !important;
|
||||
transition: all 0.2s ease !important;
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar base styling
|
||||
.Toastify__progress-bar {
|
||||
height: 4px !important;
|
||||
border-radius: 0 0 $borderRadius $borderRadius !important;
|
||||
|
||||
&--default,
|
||||
&--info {
|
||||
@include themed() {
|
||||
background: linear-gradient(90deg, t($link) 0%, #dd3b67 100%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: linear-gradient(90deg, rgb(46 213 115) 0%, rgb(26 188 156) 100%) !important;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: linear-gradient(90deg, #ffb032 0%, #ff9500 100%) !important;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: linear-gradient(90deg, rgb(255 71 87) 0%, #dd3b67 100%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Toast type variants - all use the base glassmorphism style
|
||||
.Toastify__toast--default,
|
||||
.Toastify__toast--info,
|
||||
.Toastify__toast--success,
|
||||
.Toastify__toast--warning,
|
||||
.Toastify__toast--error {
|
||||
@include themed() {
|
||||
background: t($background) !important;
|
||||
color: t($color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
},
|
||||
{
|
||||
"name": "quoteType",
|
||||
"value": "api"
|
||||
"value": "quote_pack"
|
||||
},
|
||||
{
|
||||
"name": "backgroundFilter",
|
||||
|
||||
@@ -76,6 +76,14 @@ export function uninstall(type, name) {
|
||||
const installed = JSON.parse(localStorage.getItem('installed'));
|
||||
for (let i = 0; i < installed.length; i++) {
|
||||
if (installed[i].name === name) {
|
||||
// Track uninstalled pack IDs to prevent auto-reinstall
|
||||
if (installed[i].id) {
|
||||
const uninstalledPacks = JSON.parse(localStorage.getItem('uninstalledPacks') || '[]');
|
||||
if (!uninstalledPacks.includes(installed[i].id)) {
|
||||
uninstalledPacks.push(installed[i].id);
|
||||
localStorage.setItem('uninstalledPacks', JSON.stringify(uninstalledPacks));
|
||||
}
|
||||
}
|
||||
installed.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user