Compare commits

...

68 Commits

Author SHA1 Message Date
alexsparkes
05cc274b27 Refactor: welcome modal and initial background loader 2026-01-30 23:39:45 +00:00
alexsparkes
f29b879215 feat(greeting, date): enhance turkish localisation support for date 2026-01-29 22:26:02 +00:00
alexsparkes
09308a4452 feat(i18n): add localised versions of the mue brand name 2026-01-29 18:31:42 +00:00
David Ralph
e03b1d68d0 fix(quicklinks): close #875 by enabling offline 2026-01-28 16:52:08 +00:00
David Ralph
3e434341da chore: merge branch 'dev' of https://github.com/mue/mue into dev 2026-01-28 16:45:49 +00:00
David Ralph
49630ee375 fix: quick link input, close #1124 2026-01-28 16:45:41 +00:00
alexsparkes
9d98e2124a feat(CustomSettings): replace video icon and add overlay icon for custom backgrounds 2026-01-28 16:44:27 +00:00
alexsparkes
0c9a90d693 fix: comment out box-shadow on hover effect for sortable items 2026-01-28 16:37:03 +00:00
alexsparkes
6c73bdb156 fix(modal): weird cutting off of bottom content 2026-01-28 16:31:36 +00:00
David Ralph
6465b88f30 fix: custom video background 2026-01-28 16:27:24 +00:00
David Ralph
a4e575c5f6 feat: library display option, navbar count 2026-01-28 15:49:22 +00:00
alexsparkes
05bf8edeea feat(WeatherOptions): refactor weatherType state management and update onChange handler 2026-01-28 14:02:31 +00:00
alexsparkes
50aae0f8ec feat(LocationSearch): add LocationSearch component for improved location input 2026-01-28 13:43:29 +00:00
alexsparkes
6e852eb252 feat(Appearance): add Google Fonts selection to font options 2026-01-28 11:51:42 +00:00
alexsparkes
6cb8843b49 feat(SidebarToggle): replace menu icon with sidebar icon and update button accessibility
feat(buttons): enhance button styles with active state transformations and transitions
fix(Header): add delay for setting change to ensure button click animation completes
2026-01-27 23:07:37 +00:00
alexsparkes
63b8742218 feat(Sidebar): implement collapsible sidebar with toggle functionality and update translations 2026-01-27 22:59:52 +00:00
alexsparkes
bebd551193 feat(Modal): add modal border variable and update themed styles 2026-01-27 22:33:45 +00:00
alexsparkes
c71c6442db feat(Dropdown): optimize position calculation and add scroll/resize handling 2026-01-27 22:14:45 +00:00
alexsparkes
9e692b97ce feat(Slider): add hover functionality with tooltip and indicator 2026-01-27 21:40:12 +00:00
David Ralph
7f0b37c713 feat: new date picker 2026-01-27 20:42:19 +00:00
David Ralph
4d8be5774f style: improve dropdown default 2026-01-27 20:26:41 +00:00
David Ralph
b7097979de fix: moreSettings misfiring on dropdown click 2026-01-27 20:10:09 +00:00
David Ralph
7e6bc58f2c feat: new dropdown search capability 2026-01-27 20:04:58 +00:00
alexsparkes
9fa1ddcab5 feat(ModalTabContent): add gap to settings row for improved layout 2026-01-27 16:51:04 +00:00
alexsparkes
3dad52196d feat(Radio): update header structure and styling for improved accessibility
feat(Dropdown): remove margin and clean up unused styles
style(ModalTabContent): adjust padding and add gap for button layout
2026-01-27 16:49:03 +00:00
alexsparkes
df23753971 feat(Dropdown): add default value indication and style for dropdown options 2026-01-27 16:34:49 +00:00
alexsparkes
401e711bd8 feat(ChipSelect): enhance dropdown with closing animations and position calculation 2026-01-27 16:29:07 +00:00
alexsparkes
f493eb186e feat(Dropdown): enhance dropdown menu with position calculation and closing animations 2026-01-27 16:23:54 +00:00
Alex Sparkes
79c8e1508f Merge branch 'beta' into dev
Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
2026-01-27 14:28:50 +00:00
alexsparkes
b5a451c70d chore: bump version to 7.6.1 in package.json, manifest.json, project.pbxproj, and constants.js 2026-01-27 14:27:27 +00:00
alexsparkes
7a589de14b feat(Dropdown): implement closing animation and portal rendering for dropdown menu
fix(QuoteOptions): ensure authorDetails is set to true for users upgrading from older versions
2026-01-27 14:22:15 +00:00
alexsparkes
9bf160094e fix: add blurhash dependency for image metadata encoding 2026-01-27 12:29:34 +00:00
alexsparkes
f8746a31b0 fix: add blurhash dependency for image metadata encoding 2026-01-27 12:27:18 +00:00
alexsparkes
864097c508 refactor(Items): remove unused imports and hex color conversion logic 2026-01-27 12:15:12 +00:00
Alex Sparkes
139c8e2914 Merge branch 'beta' into dev
Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
2026-01-27 12:10:35 +00:00
alexsparkes
f2a0330655 fix(QuoteOptions): ensure authorDetails is set to true for all users during migration 2026-01-27 12:06:45 +00:00
alexsparkes
89523df1cf feat(Dropdown): implement dropdown closing animation and portal rendering 2026-01-27 10:21:02 +00:00
alexsparkes
9462fe1b32 fix(Custom): remove unnecessary characters from loading state 2026-01-27 10:13:08 +00:00
alexsparkes
c7a2760709 feat(modal): enhance close button styling and theming support 2026-01-26 22:32:45 +00:00
alexsparkes
f1e961e8e4 feat(storage): implement dynamic storage quota estimation and request persistence 2026-01-26 16:48:47 +00:00
alexsparkes
616055106b fix(background/custom): prevent flashing during uploads 2026-01-26 16:44:10 +00:00
alexsparkes
9677434c00 Add new localization strings and improve image metadata utility functions
- Updated localization files for multiple languages (Hungarian, Indonesian, Japanese, Lithuanian, Latvian, Dutch, Norwegian, Persian, Portuguese, Brazilian Portuguese, Russian, Slovenian, Swedish, Tamil, Turkish, Ukrainian, Vietnamese, Simplified Chinese, Traditional Chinese) to include new strings for image management features such as "Delete Selected", "Uploading", "Tag Images", and storage information.
- Enhanced the `getDataUrlSize` and `formatBytes` functions in `imageMetadata.js` for better readability and maintainability by adding braces for conditional statements.
2026-01-26 16:28:13 +00:00
alexsparkes
cac58cdaeb feat: enhance image management features
- Added new localization strings for image management, including upload and storage information.
- Refactored custom background database functions to support metadata and backward compatibility.
- Introduced a new FolderTaggingModal component for organizing images into folders.
- Created utility functions for image metadata extraction, including dimensions, blur hash generation, and file size calculation.
- Implemented functions to delete multiple backgrounds and update background metadata.
2026-01-26 16:14:09 +00:00
alexsparkes
e42a218116 feat(background): implement custom background loading and improve state management 2026-01-26 12:24:26 +00:00
alexsparkes
40c248985d fix(quote/buttons): improve state management and event handling 2026-01-26 10:26:02 +00:00
alexsparkes
d88ed2eedd fix(greeting/events): event text box styling 2026-01-26 10:21:51 +00:00
David Ralph
ab2b969772 cleanup: remove unused code from addons and marketplace 2026-01-25 21:28:26 +00:00
David Ralph
67ba0f6718 font: replace montserrat with inter 2026-01-25 21:17:38 +00:00
Alex Sparkes
4cf5269cdc Dev (#1134)
* feat: add professional three-branch release workflow automation (#1129)

- Add version-bump workflow for semantic versioning across all files
- Add beta-release workflow for automated pre-release testing
- Add production-release workflow with manual approval gates
- Add hotfix-release workflow for emergency patches
- Create comprehensive CONTRIBUTING.md with workflow guide
- Create detailed RELEASE_PROCESS.md for maintainers
- Add PR template with release checklists
- Update CODEOWNERS to protect workflow files
- Update README with contribution links
- Remove /docs from .gitignore to allow documentation

This implements a dev  beta  main branching strategy with:
- Automated version management across 6 files
- Changelog generation from conventional commits
- GitHub Releases with build artifacts
- Environment-based approvals for production
- Back-merge support for hotfixes

* feat: new default quotes experience, improve added page

* Fix/beta workflow version check (#1131)

* fix(workflows): prevent beta release for non-beta versions

* fix(workflows): address copilot PR review feedback

- Support iterative beta versions (7.6.0-beta.1 -> 7.6.0-beta.2)
- Remove tag trigger from beta workflow to prevent premature releases
- Fix tag format in docs/summaries to include 'v' prefix
- Clarify deployment approval wording

* feat: replace mui with new style

* feat: improve time formatting in Clock component with padded digits

* fix: change Checkbox component from label to div for better semantics

* fix: change Switch component from label to div for better semantics

* feat: add smooth animation to reset functionality in Slider component

* feat: enhance accessibility and styling for form components including Checkbox, Dropdown, Radio, Slider, and Text

* feat: enhance WeatherOptions component with improved layout and auto location reset functionality

* feat: update Slider and Dropdown components with improved layout and z-index adjustments

* feat: add reset functionality to Dropdown component with toast notification

* feat: update Dropdown component styles for improved layout and structure

* feat: update languageSettings component with increased padding for better spacing

* feat: bump version to 7.6.0 across all manifests and documentation

---------

Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
Co-authored-by: David Ralph <me@davidcralph.co.uk>
2026-01-25 20:58:57 +00:00
Alex Sparkes
ce6b05f1a1 Merge branch 'beta' into dev
Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
2026-01-25 20:57:24 +00:00
alexsparkes
5c8d9a3a44 feat: bump version to 7.6.0 across all manifests and documentation 2026-01-25 20:44:44 +00:00
alexsparkes
4a2f1334f3 feat: update languageSettings component with increased padding for better spacing 2026-01-25 20:29:28 +00:00
alexsparkes
155dc46e68 feat: update Dropdown component styles for improved layout and structure 2026-01-25 20:26:00 +00:00
alexsparkes
47b7397bd4 feat: add reset functionality to Dropdown component with toast notification 2026-01-25 20:24:18 +00:00
alexsparkes
777f1faeb6 feat: update Slider and Dropdown components with improved layout and z-index adjustments 2026-01-25 20:22:33 +00:00
alexsparkes
dfb0872633 feat: enhance WeatherOptions component with improved layout and auto location reset functionality 2026-01-25 20:18:11 +00:00
alexsparkes
c186c54749 feat: enhance accessibility and styling for form components including Checkbox, Dropdown, Radio, Slider, and Text 2026-01-25 20:12:51 +00:00
alexsparkes
5392e4b27d feat: add smooth animation to reset functionality in Slider component 2026-01-25 19:25:00 +00:00
alexsparkes
c13d6ce4ac fix: change Switch component from label to div for better semantics 2026-01-25 19:23:15 +00:00
alexsparkes
9410d89cea fix: change Checkbox component from label to div for better semantics 2026-01-25 19:22:07 +00:00
alexsparkes
a6e1490edb feat: improve time formatting in Clock component with padded digits 2026-01-25 19:13:24 +00:00
David Ralph
874866bf73 feat: replace mui with new style 2026-01-25 18:12:05 +00:00
Alex Sparkes
ecfb3c6648 Sync/workflow fixes to beta (#1132)
* feat: add professional three-branch release workflow automation (#1129)

- Add version-bump workflow for semantic versioning across all files
- Add beta-release workflow for automated pre-release testing
- Add production-release workflow with manual approval gates
- Add hotfix-release workflow for emergency patches
- Create comprehensive CONTRIBUTING.md with workflow guide
- Create detailed RELEASE_PROCESS.md for maintainers
- Add PR template with release checklists
- Update CODEOWNERS to protect workflow files
- Update README with contribution links
- Remove /docs from .gitignore to allow documentation

This implements a dev  beta  main branching strategy with:
- Automated version management across 6 files
- Changelog generation from conventional commits
- GitHub Releases with build artifacts
- Environment-based approvals for production
- Back-merge support for hotfixes

* fix(workflows): prevent beta release for non-beta versions

* Fix/beta workflow version check (#1131)

* fix(workflows): prevent beta release for non-beta versions

* fix(workflows): address copilot PR review feedback

- Support iterative beta versions (7.6.0-beta.1 -> 7.6.0-beta.2)
- Remove tag trigger from beta workflow to prevent premature releases
- Fix tag format in docs/summaries to include 'v' prefix
- Clarify deployment approval wording

---------

Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
2026-01-25 17:51:15 +00:00
alexsparkes
01fcdbf9c7 chore: sync workflow fixes from main 2026-01-25 17:47:18 +00:00
David Ralph
2fca4bf9ac chore: merge branch 'dev' of https://github.com/mue/mue into dev 2026-01-25 17:35:06 +00:00
David Ralph
2918033afa feat: new default quotes experience, improve added page 2026-01-25 17:34:59 +00:00
Alex Sparkes
a29984d3aa feat: add professional three-branch release workflow automation (#1129) (#1130)
- Add version-bump workflow for semantic versioning across all files
- Add beta-release workflow for automated pre-release testing
- Add production-release workflow with manual approval gates
- Add hotfix-release workflow for emergency patches
- Create comprehensive CONTRIBUTING.md with workflow guide
- Create detailed RELEASE_PROCESS.md for maintainers
- Add PR template with release checklists
- Update CODEOWNERS to protect workflow files
- Update README with contribution links
- Remove /docs from .gitignore to allow documentation

This implements a dev  beta  main branching strategy with:
- Automated version management across 6 files
- Changelog generation from conventional commits
- GitHub Releases with build artifacts
- Environment-based approvals for production
- Back-merge support for hotfixes
2026-01-25 17:34:04 +00:00
alexsparkes
befed06832 chore: sync release automation from main 2026-01-25 17:29:03 +00:00
149 changed files with 8523 additions and 3676 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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]}"

View File

@@ -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

1328
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -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**

View File

@@ -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": {

View File

@@ -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"

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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 />}
</>
);
}

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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;

View File

@@ -1,3 +1,2 @@
export { default as ModalLoader } from './ModalLoader';
export { default as ModalNavbar } from './ModalNavbar';
export { default as ReminderInfo } from './ReminderInfo';

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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 {

View File

@@ -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));

View File

@@ -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 };

