mirror of
https://github.com/mue/mue.git
synced 2026-07-02 12:43:35 +02:00
Compare commits
68 Commits
v7.6.0-bet
...
feature/ov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05cc274b27 | ||
|
|
f29b879215 | ||
|
|
09308a4452 | ||
|
|
e03b1d68d0 | ||
|
|
3e434341da | ||
|
|
49630ee375 | ||
|
|
9d98e2124a | ||
|
|
0c9a90d693 | ||
|
|
6c73bdb156 | ||
|
|
6465b88f30 | ||
|
|
a4e575c5f6 | ||
|
|
05bf8edeea | ||
|
|
50aae0f8ec | ||
|
|
6e852eb252 | ||
|
|
6cb8843b49 | ||
|
|
63b8742218 | ||
|
|
bebd551193 | ||
|
|
c71c6442db | ||
|
|
9e692b97ce | ||
|
|
7f0b37c713 | ||
|
|
4d8be5774f | ||
|
|
b7097979de | ||
|
|
7e6bc58f2c | ||
|
|
9fa1ddcab5 | ||
|
|
3dad52196d | ||
|
|
df23753971 | ||
|
|
401e711bd8 | ||
|
|
f493eb186e | ||
|
|
79c8e1508f | ||
|
|
b5a451c70d | ||
|
|
7a589de14b | ||
|
|
9bf160094e | ||
|
|
f8746a31b0 | ||
|
|
864097c508 | ||
|
|
139c8e2914 | ||
|
|
f2a0330655 | ||
|
|
89523df1cf | ||
|
|
9462fe1b32 | ||
|
|
c7a2760709 | ||
|
|
f1e961e8e4 | ||
|
|
616055106b | ||
|
|
9677434c00 | ||
|
|
cac58cdaeb | ||
|
|
e42a218116 | ||
|
|
40c248985d | ||
|
|
d88ed2eedd | ||
|
|
ab2b969772 | ||
|
|
67ba0f6718 | ||
|
|
4cf5269cdc | ||
|
|
ce6b05f1a1 | ||
|
|
5c8d9a3a44 | ||
|
|
4a2f1334f3 | ||
|
|
155dc46e68 | ||
|
|
47b7397bd4 | ||
|
|
777f1faeb6 | ||
|
|
dfb0872633 | ||
|
|
c186c54749 | ||
|
|
5392e4b27d | ||
|
|
c13d6ce4ac | ||
|
|
9410d89cea | ||
|
|
a6e1490edb | ||
|
|
874866bf73 | ||
|
|
ecfb3c6648 | ||
|
|
01fcdbf9c7 | ||
|
|
2fca4bf9ac | ||
|
|
2918033afa | ||
|
|
a29984d3aa | ||
|
|
befed06832 |
37
.github/workflows/beta-release.yml
vendored
37
.github/workflows/beta-release.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
tags:
|
||||
- "v*-beta.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -21,7 +23,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.1'
|
||||
bun-version: "1.3.1"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -37,20 +39,13 @@ jobs:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
# Check if this is actually a beta version
|
||||
if [[ ! "$VERSION" =~ -beta\. ]]; then
|
||||
echo "❌ Version $VERSION is not a beta version (must contain '-beta.')"
|
||||
echo "Skipping beta release. Use Version Bump workflow to create a beta version first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get the latest beta or production tag
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous tag found, using all commits"
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" HEAD)
|
||||
@@ -58,13 +53,13 @@ jobs:
|
||||
echo "Generating changelog from $PREVIOUS_TAG to HEAD"
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..HEAD)
|
||||
fi
|
||||
|
||||
|
||||
# Create changelog with categorization
|
||||
FEATURES=$(echo "$COMMITS" | grep -i "^- feat" || echo "")
|
||||
FIXES=$(echo "$COMMITS" | grep -i "^- fix" || echo "")
|
||||
CHORES=$(echo "$COMMITS" | grep -i "^- chore\|^- docs\|^- style\|^- refactor" || echo "")
|
||||
OTHER=$(echo "$COMMITS" | grep -v -i "^- feat\|^- fix\|^- chore\|^- docs\|^- style\|^- refactor" || echo "")
|
||||
|
||||
|
||||
{
|
||||
echo "changelog<<EOF"
|
||||
if [ -n "$FEATURES" ]; then
|
||||
@@ -103,35 +98,35 @@ jobs:
|
||||
- name: Create or Update GitHub Pre-Release
|
||||
run: |
|
||||
RELEASE_NOTES=$(cat <<EOF
|
||||
## 🧪 Mue Beta v${{ steps.version.outputs.version }}
|
||||
|
||||
## 🧪 Mue v${{ steps.version.outputs.version }}
|
||||
|
||||
**⚠️ This is a beta release for testing purposes only.**
|
||||
|
||||
|
||||
### Testing Instructions
|
||||
1. Download the appropriate ZIP file below
|
||||
2. For Chrome: Load as unpacked extension or install from [unlisted link](https://chromewebstore.google.com/detail/mue/bngmbednanpcfochchhgbkookpiaiaid) (dev team only)
|
||||
3. For Firefox: Install via about:debugging → Load Temporary Add-on
|
||||
4. Report issues at https://github.com/mue/mue/issues
|
||||
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
|
||||
### Installation Files
|
||||
- **Chrome/Edge**: \`chrome-${{ steps.version.outputs.version }}.zip\`
|
||||
- **Firefox**: \`firefox-${{ steps.version.outputs.version }}.zip\`
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
**🔗 Demo**: [demo.muetab.com](https://demo.muetab.com)
|
||||
**📱 Beta Branch Demo**: [mue-git-beta-mue.vercel.app](https://mue-git-beta-mue.vercel.app)
|
||||
EOF
|
||||
)
|
||||
|
||||
|
||||
if [ "${{ steps.check_release.outputs.exists }}" = "true" ]; then
|
||||
echo "Updating existing release..."
|
||||
gh release edit "v${{ steps.version.outputs.version }}" \
|
||||
--notes "$RELEASE_NOTES" \
|
||||
--prerelease
|
||||
|
||||
|
||||
# Upload new files (will replace if they exist)
|
||||
gh release upload "v${{ steps.version.outputs.version }}" \
|
||||
"build/chrome-${{ steps.version.outputs.version }}.zip" \
|
||||
@@ -142,7 +137,7 @@ jobs:
|
||||
gh release create "v${{ steps.version.outputs.version }}" \
|
||||
"build/chrome-${{ steps.version.outputs.version }}.zip" \
|
||||
"build/firefox-${{ steps.version.outputs.version }}.zip" \
|
||||
--title "Beta v${{ steps.version.outputs.version }}" \
|
||||
--title "v${{ steps.version.outputs.version }}" \
|
||||
--notes "$RELEASE_NOTES" \
|
||||
--prerelease
|
||||
fi
|
||||
|
||||
2
.github/workflows/production-release.yml
vendored
2
.github/workflows/production-release.yml
vendored
@@ -176,7 +176,7 @@ jobs:
|
||||
echo "### ⚠️ Manual Steps Required" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Go to [GitHub Actions](https://github.com/${{ github.repository }}/actions/workflows/submit.yml)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Click 'Run workflow'" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Enter tag: \`v${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Enter tag: \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "4. Click 'Run workflow' to submit to Chrome/Firefox/Edge stores" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📢 Post-Release Checklist" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
6
.github/workflows/version-bump.yml
vendored
6
.github/workflows/version-bump.yml
vendored
@@ -54,12 +54,6 @@ jobs:
|
||||
BASE_VERSION=$(echo $CURRENT_VERSION | sed 's/-.*$//')
|
||||
IFS='.' read -r -a VERSION_PARTS <<< "$BASE_VERSION"
|
||||
|
||||
# Detect if current version is already a pre-release
|
||||
IS_PRERELEASE=false
|
||||
case "$CURRENT_VERSION" in
|
||||
*-*) IS_PRERELEASE=true ;;
|
||||
esac
|
||||
|
||||
MAJOR="${VERSION_PARTS[0]}"
|
||||
MINOR="${VERSION_PARTS[1]}"
|
||||
PATCH="${VERSION_PARTS[2]}"
|
||||
|
||||
@@ -263,7 +263,7 @@ When beta is stable:
|
||||
7. Manually trigger store submission:
|
||||
```
|
||||
Actions → Submit → Run workflow
|
||||
- Enter version tag (e.g., v7.6.0)
|
||||
- Enter version tag (e.g., 7.6.0)
|
||||
```
|
||||
|
||||
#### 4. Emergency Hotfix
|
||||
|
||||
@@ -184,7 +184,7 @@ Before starting any release:
|
||||
- Check build artifacts
|
||||
- **Approve deployment** in Environments → production
|
||||
|
||||
4. **Review period** - Workflow waits for your approval (10 min deployment protection)
|
||||
4. **Wait 10 minutes** (cooldown period)
|
||||
|
||||
5. **Release completes**:
|
||||
- GitHub release published
|
||||
@@ -197,7 +197,7 @@ Before starting any release:
|
||||
|
||||
1. Go to **Actions** → **Submit** → **Run workflow**
|
||||
|
||||
2. Enter version tag: `v7.6.0` (include the 'v' prefix to match the release tag)
|
||||
2. Enter version tag: `7.6.0` (no 'v' prefix)
|
||||
|
||||
3. Click **Run workflow**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_name__",
|
||||
"description": "__MSG_description__",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.0",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"permissions": ["search"],
|
||||
"action": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Mue",
|
||||
"description": "Fast, open and free-to-use new tab page for modern browsers.",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.0",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"action": {
|
||||
"default_icon": "icons/128x128.png"
|
||||
|
||||
10
package.json
10
package.json
@@ -9,7 +9,7 @@
|
||||
"homepage": "https://muetab.com",
|
||||
"bugs": "https://github.com/mue/mue/issues/new?assignees=&labels=bug&template=bug-report.md&title=%5BBUG%5D",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.1",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.1",
|
||||
"engines": {
|
||||
@@ -21,18 +21,14 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@eartharoid/i18n": "1.2.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@floating-ui/react-dom": "2.1.6",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/lexend-deca": "5.0.14",
|
||||
"@fontsource/montserrat": "5.0.19",
|
||||
"@mui/material": "7.3.7",
|
||||
"@sentry/react": "^10.36.0",
|
||||
"embla-carousel-autoplay": "8.6.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"fast-blurhash": "^1.1.4",
|
||||
"image-conversion": "^2.1.1",
|
||||
"mue": "file:",
|
||||
"react": "^19.2.3",
|
||||
"react-best-gradient-color-picker": "^3.0.14",
|
||||
"react-clock": "6.0.0",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_name__",
|
||||
"description": "__MSG_description__",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.1",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"permissions": ["search"],
|
||||
"chrome_url_overrides": {
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -289,7 +289,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -445,7 +445,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -486,7 +486,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
@@ -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,26 +25,33 @@ function AddModal({ urlError, iconError, addLink, closeModal, edit, editData, ed
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="quicklinkModalTextbox">
|
||||
<TextareaAutosize
|
||||
maxRows={1}
|
||||
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}
|
||||
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}
|
||||
placeholder={variables.getMessage('widgets.quicklinks.icon')}
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
|
||||
/>
|
||||
<div className="text-field" style={{ gridColumn: 'span 2' }}>
|
||||
<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, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-field">
|
||||
<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, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-field">
|
||||
<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, ''))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="addFooter">
|
||||
<span className="dropdown-error">
|
||||
|
||||
@@ -3,7 +3,7 @@ import Tooltip from 'components/Elements/Tooltip/Tooltip';
|
||||
|
||||
const Button = forwardRef(
|
||||
(
|
||||
{ icon, label, type, iconPlacement, onClick, active, disabled, tooltipTitle, tooltipKey, href, style },
|
||||
{ icon, label, type, iconPlacement, onClick, active, disabled, tooltipTitle, tooltipKey, href, style, badge },
|
||||
ref,
|
||||
) => {
|
||||
let className;
|
||||
@@ -46,16 +46,18 @@ const Button = forwardRef(
|
||||
<button className={className} onClick={onClick} ref={ref} disabled={disabled} style={style}>
|
||||
{icon}
|
||||
{label}
|
||||
{badge !== undefined && badge !== null && <span className="btn-badge">{badge}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
const linkButton = (
|
||||
<a className={className} onClick={onClick} ref={ref} disabled={disabled} href={href} style={style}
|
||||
<a className={className} onClick={onClick} ref={ref} disabled={disabled} href={href} style={style}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge && <span className="btn-badge">{badge}</span>}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -73,6 +75,7 @@ const Button = forwardRef(
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge && <span className="btn-badge">{badge}</span>}
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { getIconComponent, DIVIDER_LABELS } from '../constants/tabConfig';
|
||||
|
||||
function Tab({ label, currentTab, onClick, navbarTab }) {
|
||||
function Tab({ label, currentTab, onClick, navbarTab, isCollapsed }) {
|
||||
const t = useT();
|
||||
const [isExperimental, setIsExperimental] = useState(true);
|
||||
|
||||
@@ -29,10 +30,18 @@ function Tab({ label, currentTab, onClick, navbarTab }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={className} onClick={() => onClick(label)}>
|
||||
{IconComponent && <IconComponent />} <span>{label}</span>
|
||||
</button>
|
||||
{hasDivider && <hr />}
|
||||
{isCollapsed ? (
|
||||
<Tooltip title={label} placement="right">
|
||||
<button className={className} onClick={() => onClick(label)}>
|
||||
{IconComponent && <IconComponent />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button className={className} onClick={() => onClick(label)}>
|
||||
{IconComponent && <IconComponent />} <span>{label}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isCollapsed && hasDivider && <hr />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useT } from 'contexts/TranslationContext';
|
||||
import variables from 'config/variables';
|
||||
import Tab from './Tab';
|
||||
import ReminderInfo from '../components/ReminderInfo';
|
||||
import SidebarToggle from '../components/SidebarToggle';
|
||||
import ErrorBoundary from '../../../../features/misc/modals/ErrorBoundary';
|
||||
import { TAB_TYPES } from '../constants/tabConfig';
|
||||
|
||||
@@ -39,6 +40,9 @@ const Tabs = ({
|
||||
const [currentTab, setCurrentTab] = useState(initial.label);
|
||||
const [currentName, setCurrentName] = useState(initial.name);
|
||||
const [showReminder, setShowReminder] = useState(localStorage.getItem('showReminder') === 'true');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
|
||||
localStorage.getItem('sidebarCollapsed') === 'true'
|
||||
);
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const handleTabClick = (tab, name) => {
|
||||
@@ -114,13 +118,37 @@ const Tabs = ({
|
||||
setShowReminder(false);
|
||||
};
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
const newState = !isSidebarCollapsed;
|
||||
setIsSidebarCollapsed(newState);
|
||||
localStorage.setItem('sidebarCollapsed', newState.toString());
|
||||
};
|
||||
|
||||
// Show sidebar for Settings and Discover tabs
|
||||
const showSidebar = activeTab === TAB_TYPES.SETTINGS || activeTab === TAB_TYPES.DISCOVER;
|
||||
|
||||
// Keyboard shortcut for sidebar toggle (Ctrl/Cmd + B)
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
if (showSidebar) {
|
||||
setIsSidebarCollapsed((prev) => !prev);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, [showSidebar]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
{showSidebar ? (
|
||||
<div className="modalSidebar">
|
||||
<div className={`modalSidebar ${isSidebarCollapsed ? 'collapsed' : 'expanded'}`}>
|
||||
<div className="sidebarHeader">
|
||||
<SidebarToggle isCollapsed={isSidebarCollapsed} onToggle={handleToggleSidebar} />
|
||||
</div>
|
||||
{children.map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
@@ -128,9 +156,12 @@ const Tabs = ({
|
||||
label={tab.props.label}
|
||||
onClick={(nextTab) => handleTabClick(nextTab, tab.props.name)}
|
||||
navbarTab={navbar}
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
))}
|
||||
<ReminderInfo isVisible={showReminder} onHide={handleHideReminder} />
|
||||
{!isSidebarCollapsed && (
|
||||
<ReminderInfo isVisible={showReminder} onHide={handleHideReminder} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="modalTabContent" ref={contentRef}>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import variables from 'config/variables';
|
||||
import { Button } from 'components/Elements';
|
||||
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
|
||||
|
||||
const ModalNavbar = ({ currentTab, onChangeTab }) => (
|
||||
<div className="modalNavbar">
|
||||
{NAVBAR_BUTTONS.map(({ tab, icon: Icon, messageKey }) => (
|
||||
<Button
|
||||
key={tab}
|
||||
type="navigation"
|
||||
onClick={() => onChangeTab(tab)}
|
||||
icon={<Icon />}
|
||||
label={variables.getMessage(messageKey)}
|
||||
active={currentTab === tab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ModalNavbar;
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MdClose, MdChevronRight, MdArrowBack, MdArrowForward } from 'react-icons/md';
|
||||
import { Tooltip, Button } from 'components/Elements';
|
||||
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
|
||||
import { NAVBAR_BUTTONS, TAB_TYPES } from '../constants/tabConfig';
|
||||
import mueAboutIcon from 'assets/icons/mue_about.png';
|
||||
|
||||
// Map marketplace types to translation keys
|
||||
@@ -33,6 +34,38 @@ function ModalTopBar({
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
// Track installed addons count for badge
|
||||
const [installedCount, setInstalledCount] = useState(() => {
|
||||
try {
|
||||
const installed = JSON.parse(localStorage.getItem('installed')) || [];
|
||||
return installed.length;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateCount = () => {
|
||||
try {
|
||||
const installed = JSON.parse(localStorage.getItem('installed')) || [];
|
||||
setInstalledCount(installed.length);
|
||||
} catch (e) {
|
||||
setInstalledCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for storage events (changes from other tabs)
|
||||
window.addEventListener('storage', updateCount);
|
||||
|
||||
// Listen for custom event for same-tab updates
|
||||
window.addEventListener('installedAddonsChanged', updateCount);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', updateCount);
|
||||
window.removeEventListener('installedAddonsChanged', updateCount);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get the current tab label
|
||||
const currentTabButton = NAVBAR_BUTTONS.find(({ tab }) => tab === currentTab);
|
||||
const currentTabLabel = currentTabButton ? t(currentTabButton.messageKey) : '';
|
||||
@@ -204,16 +237,22 @@ function ModalTopBar({
|
||||
</div>
|
||||
<div className="topBarRight">
|
||||
<div className="topBarNavigation">
|
||||
{NAVBAR_BUTTONS.map(({ tab, icon: Icon, messageKey }) => (
|
||||
<Button
|
||||
key={tab}
|
||||
type="navigation"
|
||||
onClick={() => onTabChange(tab)}
|
||||
active={currentTab === tab}
|
||||
icon={<Icon />}
|
||||
label={t(messageKey)}
|
||||
/>
|
||||
))}
|
||||
{NAVBAR_BUTTONS.map(({ tab, icon: Icon, messageKey }) => {
|
||||
// Show badge for Library tab when there are installed addons
|
||||
const badgeValue = tab === TAB_TYPES.LIBRARY && installedCount > 0 ? installedCount : undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tab}
|
||||
type="navigation"
|
||||
onClick={() => onTabChange(tab)}
|
||||
active={currentTab === tab}
|
||||
icon={<Icon />}
|
||||
label={t(messageKey)}
|
||||
badge={badgeValue}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Tooltip title={t('modals.welcome.buttons.close')} key="closeTooltip">
|
||||
<span className="closeModal" onClick={onClose}>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FiSidebar } from "react-icons/fi";
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
|
||||
function SidebarToggle({ isCollapsed, onToggle }) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={isCollapsed ? t('modals.main.sidebar.expand') : t('modals.main.sidebar.collapse')}
|
||||
placement="right"
|
||||
>
|
||||
<button
|
||||
className="sidebarToggleButton"
|
||||
onClick={onToggle}
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<FiSidebar />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidebarToggle;
|
||||
@@ -1,3 +1,2 @@
|
||||
export { default as ModalLoader } from './ModalLoader';
|
||||
export { default as ModalNavbar } from './ModalNavbar';
|
||||
export { default as ReminderInfo } from './ReminderInfo';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@use 'modules/topBar' as *;
|
||||
@use 'modules/sidebar' as *;
|
||||
@use 'modules/navbar' as *;
|
||||
@use 'modules/buttons' as *;
|
||||
@use 'modules/modalTabContent' as *;
|
||||
@use 'modules/links' as *;
|
||||
@use 'modules/scrollbars' as *;
|
||||
@@ -57,11 +58,18 @@
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: 0.5s;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
svg {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
@@ -206,7 +214,7 @@ h5 {
|
||||
}
|
||||
|
||||
.languageSettings {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 50px;
|
||||
|
||||
.MuiFormGroup-root {
|
||||
gap: 5px;
|
||||
@@ -261,7 +269,7 @@ h5 {
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 1px t($modal-border);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
@@ -286,6 +294,7 @@ h5 {
|
||||
|
||||
padding: 15px;
|
||||
border-radius: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
// this file is too long
|
||||
@use 'modules/item' as *;
|
||||
@use 'modules/buttons' as *;
|
||||
@use 'modules/lightbox' as *;
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.creatorItems {
|
||||
.item {
|
||||
flex-flow: row !important;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
margin: 0 !important;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.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 +45,24 @@
|
||||
width: 60px !important;
|
||||
border-radius: 12px;
|
||||
transition: 0.5s;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
&.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 +114,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 +158,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,154 +218,29 @@
|
||||
|
||||
.itemTop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(100, 100, 100, 0.9);
|
||||
cursor: help;
|
||||
|
||||
.itemTabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.itemTab {
|
||||
border-radius: 999px;
|
||||
padding: 0.55rem 1.3rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background-color: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
|
||||
@include themed {
|
||||
border-color: rgb(255 255 255 / 18%);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
@include themed {
|
||||
color: t($color);
|
||||
background-color: t($modal-sidebarActive);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
&:hover .item-installed-badge {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.itemHighlights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 18px;
|
||||
&.item-sideloaded {
|
||||
cursor: default;
|
||||
|
||||
.highlightCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-secondaryColour);
|
||||
border-color: rgb(255 255 255 / 8%);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
.highlightLabel {
|
||||
font-size: 14px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.highlightValue {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.marketplaceDetails {
|
||||
.moreInfo {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
border-radius: 15px;
|
||||
flex: 0 0 300px;
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
max-height: 700px;
|
||||
|
||||
.front {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
box-sizing: border-box !important;
|
||||
border-radius: 12px 12px 0 0;
|
||||
-webkit-backdrop-filter: blur(40px) saturate(150%) brightness(75%);
|
||||
backdrop-filter: blur(40px) saturate(150%) brightness(75%);
|
||||
|
||||
@include themed {
|
||||
background-image: linear-gradient(to bottom, transparent, t($modal-background));
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 5px 25px black;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.divider {
|
||||
text-transform: uppercase;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.iconButtons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
grid-gap: 20px;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,35 +330,6 @@ p.author {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.returnButton {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgb(121 121 121 / 22.6%);
|
||||
}
|
||||
}
|
||||
|
||||
.flexTopMarketplace {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.tooltip {
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.mainTitle {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
@@ -460,147 +353,6 @@ p.author {
|
||||
}
|
||||
}
|
||||
|
||||
.collectionPage {
|
||||
// height: 200px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
@include themed {
|
||||
border-radius: t($borderRadius);
|
||||
}
|
||||
|
||||
.nice-tag {
|
||||
border-radius: 150px;
|
||||
padding: 1px 12px;
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
background-color: rgb(255 255 255 / 10%);
|
||||
border: 1px solid rgb(209 213 219 / 30%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
text-align: center;
|
||||
text-shadow: #000 0 0 15px;
|
||||
|
||||
.mainTitle {
|
||||
justify-content: center;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #ccc !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 36px 48px;
|
||||
margin: 15px 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
align-items: center;
|
||||
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 15px;
|
||||
max-width: 250px;
|
||||
text-shadow: #000 0 0 15px;
|
||||
|
||||
.title {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #ccc !important;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 5;
|
||||
line-clamp: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.marketplaceRefresh {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.marketplaceSearch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 30px;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 3px t($modal-sidebarActive);
|
||||
background: t($modal-sidebar);
|
||||
}
|
||||
|
||||
input {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
@include themed {
|
||||
&:focus-within {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 1px t($color);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: t($modal-sidebarActive);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inCollection {
|
||||
// background-image: linear-gradient(to left, transparent, #000),
|
||||
// url('https://external-preview.redd.it/JyhsEoGMhKIMi3kvfBS24L0IllAO_KrIm4UI-dA1Ax4.jpg?auto=webp&s=b5adf9859b2c1855a5b3085f9453a6e878548505');
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
margin: 10px 0;
|
||||
|
||||
@include themed {
|
||||
// background-color: t($modal-secondaryColour);
|
||||
// box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.createYourOwn {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -691,3 +443,100 @@ p.author {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-toggle-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.view-toggle-btn {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
@include themed {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
grid-template-columns: unset !important;
|
||||
gap: 12px !important;
|
||||
|
||||
.item {
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
padding: 1rem 1.5rem !important;
|
||||
gap: 20px !important;
|
||||
|
||||
&:hover {
|
||||
transform: translate3d(5px, 0, 0) !important;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
flex: 1;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-chips {
|
||||
margin-top: 0 !important;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.side {
|
||||
float: right;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
p.description {
|
||||
margin-top: 0;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.moreInfo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 30px;
|
||||
|
||||
.items {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: 1 0 40% !important;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
|
||||
svg {
|
||||
@include themed {
|
||||
background-image: t($slightGradient);
|
||||
box-shadow: t($boxShadow);
|
||||
}
|
||||
|
||||
font-size: 18px;
|
||||
padding: 7px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
font-size: medium;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
// text-transform: uppercase;
|
||||
font-size: small;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
@include themed {
|
||||
background: t($modal-secondaryColour);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.subHeader {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
|
||||
.itemWarning {
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
svg {
|
||||
@include themed {
|
||||
background-image: t($slightGradient);
|
||||
box-shadow: t($boxShadow);
|
||||
}
|
||||
|
||||
padding: 7px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: 1 0 40% !important;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1 0 44%;
|
||||
|
||||
svg {
|
||||
@include themed {
|
||||
background-image: t($slightGradient);
|
||||
box-shadow: t($boxShadow);
|
||||
}
|
||||
|
||||
font-size: 18px;
|
||||
padding: 7px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: medium;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: small;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.showMoreItems {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.marketplaceDescription {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 15px;
|
||||
|
||||
.subtitle {
|
||||
-webkit-user-select: text !important;
|
||||
user-select: text !important;
|
||||
}
|
||||
}
|
||||
|
||||
.moreFromCurator {
|
||||
margin-top: 50px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 15px;
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
// Default button behavior for all modal buttons
|
||||
.btn-default {
|
||||
@include modal-button(standard);
|
||||
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.updateCheck {
|
||||
flex-flow: row !important;
|
||||
}
|
||||
@@ -11,6 +23,11 @@
|
||||
margin-top: 0;
|
||||
float: none !important;
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -20,6 +37,11 @@
|
||||
margin-top: 0;
|
||||
float: none !important;
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-navigation {
|
||||
@@ -27,7 +49,7 @@
|
||||
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px !important;
|
||||
transition: all 0.2s ease;
|
||||
transition: background 0.2s ease, transform 0.1s ease;
|
||||
position: relative;
|
||||
|
||||
@include themed {
|
||||
@@ -60,6 +82,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
span,
|
||||
svg {
|
||||
font-size: 1.1em !important;
|
||||
@@ -72,6 +98,51 @@
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-badge {
|
||||
margin-left: 3px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75em !important;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
.light & {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-navigation-active .btn-badge {
|
||||
@include themed {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.light & {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* safari fix */
|
||||
@@ -112,7 +183,7 @@
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
transition: 0.5s;
|
||||
transition: background 0.2s ease, transform 0.1s ease, border 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
@@ -125,13 +196,15 @@
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 1px t($color);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 1px t($color);
|
||||
box-shadow: 0 0 0 2px t($color);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +212,7 @@
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +236,11 @@ a.btn-collection {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
border-radius: 8px !important;
|
||||
padding: 0 !important;
|
||||
|
||||
@include modal-button(standard);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95) !important;
|
||||
}
|
||||
}
|
||||
@@ -7,68 +7,72 @@
|
||||
@include modal-button(standard);
|
||||
} */
|
||||
|
||||
padding: 1rem 2rem 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
// height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
@include themed {
|
||||
padding: 1rem 2rem 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: t($modal-background);
|
||||
|
||||
margin: 0;
|
||||
border-radius: t($borderRadius);
|
||||
}
|
||||
|
||||
@extend %tabText;
|
||||
@extend %tabText;
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
background: rgb(196 196 196 / 74%);
|
||||
outline: none;
|
||||
hr {
|
||||
width: 100%;
|
||||
background: rgb(196 196 196 / 74%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: 0.4s ease-in-out;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
|
||||
@include themed {
|
||||
border-bottom: 1px solid t($modal-border);
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
&.settingsNoBorder {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: 0.4s ease-in-out;
|
||||
flex-flow: column;
|
||||
max-width: 50%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* border-top: 1px solid #ccc; */
|
||||
border-bottom: 1px solid #676767;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
.action {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: flex-end;
|
||||
width: 300px;
|
||||
gap: 10px;
|
||||
|
||||
&.settingsNoBorder {
|
||||
border-bottom: none;
|
||||
button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
.link {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: flex-end;
|
||||
width: 300px;
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +217,30 @@ table {
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
@@ -264,7 +292,8 @@ table {
|
||||
padding-left: 10px;
|
||||
padding-right: 5px;
|
||||
|
||||
input[type='tel'] {
|
||||
input[type='tel'],
|
||||
input[type='number'] {
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
@include themed {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
// padding: 1rem 1.5rem 4rem 1.5rem;
|
||||
padding: 0.5rem 0 0 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: t($modal-sidebar);
|
||||
border-radius: 12px 0 0 12px;
|
||||
overflow-y: auto;
|
||||
@@ -13,37 +12,53 @@
|
||||
height: 100%;
|
||||
min-width: 250px;
|
||||
flex-shrink: 0;
|
||||
transition: min-width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// Container for toggle button positioning
|
||||
.sidebarHeader {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.5rem;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
color: t($subColor);
|
||||
font-size: 17px;
|
||||
font-size: 20px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
background: #ccc;
|
||||
margin: 0 1.75rem;
|
||||
background: t($modal-sidebarActive);
|
||||
margin: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
button {
|
||||
button:not(.sidebarToggleButton) {
|
||||
color: t($color);
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0.2rem;
|
||||
padding: 0.5rem;
|
||||
transition: 0.5s;
|
||||
gap: 12px;
|
||||
margin: 0.15rem 0.25rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
transition: background 0.2s ease, transform 0.1s ease;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
min-width: calc(100% - 1.2em);
|
||||
width: calc(100% - 0.5rem);
|
||||
text-align: left;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 1rem;
|
||||
@@ -51,21 +66,129 @@
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
|
||||
svg {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 0.5px t($color);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 0.5px t($color);
|
||||
box-shadow: 0 0 0 2px t($color);
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-list-active {
|
||||
background: t($modal-sidebarActive);
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
// Active indicator line
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: t($color);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsed state
|
||||
&.collapsed {
|
||||
min-width: 64px;
|
||||
padding: 0.75rem 0.25rem;
|
||||
|
||||
.sidebarHeader {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button:not(.sidebarToggleButton) {
|
||||
justify-content: center;
|
||||
padding: 0.65rem;
|
||||
gap: 0;
|
||||
|
||||
span {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-list-active::before {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60%;
|
||||
height: 3px;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
opacity: 0;
|
||||
margin: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarToggleButton {
|
||||
@include themed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: t($subColor);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
|
||||
svg {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: t($modal-sidebarActive);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 2px t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,20 @@ h4 {
|
||||
}
|
||||
|
||||
.imagesTopBar {
|
||||
padding-top: 25px;
|
||||
position: sticky;
|
||||
top: -20px;
|
||||
z-index: 90;
|
||||
padding: 25px 0 15px 0;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
div:nth-child(1) {
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
}
|
||||
|
||||
.imagesTopBarTitle {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
@@ -121,18 +128,139 @@ h4 {
|
||||
.topbarbuttons {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 25px;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
button:not(.MuiButtonBase-root) {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.imagesControlBar {
|
||||
position: sticky;
|
||||
top: 68px;
|
||||
z-index: 89;
|
||||
padding: 12px 0;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
border-bottom: 1px solid t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
.controlBarLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
.image-count {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
font-weight: 400;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
.request-storage-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 13px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: #ff5c25;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.delete-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: rgb(255 71 87);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.select-all-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controlBarRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.customcss 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;
|
||||
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;
|
||||
}
|
||||
|
||||
.preferences {
|
||||
|
||||
@@ -95,6 +95,413 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced custom images grid
|
||||
.images-grid {
|
||||
display: grid;
|
||||
padding: 1px;
|
||||
|
||||
// Show all checkboxes when in selection mode (any image selected)
|
||||
&.selection-mode {
|
||||
.image-checkbox {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
|
||||
@include themed {
|
||||
.image-card {
|
||||
position: relative;
|
||||
border-radius: t($borderRadius);
|
||||
background: t($modal-secondaryColour);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: t($boxShadow);
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
outline: 3px solid #ff5c25;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
// box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.image-nav-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-checkbox {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 12;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
|
||||
&:checked {
|
||||
background: #ff5c25;
|
||||
border-color: #ff5c25;
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #ff5c25;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep checkbox visible when checked
|
||||
&:has(input:checked) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: t($modal-sidebar);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-icon-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: t($modal-sidebar);
|
||||
|
||||
.customvideoicon {
|
||||
font-size: 60px;
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.blur-placeholder {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.image-nav-buttons {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
|
||||
.nav-button {
|
||||
pointer-events: all;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-metadata {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.image-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: t($color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.image-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: t($subColor);
|
||||
|
||||
.detail {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.folder-tag {
|
||||
padding: 2px 8px;
|
||||
background: t($modal-sidebarActive);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 71, 87, 0.9);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
z-index: 11;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 71 87);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Show delete button when card is hovered or has checkbox visible
|
||||
&:hover .delete-button,
|
||||
.image-checkbox:has(input:checked) ~ * .delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Storage quota display
|
||||
.storage-quota {
|
||||
padding: 15px 20px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 50px;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-secondaryColour);
|
||||
border-top: 1px solid t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.quota-text {
|
||||
font-size: 13px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.quota-info-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quota-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
}
|
||||
|
||||
.quota-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folder tagging modal styles
|
||||
.taggingModalContent {
|
||||
padding: 20px;
|
||||
|
||||
p.subtitle {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.taggingInput {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
color: t($color);
|
||||
border-color: t($modal-sidebarActive);
|
||||
|
||||
&:focus {
|
||||
border-color: #ff5c25;
|
||||
box-shadow: 0 0 0 3px rgba(255, 92, 37, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
margin-bottom: 100px;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
}
|
||||
|
||||
.dropzone-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.photosEmpty {
|
||||
padding: 60px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.emptyNewMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overviewGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
|
||||
@@ -127,4 +127,4 @@ function ShareModal({ modalClose, data }) {
|
||||
|
||||
const MemoizedSharemodal = memo(ShareModal);
|
||||
|
||||
export { MemoizedSharemodal as default, MemoizedSharemodal as ShareModal };
|
||||
export { MemoizedSharemodal as default, MemoizedSharemodal as ShareModal };
|
||||
@@ -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,8 @@
|
||||
import { useState, memo } from 'react';
|
||||
import { useState, memo, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MdExpandMore, MdClose, MdCheck } 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 +10,161 @@ function ChipSelect({ label, options, onChange }) {
|
||||
start = [];
|
||||
}
|
||||
|
||||
const [optionsSelected, setoptionsSelected] = useState(start);
|
||||
const [optionsSelected, setOptionsSelected] = useState(start);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const containerRef = useRef(null);
|
||||
const controlRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
setoptionsSelected(typeof value === 'string' ? value.split(',') : value);
|
||||
localStorage.setItem('apiCategories', value);
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setIsClosing(false);
|
||||
}, 200); // Match animation duration
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [closeDropdown]);
|
||||
|
||||
const calculatePosition = useCallback(() => {
|
||||
if (controlRef.current) {
|
||||
const rect = controlRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Estimate menu height
|
||||
const estimatedMenuHeight = Math.min(options.length * 44, 250);
|
||||
|
||||
// Calculate if dropdown would overflow bottom of viewport
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap;
|
||||
const spaceAbove = rect.top - gap;
|
||||
|
||||
// If not enough space below but more space above, flip to top
|
||||
const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
return {
|
||||
top: shouldFlipUp ? rect.top - gap : rect.bottom + gap,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: shouldFlipUp ? Math.min(250, spaceAbove) : Math.min(250, spaceBelow),
|
||||
flipped: shouldFlipUp,
|
||||
};
|
||||
}
|
||||
return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false };
|
||||
}, [options]);
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
const position = calculatePosition();
|
||||
setMenuPosition(position);
|
||||
setIsOpen(true);
|
||||
}, [calculatePosition]);
|
||||
|
||||
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>
|
||||
)}
|
||||
<div className="chipSelect" ref={containerRef}>
|
||||
{label && <label className="chipSelect-label">{label}</label>}
|
||||
<div
|
||||
ref={controlRef}
|
||||
className="chipSelect-control"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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-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 || isClosing) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`chipSelect-dropdown ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
width: `${menuPosition.width}px`,
|
||||
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.name}
|
||||
className={`chipSelect-option ${optionsSelected.includes(option.name) ? 'selected' : ''}`}
|
||||
onClick={() => handleToggle(option.name)}
|
||||
>
|
||||
<div className="chipSelect-option-checkbox">
|
||||
{optionsSelected.includes(option.name) && <MdCheck />}
|
||||
</div>
|
||||
<span className="chipSelect-option-label">
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}
|
||||
{option.count && ` (${option.count})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
272
src/components/Form/Settings/ChipSelect/ChipSelect.scss
Normal file
272
src/components/Form/Settings/ChipSelect/ChipSelect.scss
Normal file
@@ -0,0 +1,272 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(chipSelectSlideIn) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(chipSelectSlideOut) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(chipSelectSlideInUp) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(chipSelectSlideOutUp) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
margin-top: 10px;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
.chipSelect-label {
|
||||
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: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 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: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
text-transform: capitalize;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
@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;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-dropdown {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
z-index: 9999;
|
||||
@include animation(chipSelectSlideIn 0.2s ease-out);
|
||||
will-change: transform, opacity;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
&.flipped {
|
||||
@include animation(chipSelectSlideInUp 0.2s ease-out);
|
||||
|
||||
&.closing {
|
||||
@include animation(chipSelectSlideOutUp 0.2s ease-out forwards);
|
||||
}
|
||||
}
|
||||
|
||||
&.closing:not(.flipped) {
|
||||
@include animation(chipSelectSlideOut 0.2s ease-out forwards);
|
||||
}
|
||||
|
||||
&::-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option-checkbox {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
|
||||
@include themed {
|
||||
border: 2px solid t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&.selected .chipSelect-option-checkbox {
|
||||
@include themed {
|
||||
background: t($link);
|
||||
border-color: t($link);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option-label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
196
src/components/Form/Settings/DatePicker/DatePicker.jsx
Normal file
196
src/components/Form/Settings/DatePicker/DatePicker.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { memo, useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MdExpandMore, MdChevronLeft, MdChevronRight } from 'react-icons/md';
|
||||
|
||||
import './DatePicker.scss';
|
||||
|
||||
const DatePicker = memo((props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const [viewDate, setViewDate] = useState(props.value || new Date());
|
||||
const containerRef = useRef(null);
|
||||
const controlRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setIsClosing(false);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [closeDropdown]);
|
||||
|
||||
const calculatePosition = useCallback(() => {
|
||||
if (controlRef.current) {
|
||||
const rect = controlRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const estimatedMenuHeight = 320;
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap;
|
||||
const spaceAbove = rect.top - gap;
|
||||
const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
return {
|
||||
top: shouldFlipUp ? rect.top - gap : rect.bottom + gap,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: shouldFlipUp ? Math.min(320, spaceAbove) : Math.min(320, spaceBelow),
|
||||
flipped: shouldFlipUp,
|
||||
};
|
||||
}
|
||||
return { top: 0, left: 0, width: 0, maxHeight: 320, flipped: false };
|
||||
}, []);
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
const position = calculatePosition();
|
||||
setMenuPosition(position);
|
||||
setIsOpen(true);
|
||||
}, [calculatePosition]);
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'Select Date';
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return props.hideYear ? `${month}/${day}` : `${month}/${day}/${year}`;
|
||||
};
|
||||
|
||||
const getDaysInMonth = (date) => {
|
||||
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||
};
|
||||
|
||||
const getFirstDayOfMonth = (date) => {
|
||||
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
};
|
||||
|
||||
const handleDateSelect = (day) => {
|
||||
const newDate = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
|
||||
if (props.onChange) {
|
||||
props.onChange(newDate);
|
||||
}
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const renderCalendar = () => {
|
||||
const daysInMonth = getDaysInMonth(viewDate);
|
||||
const firstDay = getFirstDayOfMonth(viewDate);
|
||||
const days = [];
|
||||
const today = new Date();
|
||||
const selectedDate = props.value;
|
||||
|
||||
// Empty cells for days before the first day of the month
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="calendar-day empty" />);
|
||||
}
|
||||
|
||||
// Days of the month
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
const isSelected = selectedDate && date.toDateString() === selectedDate.toDateString();
|
||||
|
||||
days.push(
|
||||
<div
|
||||
key={day}
|
||||
className={`calendar-day ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => handleDateSelect(day)}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="datepicker" ref={containerRef} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
ref={controlRef}
|
||||
className="datepicker-control"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="datepicker-value">{formatDate(props.value)}</span>
|
||||
<MdExpandMore className={`datepicker-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{(isOpen || isClosing) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`datepicker-menu ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="calendar-header">
|
||||
<button onClick={handlePreviousMonth} className="calendar-nav">
|
||||
<MdChevronLeft />
|
||||
</button>
|
||||
<span className="calendar-month">
|
||||
{monthNames[viewDate.getMonth()]}{props.hideYear ? '' : ` ${viewDate.getFullYear()}`}
|
||||
</span>
|
||||
<button onClick={handleNextMonth} className="calendar-nav">
|
||||
<MdChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
<div className="calendar-weekdays">
|
||||
<div>Su</div>
|
||||
<div>Mo</div>
|
||||
<div>Tu</div>
|
||||
<div>We</div>
|
||||
<div>Th</div>
|
||||
<div>Fr</div>
|
||||
<div>Sa</div>
|
||||
</div>
|
||||
<div className="calendar-grid">
|
||||
{renderCalendar()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DatePicker.displayName = 'DatePicker';
|
||||
|
||||
export { DatePicker as default, DatePicker };
|
||||
256
src/components/Form/Settings/DatePicker/DatePicker.scss
Normal file
256
src/components/Form/Settings/DatePicker/DatePicker.scss
Normal file
@@ -0,0 +1,256 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(datepickerSlideIn) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(datepickerSlideOut) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(datepickerSlideInUp) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(datepickerSlideOutUp) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
|
||||
.datepicker-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-value {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.datepicker-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
margin: -4px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-menu {
|
||||
z-index: 9999;
|
||||
@include animation(datepickerSlideIn 0.2s ease-out);
|
||||
will-change: transform, opacity;
|
||||
padding: 16px;
|
||||
min-width: 280px;
|
||||
box-sizing: border-box;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
&.flipped {
|
||||
@include animation(datepickerSlideInUp 0.2s ease-out);
|
||||
|
||||
&.closing {
|
||||
@include animation(datepickerSlideOutUp 0.2s ease-out forwards);
|
||||
}
|
||||
}
|
||||
|
||||
&.closing:not(.flipped) {
|
||||
@include animation(datepickerSlideOut 0.2s ease-out forwards);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 8px;
|
||||
|
||||
.calendar-nav {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-month {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
div {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 8px 4px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
|
||||
.calendar-day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
|
||||
&:hover:not(.empty) {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
&.today {
|
||||
border: 2px solid t($link);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: t($link);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: t($link);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&.empty {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/DatePicker/index.jsx
Normal file
1
src/components/Form/Settings/DatePicker/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DatePicker';
|
||||
@@ -1,69 +1,385 @@
|
||||
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, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MdExpandMore, MdCheck, MdRefresh, MdClose } 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,
|
||||
);
|
||||
const dropdown = useRef();
|
||||
const [value, setValue] = useState(localStorage.getItem(props.name) || props.items[0]?.value);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const containerRef = useRef(null);
|
||||
const controlRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const optionsRef = useRef([]);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const onChange = useCallback((e) => {
|
||||
const newValue = e.target.value;
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setIsClosing(false);
|
||||
setFocusedIndex(-1);
|
||||
setSearchQuery('');
|
||||
}, 200); // Match animation duration
|
||||
}, []);
|
||||
|
||||
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) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [closeDropdown]);
|
||||
|
||||
// Memoize items count to avoid unnecessary recalculations
|
||||
const itemsCount = useMemo(() =>
|
||||
props.items.filter((i) => i !== null).length,
|
||||
[props.items]
|
||||
);
|
||||
|
||||
const calculatePosition = useCallback(() => {
|
||||
if (controlRef.current) {
|
||||
const rect = controlRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Estimate menu height (will be more accurate after first render)
|
||||
const estimatedMenuHeight = Math.min(itemsCount * 44, 250);
|
||||
|
||||
// Calculate if dropdown would overflow bottom of viewport
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap;
|
||||
const spaceAbove = rect.top - gap;
|
||||
|
||||
// If not enough space below but more space above, flip to top
|
||||
const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
return {
|
||||
top: shouldFlipUp ? rect.top - gap : rect.bottom + gap,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: shouldFlipUp ? Math.min(250, spaceAbove) : Math.min(250, spaceBelow),
|
||||
flipped: shouldFlipUp,
|
||||
};
|
||||
}
|
||||
return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false };
|
||||
}, [itemsCount]);
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
const position = calculatePosition();
|
||||
setMenuPosition(position);
|
||||
setIsOpen(true);
|
||||
}, [calculatePosition]);
|
||||
|
||||
// Update dropdown position on scroll or resize
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let rafId = null;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
const newPosition = calculatePosition();
|
||||
setMenuPosition(newPosition);
|
||||
});
|
||||
};
|
||||
|
||||
// Listen to window scroll and resize
|
||||
window.addEventListener('scroll', updatePosition, { passive: true });
|
||||
window.addEventListener('resize', updatePosition, { passive: true });
|
||||
|
||||
// Find and listen to scrollable ancestors
|
||||
let element = controlRef.current?.parentElement;
|
||||
const scrollableElements = [];
|
||||
while (element) {
|
||||
const hasScrollableContent = element.scrollHeight > element.clientHeight;
|
||||
const overflowYStyle = window.getComputedStyle(element).overflowY;
|
||||
const isOverflowYScrollable = overflowYStyle !== 'visible' && overflowYStyle !== 'hidden';
|
||||
|
||||
if (hasScrollableContent && isOverflowYScrollable) {
|
||||
scrollableElements.push(element);
|
||||
element.addEventListener('scroll', updatePosition, { passive: true });
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [value, props]);
|
||||
return () => {
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('scroll', updatePosition);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
scrollableElements.forEach(el =>
|
||||
el.removeEventListener('scroll', updatePosition)
|
||||
);
|
||||
};
|
||||
}, [isOpen, calculatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && props.searchable && searchInputRef.current) {
|
||||
// Focus the search input when dropdown opens
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen, props.searchable]);
|
||||
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
}
|
||||
}, [isOpen, openDropdown]);
|
||||
|
||||
const handleInputClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
}
|
||||
}, [isOpen, openDropdown]);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
// When focusing, if not default value, pre-fill with current value for editing
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
if (value !== defaultValue && !searchQuery) {
|
||||
const currentItem = props.items.find((item) => item?.value === value);
|
||||
const currentText = currentItem?.text || value;
|
||||
setSearchQuery(currentText);
|
||||
}
|
||||
}, [value, props.default, props.items, searchQuery]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newValue) => {
|
||||
if (newValue === variables.getMessage('modals.main.loading')) {
|
||||
return;
|
||||
}
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
setValue(newValue);
|
||||
closeDropdown();
|
||||
|
||||
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();
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
closeDropdown();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
} 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, openDropdown, closeDropdown],
|
||||
);
|
||||
|
||||
const handleOptionKeyDown = useCallback(
|
||||
(e, item) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(item.value);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const resetItem = useCallback((e) => {
|
||||
e?.stopPropagation();
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
onChange(defaultValue);
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [onChange, props.default, props.items]);
|
||||
|
||||
const clearSearch = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setSearchQuery('');
|
||||
if (props.searchable) {
|
||||
// Reset to default value (first item, usually "Automatic")
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
onChange(defaultValue);
|
||||
}
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [props, onChange]);
|
||||
|
||||
const id = 'dropdown' + props.name;
|
||||
const label = props.label || '';
|
||||
const selectedItem = props.items.find((item) => item?.value === value);
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
|
||||
// Filter items based on search query
|
||||
const filteredItems = props.searchable && searchQuery
|
||||
? props.items.filter((item) =>
|
||||
item !== null && item.text.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: props.items;
|
||||
|
||||
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} onClick={(e) => e.stopPropagation()}>
|
||||
{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
|
||||
ref={controlRef}
|
||||
className={`dropdown-control ${props.searchable && (isOpen || searchQuery) ? 'searching' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (props.disabled) return;
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}}
|
||||
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,
|
||||
{props.searchable ? (
|
||||
<>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="dropdown-search-input-control"
|
||||
placeholder={selectedItem?.text || value}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onClick={handleInputClick}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (searchQuery) {
|
||||
setSearchQuery('');
|
||||
} else {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(searchQuery || value !== (props.default || props.items[0]?.value)) && (
|
||||
<MdClose className="dropdown-clear" onClick={clearSearch} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="dropdown-value">{selectedItem?.text || value}</span>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{(isOpen || isClosing) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`dropdown-menu ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
|
||||
role="listbox"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
width: `${menuPosition.width}px`,
|
||||
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
{filteredItems.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}
|
||||
{item.value === defaultValue && (
|
||||
<span className="dropdown-option-default">
|
||||
{' '}
|
||||
({variables.getMessage('modals.main.settings.buttons.default')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{value === item.value && <MdCheck className="dropdown-option-check" />}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
321
src/components/Form/Settings/Dropdown/Dropdown.scss
Normal file
321
src/components/Form/Settings/Dropdown/Dropdown.scss
Normal file
@@ -0,0 +1,321 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(dropdownSlideIn) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(dropdownSlideOut) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(dropdownSlideInUp) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(dropdownSlideOutUp) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
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;
|
||||
gap: 8px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
&.searching {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.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: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
margin: -4px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-search-input-control {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
|
||||
&::placeholder {
|
||||
color: t($color);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-clear {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 9999;
|
||||
@include animation(dropdownSlideIn 0.2s ease-out);
|
||||
will-change: transform, opacity;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
&.flipped {
|
||||
@include animation(dropdownSlideInUp 0.2s ease-out);
|
||||
|
||||
&.closing {
|
||||
@include animation(dropdownSlideOutUp 0.2s ease-out forwards);
|
||||
}
|
||||
}
|
||||
|
||||
&.closing:not(.flipped) {
|
||||
@include animation(dropdownSlideOut 0.2s ease-out forwards);
|
||||
}
|
||||
|
||||
&::-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-default {
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
|
||||
import { compressAccurately, filetoDataURL } from 'image-conversion';
|
||||
import videoCheck from 'features/background/api/videoCheck';
|
||||
|
||||
const FileUpload = memo(({ id, type, accept, loadFunction }) => {
|
||||
const FileUpload = memo(({ id, type, accept, loadFunction, multiple }) => {
|
||||
useEffect(() => {
|
||||
const fileInput = document.getElementById(id);
|
||||
if (!fileInput) return;
|
||||
@@ -20,40 +20,48 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => {
|
||||
return loadFunction(e.target.result);
|
||||
};
|
||||
} else {
|
||||
// background upload - handle multiple files
|
||||
const settings = {};
|
||||
// Pass files directly to loadFunction if it's a newer implementation
|
||||
if (typeof loadFunction === 'function' && loadFunction.length === 1) {
|
||||
loadFunction(files);
|
||||
} else {
|
||||
// Legacy background upload - handle multiple files
|
||||
const settings = {};
|
||||
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
settings[key] = localStorage.getItem(key);
|
||||
});
|
||||
|
||||
const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length;
|
||||
|
||||
// Process each file
|
||||
files.forEach((file, index) => {
|
||||
if (videoCheck(file.type) === true) {
|
||||
if (settingsSize + file.size > 4850000) {
|
||||
return toast(variables.getMessage('toasts.no_storage'));
|
||||
}
|
||||
|
||||
return loadFunction(file, index);
|
||||
}
|
||||
|
||||
compressAccurately(file, {
|
||||
size: 450,
|
||||
accuracy: 0.9,
|
||||
}).then(async (res) => {
|
||||
if (settingsSize + res.size > 4850000) {
|
||||
return toast(variables.getMessage('toasts.no_storage'));
|
||||
}
|
||||
|
||||
loadFunction({
|
||||
target: {
|
||||
result: await filetoDataURL(res),
|
||||
},
|
||||
}, index);
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
settings[key] = localStorage.getItem(key);
|
||||
});
|
||||
});
|
||||
|
||||
const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length;
|
||||
|
||||
// Process each file
|
||||
files.forEach((file, index) => {
|
||||
if (videoCheck(file.type) === true) {
|
||||
if (settingsSize + file.size > 4850000) {
|
||||
return toast(variables.getMessage('toasts.no_storage'));
|
||||
}
|
||||
|
||||
return loadFunction(file, index);
|
||||
}
|
||||
|
||||
compressAccurately(file, {
|
||||
size: 450,
|
||||
accuracy: 0.9,
|
||||
}).then(async (res) => {
|
||||
if (settingsSize + res.size > 4850000) {
|
||||
return toast(variables.getMessage('toasts.no_storage'));
|
||||
}
|
||||
|
||||
loadFunction(
|
||||
{
|
||||
target: {
|
||||
result: await filetoDataURL(res),
|
||||
},
|
||||
},
|
||||
index,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,7 +72,7 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => {
|
||||
fileInput.onchange = null;
|
||||
}
|
||||
};
|
||||
}, [id, type, loadFunction]);
|
||||
}, [id, type, loadFunction, multiple]);
|
||||
|
||||
return (
|
||||
<input
|
||||
@@ -72,7 +80,7 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => {
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
accept={accept}
|
||||
multiple={type !== 'settings'}
|
||||
multiple={multiple !== undefined ? multiple : type !== 'settings'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
435
src/components/Form/Settings/LocationSearch/LocationSearch.jsx
Normal file
435
src/components/Form/Settings/LocationSearch/LocationSearch.jsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MdExpandMore, MdCheck, MdClose, MdMyLocation } from 'react-icons/md';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './LocationSearch.scss';
|
||||
|
||||
const LocationSearch = memo((props) => {
|
||||
const { label, name, category, placeholder, disabled } = props;
|
||||
|
||||
// Load location data from localStorage (new JSON format or legacy string)
|
||||
const [locationData, setLocationData] = useState(() => {
|
||||
const stored = localStorage.getItem(name);
|
||||
if (!stored) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && typeof parsed === 'object' && parsed.displayName) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Legacy format: plain string city name
|
||||
return { displayName: stored, legacy: true };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const controlRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setIsClosing(false);
|
||||
setFocusedIndex(-1);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [closeDropdown]);
|
||||
|
||||
const itemsCount = useMemo(() => suggestions.length, [suggestions]);
|
||||
|
||||
const calculatePosition = useCallback(() => {
|
||||
if (controlRef.current) {
|
||||
const rect = controlRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const estimatedMenuHeight = Math.min(Math.max(itemsCount, 1) * 56, 250);
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap;
|
||||
const spaceAbove = rect.top - gap;
|
||||
const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
return {
|
||||
top: shouldFlipUp ? rect.top - gap : rect.bottom + gap,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: shouldFlipUp ? Math.min(250, spaceAbove) : Math.min(250, spaceBelow),
|
||||
flipped: shouldFlipUp,
|
||||
};
|
||||
}
|
||||
return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false };
|
||||
}, [itemsCount]);
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
const position = calculatePosition();
|
||||
setMenuPosition(position);
|
||||
setIsOpen(true);
|
||||
}, [calculatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let rafId = null;
|
||||
const updatePosition = () => {
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
rafId = window.requestAnimationFrame(() => {
|
||||
const newPosition = calculatePosition();
|
||||
setMenuPosition(newPosition);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', updatePosition, { passive: true });
|
||||
window.addEventListener('resize', updatePosition, { passive: true });
|
||||
|
||||
let element = controlRef.current?.parentElement;
|
||||
const scrollableElements = [];
|
||||
while (element) {
|
||||
const hasScrollableContent = element.scrollHeight > element.clientHeight;
|
||||
const overflowYStyle = window.getComputedStyle(element).overflowY;
|
||||
const isOverflowYScrollable = overflowYStyle !== 'visible' && overflowYStyle !== 'hidden';
|
||||
|
||||
if (hasScrollableContent && isOverflowYScrollable) {
|
||||
scrollableElements.push(element);
|
||||
element.addEventListener('scroll', updatePosition, { passive: true });
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafId) window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('scroll', updatePosition);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
scrollableElements.forEach((el) => el.removeEventListener('scroll', updatePosition));
|
||||
};
|
||||
}, [isOpen, calculatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && searchInputRef.current) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Debounced search function
|
||||
const debouncedSearch = useDebouncedCallback(async (query) => {
|
||||
if (query.length < 2) {
|
||||
setSuggestions([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel previous request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${variables.constants.API_URL}/geocode?q=${encodeURIComponent(query)}`,
|
||||
{ signal: abortControllerRef.current.signal },
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Search failed');
|
||||
|
||||
const data = await response.json();
|
||||
setSuggestions(data);
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Location search error:', err);
|
||||
setSuggestions([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
setIsLoading(value.length >= 2);
|
||||
debouncedSearch(value);
|
||||
if (!isOpen && value.length > 0) {
|
||||
openDropdown();
|
||||
}
|
||||
},
|
||||
[isOpen, debouncedSearch, openDropdown],
|
||||
);
|
||||
|
||||
const handleInputClick = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
}
|
||||
},
|
||||
[isOpen, openDropdown],
|
||||
);
|
||||
|
||||
const selectLocation = useCallback(
|
||||
(location) => {
|
||||
const locationObj = {
|
||||
name: location.name,
|
||||
displayName: location.displayName,
|
||||
lat: location.lat,
|
||||
lon: location.lon,
|
||||
country: location.country,
|
||||
state: location.state,
|
||||
};
|
||||
|
||||
localStorage.setItem(name, JSON.stringify(locationObj));
|
||||
localStorage.removeItem('currentWeather');
|
||||
setLocationData(locationObj);
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
closeDropdown();
|
||||
|
||||
EventBus.emit('refresh', category);
|
||||
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
localStorage.setItem('showReminder', true);
|
||||
},
|
||||
[name, category, closeDropdown],
|
||||
);
|
||||
|
||||
const handleAutoLocation = useCallback(() => {
|
||||
setSearchQuery(variables.getMessage('modals.main.loading'));
|
||||
setSuggestions([]);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
try {
|
||||
const data = await (
|
||||
await fetch(
|
||||
`${variables.constants.API_URL}/gps?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}`,
|
||||
)
|
||||
).json();
|
||||
|
||||
if (data && data[0]) {
|
||||
const loc = data[0];
|
||||
const locationObj = {
|
||||
name: loc.name,
|
||||
displayName: [loc.name, loc.state, loc.country].filter(Boolean).join(', '),
|
||||
lat: position.coords.latitude,
|
||||
lon: position.coords.longitude,
|
||||
country: loc.country,
|
||||
state: loc.state,
|
||||
};
|
||||
|
||||
localStorage.setItem(name, JSON.stringify(locationObj));
|
||||
localStorage.removeItem('currentWeather');
|
||||
setLocationData(locationObj);
|
||||
setSearchQuery('');
|
||||
|
||||
EventBus.emit('refresh', category);
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
localStorage.setItem('showReminder', true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auto location error:', err);
|
||||
setSearchQuery('');
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
setSearchQuery('');
|
||||
},
|
||||
{ enableHighAccuracy: true },
|
||||
);
|
||||
}, [name, category]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if (disabled) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (isOpen && focusedIndex >= 0 && suggestions[focusedIndex]) {
|
||||
selectLocation(suggestions[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
if (searchQuery) {
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
} else {
|
||||
closeDropdown();
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen && searchQuery.length >= 2) {
|
||||
openDropdown();
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev));
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
isOpen,
|
||||
suggestions,
|
||||
focusedIndex,
|
||||
disabled,
|
||||
searchQuery,
|
||||
openDropdown,
|
||||
closeDropdown,
|
||||
selectLocation,
|
||||
],
|
||||
);
|
||||
|
||||
const clearSearch = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
setSearchQuery('');
|
||||
setSuggestions([]);
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const id = 'location-search-' + name;
|
||||
const displayValue = locationData?.displayName || placeholder || 'Search location...';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`location-search ${id} ${disabled ? 'disabled' : ''}`}
|
||||
ref={containerRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label && (
|
||||
<div className="location-search-header">
|
||||
<label className="location-search-label">{label}</label>
|
||||
<span className="location-search-auto" onClick={handleAutoLocation}>
|
||||
<MdMyLocation />
|
||||
{variables.getMessage('modals.main.settings.sections.weather.auto')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={controlRef}
|
||||
className={`location-search-control ${isOpen || searchQuery ? 'searching' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label || name}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="location-search-input"
|
||||
placeholder={displayValue}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onClick={handleInputClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{searchQuery && <MdClose className="location-search-clear" onClick={clearSearch} />}
|
||||
{isLoading ? (
|
||||
<span className="location-search-loading" />
|
||||
) : (
|
||||
<MdExpandMore className={`location-search-arrow ${isOpen ? 'open' : ''}`} />
|
||||
)}
|
||||
</div>
|
||||
{(isOpen || isClosing) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`location-search-menu ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
|
||||
role="listbox"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
width: `${menuPosition.width}px`,
|
||||
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
{suggestions.length > 0 ? (
|
||||
suggestions.map((item, index) => (
|
||||
<div
|
||||
key={`${item.lat}-${item.lon}`}
|
||||
className={`location-search-option ${index === focusedIndex ? 'focused' : ''}`}
|
||||
onClick={() => selectLocation(item)}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="location-search-option-text">
|
||||
<span className="location-search-option-name">{item.name}</span>
|
||||
{(item.state || item.country) && (
|
||||
<span className="location-search-option-detail">
|
||||
{[item.state, item.country].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<MdCheck className="location-search-option-check" />
|
||||
</div>
|
||||
))
|
||||
) : searchQuery.length >= 2 && !isLoading ? (
|
||||
<div className="location-search-empty">
|
||||
{variables.getMessage('widgets.weather.not_found')}
|
||||
</div>
|
||||
) : searchQuery.length < 2 && searchQuery.length > 0 ? (
|
||||
<div className="location-search-empty">
|
||||
{variables.getMessage('modals.main.settings.sections.weather.location')}...
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LocationSearch.displayName = 'LocationSearch';
|
||||
|
||||
export { LocationSearch as default, LocationSearch };
|
||||
351
src/components/Form/Settings/LocationSearch/LocationSearch.scss
Normal file
351
src/components/Form/Settings/LocationSearch/LocationSearch.scss
Normal file
@@ -0,0 +1,351 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(locationSearchSlideIn) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(locationSearchSlideOut) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(locationSearchSlideInUp) {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(locationSearchSlideOutUp) {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%) translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@include keyframes(locationSearchSpin) {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.location-search {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.location-search-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.location-search-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-auto {
|
||||
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: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
cursor: text;
|
||||
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-within {
|
||||
@include themed {
|
||||
border-color: t($link);
|
||||
box-shadow: 0 0 0 3px rgba(t($link), 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
|
||||
&::placeholder {
|
||||
color: t($color);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-clear {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-loading {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 50%;
|
||||
@include animation(locationSearchSpin 0.8s linear infinite);
|
||||
|
||||
@include themed {
|
||||
border-top-color: t($link);
|
||||
border-right-color: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
margin: -4px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-menu {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 9999;
|
||||
@include animation(locationSearchSlideIn 0.2s ease-out);
|
||||
will-change: transform, opacity;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
&.flipped {
|
||||
@include animation(locationSearchSlideInUp 0.2s ease-out);
|
||||
|
||||
&.closing {
|
||||
@include animation(locationSearchSlideOutUp 0.2s ease-out forwards);
|
||||
}
|
||||
}
|
||||
|
||||
&.closing:not(.flipped) {
|
||||
@include animation(locationSearchSlideOut 0.2s ease-out forwards);
|
||||
}
|
||||
|
||||
&::-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-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;
|
||||
}
|
||||
|
||||
&.focused {
|
||||
background: t($modal-sidebarActive);
|
||||
border-left: 2px solid t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-option-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-search-option-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.location-search-option-detail {
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-option-check {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
|
||||
@include themed {
|
||||
background: t($link);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .location-search-option-check {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.location-search-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/LocationSearch/index.jsx
Normal file
1
src/components/Form/Settings/LocationSearch/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './LocationSearch';
|
||||
@@ -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 && (
|
||||
<div className="radio-header">
|
||||
<label className="radio-header-label">{props.title}</label>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
162
src/components/Form/Settings/Radio/Radio.scss
Normal file
162
src/components/Form/Settings/Radio/Radio.scss
Normal file
@@ -0,0 +1,162 @@
|
||||
@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%;
|
||||
|
||||
.radio-header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.radio-header-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.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,23 @@
|
||||
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 [hoverValue, setHoverValue] = useState(null);
|
||||
const [hoverPosition, setHoverPosition] = useState(0);
|
||||
const animationRef = useRef(null);
|
||||
const sliderRef = 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 +26,138 @@ 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 handleMouseMove = useCallback(
|
||||
(e) => {
|
||||
if (!sliderRef.current || props.disabled) return;
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
|
||||
const range = Number(props.max) - Number(props.min);
|
||||
const rawValue = (percentage / 100) * range + Number(props.min);
|
||||
const step = Number(props.step) || 1;
|
||||
const snappedValue = Math.round(rawValue / step) * step;
|
||||
const clampedValue = Math.max(Number(props.min), Math.min(Number(props.max), snappedValue));
|
||||
|
||||
setHoverPosition(percentage);
|
||||
setHoverValue(clampedValue);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverValue(null);
|
||||
}, []);
|
||||
|
||||
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" onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave}>
|
||||
<input
|
||||
ref={sliderRef}
|
||||
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}
|
||||
/>
|
||||
{hoverValue !== null && !props.disabled && (
|
||||
<>
|
||||
<div className="slider-hover-indicator" style={{ left: `${hoverPosition}%` }} />
|
||||
<div className="slider-hover-tooltip" style={{ left: `${hoverPosition}%` }}>
|
||||
{hoverValue}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
239
src/components/Form/Settings/Slider/Slider.scss
Normal file
239
src/components/Form/Settings/Slider/Slider.scss
Normal file
@@ -0,0 +1,239 @@
|
||||
@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;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.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: all 0.2s 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%
|
||||
);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
height: 8px;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
&::-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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-hover-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.6;
|
||||
|
||||
@include themed {
|
||||
background: t($link);
|
||||
box-shadow: 0 0 8px rgba(t($link), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.slider-hover-tooltip {
|
||||
position: absolute;
|
||||
top: -35px;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
|
||||
@include themed {
|
||||
background: t($link);
|
||||
color: t($background);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
|
||||
@include themed {
|
||||
border-top-color: t($link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
114
src/components/Form/Settings/Text/Text.scss
Normal file
114
src/components/Form/Settings/Text/Text.scss
Normal file
@@ -0,0 +1,114 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
&.event-name-input {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.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';
|
||||
@@ -2,7 +2,10 @@ export * from './Checkbox';
|
||||
export * from './ChipSelect';
|
||||
export * from './Dropdown';
|
||||
export * from './FileUpload';
|
||||
export * from './LocationSearch';
|
||||
export * from './Radio';
|
||||
export * from './SearchInput';
|
||||
export * from './Slider';
|
||||
export * from './Switch';
|
||||
export * from './Text';
|
||||
export * from './Textarea';
|
||||
|
||||
@@ -22,24 +22,28 @@ function Header(props) {
|
||||
|
||||
const changeSetting = () => {
|
||||
const toggle = localStorage.getItem(props.setting) === 'true';
|
||||
localStorage.setItem(props.setting, !toggle);
|
||||
setSetting(!toggle);
|
||||
|
||||
variables.stats.postEvent(
|
||||
'setting',
|
||||
`${props.name} ${setting === true ? 'enabled' : 'disabled'}`,
|
||||
);
|
||||
// Small delay to let the button click animation complete
|
||||
setTimeout(() => {
|
||||
localStorage.setItem(props.setting, !toggle);
|
||||
setSetting(!toggle);
|
||||
|
||||
EventBus.emit('toggle', props.setting);
|
||||
variables.stats.postEvent(
|
||||
'setting',
|
||||
`${props.name} ${setting === true ? 'enabled' : 'disabled'}`,
|
||||
);
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
EventBus.emit('toggle', props.setting);
|
||||
|
||||
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);
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const VisibilityToggle = () => (
|
||||
|
||||
@@ -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.1';
|
||||
|
||||
76
src/config/googleFonts.json
Normal file
76
src/config/googleFonts.json
Normal file
@@ -0,0 +1,76 @@
|
||||
[
|
||||
"Lexend Deca",
|
||||
"Inter",
|
||||
"Lexend",
|
||||
"Roboto",
|
||||
"Open Sans",
|
||||
"Lato",
|
||||
"Montserrat",
|
||||
"Oswald",
|
||||
"Raleway",
|
||||
"Poppins",
|
||||
"Merriweather",
|
||||
"Nunito",
|
||||
"PT Sans",
|
||||
"Playfair Display",
|
||||
"Ubuntu",
|
||||
"Noto Sans",
|
||||
"Mukta",
|
||||
"Rubik",
|
||||
"Libre Baskerville",
|
||||
"Work Sans",
|
||||
"Quicksand",
|
||||
"Roboto Condensed",
|
||||
"Libre Franklin",
|
||||
"Karla",
|
||||
"Manrope",
|
||||
"Source Sans Pro",
|
||||
"Fira Sans",
|
||||
"DM Sans",
|
||||
"Oxygen",
|
||||
"Josefin Sans",
|
||||
"Crimson Text",
|
||||
"Arimo",
|
||||
"Barlow",
|
||||
"Cabin",
|
||||
"Inconsolata",
|
||||
"Hind",
|
||||
"Abril Fatface",
|
||||
"Bebas Neue",
|
||||
"Mulish",
|
||||
"Titillium Web",
|
||||
"Noto Serif",
|
||||
"IBM Plex Sans",
|
||||
"Heebo",
|
||||
"Archivo",
|
||||
"Bitter",
|
||||
"EB Garamond",
|
||||
"Anton",
|
||||
"Dosis",
|
||||
"Indie Flower",
|
||||
"Lobster",
|
||||
"Pacifico",
|
||||
"Shadows Into Light",
|
||||
"Dancing Script",
|
||||
"Caveat",
|
||||
"Righteous",
|
||||
"Comfortaa",
|
||||
"Amatic SC",
|
||||
"Teko",
|
||||
"Fjalla One",
|
||||
"Architects Daughter",
|
||||
"Permanent Marker",
|
||||
"Zilla Slab",
|
||||
"Fredoka",
|
||||
"Cairo",
|
||||
"Exo 2",
|
||||
"Urbanist",
|
||||
"Outfit",
|
||||
"Pathway Gothic One",
|
||||
"Yanone Kaffeesatz",
|
||||
"Red Hat Display",
|
||||
"Space Grotesk",
|
||||
"Overpass",
|
||||
"Silkscreen",
|
||||
"Sometype Mono"
|
||||
]
|
||||
@@ -3,7 +3,7 @@ import { supportsAVIF } from './avifSupport';
|
||||
import { getOfflineImage } from './offlineImage';
|
||||
import { randomColourStyleBuilder } from './randomColour';
|
||||
import videoCheck from './videoCheck';
|
||||
import { getAllBackgrounds } from 'utils/customBackgroundDB';
|
||||
import { getAllBackgrounds, getAllBackgroundsWithMetadata } from 'utils/customBackgroundDB';
|
||||
|
||||
const parseJSON = (key, fallback = null) => {
|
||||
const item = localStorage.getItem(key);
|
||||
@@ -73,9 +73,7 @@ export async function fetchAPIImageData(excludedPun = null) {
|
||||
* Gets background data based on current configuration
|
||||
*/
|
||||
export async function getBackgroundData() {
|
||||
const isOffline =
|
||||
localStorage.getItem('offlineMode') === 'true' ||
|
||||
localStorage.getItem('showWelcome') === 'true';
|
||||
const isOffline = localStorage.getItem('offlineMode') === 'true';
|
||||
|
||||
// Handle favourited background
|
||||
const fav = parseJSON('favourite');
|
||||
@@ -172,12 +170,16 @@ async function getAPIBackground(isOffline) {
|
||||
* Gets custom background
|
||||
*/
|
||||
async function getCustomBackground(isOffline) {
|
||||
// Try to get from IndexedDB first
|
||||
let backgrounds = await getAllBackgrounds();
|
||||
// Get full metadata from IndexedDB
|
||||
let backgrounds = await getAllBackgroundsWithMetadata();
|
||||
|
||||
// Fallback to localStorage if IndexedDB is empty
|
||||
// Fallback to localStorage URLs if IndexedDB is empty
|
||||
if (!backgrounds || backgrounds.length === 0) {
|
||||
backgrounds = parseJSON('customBackground', []);
|
||||
const urls = parseJSON('customBackground', []);
|
||||
if (urls && urls.length > 0) {
|
||||
// Convert old URL format to metadata format
|
||||
backgrounds = urls.map((url) => ({ url, photoInfo: { hidden: true } }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!backgrounds || backgrounds.length === 0) return null;
|
||||
@@ -187,23 +189,33 @@ async function getCustomBackground(isOffline) {
|
||||
// Check if selected is valid before using it
|
||||
if (!selected) return null;
|
||||
|
||||
if (isOffline && !selected.startsWith('data:')) return getOfflineImage('custom');
|
||||
const url = selected.url || selected;
|
||||
|
||||
if (isOffline && !url.startsWith('data:')) {
|
||||
return getOfflineImage('custom');
|
||||
}
|
||||
|
||||
const data = {
|
||||
url: selected,
|
||||
url,
|
||||
type: 'custom',
|
||||
video: videoCheck(selected),
|
||||
photoInfo: { hidden: true },
|
||||
video: videoCheck(url),
|
||||
photoInfo: {
|
||||
hidden: true,
|
||||
blur_hash: selected.blurHash || null,
|
||||
},
|
||||
};
|
||||
|
||||
// Don't store full image data in localStorage to avoid quota errors
|
||||
// Just store metadata
|
||||
try {
|
||||
localStorage.setItem('currentBackground', JSON.stringify({
|
||||
type: 'custom',
|
||||
video: data.video,
|
||||
photoInfo: data.photoInfo,
|
||||
}));
|
||||
localStorage.setItem(
|
||||
'currentBackground',
|
||||
JSON.stringify({
|
||||
type: 'custom',
|
||||
video: data.video,
|
||||
photoInfo: data.photoInfo,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
// Ignore quota errors for currentBackground
|
||||
console.warn('Could not save currentBackground to localStorage:', e);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* If the URL starts with `data:video/` or ends with `.mp4`, `.webm`, or `.ogg`, then it's a video.
|
||||
* @param url - The URL of the file to be checked.
|
||||
* @returns A function that takes a url and returns a boolean.
|
||||
* Checks if the given URL or MIME type represents a video file.
|
||||
* Supports both URLs (data:video/, .mp4, .webm, .ogg) and MIME types (video/mp4, video/webm, video/ogg).
|
||||
* @param urlOrMimeType - The URL or MIME type to check.
|
||||
* @returns true if it's a video, false otherwise.
|
||||
*/
|
||||
export default function videoCheck(url) {
|
||||
export default function videoCheck(urlOrMimeType) {
|
||||
if (!urlOrMimeType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
url.startsWith('data:video/') ||
|
||||
url.endsWith('.mp4') ||
|
||||
url.endsWith('.webm') ||
|
||||
url.endsWith('.ogg')
|
||||
urlOrMimeType.startsWith('data:video/') ||
|
||||
urlOrMimeType.startsWith('video/') ||
|
||||
urlOrMimeType.endsWith('.mp4') ||
|
||||
urlOrMimeType.endsWith('.webm') ||
|
||||
urlOrMimeType.endsWith('.ogg')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import PhotoInformation from './PhotoInformation';
|
||||
import variables from 'config/variables';
|
||||
import { updateHash } from 'utils/deepLinking';
|
||||
import EventBus from 'utils/eventbus';
|
||||
import { getAllBackgrounds } from 'utils/customBackgroundDB';
|
||||
|
||||
/**
|
||||
* BackgroundImage component for rendering image backgrounds
|
||||
*/
|
||||
function BackgroundImage({ photoInfo, currentAPI, url }) {
|
||||
const isCustomType = localStorage.getItem('backgroundType') === 'custom';
|
||||
const customBackgrounds = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('customBackground');
|
||||
return stored && stored !== 'null' ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const hasNoCustomImages = isCustomType && (!customBackgrounds || customBackgrounds.length === 0);
|
||||
const [customBackgrounds, setCustomBackgrounds] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCustomBackgrounds = async () => {
|
||||
if (isCustomType) {
|
||||
try {
|
||||
const backgrounds = await getAllBackgrounds();
|
||||
setCustomBackgrounds(backgrounds || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom backgrounds:', error);
|
||||
setCustomBackgrounds([]);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadCustomBackgrounds();
|
||||
}, [isCustomType]);
|
||||
|
||||
const hasNoCustomImages =
|
||||
isCustomType && !isLoading && (!customBackgrounds || customBackgrounds.length === 0);
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
updateHash('#settings/background/source');
|
||||
@@ -28,21 +42,24 @@ function BackgroundImage({ photoInfo, currentAPI, url }) {
|
||||
<>
|
||||
<div id="backgroundImage" />
|
||||
{hasNoCustomImages && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
color: 'white',
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
padding: '20px 30px',
|
||||
borderRadius: '10px',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
color: 'white',
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
padding: '20px 30px',
|
||||
borderRadius: '10px',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: '0 0 10px 0', fontSize: '20px' }}>
|
||||
{variables.getMessage('widgets.background.no_images_title') || 'No Custom Images'}
|
||||
</h2>
|
||||
<p style={{ margin: '0 0 15px 0', fontSize: '14px', opacity: 0.9 }}>
|
||||
{variables.getMessage('widgets.background.no_images_description') || 'Please add custom images in the Background settings'}
|
||||
{variables.getMessage('widgets.background.no_images_description') ||
|
||||
'Please add custom images in the Background settings'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleOpenSettings}
|
||||
@@ -68,9 +85,7 @@ function BackgroundImage({ photoInfo, currentAPI, url }) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{photoInfo?.credit && (
|
||||
<PhotoInformation info={photoInfo} api={currentAPI} url={url} />
|
||||
)}
|
||||
{photoInfo?.credit && <PhotoInformation info={photoInfo} api={currentAPI} url={url} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@ import { getBackgroundFilterStyle, getBackgroundOverlayStyle } from '../api/back
|
||||
export function useBackgroundEvents(backgroundData, refreshBackground) {
|
||||
useEffect(() => {
|
||||
const handleEvent = (event) => {
|
||||
if (event === 'welcomeLanguage') {
|
||||
localStorage.setItem('welcomeImage', JSON.stringify(backgroundData));
|
||||
} else if (event === 'background') {
|
||||
if (event === 'background') {
|
||||
handleVisibilityToggle();
|
||||
} else if (['marketplacebackgrounduninstall', 'backgroundwelcome', 'backgroundrefresh'].includes(event)) {
|
||||
refreshBackground();
|
||||
|
||||
@@ -12,16 +12,6 @@ export function useBackgroundLoader(updateBackground, resetBackground) {
|
||||
isLoadingRef.current = true;
|
||||
|
||||
try {
|
||||
// Check for welcome tab first
|
||||
const welcomeTab = localStorage.getItem('welcomeTab');
|
||||
if (welcomeTab) {
|
||||
const welcomeImage = localStorage.getItem('welcomeImage');
|
||||
if (welcomeImage) {
|
||||
updateBackground(JSON.parse(welcomeImage));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await getBackgroundData();
|
||||
if (data) {
|
||||
updateBackground(data);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
65
src/features/background/options/FolderTaggingModal.jsx
Normal file
65
src/features/background/options/FolderTaggingModal.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import variables from 'config/variables';
|
||||
import { MdClose } from 'react-icons/md';
|
||||
import { Button } from 'components/Elements';
|
||||
|
||||
const FolderTaggingModal = ({ files, onConfirm, onCancel }) => {
|
||||
const [folderName, setFolderName] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(folderName.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="smallModal">
|
||||
<div className="shareHeader">
|
||||
<span className="title">
|
||||
{variables.getMessage('modals.main.settings.sections.background.source.tag_images')}
|
||||
</span>
|
||||
<button className="closeModal" onClick={onCancel}>
|
||||
<MdClose />
|
||||
</button>
|
||||
</div>
|
||||
<div className="taggingModalContent">
|
||||
<p className="subtitle">
|
||||
{variables.getMessage('modals.main.settings.sections.background.source.tag_description', {
|
||||
count: files.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="taggingInput">
|
||||
<label>
|
||||
{variables.getMessage('modals.main.settings.sections.background.source.folder_name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={variables.getMessage(
|
||||
'modals.main.settings.sections.background.source.folder_placeholder',
|
||||
)}
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resetFooter">
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={onCancel}
|
||||
label={variables.getMessage('modals.main.settings.buttons.cancel')}
|
||||
/>
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={handleConfirm}
|
||||
label={variables.getMessage('modals.main.settings.buttons.continue')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderTaggingModal;
|
||||
@@ -106,7 +106,8 @@ const Greeting = () => {
|
||||
if (birth.getDate() === now.getDate() && birth.getMonth() === now.getMonth()) {
|
||||
if (localStorage.getItem('birthdayage') === 'true' && calculateAge(birth) !== 0) {
|
||||
const text = t('widgets.greeting.birthday').split(' ');
|
||||
message = `${text[0]} ${nth(calculateAge(birth))} ${text[1]}`;
|
||||
const lang = variables.languagecode.split('_')[0];
|
||||
message = `${text[0]} ${nth(calculateAge(birth), lang)} ${text[1]}`;
|
||||
} else {
|
||||
message = t('widgets.greeting.birthday');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Section,
|
||||
} from 'components/Layout/Settings';
|
||||
import { Checkbox, Switch, Text } from 'components/Form/Settings';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
import { DatePicker } from 'components/Form/Settings/DatePicker';
|
||||
import { Button } from 'components/Elements';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -147,11 +147,12 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
|
||||
<p style={{ marginRight: 'auto' }}>
|
||||
{variables.getMessage(`${GREETING_SECTION}.birthday_date`)}
|
||||
</p>
|
||||
<input
|
||||
type="date"
|
||||
onChange={changeDate}
|
||||
value={birthday.toISOString().substring(0, 10)}
|
||||
required
|
||||
<DatePicker
|
||||
value={birthday}
|
||||
onChange={(newDate) => {
|
||||
localStorage.setItem('birthday', newDate);
|
||||
setBirthday(newDate);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Action>
|
||||
@@ -192,47 +193,32 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
|
||||
<span className="subtitle">
|
||||
{variables.getMessage(`${GREETING_SECTION}.event_name`)}
|
||||
</span>
|
||||
<TextareaAutosize
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input event-name-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>
|
||||
<div>
|
||||
<div className="messageAction">
|
||||
<div className="eventDateSelection">
|
||||
<label className="subtitle">
|
||||
{variables.getMessage(`${GREETING_SECTION}.day`)}:
|
||||
</label>
|
||||
<input
|
||||
id="day"
|
||||
type="tel"
|
||||
value={event.date}
|
||||
onChange={(e) => {
|
||||
const updatedEvent = { ...event, date: parseInt(e.target.value, 10) };
|
||||
updateEvent(index, updatedEvent);
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
<label className="subtitle">
|
||||
{variables.getMessage(`${GREETING_SECTION}.month`)}:
|
||||
</label>
|
||||
<input
|
||||
id="month"
|
||||
type="tel"
|
||||
value={event.month}
|
||||
onChange={(e) => {
|
||||
const updatedEvent = { ...event, month: parseInt(e.target.value, 10) };
|
||||
updateEvent(index, updatedEvent);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DatePicker
|
||||
value={new Date(2000, event.month - 1, event.date)}
|
||||
hideYear={true}
|
||||
onChange={(newDate) => {
|
||||
const updatedEvent = {
|
||||
...event,
|
||||
month: newDate.getMonth() + 1,
|
||||
date: newDate.getDate(),
|
||||
};
|
||||
updateEvent(index, updatedEvent);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => removeEvent(index)}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import variables from 'config/variables';
|
||||
import React, { memo, useState, useMemo } from 'react';
|
||||
import { MdAutoFixHigh, MdOutlineArrowForward, MdOutlineOpenInNew, MdCheckCircle } from 'react-icons/md';
|
||||
import {
|
||||
MdCheckCircle,
|
||||
MdOutlineUploadFile,
|
||||
MdClose,
|
||||
} from 'react-icons/md';
|
||||
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
|
||||
|
||||
import { Button } from 'components/Elements';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import Dropdown from '../../../../components/Form/Settings/Dropdown/Dropdown';
|
||||
|
||||
function filterItems(item, filter, categoryFilter) {
|
||||
@@ -28,73 +32,86 @@ 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
|
||||
const hexToRgb = (hex) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const getGradientStyle = () => {
|
||||
if (!item.colour) return {};
|
||||
|
||||
const rgb = hexToRgb(item.colour);
|
||||
if (!rgb) return {};
|
||||
|
||||
const baseColor = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
|
||||
|
||||
return {
|
||||
'--item-gradient0': `rgba(${baseColor}, 0.38)`,
|
||||
'--item-gradient10': `rgba(${baseColor}, 0.35)`,
|
||||
'--item-gradient75': `rgba(${baseColor}, 0.14)`,
|
||||
'--item-gradient100': `rgba(${baseColor}, 0.06)`,
|
||||
backgroundImage: `radial-gradient(circle at center 25%, var(--item-gradient0) 0%, var(--item-gradient10) 10%, var(--item-gradient75) 75%, var(--item-gradient100) 100%)`,
|
||||
};
|
||||
};
|
||||
|
||||
const getBadgeStyle = () => {
|
||||
if (!item.colour) return {};
|
||||
|
||||
const rgb = hexToRgb(item.colour);
|
||||
if (!rgb) return {};
|
||||
|
||||
const baseColor = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
|
||||
|
||||
return {
|
||||
backgroundColor: `rgba(${baseColor}, 0.9)`,
|
||||
};
|
||||
};
|
||||
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 && (
|
||||
<div className="item-installed-badge" style={getBadgeStyle()}>
|
||||
{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: isAdded ? '48px' : '12px', zIndex: 2 }}
|
||||
>
|
||||
<div className="item-sideload-badge">
|
||||
<MdOutlineUploadFile />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isInstalled && item.colour && !isSideloaded && !isAdded && (
|
||||
<div className="item-installed-badge">
|
||||
<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 +123,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>
|
||||
@@ -127,15 +141,14 @@ function Items({
|
||||
isCurator,
|
||||
type,
|
||||
items,
|
||||
collection,
|
||||
toggleFunction,
|
||||
collectionFunction,
|
||||
onCollection,
|
||||
filter,
|
||||
moreByCreator,
|
||||
showCreateYourOwn,
|
||||
filterOptions = false,
|
||||
onSortChange,
|
||||
isAdded = false,
|
||||
onUninstall,
|
||||
viewType = 'grid',
|
||||
}) {
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [sortType, setSortType] = useState(localStorage.getItem('sortMarketplace') || 'a-z');
|
||||
@@ -161,48 +174,8 @@ function Items({
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowCollection =
|
||||
((collection && !onCollection && (filter === null || filter === '')) ||
|
||||
(type === 'collections' && !onCollection && (filter === null || filter === ''))) &&
|
||||
type !== 'preset_settings';
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldShowCollection && (
|
||||
<div
|
||||
className="collection"
|
||||
style={
|
||||
collection?.news
|
||||
? { backgroundColor: collection?.background_colour }
|
||||
: {
|
||||
backgroundImage: `linear-gradient(to right, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.7), transparent, rgba(0, 0, 0, 0.7), rgba(0 ,0, 0, 0.9)), url('${collection?.img}')`,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="content">
|
||||
<span className="title">{collection?.display_name}</span>
|
||||
<span className="subtitle">{collection?.description}</span>
|
||||
</div>
|
||||
{collection?.news === true ? (
|
||||
<a
|
||||
className="btn-collection"
|
||||
href={collection?.news_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{variables.getMessage('modals.main.marketplace.learn_more')} <MdOutlineOpenInNew />
|
||||
</a>
|
||||
) : (
|
||||
<Button
|
||||
type="collection"
|
||||
onClick={() => collectionFunction(collection?.name)}
|
||||
icon={<MdOutlineArrowForward />}
|
||||
label={variables.getMessage('modals.main.marketplace.explore_collection')}
|
||||
iconPlacement={'right'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Items Filter Options */}
|
||||
{filterOptions && (
|
||||
<div className="filter-options-container">
|
||||
@@ -228,7 +201,7 @@ function Items({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`items ${moreByCreator ? 'creatorItems' : ''}`}>
|
||||
<div className={`items ${viewType === 'list' ? 'items-list' : 'items-grid'}`}>
|
||||
{items
|
||||
?.filter((item) => filterItems(item, filter, filterOptions ? selectedCategory : 'all'))
|
||||
.map((item, index) => (
|
||||
@@ -239,29 +212,13 @@ function Items({
|
||||
type={type}
|
||||
onCollection={onCollection}
|
||||
isInstalled={installedNames.has(item.name)}
|
||||
isAdded={isAdded}
|
||||
onUninstall={onUninstall}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="loader"></div>
|
||||
{!onCollection && showCreateYourOwn ? (
|
||||
<div className="createYourOwn">
|
||||
<MdAutoFixHigh />
|
||||
<span className="title">{variables.getMessage('modals.main.marketplace.cant_find')}</span>
|
||||
<span className="subtitle">
|
||||
{variables.getMessage('modals.main.marketplace.knowledgebase_one') + ' '}
|
||||
<a
|
||||
className="link"
|
||||
target="_blank"
|
||||
href={variables.constants.KNOWLEDGEBASE}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{variables.getMessage('modals.main.marketplace.knowledgebase_two')}
|
||||
</a>
|
||||
{' ' + variables.getMessage('modals.main.marketplace.knowledgebase_three')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const useMarketplaceInstall = () => {
|
||||
toast(variables.getMessage('toasts.installed'));
|
||||
variables.stats.postEvent('marketplace-item', `${data.display_name || data.name} installed`);
|
||||
variables.stats.postEvent('marketplace', 'Install');
|
||||
window.dispatchEvent(new Event('installedAddonsChanged'));
|
||||
};
|
||||
|
||||
const uninstallItem = (type, name) => {
|
||||
@@ -21,6 +22,7 @@ export const useMarketplaceInstall = () => {
|
||||
toast(variables.getMessage('toasts.uninstalled'));
|
||||
variables.stats.postEvent('marketplace-item', `${name} uninstalled`);
|
||||
variables.stats.postEvent('marketplace', 'Uninstall');
|
||||
window.dispatchEvent(new Event('installedAddonsChanged'));
|
||||
};
|
||||
|
||||
const installCollection = async (items) => {
|
||||
@@ -51,6 +53,7 @@ export const useMarketplaceInstall = () => {
|
||||
}
|
||||
|
||||
toast(variables.getMessage('toasts.installed'));
|
||||
window.dispatchEvent(new Event('installedAddonsChanged'));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
if (!controllerRef.current.signal.aborted) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { MdUpdate, MdOutlineExtensionOff, MdSendTimeExtension, MdExplore } from 'react-icons/md';
|
||||
import { MdUpdate, MdOutlineExtensionOff, MdSendTimeExtension, MdExplore, MdViewModule, MdViewList } from 'react-icons/md';
|
||||
import { toast } from 'react-toastify';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
@@ -17,6 +17,7 @@ const Added = memo(() => {
|
||||
const [installed, setInstalled] = useState(JSON.parse(localStorage.getItem('installed')));
|
||||
const [showFailed, setShowFailed] = useState(false);
|
||||
const [failedReason, setFailedReason] = useState('');
|
||||
const [viewType, setViewType] = useState(localStorage.getItem('addonsViewType') || 'grid');
|
||||
|
||||
const installAddon = useCallback((input) => {
|
||||
let failedReasonText = '';
|
||||
@@ -55,6 +56,7 @@ const Added = memo(() => {
|
||||
toast(variables.getMessage('toasts.installed'));
|
||||
variables.stats.postEvent('marketplace', 'Sideload');
|
||||
setInstalled(JSON.parse(localStorage.getItem('installed')));
|
||||
window.dispatchEvent(new Event('installedAddonsChanged'));
|
||||
}, []);
|
||||
|
||||
const getSideloadButton = useCallback(() => {
|
||||
@@ -84,27 +86,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;
|
||||
@@ -152,8 +151,16 @@ const Added = memo(() => {
|
||||
localStorage.setItem('installed', JSON.stringify([]));
|
||||
toast(variables.getMessage('toasts.uninstalled_all'));
|
||||
setInstalled([]);
|
||||
window.dispatchEvent(new Event('installedAddonsChanged'));
|
||||
}, [installed]);
|
||||
|
||||
const handleUninstall = useCallback((type, name) => {
|
||||
uninstall(type, name);
|
||||
toast(variables.getMessage('toasts.uninstalled'));
|
||||
setInstalled(JSON.parse(localStorage.getItem('installed')));
|
||||
window.dispatchEvent(new Event('installedAddonsChanged'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sortAddons(localStorage.getItem('sortAddons'), false);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -189,6 +196,11 @@ const Added = memo(() => {
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
|
||||
const toggleViewType = useCallback((type) => {
|
||||
setViewType(type);
|
||||
localStorage.setItem('addonsViewType', type);
|
||||
}, []);
|
||||
|
||||
if (installed.length === 0) {
|
||||
return (
|
||||
<>
|
||||
@@ -237,23 +249,42 @@ const Added = memo(() => {
|
||||
/>
|
||||
</CustomActions>
|
||||
</Header>
|
||||
<Dropdown
|
||||
label={variables.getMessage('modals.main.addons.sort.title')}
|
||||
name="sortAddons"
|
||||
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') },
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '15px', marginBottom: '15px' }}>
|
||||
<Dropdown
|
||||
label={variables.getMessage('modals.main.addons.sort.title')}
|
||||
name="sortAddons"
|
||||
onChange={(value) => sortAddons(value)}
|
||||
items={[
|
||||
{ value: 'newest', text: variables.getMessage('modals.main.addons.sort.newest') },
|
||||
{ value: 'a-z', text: variables.getMessage('modals.main.addons.sort.a_z') },
|
||||
{ value: 'recently-updated', text: 'Recently Updated' },
|
||||
]}
|
||||
/>
|
||||
<div className="view-toggle-buttons">
|
||||
<button
|
||||
className={`view-toggle-btn ${viewType === 'grid' ? 'active' : ''}`}
|
||||
onClick={() => toggleViewType('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<MdViewModule />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn ${viewType === 'list' ? 'active' : ''}`}
|
||||
onClick={() => toggleViewType('list')}
|
||||
aria-label="List view"
|
||||
>
|
||||
<MdViewList />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Items
|
||||
items={installed}
|
||||
isAdded={true}
|
||||
filter=""
|
||||
toggleFunction={(input) => toggle('item', input)}
|
||||
showCreateYourOwn={false}
|
||||
onUninstall={handleUninstall}
|
||||
viewType={viewType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,46 @@ 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);
|
||||
window.dispatchEvent(new Event('installedAddonsChanged'));
|
||||
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 +97,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,17 +122,21 @@ const Modals = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeWelcome = () => {
|
||||
const closeWelcome = async () => {
|
||||
localStorage.setItem('showWelcome', false);
|
||||
localStorage.setItem('justCompletedWelcome', 'true');
|
||||
setWelcomeModal(false);
|
||||
|
||||
await tryInstallDefaultPack();
|
||||
|
||||
EventBus.emit('refresh', 'widgetsWelcomeDone');
|
||||
EventBus.emit('refresh', 'widgets');
|
||||
EventBus.emit('refresh', 'backgroundwelcome');
|
||||
};
|
||||
|
||||
const previewWelcome = () => {
|
||||
localStorage.setItem('showWelcome', false);
|
||||
localStorage.setItem('welcomePreview', true);
|
||||
localStorage.setItem('justCompletedWelcome', 'true');
|
||||
setWelcomeModal(false);
|
||||
setPreview(true);
|
||||
EventBus.emit('refresh', 'widgetsWelcome');
|
||||
@@ -132,7 +182,7 @@ const Modals = () => {
|
||||
onRequestClose={() => closeWelcome()}
|
||||
isOpen={welcomeModal}
|
||||
className="Modal welcomemodal mainModal"
|
||||
overlayClassName="Overlay mainModal"
|
||||
overlayClassName="Overlay welcomeOverlay"
|
||||
shouldCloseOnOverlayClick={false}
|
||||
ariaHideApp={false}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { BiDonateHeart } from 'react-icons/bi';
|
||||
import { Tooltip, Button } from 'components/Elements';
|
||||
import other_contributors from 'utils/data/other_contributors.json';
|
||||
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
|
||||
class About extends PureComponent {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -142,7 +144,7 @@ class About extends PureComponent {
|
||||
alt="Logo"
|
||||
/>
|
||||
<div className="aboutText">
|
||||
<span className="title">Mue</span>
|
||||
<span className="title">{variables.getMessage('branding.name')}</span>
|
||||
<span className="subtitle">
|
||||
{variables.getMessage('modals.main.settings.sections.about.version.title')}{' '}
|
||||
{variables.constants.VERSION}
|
||||
|
||||
@@ -139,6 +139,7 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
|
||||
<Dropdown
|
||||
name="timezone"
|
||||
category="timezone"
|
||||
searchable={true}
|
||||
items={[
|
||||
{
|
||||
value: 'auto',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useState } from 'react';
|
||||
|
||||
import variables from 'config/variables';
|
||||
import googleFonts from 'config/googleFonts.json';
|
||||
|
||||
import { Checkbox, Dropdown, Radio, Slider, Text } from 'components/Form/Settings';
|
||||
import { Header, Section, Row, Content, Action } from 'components/Layout/Settings';
|
||||
@@ -10,7 +11,6 @@ import { MdAccessibility } from 'react-icons/md';
|
||||
import values from 'utils/data/slider_values.json';
|
||||
|
||||
function AppearanceOptions({ currentSubSection, onSubSectionChange, sectionName }) {
|
||||
|
||||
const ThemeSelection = () => {
|
||||
return (
|
||||
<Row>
|
||||
@@ -55,16 +55,15 @@ function AppearanceOptions({ currentSubSection, onSubSectionChange, sectionName
|
||||
)}
|
||||
/>
|
||||
<Action>
|
||||
<Checkbox
|
||||
name="fontGoogle"
|
||||
text={variables.getMessage('modals.main.settings.sections.appearance.font.google')}
|
||||
category="other"
|
||||
/>
|
||||
<Text
|
||||
title={variables.getMessage('modals.main.settings.sections.appearance.font.custom')}
|
||||
<Dropdown
|
||||
label={variables.getMessage('modals.main.settings.sections.appearance.font.custom')}
|
||||
name="font"
|
||||
upperCaseFirst={true}
|
||||
category="other"
|
||||
searchable={true}
|
||||
items={googleFonts.map((font) => ({
|
||||
value: font,
|
||||
text: font,
|
||||
}))}
|
||||
/>
|
||||
{/* names are taken from https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight */}
|
||||
<Dropdown
|
||||
@@ -74,6 +73,10 @@ function AppearanceOptions({ currentSubSection, onSubSectionChange, sectionName
|
||||
name="fontweight"
|
||||
category="other"
|
||||
items={[
|
||||
{
|
||||
value: '400',
|
||||
text: variables.getMessage(fontWeight + '.normal'),
|
||||
},
|
||||
{
|
||||
value: '100',
|
||||
text: variables.getMessage(fontWeight + '.thin'),
|
||||
@@ -86,10 +89,6 @@ function AppearanceOptions({ currentSubSection, onSubSectionChange, sectionName
|
||||
value: '300',
|
||||
text: variables.getMessage(fontWeight + '.light'),
|
||||
},
|
||||
{
|
||||
value: '400',
|
||||
text: variables.getMessage(fontWeight + '.normal'),
|
||||
},
|
||||
{
|
||||
value: '500',
|
||||
text: variables.getMessage(fontWeight + '.medium'),
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -19,8 +19,9 @@ const Weather = lazy(() => import('../../weather/Weather'));
|
||||
|
||||
const Widgets = () => {
|
||||
const online = localStorage.getItem('offlineMode') === 'false';
|
||||
const [order, setOrder] = useState(JSON.parse(localStorage.getItem('order')));
|
||||
const [order, setOrder] = useState(JSON.parse(localStorage.getItem('order')) || []);
|
||||
const [welcome, setWelcome] = useState(localStorage.getItem('showWelcome'));
|
||||
const [fadeIn, setFadeIn] = useState(false);
|
||||
|
||||
const enabled = (key) => {
|
||||
return localStorage.getItem(key) === 'true';
|
||||
@@ -33,7 +34,7 @@ const Widgets = () => {
|
||||
greeting: enabled('greeting') && <Greeting />,
|
||||
quote: enabled('quote') && <Quote />,
|
||||
date: enabled('date') && <Date />,
|
||||
quicklinks: enabled('quicklinksenabled') && online ? <QuickLinks /> : null,
|
||||
quicklinks: enabled('quicklinksenabled') && <QuickLinks />,
|
||||
message: enabled('message') && <Message />,
|
||||
}),
|
||||
[order], // Re-create widgets when order changes
|
||||
@@ -43,7 +44,7 @@ const Widgets = () => {
|
||||
const handleRefresh = (data) => {
|
||||
switch (data) {
|
||||
case 'widgets':
|
||||
return setOrder(JSON.parse(localStorage.getItem('order')));
|
||||
return setOrder(JSON.parse(localStorage.getItem('order')) || []);
|
||||
case 'widgetsWelcome':
|
||||
setWelcome(localStorage.getItem('showWelcome'));
|
||||
localStorage.setItem('showWelcome', true);
|
||||
@@ -54,6 +55,15 @@ const Widgets = () => {
|
||||
case 'widgetsWelcomeDone':
|
||||
setWelcome(localStorage.getItem('showWelcome'));
|
||||
window.onbeforeunload = null;
|
||||
|
||||
// Check if user just completed welcome
|
||||
if (localStorage.getItem('justCompletedWelcome') === 'true') {
|
||||
setFadeIn(true);
|
||||
// Clear the flag after animations complete
|
||||
setTimeout(() => {
|
||||
localStorage.removeItem('justCompletedWelcome');
|
||||
}, 2000);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -66,11 +76,15 @@ const Widgets = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// don't show when welcome is there
|
||||
return welcome !== 'false' ? (
|
||||
<WidgetsLayout />
|
||||
) : (
|
||||
<WidgetsLayout>
|
||||
// Determine class based on state
|
||||
const getClassName = () => {
|
||||
if (welcome !== 'false') return 'behind-welcome';
|
||||
if (fadeIn) return 'fade-in-widgets';
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<WidgetsLayout className={getClassName()}>
|
||||
<Suspense fallback={<></>}>
|
||||
{enabled('searchBar') && <Search />}
|
||||
{order.map((element, key) => (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -32,7 +32,7 @@ const QuickLinks = memo(() => {
|
||||
link.style.fontSize = `${14 * Number(zoom / 100)}px`;
|
||||
}
|
||||
|
||||
if (localStorage.getItem('quickLinksStyle') !== 'text') {
|
||||
if (localStorage.getItem('quickLinksStyle') !== 'text_only') {
|
||||
for (const img of element.getElementsByTagName('img')) {
|
||||
img.style.height = `${30 * Number(zoom / 100)}px`;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const QuickLinks = memo(() => {
|
||||
const tooltipEnabled = localStorage.getItem('quicklinkstooltip');
|
||||
|
||||
const quickLink = (item, index) => {
|
||||
if (localStorage.getItem('quickLinksStyle') === 'text') {
|
||||
if (localStorage.getItem('quickLinksStyle') === 'text_only') {
|
||||
return (
|
||||
<a
|
||||
className="quicklinkstext"
|
||||
|
||||
@@ -145,6 +145,10 @@
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
@@ -288,7 +292,8 @@ button.quicklinks {
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
min-width: 100px;
|
||||
background-image: linear-gradient(to left, rgb(0 0 0), transparent, rgb(0 0 0)),
|
||||
background-image:
|
||||
linear-gradient(to left, rgb(0 0 0), transparent, rgb(0 0 0)),
|
||||
url('https://media.cntraveller.com/photos/615ee85…/16:9/w_2580,c_limit/Best%20Cities%20in%20the%20World%20-%20Grid.jpg');
|
||||
transition: 0.8s;
|
||||
text-align: left;
|
||||
@@ -605,7 +610,7 @@ button.quicklinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
.quicklink-wrapper .quicklinkstext {
|
||||
@@ -615,6 +620,10 @@ button.quicklinks {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -1,58 +1,60 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MdContentCopy, MdStarBorder, MdStar, MdIosShare } from 'react-icons/md';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { useT } from 'contexts';
|
||||
import variables from 'config/variables';
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
/**
|
||||
* Quote action buttons component
|
||||
*/
|
||||
export default function QuoteButtons({
|
||||
onCopy,
|
||||
onFavourite,
|
||||
onShare,
|
||||
isFavourited,
|
||||
}) {
|
||||
export default function QuoteButtons({ onCopy, onFavourite, onShare, isFavourited }) {
|
||||
const t = useT();
|
||||
const showCopy = localStorage.getItem('copyButton') !== 'false';
|
||||
const showShare = localStorage.getItem('quoteShareButton') !== 'false';
|
||||
const showFavourite = localStorage.getItem('favouriteQuoteEnabled') === 'true';
|
||||
const [showCopy, setShowCopy] = useState(localStorage.getItem('copyButton') !== 'false');
|
||||
const [showShare, setShowShare] = useState(localStorage.getItem('quoteShareButton') !== 'false');
|
||||
const [showFavourite, setShowFavourite] = useState(
|
||||
localStorage.getItem('favouriteQuoteEnabled') === 'true',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRefresh = (data) => {
|
||||
if (data === 'quote') {
|
||||
setShowCopy(localStorage.getItem('copyButton') !== 'false');
|
||||
setShowShare(localStorage.getItem('quoteShareButton') !== 'false');
|
||||
setShowFavourite(localStorage.getItem('favouriteQuoteEnabled') === 'true');
|
||||
}
|
||||
};
|
||||
|
||||
EventBus.on('refresh', handleRefresh);
|
||||
return () => {
|
||||
EventBus.off('refresh', handleRefresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCopy && (
|
||||
<Tooltip title={t('widgets.quote.copy')}>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
aria-label={t('widgets.quote.copy')}
|
||||
>
|
||||
<button onClick={onCopy} aria-label={t('widgets.quote.copy')}>
|
||||
<MdContentCopy className="copyButton" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showShare && (
|
||||
<Tooltip title={t('widgets.quote.share')}>
|
||||
<button
|
||||
onClick={onShare}
|
||||
aria-label={t('widgets.quote.share')}
|
||||
>
|
||||
<button onClick={onShare} aria-label={t('widgets.quote.share')}>
|
||||
<MdIosShare className="copyButton" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showFavourite && (
|
||||
<Tooltip
|
||||
title={
|
||||
isFavourited
|
||||
? t('widgets.quote.unfavourite')
|
||||
: t('widgets.quote.favourite')
|
||||
}
|
||||
title={isFavourited ? t('widgets.quote.unfavourite') : t('widgets.quote.favourite')}
|
||||
>
|
||||
<button
|
||||
onClick={onFavourite}
|
||||
aria-label={
|
||||
isFavourited
|
||||
? t('widgets.quote.unfavourite')
|
||||
: t('widgets.quote.favourite')
|
||||
isFavourited ? t('widgets.quote.unfavourite') : t('widgets.quote.favourite')
|
||||
}
|
||||
>
|
||||
{isFavourited ? (
|
||||
|
||||
@@ -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,23 @@ 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;
|
||||
});
|
||||
|
||||
// Migration: Force authorDetails on for users upgrading from older versions
|
||||
useState(() => {
|
||||
if (localStorage.getItem('authorDetails') === null) {
|
||||
localStorage.setItem('authorDetails', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
const [customQuote, setCustomQuote] = useState(getCustom());
|
||||
|
||||
const handleCustomQuote = (e, text, index, type) => {
|
||||
@@ -93,10 +108,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 +173,19 @@ 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',
|
||||
)}
|
||||
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',
|
||||
)}
|
||||
placeholder={variables.getMessage('modals.main.settings.sections.quote.author')}
|
||||
className="subtitle"
|
||||
onChange={(e) => handleCustomQuote(e, true, index, 'author')}
|
||||
varient="outlined"
|
||||
minRows={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -187,9 +194,7 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
|
||||
type="settings"
|
||||
onClick={() => modifyCustomQuote('remove', index)}
|
||||
icon={<MdCancel />}
|
||||
label={variables.getMessage(
|
||||
'modals.main.marketplace.product.buttons.remove',
|
||||
)}
|
||||
label={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
|
||||
/>
|
||||
</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);
|
||||
|
||||
@@ -29,11 +29,13 @@ const DateWidget = () => {
|
||||
dateToday.setMonth(0, 1 + ((4 - dateToday.getDay() + 7) % 7));
|
||||
}
|
||||
|
||||
setWeekNumber(
|
||||
`${variables.getMessage('widgets.date.week')} ${
|
||||
1 + Math.ceil((firstThursday - dateToday) / 604800000)
|
||||
}`,
|
||||
);
|
||||
const weekLabel = variables.getMessage('widgets.date.week');
|
||||
const weekNum = 1 + Math.ceil((firstThursday - dateToday) / 604800000);
|
||||
// Support {number} placeholder for locales that need different word order (e.g., Turkish: "{number}. Hafta")
|
||||
const weekText = weekLabel.includes('{number}')
|
||||
? weekLabel.replace('{number}', weekNum)
|
||||
: `${weekLabel} ${weekNum}`;
|
||||
setWeekNumber(weekText);
|
||||
};
|
||||
|
||||
const getDate = () => {
|
||||
@@ -97,7 +99,7 @@ const DateWidget = () => {
|
||||
// Long date
|
||||
const lang = variables.languagecode.split('_')[0];
|
||||
const datenth =
|
||||
localStorage.getItem('datenth') === 'true' ? nth(date.getDate()) : date.getDate();
|
||||
localStorage.getItem('datenth') === 'true' ? nth(date.getDate(), lang) : date.getDate();
|
||||
const dateDay =
|
||||
localStorage.getItem('dayofweek') === 'true'
|
||||
? date.toLocaleDateString(lang, { weekday: 'long' })
|
||||
|
||||
@@ -13,7 +13,21 @@ import { getWeather } from './api/getWeather.js';
|
||||
import './weather.scss';
|
||||
|
||||
const WeatherWidget = memo(() => {
|
||||
const [location, setLocation] = useState(localStorage.getItem('location') || 'London');
|
||||
const [location, setLocation] = useState(() => {
|
||||
const stored = localStorage.getItem('location');
|
||||
if (!stored) return 'London';
|
||||
|
||||
// Try parsing as new JSON format
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Legacy string format
|
||||
}
|
||||
return stored;
|
||||
});
|
||||
const [done, setDone] = useState(false);
|
||||
const [weatherData, setWeatherData] = useState({});
|
||||
|
||||
@@ -57,10 +71,14 @@ const WeatherWidget = memo(() => {
|
||||
return <WeatherSkeleton weatherType={weatherType} />;
|
||||
}
|
||||
|
||||
// Get display name from location (handles both object and string formats)
|
||||
const locationDisplay =
|
||||
typeof location === 'object' ? location.displayName || location.name : location;
|
||||
|
||||
if (!weatherData.weather) {
|
||||
return (
|
||||
<div className="weather">
|
||||
<span className="loc">{location}</span>
|
||||
<span className="loc">{locationDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +105,7 @@ const WeatherWidget = memo(() => {
|
||||
amount: `${formatNumber(weatherData.weather.feels_like)}${weatherData.temp_text}`,
|
||||
})}
|
||||
</span>
|
||||
<span className="loc">{location}</span>
|
||||
<span className="loc">{locationDisplay}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,19 @@ export const getWeather = async (location) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(variables.constants.API_URL + `/weather?city=${location}`);
|
||||
// Build URL based on location type
|
||||
let url;
|
||||
if (typeof location === 'object' && location.lat && location.lon) {
|
||||
// New format: use coordinates (preferred)
|
||||
url = `${variables.constants.API_URL}/weather?lat=${location.lat}&lon=${location.lon}`;
|
||||
} else {
|
||||
// Legacy format: use city name string
|
||||
const cityName =
|
||||
typeof location === 'object' ? location.displayName || location.name : location;
|
||||
url = `${variables.constants.API_URL}/weather?city=${encodeURIComponent(cityName)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Weather API response not ok:', response.status, response.statusText);
|
||||
|
||||
@@ -1,61 +1,20 @@
|
||||
import { useCallback } from 'react';
|
||||
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 { Radio, Dropdown, Checkbox, LocationSearch } from 'components/Form/Settings';
|
||||
import variables from 'config/variables';
|
||||
|
||||
const useWeatherSettings = () => {
|
||||
const [location, setLocation] = useLocalStorageState('location', '');
|
||||
const [windSpeed, setWindSpeed] = useLocalStorageState('windspeed', 'true');
|
||||
|
||||
const showReminder = useCallback(() => {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
localStorage.setItem('showReminder', true);
|
||||
}, []);
|
||||
|
||||
const changeLocation = (e) => {
|
||||
localStorage.removeItem('currentWeather');
|
||||
setLocation(e.target.value);
|
||||
showReminder();
|
||||
};
|
||||
|
||||
const getAutoLocation = useCallback(() => {
|
||||
setLocation(variables.getMessage('modals.main.loading'));
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const data = await (
|
||||
await fetch(
|
||||
`${variables.constants.API_URL}/gps?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}`,
|
||||
)
|
||||
).json();
|
||||
setLocation(data[0].name);
|
||||
showReminder();
|
||||
},
|
||||
(error) => {
|
||||
console.error(error);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
);
|
||||
}, [setLocation, showReminder]);
|
||||
|
||||
return {
|
||||
location,
|
||||
windSpeed: windSpeed !== 'true',
|
||||
setWindSpeed,
|
||||
changeLocation,
|
||||
getAutoLocation,
|
||||
};
|
||||
};
|
||||
|
||||
const WeatherOptions = () => {
|
||||
const { location, windSpeed, setWindSpeed, changeLocation, getAutoLocation } =
|
||||
useWeatherSettings();
|
||||
const weatherType = localStorage.getItem('weatherType');
|
||||
const { windSpeed, setWindSpeed } = useWeatherSettings();
|
||||
const [weatherType, setWeatherType] = useLocalStorageState('weatherType', '1');
|
||||
const WEATHER_SECTION = 'modals.main.settings.sections.weather';
|
||||
|
||||
const WidgetType = () => (
|
||||
@@ -66,7 +25,7 @@ const WeatherOptions = () => {
|
||||
label={variables.getMessage('modals.main.settings.sections.time.type')}
|
||||
name="weatherType"
|
||||
category="weather"
|
||||
onChange={() => this.forceUpdate()}
|
||||
onChange={(value) => setWeatherType(value)}
|
||||
items={[
|
||||
{ value: '1', text: variables.getMessage(`${WEATHER_SECTION}.options.basic`) },
|
||||
{ value: '2', text: variables.getMessage(`${WEATHER_SECTION}.options.standard`) },
|
||||
@@ -82,18 +41,12 @@ const WeatherOptions = () => {
|
||||
<Row>
|
||||
<Content title={variables.getMessage(`${WEATHER_SECTION}.location`)} />
|
||||
<Action>
|
||||
<TextField
|
||||
<LocationSearch
|
||||
label={variables.getMessage(`${WEATHER_SECTION}.location`)}
|
||||
value={location}
|
||||
onChange={changeLocation}
|
||||
name="location"
|
||||
category="weather"
|
||||
placeholder="London"
|
||||
variant="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<span className="link" onClick={getAutoLocation}>
|
||||
<MdAutoAwesome />
|
||||
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
|
||||
</span>
|
||||
</Action>
|
||||
</Row>
|
||||
);
|
||||
@@ -190,11 +143,11 @@ const WeatherOptions = () => {
|
||||
zoomCategory="weather"
|
||||
visibilityToggle={true}
|
||||
>
|
||||
<WidgetType />
|
||||
{WidgetType()}
|
||||
{/* https://stackoverflow.com/a/65328486 when using inputs it may defocus so we do the {} instead of <> */}
|
||||
{LocationSetting()}
|
||||
<TemperatureFormat />
|
||||
{weatherType === '4' && <CustomOptions />}
|
||||
{TemperatureFormat()}
|
||||
{weatherType === '4' && CustomOptions()}
|
||||
</PreferencesWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,141 +1,16 @@
|
||||
// Importing necessary libraries and components
|
||||
import { useState, useEffect } from 'react';
|
||||
import variables from 'config/variables';
|
||||
import { MdArrowBackIosNew, MdArrowForwardIos, MdOutlinePreview } from 'react-icons/md';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import { ProgressBar, AsideImage } from './components/Elements';
|
||||
import { Button } from 'components/Elements';
|
||||
import { Wrapper, Panel } from './components/Layout';
|
||||
import { SimpleWelcome } from './components/Sections';
|
||||
|
||||
import './welcome.scss';
|
||||
|
||||
import {
|
||||
Intro,
|
||||
ChooseLanguage,
|
||||
ImportSettings,
|
||||
ThemeSelection,
|
||||
StyleSelection,
|
||||
PrivacyOptions,
|
||||
Final,
|
||||
} from './components/Sections';
|
||||
|
||||
// WelcomeModal component
|
||||
function WelcomeModal({ modalClose, modalSkip }) {
|
||||
// State variables
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const [buttonText, setButtonText] = useState(variables.getMessage('modals.welcome.buttons.next'));
|
||||
const [importedSettings, setImportedSettings] = useState([]);
|
||||
const finalTab = 6;
|
||||
|
||||
// useEffect hook to handle tab changes
|
||||
useEffect(() => {
|
||||
// Get the current welcome tab from local storage
|
||||
const welcomeTab = localStorage.getItem('welcomeTab');
|
||||
if (welcomeTab) {
|
||||
const tab = Number(welcomeTab);
|
||||
setCurrentTab(tab);
|
||||
setButtonText(
|
||||
tab !== finalTab + 1
|
||||
? variables.getMessage('modals.welcome.buttons.next')
|
||||
: variables.getMessage('modals.welcome.buttons.finish'),
|
||||
);
|
||||
}
|
||||
}, [finalTab]);
|
||||
|
||||
// Function to update the current tab and button text
|
||||
const updateTabAndButtonText = (newTab) => {
|
||||
setCurrentTab(newTab);
|
||||
setButtonText(
|
||||
newTab !== finalTab
|
||||
? variables.getMessage('modals.welcome.buttons.next')
|
||||
: variables.getMessage('modals.welcome.buttons.finish'),
|
||||
);
|
||||
|
||||
localStorage.setItem('bgtransition', true);
|
||||
localStorage.removeItem('welcomeTab');
|
||||
};
|
||||
|
||||
// Functions to navigate to the previous and next tabs
|
||||
const prevTab = () => {
|
||||
updateTabAndButtonText(currentTab - 1);
|
||||
};
|
||||
|
||||
const nextTab = () => {
|
||||
if (buttonText === variables.getMessage('modals.welcome.buttons.finish')) {
|
||||
modalClose();
|
||||
return;
|
||||
}
|
||||
updateTabAndButtonText(currentTab + 1);
|
||||
};
|
||||
|
||||
// Function to switch to a specific tab
|
||||
const switchToTab = (tab) => {
|
||||
updateTabAndButtonText(tab);
|
||||
};
|
||||
|
||||
// Navigation component
|
||||
const Navigation = () => {
|
||||
return (
|
||||
<div className="welcomeButtons">
|
||||
{currentTab !== 0 ? (
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => prevTab()}
|
||||
icon={<MdArrowBackIosNew />}
|
||||
label={variables.getMessage('modals.welcome.buttons.previous')}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => modalSkip()}
|
||||
icon={<MdOutlinePreview />}
|
||||
label={variables.getMessage('modals.welcome.buttons.preview')}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => nextTab()}
|
||||
icon={<MdArrowForwardIos />}
|
||||
label={buttonText}
|
||||
iconPlacement={'right'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mapping of tab numbers to components
|
||||
const tabComponents = {
|
||||
0: <Intro />,
|
||||
1: <ChooseLanguage />,
|
||||
2: <ImportSettings setImportedSettings={setImportedSettings} switchTab={switchToTab} />,
|
||||
3: <ThemeSelection />,
|
||||
4: <StyleSelection />,
|
||||
5: <PrivacyOptions />,
|
||||
6: (
|
||||
<Final currentTab={currentTab} switchTab={switchToTab} importedSettings={importedSettings} />
|
||||
),
|
||||
};
|
||||
|
||||
// Current tab component
|
||||
const CurrentTab = tabComponents[currentTab] || <Intro />;
|
||||
|
||||
// Render the WelcomeModal component
|
||||
// Render the simplified welcome component
|
||||
return (
|
||||
<Wrapper>
|
||||
<Panel type="aside">
|
||||
<AsideImage currentTab={currentTab} />
|
||||
<ProgressBar numberOfTabs={finalTab + 1} currentTab={currentTab} switchTab={switchToTab} />
|
||||
</Panel>
|
||||
<Panel type="content">
|
||||
{CurrentTab}
|
||||
<Navigation
|
||||
currentTab={currentTab}
|
||||
changeTab={switchToTab}
|
||||
buttonText={buttonText}
|
||||
modalSkip={modalSkip}
|
||||
/>
|
||||
<Panel type="content" className="simpleWelcome">
|
||||
<SimpleWelcome modalClose={modalClose} modalSkip={modalSkip} />
|
||||
</Panel>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
const images = [
|
||||
'/src/assets/icons/undraw_celebration.svg',
|
||||
'/src/assets/icons/undraw_around_the_world_modified.svg',
|
||||
'/src/assets/icons/undraw_add_files_modified.svg',
|
||||
'/src/assets/icons/undraw_dark_mode.svg',
|
||||
'/src/assets/icons/undraw_making_art.svg',
|
||||
'/src/assets/icons/undraw_private_data_modified.svg',
|
||||
'/src/assets/icons/undraw_upgrade_modified.svg',
|
||||
];
|
||||
|
||||
function AsideImage({ currentTab }) {
|
||||
const altTexts = [
|
||||
'Celebration icon',
|
||||
'Around the world icon',
|
||||
'Add files icon',
|
||||
'Dark mode icon',
|
||||
'Making art icon',
|
||||
'Private data icon',
|
||||
'Upgrade icon',
|
||||
];
|
||||
return (
|
||||
<img
|
||||
className="showcaseimg"
|
||||
alt={altTexts[currentTab]}
|
||||
draggable={false}
|
||||
src={images[currentTab]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { AsideImage as default, AsideImage };
|
||||
@@ -1 +0,0 @@
|
||||
export * from './AsideImage';
|
||||
@@ -1,33 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
const Step = memo(({ isActive, index, onClick }) => {
|
||||
const className = isActive ? 'step active' : 'step';
|
||||
|
||||
return (
|
||||
<div className={className} onClick={onClick}>
|
||||
<span>{index + 1}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Step.displayName = 'Step';
|
||||
|
||||
function ProgressBar({ numberOfTabs, currentTab, switchTab }) {
|
||||
return (
|
||||
<div className="progressbar">
|
||||
{Array.from({ length: numberOfTabs }, (_, index) => (
|
||||
<Step
|
||||
key={index}
|
||||
isActive={index === currentTab}
|
||||
index={index}
|
||||
onClick={() => switchTab(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoizedProgressBar = memo(ProgressBar);
|
||||
|
||||
export default MemoizedProgressBar;
|
||||
export { MemoizedProgressBar as ProgressBar };
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ProgressBar';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ProgressBar';
|
||||
export * from './AsideImage';
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { MdOutlineOpenInNew, MdSearch } from 'react-icons/md';
|
||||
import { TextField, InputAdornment } from '@mui/material';
|
||||
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 { Header, Content } from '../Layout';
|
||||
|
||||
function ChooseLanguage() {
|
||||
const t = useT();
|
||||
const { language: currentLanguage, changeLanguage } = useTranslation();
|
||||
const title = t('modals.welcome.sections.language.title');
|
||||
const description = t('modals.welcome.sections.language.description');
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const languageOptions = useMemo(() => {
|
||||
const currentLanguageISO = currentLanguage.replace('_', '-');
|
||||
const displayNames = new Intl.DisplayNames([currentLanguageISO], { type: 'language' });
|
||||
|
||||
const mappedLanguages = languages.map((lang) => {
|
||||
const nativeName = lang.name;
|
||||
const isoCode = lang.value.replace('_', '-');
|
||||
const percentage = translationPercentages[lang.value]?.percent || 0;
|
||||
|
||||
let translatedName;
|
||||
try {
|
||||
translatedName = displayNames.of(isoCode);
|
||||
if (translatedName) {
|
||||
translatedName = translatedName.split(' (')[0];
|
||||
}
|
||||
} catch {
|
||||
translatedName = nativeName;
|
||||
}
|
||||
|
||||
const displayName =
|
||||
!translatedName || translatedName === nativeName ? (
|
||||
<>
|
||||
{nativeName} <span style={{ color: '#999', fontSize: '0.85em' }}>({percentage}%)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{nativeName}{' '}
|
||||
<span style={{ color: '#999', fontSize: '0.85em' }}>
|
||||
({translatedName} • {percentage}%)
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
value: lang.value,
|
||||
nativeName,
|
||||
percentage,
|
||||
searchText: `${nativeName} ${translatedName || ''}`.toLowerCase(),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort alphabetically by native name
|
||||
return mappedLanguages.sort((a, b) => a.nativeName.localeCompare(b.nativeName));
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Filter languages based on search query
|
||||
const filteredLanguages = useMemo(() => {
|
||||
if (!searchQuery.trim()) return languageOptions;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return languageOptions.filter((lang) => lang.searchText.includes(query));
|
||||
}, [languageOptions, searchQuery]);
|
||||
|
||||
// Detect system language
|
||||
const systemLanguage = useMemo(() => {
|
||||
const browserLang = navigator.language.replace('-', '_');
|
||||
return (
|
||||
languages.find((l) => l.value === browserLang) ||
|
||||
languages.find((l) => l.value.startsWith(browserLang.split('_')[0]))
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Content>
|
||||
<Header
|
||||
title={title}
|
||||
subtitle={
|
||||
<>
|
||||
{description}{' '}
|
||||
<a
|
||||
href={variables.constants.TRANSLATIONS_URL}
|
||||
className="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.2em' }}
|
||||
>
|
||||
GitHub <MdOutlineOpenInNew />
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{systemLanguage && systemLanguage.value !== currentLanguage && (
|
||||
<button
|
||||
className="uploadbg"
|
||||
onClick={() => changeLanguage(systemLanguage.value)}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
{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 className="languageSettings">
|
||||
<Radio name="language" options={filteredLanguages} category="welcomeLanguage" />
|
||||
</div>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { ChooseLanguage as default, ChooseLanguage };
|
||||
@@ -1,43 +0,0 @@
|
||||
import variables from 'config/variables';
|
||||
import languages from '@/i18n/languages.json';
|
||||
import { Header, Content } from '../Layout';
|
||||
|
||||
function Final(props) {
|
||||
return (
|
||||
<Content>
|
||||
<Header
|
||||
title={variables.getMessage('modals.welcome.sections.final.title')}
|
||||
subtitle={variables.getMessage('modals.welcome.sections.final.description')}
|
||||
/>
|
||||
<span className="title">{variables.getMessage('modals.welcome.sections.final.changes')}</span>
|
||||
<span className="subtitle">
|
||||
{variables.getMessage('modals.welcome.sections.final.changes_description')}
|
||||
</span>
|
||||
<div className="themesToggleArea themesToggleAreaWelcome">
|
||||
<div className="toggle" onClick={() => props.switchTab(1)}>
|
||||
<span>
|
||||
{variables.getMessage('modals.main.settings.sections.language.title')}:{' '}
|
||||
{languages.find((i) => i.value === localStorage.getItem('language')).name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="toggle" onClick={() => props.switchTab(3)}>
|
||||
<span>
|
||||
{variables.getMessage('modals.main.settings.sections.appearance.theme.title')}:{' '}
|
||||
{variables.getMessage(
|
||||
'modals.main.settings.sections.appearance.theme.' + localStorage.getItem('theme'),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{props.importedSettings.length !== 0 && (
|
||||
<div className="toggle" onClick={() => props.switchTab(2)}>
|
||||
{variables.getMessage('modals.welcome.sections.final.imported', {
|
||||
amount: props.importedSettings.length,
|
||||
})}{' '}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Final as default, Final };
|
||||
@@ -1,66 +0,0 @@
|
||||
import variables from 'config/variables';
|
||||
import { FileUpload } from 'components/Form/Settings';
|
||||
import { MdCloudUpload } from 'react-icons/md';
|
||||
import { importSettings as importSettingsFunction } from 'utils/settings';
|
||||
import { Header, Content } from '../Layout';
|
||||
import default_settings from 'utils/data/default_settings.json';
|
||||
|
||||
function ImportSettings(props) {
|
||||
const importSettings = (e) => {
|
||||
importSettingsFunction(e, true);
|
||||
|
||||
const settings = [];
|
||||
const data = JSON.parse(e);
|
||||
Object.keys(data).forEach((setting) => {
|
||||
// language and theme already shown, the others are only used internally
|
||||
if (
|
||||
setting === 'language' ||
|
||||
setting === 'theme' ||
|
||||
setting === 'firstRun' ||
|
||||
setting === 'showWelcome' ||
|
||||
setting === 'showReminder'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultSetting = default_settings.find((i) => i.name === setting);
|
||||
if (defaultSetting !== undefined) {
|
||||
if (data[setting] === String(defaultSetting.value)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
settings.push({
|
||||
name: setting,
|
||||
value: data[setting],
|
||||
});
|
||||
});
|
||||
|
||||
props.setImportedSettings(settings);
|
||||
props.switchTab(6);
|
||||
};
|
||||
return (
|
||||
<Content>
|
||||
<Header
|
||||
title={variables.getMessage('modals.welcome.sections.settings.title')}
|
||||
subtitle={variables.getMessage('modals.welcome.sections.settings.description')}
|
||||
/>
|
||||
<button className="upload" onClick={() => document.getElementById('file-input').click()}>
|
||||
<MdCloudUpload />
|
||||
<span>{variables.getMessage('modals.main.settings.buttons.import')}</span>
|
||||
</button>
|
||||
<FileUpload
|
||||
id="file-input"
|
||||
accept="application/json"
|
||||
type="settings"
|
||||
loadFunction={(e) => importSettings(e)}
|
||||
/>
|
||||
<span className="title">{variables.getMessage('modals.welcome.tip')}</span>
|
||||
<span className="subtitle">
|
||||
{variables.getMessage('modals.welcome.sections.settings.tip')}
|
||||
</span>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { ImportSettings as default, ImportSettings };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user