View File

@@ -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>
);
});

View 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;
}
}
}

View File

@@ -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>
);
}

View 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;
}
}

View 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 };

View 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;
}
}
}
}

View File

@@ -0,0 +1 @@
export * from './DatePicker';

View File

@@ -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>
);
});

View 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;
}
}
}

View File

@@ -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'}
/>
);
});

View 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 };

View 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);
}
}

View File

@@ -0,0 +1 @@
export * from './LocationSearch';

View File

@@ -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>
);
});

View 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;
}
}
}

View 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 };

View 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);
}
}
}
}

View File

@@ -0,0 +1 @@
export * from './SearchInput';

View File

@@ -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>
);
});

View 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);
}
}
}
}

View File

@@ -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>
);
});

View 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;
}
}

View File

@@ -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>
);
});

View 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;
}
}

View 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 };

View 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;
}
}

View File

@@ -0,0 +1 @@
export * from './Textarea';

View File

@@ -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';

View File

@@ -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 = () => (

View File

@@ -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';

View 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"
]

View File

@@ -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);

View File

@@ -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')
);
}

View File

@@ -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} />}
</>
);
}

View File

@@ -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();

View File

@@ -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

View 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;

View File

@@ -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');
}

View File

@@ -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)}

View File

@@ -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}
</>
);
}

View File

@@ -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) {

View File

@@ -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}
/>
</>
);

View File

@@ -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>

View File

@@ -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}
>

View File

@@ -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}

View File

@@ -139,6 +139,7 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
<Dropdown
name="timezone"
category="timezone"
searchable={true}
items={[
{
value: 'auto',

View File

@@ -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'),

View File

@@ -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)}

View File

@@ -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' }}>

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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(

View File

@@ -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"

View File

@@ -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;

View File

@@ -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 ? (

View File

@@ -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]);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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' })

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>
</>
);

View File

@@ -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>
);

View File

@@ -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 };

View File

@@ -1 +0,0 @@
export * from './AsideImage';

View File

@@ -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 };

View File

@@ -1 +0,0 @@
export * from './ProgressBar';

View File

@@ -1,2 +0,0 @@
export * from './ProgressBar';
export * from './AsideImage';

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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